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