tagliatelle

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

include-bulk.go (13333B)


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