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