tagliatelle

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

bulk.go (13759B)


      1 package app
      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 func parseFileIDRange(rangeStr string) ([]int, error) {
    267 	var fileIDs []int
    268 	parts := strings.Split(rangeStr, ",")
    269 
    270 	for _, part := range parts {
    271 		part = strings.TrimSpace(part)
    272 		if part == "" {
    273 			continue
    274 		}
    275 
    276 		if strings.Contains(part, "-") {
    277 			rangeParts := strings.Split(part, "-")
    278 			if len(rangeParts) != 2 {
    279 				return nil, fmt.Errorf("invalid range format: %s", part)
    280 			}
    281 
    282 			start, err := strconv.Atoi(strings.TrimSpace(rangeParts[0]))
    283 			if err != nil {
    284 				return nil, fmt.Errorf("invalid start ID in range %s: %v", part, err)
    285 			}
    286 
    287 			end, err := strconv.Atoi(strings.TrimSpace(rangeParts[1]))
    288 			if err != nil {
    289 				return nil, fmt.Errorf("invalid end ID in range %s: %v", part, err)
    290 			}
    291 
    292 			if start > end {
    293 				return nil, fmt.Errorf("invalid range %s: start must be <= end", part)
    294 			}
    295 
    296 			for i := start; i <= end; i++ {
    297 				fileIDs = append(fileIDs, i)
    298 			}
    299 		} else {
    300 			id, err := strconv.Atoi(part)
    301 			if err != nil {
    302 				return nil, fmt.Errorf("invalid file ID: %s", part)
    303 			}
    304 			fileIDs = append(fileIDs, id)
    305 		}
    306 	}
    307 
    308 	uniqueIDs := make(map[int]bool)
    309 	var result []int
    310 	for _, id := range fileIDs {
    311 		if !uniqueIDs[id] {
    312 			uniqueIDs[id] = true
    313 			result = append(result, id)
    314 		}
    315 	}
    316 
    317 	return result, nil
    318 }
    319 
    320 func getFileIDsFromTagQuery(query string) ([]int, error) {
    321 	query = strings.TrimSpace(query)
    322 	if query == "" {
    323 		return nil, fmt.Errorf("empty query")
    324 	}
    325 
    326 	// Check if query contains OR operator
    327 	if strings.Contains(strings.ToUpper(query), " OR ") {
    328 		return getFileIDsFromORQuery(query)
    329 	}
    330 
    331 	// Otherwise treat as AND query (comma-separated or single tag)
    332 	return getFileIDsFromANDQuery(query)
    333 }
    334 
    335 // getFileIDsFromANDQuery handles comma-separated tags (AND logic)
    336 func getFileIDsFromANDQuery(query string) ([]int, error) {
    337 	tagPairs := strings.Split(query, ",")
    338 	var tags []TagPair
    339 
    340 	for _, pair := range tagPairs {
    341 		pair = strings.TrimSpace(pair)
    342 		if pair == "" {
    343 			continue
    344 		}
    345 
    346 		parts := strings.SplitN(pair, ":", 2)
    347 		if len(parts) != 2 {
    348 			return nil, fmt.Errorf("invalid tag format '%s', expected 'category:value'", pair)
    349 		}
    350 
    351 		tags = append(tags, TagPair{
    352 			Category: strings.TrimSpace(parts[0]),
    353 			Value:    strings.TrimSpace(parts[1]),
    354 		})
    355 	}
    356 
    357 	if len(tags) == 0 {
    358 		return nil, fmt.Errorf("no valid tags found in query")
    359 	}
    360 
    361 	// Query database for files matching ALL tags
    362 	return findFilesWithAllTags(tags)
    363 }
    364 
    365 // getFileIDsFromORQuery handles OR-separated tags
    366 func getFileIDsFromORQuery(query string) ([]int, error) {
    367 	// Split on " OR " case-insensitively by working on the uppercased copy for
    368 	// index finding, but extracting substrings from the original query.
    369 	upperQuery := strings.ToUpper(query)
    370 	const sep = " OR "
    371 	var rawPairs []string
    372 	start := 0
    373 	for {
    374 		idx := strings.Index(upperQuery[start:], sep)
    375 		if idx < 0 {
    376 			rawPairs = append(rawPairs, query[start:])
    377 			break
    378 		}
    379 		rawPairs = append(rawPairs, query[start:start+idx])
    380 		start += idx + len(sep)
    381 	}
    382 
    383 	var tags []TagPair
    384 	for _, pair := range rawPairs {
    385 		pair = strings.TrimSpace(pair)
    386 		if pair == "" {
    387 			continue
    388 		}
    389 
    390 		parts := strings.SplitN(pair, ":", 2)
    391 		if len(parts) != 2 {
    392 			return nil, fmt.Errorf("invalid tag format '%s', expected 'category:value'", pair)
    393 		}
    394 
    395 		tags = append(tags, TagPair{
    396 			Category: strings.TrimSpace(parts[0]),
    397 			Value:    strings.TrimSpace(parts[1]),
    398 		})
    399 	}
    400 
    401 	if len(tags) == 0 {
    402 		return nil, fmt.Errorf("no valid tags found in query")
    403 	}
    404 
    405 	// Query database for files matching ANY tag
    406 	return findFilesWithAnyTag(tags)
    407 }
    408 
    409 func validateFileIDs(fileIDs []int) ([]File, error) {
    410 	if len(fileIDs) == 0 {
    411 		return nil, fmt.Errorf("no file IDs provided")
    412 	}
    413 
    414 	placeholders := make([]string, len(fileIDs))
    415 	args := make([]interface{}, len(fileIDs))
    416 	for i, id := range fileIDs {
    417 		placeholders[i] = "?"
    418 		args[i] = id
    419 	}
    420 
    421 	query := fmt.Sprintf("SELECT id, filename, path FROM files WHERE id IN (%s) ORDER BY id",
    422 		strings.Join(placeholders, ","))
    423 
    424 	rows, err := DB.Query(query, args...)
    425 	if err != nil {
    426 		return nil, fmt.Errorf("database error: %v", err)
    427 	}
    428 	defer rows.Close()
    429 
    430 	var files []File
    431 	foundIDs := make(map[int]bool)
    432 
    433 	for rows.Next() {
    434 		var f File
    435 		err := rows.Scan(&f.ID, &f.Filename, &f.Path)
    436 		if err != nil {
    437 			return nil, fmt.Errorf("error scanning file: %v", err)
    438 		}
    439 		files = append(files, f)
    440 		foundIDs[f.ID] = true
    441 	}
    442 
    443 	var missingIDs []int
    444 	for _, id := range fileIDs {
    445 		if !foundIDs[id] {
    446 			missingIDs = append(missingIDs, id)
    447 		}
    448 	}
    449 
    450 	if len(missingIDs) > 0 {
    451 		return files, fmt.Errorf("file IDs not found: %v", missingIDs)
    452 	}
    453 
    454 	return files, nil
    455 }
    456 
    457 func findFilesWithAnyTag(tags []TagPair) ([]int, error) {
    458 	if len(tags) == 0 {
    459 		return nil, fmt.Errorf("no tags specified")
    460 	}
    461 
    462 	// Build query with OR conditions
    463 	query := `
    464 		SELECT DISTINCT f.id
    465 		FROM files f
    466 		INNER JOIN file_tags ft ON f.id = ft.file_id
    467 		INNER JOIN tags t ON ft.tag_id = t.id
    468 		INNER JOIN categories c ON t.category_id = c.id
    469 		WHERE `
    470 
    471 	var conditions []string
    472 	var args []interface{}
    473 
    474 	for _, tag := range tags {
    475 		conditions = append(conditions, "(c.name = ? AND t.value = ?)")
    476 		args = append(args, tag.Category, tag.Value)
    477 	}
    478 
    479 	query += strings.Join(conditions, " OR ")
    480 	query += " ORDER BY f.id"
    481 
    482 	rows, err := DB.Query(query, args...)
    483 	if err != nil {
    484 		return nil, fmt.Errorf("database query failed: %w", err)
    485 	}
    486 	defer rows.Close()
    487 
    488 	var fileIDs []int
    489 	for rows.Next() {
    490 		var id int
    491 		if err := rows.Scan(&id); err != nil {
    492 			return nil, fmt.Errorf("scan error: %w", err)
    493 		}
    494 		fileIDs = append(fileIDs, id)
    495 	}
    496 
    497 	return fileIDs, rows.Err()
    498 }
    499 
    500 func findFilesWithAllTags(tags []TagPair) ([]int, error) {
    501 	if len(tags) == 0 {
    502 		return nil, fmt.Errorf("no tags specified")
    503 	}
    504 
    505 	// Build query with subqueries for each tag
    506 	query := `
    507 		SELECT f.id
    508 		FROM files f
    509 		WHERE `
    510 
    511 	var conditions []string
    512 	var args []interface{}
    513 
    514 	for _, tag := range tags {
    515 		conditions = append(conditions, `
    516 			EXISTS (
    517 				SELECT 1 FROM file_tags ft
    518 				JOIN tags t ON ft.tag_id = t.id
    519 				JOIN categories c ON t.category_id = c.id
    520 				WHERE ft.file_id = f.id
    521 				AND c.name = ?
    522 				AND t.value = ?
    523 			)`)
    524 		args = append(args, tag.Category, tag.Value)
    525 	}
    526 
    527 	query += strings.Join(conditions, " AND ")
    528 	query += " ORDER BY f.id"
    529 
    530 	rows, err := DB.Query(query, args...)
    531 	if err != nil {
    532 		return nil, fmt.Errorf("database query failed: %w", err)
    533 	}
    534 	defer rows.Close()
    535 
    536 	var fileIDs []int
    537 	for rows.Next() {
    538 		var id int
    539 		if err := rows.Scan(&id); err != nil {
    540 			return nil, fmt.Errorf("scan error: %w", err)
    541 		}
    542 		fileIDs = append(fileIDs, id)
    543 	}
    544 
    545 	return fileIDs, rows.Err()
    546 }