tagliatelle

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

include-bulk.go (13269B)


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