tagliatelle

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

include-bulk.go (13408B)


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