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