tagliatelle

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

include-bulk.go (13745B)


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