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 }