tagliatelle

Unnamed repository; edit this file 'description' to name the repository.
Log | Files | Refs

include-bulk.go (13789B)


      1 package main
      2 
      3 import (
      4 	"database/sql"
      5 	"fmt"
      6 	"log"
      7 	"net/http"
      8 	"strconv"
      9 	"strings"
     10 )
     11 
     12 func applyBulkTagOperations(fileIDs []int, category, value, operation string) error {
     13 	category = strings.TrimSpace(category)
     14 	value = strings.TrimSpace(value)
     15 	if category == "" {
     16 		return fmt.Errorf("category cannot be empty")
     17 	}
     18 
     19 	if operation == "add" && value == "" {
     20 		return fmt.Errorf("value cannot be empty when adding tags")
     21 	}
     22 
     23 	tx, err := db.Begin()
     24 	if err != nil {
     25 		return fmt.Errorf("failed to start transaction: %v", err)
     26 	}
     27 	defer tx.Rollback()
     28 
     29 	var catID int
     30 	err = tx.QueryRow("SELECT id FROM categories WHERE name=?", category).Scan(&catID)
     31 	if err != nil && err != sql.ErrNoRows {
     32 		return fmt.Errorf("failed to query category: %v", err)
     33 	}
     34 
     35 	if catID == 0 {
     36 		if operation == "remove" {
     37 			return fmt.Errorf("cannot remove non-existent category: %s", category)
     38 		}
     39 		res, err := tx.Exec("INSERT INTO categories(name) VALUES(?)", category)
     40 		if err != nil {
     41 			return fmt.Errorf("failed to create category: %v", err)
     42 		}
     43 		cid, _ := res.LastInsertId()
     44 		catID = int(cid)
     45 	}
     46 
     47 	var tagID int
     48 	if value != "" {
     49 		err = tx.QueryRow("SELECT id FROM tags WHERE category_id=? AND value=?", catID, value).Scan(&tagID)
     50 		if err != nil && err != sql.ErrNoRows {
     51 			return fmt.Errorf("failed to query tag: %v", err)
     52 		}
     53 
     54 		if tagID == 0 {
     55 			if operation == "remove" {
     56 				return fmt.Errorf("cannot remove non-existent tag: %s=%s", category, value)
     57 			}
     58 			res, err := tx.Exec("INSERT INTO tags(category_id, value) VALUES(?, ?)", catID, value)
     59 			if err != nil {
     60 				return fmt.Errorf("failed to create tag: %v", err)
     61 			}
     62 			tid, _ := res.LastInsertId()
     63 			tagID = int(tid)
     64 		}
     65 	}
     66 
     67 	for _, fileID := range fileIDs {
     68 		if operation == "add" {
     69 			_, err = tx.Exec("INSERT OR IGNORE INTO file_tags(file_id, tag_id) VALUES (?, ?)", fileID, tagID)
     70 		} else if operation == "remove" {
     71 			if value != "" {
     72 				_, err = tx.Exec("DELETE FROM file_tags WHERE file_id=? AND tag_id=?", fileID, tagID)
     73 			} else {
     74 				_, err = tx.Exec(`DELETE FROM file_tags WHERE file_id=? AND tag_id IN (SELECT t.id FROM tags t WHERE t.category_id=?)`, fileID, catID)
     75 			}
     76 		} else {
     77 			return fmt.Errorf("invalid operation: %s (must be 'add' or 'remove')", operation)
     78 		}
     79 		if err != nil {
     80 			return fmt.Errorf("failed to %s tag for file %d: %v", operation, fileID, err)
     81 		}
     82 	}
     83 
     84 	return tx.Commit()
     85 }
     86 
     87 func getBulkTagFormData() BulkTagFormData {
     88 	catRows, err := db.Query("SELECT name FROM categories ORDER BY name")
     89 	if err != nil {
     90 		log.Printf("Error: getBulkTagFormData: failed to query categories: %v", err)
     91 	}
     92 	var cats []string
     93 	if catRows != nil {
     94 		for catRows.Next() {
     95 			var c string
     96 			catRows.Scan(&c)
     97 			cats = append(cats, c)
     98 		}
     99 		catRows.Close()
    100 	}
    101 
    102 	recentRows, err := db.Query("SELECT id, filename FROM files ORDER BY id DESC LIMIT 20")
    103 	if err != nil {
    104 		log.Printf("Error: getBulkTagFormData: failed to query recent files: %v", err)
    105 	}
    106 	var recentFiles []File
    107 	if recentRows != nil {
    108 		for recentRows.Next() {
    109 			var f File
    110 			recentRows.Scan(&f.ID, &f.Filename)
    111 			recentFiles = append(recentFiles, f)
    112 		}
    113 		recentRows.Close()
    114 	}
    115 
    116 	return BulkTagFormData{
    117 		Categories:  cats,
    118 		RecentFiles: recentFiles,
    119 		FormData: struct {
    120 			FileRange string
    121 			Category  string
    122 			Value     string
    123 			Operation string
    124 			TagQuery      string
    125 			SelectionMode string
    126 		}{Operation: "add"},
    127 	}
    128 }
    129 
    130 func bulkTagHandler(w http.ResponseWriter, r *http.Request) {
    131 	if r.Method == http.MethodGet {
    132 		formData := getBulkTagFormData()
    133 		pageData := buildPageData("Bulk Tag Editor", formData)
    134 		renderTemplate(w, "bulk-tag.html", pageData)
    135 		return
    136 	}
    137 	if r.Method == http.MethodPost {
    138 		rangeStr := strings.TrimSpace(r.FormValue("file_range"))
    139 		tagQuery := strings.TrimSpace(r.FormValue("tag_query"))
    140 		selectionMode := r.FormValue("selection_mode")
    141 		category := strings.TrimSpace(r.FormValue("category"))
    142 		value := strings.TrimSpace(r.FormValue("value"))
    143 		operation := r.FormValue("operation")
    144 
    145 		formData := getBulkTagFormData()
    146 		formData.FormData.FileRange = rangeStr
    147 		formData.FormData.TagQuery = tagQuery
    148 		formData.FormData.SelectionMode = selectionMode
    149 		formData.FormData.Category = category
    150 		formData.FormData.Value = value
    151 		formData.FormData.Operation = operation
    152 
    153 		createErrorResponse := func(errorMsg string) {
    154 			formData.Error = errorMsg
    155 			pageData := buildPageData("Bulk Tag Editor", formData)
    156 			renderTemplate(w, "bulk-tag.html", pageData)
    157 		}
    158 
    159 		// Validate selection mode
    160 		if selectionMode == "" {
    161 			selectionMode = "range" // default
    162 		}
    163 
    164 		// Validate inputs based on selection mode
    165 		if selectionMode == "range" && rangeStr == "" {
    166 			createErrorResponse("File range cannot be empty")
    167 			return
    168 		}
    169 		if selectionMode == "tags" && tagQuery == "" {
    170 			createErrorResponse("Tag query cannot be empty")
    171 			return
    172 		}
    173 		if category == "" {
    174 			createErrorResponse("Category cannot be empty")
    175 			return
    176 		}
    177 		if operation == "add" && value == "" {
    178 			createErrorResponse("Value cannot be empty when adding tags")
    179 			return
    180 		}
    181 
    182 		// Get file IDs based on selection mode
    183 		var fileIDs []int
    184 		var err error
    185 
    186 		if selectionMode == "range" {
    187 			fileIDs, err = parseFileIDRange(rangeStr)
    188 			if err != nil {
    189 				createErrorResponse(fmt.Sprintf("Invalid file range: %v", err))
    190 				return
    191 			}
    192 		} else if selectionMode == "tags" {
    193 			fileIDs, err = getFileIDsFromTagQuery(tagQuery)
    194 			if err != nil {
    195 				createErrorResponse(fmt.Sprintf("Tag query error: %v", err))
    196 				return
    197 			}
    198 			if len(fileIDs) == 0 {
    199 				createErrorResponse("No files match the tag query")
    200 				return
    201 			}
    202 		} else {
    203 			createErrorResponse("Invalid selection mode")
    204 			return
    205 		}
    206 
    207 		validFiles, err := validateFileIDs(fileIDs)
    208 		if err != nil {
    209 			createErrorResponse(fmt.Sprintf("File validation error: %v", err))
    210 			return
    211 		}
    212 
    213 		err = applyBulkTagOperations(fileIDs, category, value, operation)
    214 		if err != nil {
    215 			createErrorResponse(fmt.Sprintf("Tag operation failed: %v", err))
    216 			return
    217 		}
    218 
    219 		// Build success message
    220 		var successMsg string
    221 		var selectionDesc string
    222 		if selectionMode == "range" {
    223 			selectionDesc = fmt.Sprintf("file range '%s'", rangeStr)
    224 		} else {
    225 			selectionDesc = fmt.Sprintf("tag query '%s'", tagQuery)
    226 		}
    227 
    228 		if operation == "add" {
    229 			successMsg = fmt.Sprintf("Tag '%s: %s' added to %d files matching %s",
    230 				category, value, len(validFiles), selectionDesc)
    231 		} else {
    232 			if value != "" {
    233 				successMsg = fmt.Sprintf("Tag '%s: %s' removed from %d files matching %s",
    234 					category, value, len(validFiles), selectionDesc)
    235 			} else {
    236 				successMsg = fmt.Sprintf("All '%s' category tags removed from %d files matching %s",
    237 					category, len(validFiles), selectionDesc)
    238 			}
    239 		}
    240 
    241 		// Add file list
    242 		var filenames []string
    243 		for _, f := range validFiles {
    244 			filenames = append(filenames, f.Filename)
    245 		}
    246 		if len(filenames) <= 5 {
    247 			successMsg += fmt.Sprintf(": %s", strings.Join(filenames, ", "))
    248 		} else {
    249 			successMsg += fmt.Sprintf(": %s and %d more", strings.Join(filenames[:5], ", "), len(filenames)-5)
    250 		}
    251 
    252 		formData.Success = successMsg
    253 		pageData := buildPageData("Bulk Tag Editor", formData)
    254 		renderTemplate(w, "bulk-tag.html", pageData)
    255 		return
    256 	}
    257 	renderError(w, "Method not allowed", http.StatusMethodNotAllowed)
    258 }
    259 
    260 
    261 func parseFileIDRange(rangeStr string) ([]int, error) {
    262 	var fileIDs []int
    263 	parts := strings.Split(rangeStr, ",")
    264 
    265 	for _, part := range parts {
    266 		part = strings.TrimSpace(part)
    267 		if part == "" {
    268 			continue
    269 		}
    270 
    271 		if strings.Contains(part, "-") {
    272 			rangeParts := strings.Split(part, "-")
    273 			if len(rangeParts) != 2 {
    274 				return nil, fmt.Errorf("invalid range format: %s", part)
    275 			}
    276 
    277 			start, err := strconv.Atoi(strings.TrimSpace(rangeParts[0]))
    278 			if err != nil {
    279 				return nil, fmt.Errorf("invalid start ID in range %s: %v", part, err)
    280 			}
    281 
    282 			end, err := strconv.Atoi(strings.TrimSpace(rangeParts[1]))
    283 			if err != nil {
    284 				return nil, fmt.Errorf("invalid end ID in range %s: %v", part, err)
    285 			}
    286 
    287 			if start > end {
    288 				return nil, fmt.Errorf("invalid range %s: start must be <= end", part)
    289 			}
    290 
    291 			for i := start; i <= end; i++ {
    292 				fileIDs = append(fileIDs, i)
    293 			}
    294 		} else {
    295 			id, err := strconv.Atoi(part)
    296 			if err != nil {
    297 				return nil, fmt.Errorf("invalid file ID: %s", part)
    298 			}
    299 			fileIDs = append(fileIDs, id)
    300 		}
    301 	}
    302 
    303 	uniqueIDs := make(map[int]bool)
    304 	var result []int
    305 	for _, id := range fileIDs {
    306 		if !uniqueIDs[id] {
    307 			uniqueIDs[id] = true
    308 			result = append(result, id)
    309 		}
    310 	}
    311 
    312 	return result, nil
    313 }
    314 
    315 func getFileIDsFromTagQuery(query string) ([]int, error) {
    316 	query = strings.TrimSpace(query)
    317 	if query == "" {
    318 		return nil, fmt.Errorf("empty query")
    319 	}
    320 
    321 	// Check if query contains OR operator
    322 	if strings.Contains(strings.ToUpper(query), " OR ") {
    323 		return getFileIDsFromORQuery(query)
    324 	}
    325 
    326 	// Otherwise treat as AND query (comma-separated or single tag)
    327 	return getFileIDsFromANDQuery(query)
    328 }
    329 
    330 // getFileIDsFromANDQuery handles comma-separated tags (AND logic)
    331 func getFileIDsFromANDQuery(query string) ([]int, error) {
    332 	tagPairs := strings.Split(query, ",")
    333 	var tags []TagPair
    334 
    335 	for _, pair := range tagPairs {
    336 		pair = strings.TrimSpace(pair)
    337 		if pair == "" {
    338 			continue
    339 		}
    340 
    341 		parts := strings.SplitN(pair, ":", 2)
    342 		if len(parts) != 2 {
    343 			return nil, fmt.Errorf("invalid tag format '%s', expected 'category:value'", pair)
    344 		}
    345 
    346 		tags = append(tags, TagPair{
    347 			Category: strings.TrimSpace(parts[0]),
    348 			Value:    strings.TrimSpace(parts[1]),
    349 		})
    350 	}
    351 
    352 	if len(tags) == 0 {
    353 		return nil, fmt.Errorf("no valid tags found in query")
    354 	}
    355 
    356 	// Query database for files matching ALL tags
    357 	return findFilesWithAllTags(tags)
    358 }
    359 
    360 // getFileIDsFromORQuery handles OR-separated tags
    361 func getFileIDsFromORQuery(query string) ([]int, error) {
    362 	// Split on " OR " case-insensitively by working on the uppercased copy for
    363 	// index finding, but extracting substrings from the original query.
    364 	upperQuery := strings.ToUpper(query)
    365 	const sep = " OR "
    366 	var rawPairs []string
    367 	start := 0
    368 	for {
    369 		idx := strings.Index(upperQuery[start:], sep)
    370 		if idx < 0 {
    371 			rawPairs = append(rawPairs, query[start:])
    372 			break
    373 		}
    374 		rawPairs = append(rawPairs, query[start:start+idx])
    375 		start += idx + len(sep)
    376 	}
    377 
    378 	var tags []TagPair
    379 	for _, pair := range rawPairs {
    380 		pair = strings.TrimSpace(pair)
    381 		if pair == "" {
    382 			continue
    383 		}
    384 
    385 		parts := strings.SplitN(pair, ":", 2)
    386 		if len(parts) != 2 {
    387 			return nil, fmt.Errorf("invalid tag format '%s', expected 'category:value'", pair)
    388 		}
    389 
    390 		tags = append(tags, TagPair{
    391 			Category: strings.TrimSpace(parts[0]),
    392 			Value:    strings.TrimSpace(parts[1]),
    393 		})
    394 	}
    395 
    396 	if len(tags) == 0 {
    397 		return nil, fmt.Errorf("no valid tags found in query")
    398 	}
    399 
    400 	// Query database for files matching ANY tag
    401 	return findFilesWithAnyTag(tags)
    402 }
    403 
    404 func validateFileIDs(fileIDs []int) ([]File, error) {
    405 	if len(fileIDs) == 0 {
    406 		return nil, fmt.Errorf("no file IDs provided")
    407 	}
    408 
    409 	placeholders := make([]string, len(fileIDs))
    410 	args := make([]interface{}, len(fileIDs))
    411 	for i, id := range fileIDs {
    412 		placeholders[i] = "?"
    413 		args[i] = id
    414 	}
    415 
    416 	query := fmt.Sprintf("SELECT id, filename, path FROM files WHERE id IN (%s) ORDER BY id",
    417 		strings.Join(placeholders, ","))
    418 
    419 	rows, err := db.Query(query, args...)
    420 	if err != nil {
    421 		return nil, fmt.Errorf("database error: %v", err)
    422 	}
    423 	defer rows.Close()
    424 
    425 	var files []File
    426 	foundIDs := make(map[int]bool)
    427 
    428 	for rows.Next() {
    429 		var f File
    430 		err := rows.Scan(&f.ID, &f.Filename, &f.Path)
    431 		if err != nil {
    432 			return nil, fmt.Errorf("error scanning file: %v", err)
    433 		}
    434 		files = append(files, f)
    435 		foundIDs[f.ID] = true
    436 	}
    437 
    438 	var missingIDs []int
    439 	for _, id := range fileIDs {
    440 		if !foundIDs[id] {
    441 			missingIDs = append(missingIDs, id)
    442 		}
    443 	}
    444 
    445 	if len(missingIDs) > 0 {
    446 		return files, fmt.Errorf("file IDs not found: %v", missingIDs)
    447 	}
    448 
    449 	return files, nil
    450 }
    451 
    452 func findFilesWithAnyTag(tags []TagPair) ([]int, error) {
    453 	if len(tags) == 0 {
    454 		return nil, fmt.Errorf("no tags specified")
    455 	}
    456 
    457 	// Build query with OR conditions
    458 	query := `
    459 		SELECT DISTINCT f.id
    460 		FROM files f
    461 		INNER JOIN file_tags ft ON f.id = ft.file_id
    462 		INNER JOIN tags t ON ft.tag_id = t.id
    463 		INNER JOIN categories c ON t.category_id = c.id
    464 		WHERE `
    465 
    466 	var conditions []string
    467 	var args []interface{}
    468 
    469 	for _, tag := range tags {
    470 		conditions = append(conditions, "(c.name = ? AND t.value = ?)")
    471 		args = append(args, tag.Category, tag.Value)
    472 	}
    473 
    474 	query += strings.Join(conditions, " OR ")
    475 	query += " ORDER BY f.id"
    476 
    477 	rows, err := db.Query(query, args...)
    478 	if err != nil {
    479 		return nil, fmt.Errorf("database query failed: %w", err)
    480 	}
    481 	defer rows.Close()
    482 
    483 	var fileIDs []int
    484 	for rows.Next() {
    485 		var id int
    486 		if err := rows.Scan(&id); err != nil {
    487 			return nil, fmt.Errorf("scan error: %w", err)
    488 		}
    489 		fileIDs = append(fileIDs, id)
    490 	}
    491 
    492 	return fileIDs, rows.Err()
    493 }
    494 
    495 
    496 func findFilesWithAllTags(tags []TagPair) ([]int, error) {
    497 	if len(tags) == 0 {
    498 		return nil, fmt.Errorf("no tags specified")
    499 	}
    500 
    501 	// Build query with subqueries for each tag
    502 	query := `
    503 		SELECT f.id
    504 		FROM files f
    505 		WHERE `
    506 
    507 	var conditions []string
    508 	var args []interface{}
    509 
    510 	for _, tag := range tags {
    511 		conditions = append(conditions, `
    512 			EXISTS (
    513 				SELECT 1 FROM file_tags ft
    514 				JOIN tags t ON ft.tag_id = t.id
    515 				JOIN categories c ON t.category_id = c.id
    516 				WHERE ft.file_id = f.id
    517 				AND c.name = ?
    518 				AND t.value = ?
    519 			)`)
    520 		args = append(args, tag.Category, tag.Value)
    521 	}
    522 
    523 	query += strings.Join(conditions, " AND ")
    524 	query += " ORDER BY f.id"
    525 
    526 	rows, err := db.Query(query, args...)
    527 	if err != nil {
    528 		return nil, fmt.Errorf("database query failed: %w", err)
    529 	}
    530 	defer rows.Close()
    531 
    532 	var fileIDs []int
    533 	for rows.Next() {
    534 		var id int
    535 		if err := rows.Scan(&id); err != nil {
    536 			return nil, fmt.Errorf("scan error: %w", err)
    537 		}
    538 		fileIDs = append(fileIDs, id)
    539 	}
    540 
    541 	return fileIDs, rows.Err()
    542 }