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