commit c61ff5c62e57490fb52324da45cebb1926307458
parent 1f7304c63d8e50776c8e3bc937e5822cc6c0d46e
Author: breadcat <breadcat@users.noreply.github.com>
Date: Wed, 10 Dec 2025 16:39:58 +0000
Support tags as inputs for bulk operations
Diffstat:
| M | main.go | | | 253 | +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---- |
| M | static/bulk-tag.js | | | 90 | +++++++++++++++++++++++++++++++++++++++++++++++++++++++++---------------------- |
| M | templates/bulk-tag.html | | | 47 | +++++++++++++++++++++++++++++++++-------------- |
3 files changed, 339 insertions(+), 51 deletions(-)
diff --git a/main.go b/main.go
@@ -1700,6 +1700,8 @@ type BulkTagFormData struct {
Category string
Value string
Operation string
+ TagQuery string
+ SelectionMode string
}
}
@@ -1730,6 +1732,8 @@ func getBulkTagFormData() BulkTagFormData {
Category string
Value string
Operation string
+ TagQuery string
+ SelectionMode string
}{Operation: "add"},
}
}
@@ -1741,15 +1745,18 @@ func bulkTagHandler(w http.ResponseWriter, r *http.Request) {
renderTemplate(w, "bulk-tag.html", pageData)
return
}
-
if r.Method == http.MethodPost {
rangeStr := strings.TrimSpace(r.FormValue("file_range"))
+ tagQuery := strings.TrimSpace(r.FormValue("tag_query"))
+ selectionMode := r.FormValue("selection_mode")
category := strings.TrimSpace(r.FormValue("category"))
value := strings.TrimSpace(r.FormValue("value"))
operation := r.FormValue("operation")
formData := getBulkTagFormData()
formData.FormData.FileRange = rangeStr
+ formData.FormData.TagQuery = tagQuery
+ formData.FormData.SelectionMode = selectionMode
formData.FormData.Category = category
formData.FormData.Value = value
formData.FormData.Operation = operation
@@ -1760,24 +1767,51 @@ func bulkTagHandler(w http.ResponseWriter, r *http.Request) {
renderTemplate(w, "bulk-tag.html", pageData)
}
- if rangeStr == "" {
+ // Validate selection mode
+ if selectionMode == "" {
+ selectionMode = "range" // default
+ }
+
+ // Validate inputs based on selection mode
+ if selectionMode == "range" && rangeStr == "" {
createErrorResponse("File range cannot be empty")
return
}
-
+ if selectionMode == "tags" && tagQuery == "" {
+ createErrorResponse("Tag query cannot be empty")
+ return
+ }
if category == "" {
createErrorResponse("Category cannot be empty")
return
}
-
if operation == "add" && value == "" {
createErrorResponse("Value cannot be empty when adding tags")
return
}
- fileIDs, err := parseFileIDRange(rangeStr)
- if err != nil {
- createErrorResponse(fmt.Sprintf("Invalid file range: %v", err))
+ // Get file IDs based on selection mode
+ var fileIDs []int
+ var err error
+
+ if selectionMode == "range" {
+ fileIDs, err = parseFileIDRange(rangeStr)
+ if err != nil {
+ createErrorResponse(fmt.Sprintf("Invalid file range: %v", err))
+ return
+ }
+ } else if selectionMode == "tags" {
+ fileIDs, err = getFileIDsFromTagQuery(tagQuery)
+ if err != nil {
+ createErrorResponse(fmt.Sprintf("Tag query error: %v", err))
+ return
+ }
+ if len(fileIDs) == 0 {
+ createErrorResponse("No files match the tag query")
+ return
+ }
+ } else {
+ createErrorResponse("Invalid selection mode")
return
}
@@ -1793,22 +1827,33 @@ func bulkTagHandler(w http.ResponseWriter, r *http.Request) {
return
}
+ // Build success message
var successMsg string
+ var selectionDesc string
+ if selectionMode == "range" {
+ selectionDesc = fmt.Sprintf("file range '%s'", rangeStr)
+ } else {
+ selectionDesc = fmt.Sprintf("tag query '%s'", tagQuery)
+ }
+
if operation == "add" {
- successMsg = fmt.Sprintf("Tag '%s: %s' added to %d files", category, value, len(validFiles))
+ successMsg = fmt.Sprintf("Tag '%s: %s' added to %d files matching %s",
+ category, value, len(validFiles), selectionDesc)
} else {
if value != "" {
- successMsg = fmt.Sprintf("Tag '%s: %s' removed from %d files", category, value, len(validFiles))
+ successMsg = fmt.Sprintf("Tag '%s: %s' removed from %d files matching %s",
+ category, value, len(validFiles), selectionDesc)
} else {
- successMsg = fmt.Sprintf("All '%s' category tags removed from %d files", category, len(validFiles))
+ successMsg = fmt.Sprintf("All '%s' category tags removed from %d files matching %s",
+ category, len(validFiles), selectionDesc)
}
}
+ // Add file list
var filenames []string
for _, f := range validFiles {
filenames = append(filenames, f.Filename)
}
-
if len(filenames) <= 5 {
successMsg += fmt.Sprintf(": %s", strings.Join(filenames, ", "))
} else {
@@ -1820,10 +1865,194 @@ func bulkTagHandler(w http.ResponseWriter, r *http.Request) {
renderTemplate(w, "bulk-tag.html", pageData)
return
}
-
renderError(w, "Method not allowed", http.StatusMethodNotAllowed)
}
+// getFileIDsFromTagQuery parses a tag query and returns matching file IDs
+// Supports queries like:
+// - "colour:blue" (single tag)
+// - "colour:blue,size:large" (multiple tags - AND logic)
+// - "colour:blue OR colour:red" (OR logic)
+func getFileIDsFromTagQuery(query string) ([]int, error) {
+ query = strings.TrimSpace(query)
+ if query == "" {
+ return nil, fmt.Errorf("empty query")
+ }
+
+ // Check if query contains OR operator
+ if strings.Contains(strings.ToUpper(query), " OR ") {
+ return getFileIDsFromORQuery(query)
+ }
+
+ // Otherwise treat as AND query (comma-separated or single tag)
+ return getFileIDsFromANDQuery(query)
+}
+
+// getFileIDsFromANDQuery handles comma-separated tags (AND logic)
+func getFileIDsFromANDQuery(query string) ([]int, error) {
+ tagPairs := strings.Split(query, ",")
+ var tags []TagPair
+
+ for _, pair := range tagPairs {
+ pair = strings.TrimSpace(pair)
+ if pair == "" {
+ continue
+ }
+
+ parts := strings.SplitN(pair, ":", 2)
+ if len(parts) != 2 {
+ return nil, fmt.Errorf("invalid tag format '%s', expected 'category:value'", pair)
+ }
+
+ tags = append(tags, TagPair{
+ Category: strings.TrimSpace(parts[0]),
+ Value: strings.TrimSpace(parts[1]),
+ })
+ }
+
+ if len(tags) == 0 {
+ return nil, fmt.Errorf("no valid tags found in query")
+ }
+
+ // Query database for files matching ALL tags
+ return findFilesWithAllTags(tags)
+}
+
+// getFileIDsFromORQuery handles OR-separated tags
+func getFileIDsFromORQuery(query string) ([]int, error) {
+ tagPairs := strings.Split(strings.ToUpper(query), " OR ")
+ var tags []TagPair
+
+ for _, pair := range tagPairs {
+ pair = strings.TrimSpace(pair)
+ if pair == "" {
+ continue
+ }
+
+ parts := strings.SplitN(pair, ":", 2)
+ if len(parts) != 2 {
+ return nil, fmt.Errorf("invalid tag format '%s', expected 'category:value'", pair)
+ }
+
+ tags = append(tags, TagPair{
+ Category: strings.TrimSpace(parts[0]),
+ Value: strings.TrimSpace(parts[1]),
+ })
+ }
+
+ if len(tags) == 0 {
+ return nil, fmt.Errorf("no valid tags found in query")
+ }
+
+ // Query database for files matching ANY tag
+ return findFilesWithAnyTag(tags)
+}
+
+// TagPair represents a category-value pair
+type TagPair struct {
+ Category string
+ Value string
+}
+
+// findFilesWithAllTags returns file IDs that have ALL the specified tags
+func findFilesWithAllTags(tags []TagPair) ([]int, error) {
+ if len(tags) == 0 {
+ return nil, fmt.Errorf("no tags specified")
+ }
+
+ // Build query with subqueries for each tag
+ query := `
+ SELECT f.id
+ FROM files f
+ WHERE `
+
+ var conditions []string
+ var args []interface{}
+ argIndex := 1
+
+ for _, tag := range tags {
+ conditions = append(conditions, fmt.Sprintf(`
+ EXISTS (
+ SELECT 1 FROM file_tags ft
+ JOIN tags t ON ft.tag_id = t.id
+ JOIN categories c ON t.category_id = c.id
+ WHERE ft.file_id = f.id
+ AND c.name = $%d
+ AND t.value = $%d
+ )`, argIndex, argIndex+1))
+ args = append(args, tag.Category, tag.Value)
+ argIndex += 2
+ }
+
+ query += strings.Join(conditions, " AND ")
+ query += " ORDER BY f.id"
+
+ rows, err := db.Query(query, args...)
+ if err != nil {
+ return nil, fmt.Errorf("database query failed: %w", err)
+ }
+ defer rows.Close()
+
+ var fileIDs []int
+ for rows.Next() {
+ var id int
+ if err := rows.Scan(&id); err != nil {
+ return nil, fmt.Errorf("scan error: %w", err)
+ }
+ fileIDs = append(fileIDs, id)
+ }
+
+ return fileIDs, rows.Err()
+}
+
+// findFilesWithAnyTag returns file IDs that have ANY of the specified tags
+func findFilesWithAnyTag(tags []TagPair) ([]int, error) {
+ if len(tags) == 0 {
+ return nil, fmt.Errorf("no tags specified")
+ }
+
+ // Build query with OR conditions
+ query := `
+ SELECT DISTINCT f.id
+ FROM files f
+ INNER JOIN file_tags ft ON f.id = ft.file_id
+ INNER JOIN tags t ON ft.tag_id = t.id
+ INNER JOIN categories c ON t.category_id = c.id
+ WHERE `
+
+ var conditions []string
+ var args []interface{}
+ argIndex := 1
+
+ for _, tag := range tags {
+ conditions = append(conditions, fmt.Sprintf(
+ "(c.name = $%d AND t.value = $%d)",
+ argIndex, argIndex+1))
+ args = append(args, tag.Category, tag.Value)
+ argIndex += 2
+ }
+
+ query += strings.Join(conditions, " OR ")
+ query += " ORDER BY f.id"
+
+ rows, err := db.Query(query, args...)
+ if err != nil {
+ return nil, fmt.Errorf("database query failed: %w", err)
+ }
+ defer rows.Close()
+
+ var fileIDs []int
+ for rows.Next() {
+ var id int
+ if err := rows.Scan(&id); err != nil {
+ return nil, fmt.Errorf("scan error: %w", err)
+ }
+ fileIDs = append(fileIDs, id)
+ }
+
+ return fileIDs, rows.Err()
+}
+
func sanitizeFilename(filename string) string {
if filename == "" {
return "file"
diff --git a/static/bulk-tag.js b/static/bulk-tag.js
@@ -1,7 +1,6 @@
document.addEventListener('DOMContentLoaded', function () {
const fileRangeInput = document.getElementById('file_range');
if (!fileRangeInput) return;
-
const fileForm = fileRangeInput.closest('form');
if (!fileForm) return;
@@ -9,9 +8,7 @@ document.addEventListener('DOMContentLoaded', function () {
const checkedOp = fileForm.querySelector('input[name="operation"]:checked');
const valueField = fileForm.querySelector('#value');
const valueLabel = fileForm.querySelector('label[for="value"]');
-
if (!checkedOp || !valueField || !valueLabel) return;
-
if (checkedOp.value === 'add') {
valueField.required = true;
valueLabel.innerHTML = 'Value <span class="required">(required)</span>:';
@@ -19,47 +16,90 @@ document.addEventListener('DOMContentLoaded', function () {
valueField.required = false;
valueLabel.innerHTML = 'Value:';
}
-}
+ }
+
+ function toggleSelectionMode() {
+ const rangeMode = fileForm.querySelector('input[name="selection_mode"][value="range"]');
+ if (!rangeMode) return;
+
+ const isRangeMode = rangeMode.checked;
+ const rangeSelection = document.getElementById('range-selection');
+ const tagSelection = document.getElementById('tag-selection');
+ const fileRangeField = document.getElementById('file_range');
+ const tagQueryField = document.getElementById('tag_query');
+
+ if (rangeSelection) rangeSelection.style.display = isRangeMode ? 'block' : 'none';
+ if (tagSelection) tagSelection.style.display = isRangeMode ? 'none' : 'block';
+
+ // Update required attributes
+ if (fileRangeField) fileRangeField.required = isRangeMode;
+ if (tagQueryField) tagQueryField.required = !isRangeMode;
+ }
-// Set up event listeners for radio buttons inside this form
-fileForm.querySelectorAll('input[name="operation"]').forEach(function (radio) {
+ // Set up event listeners for operation radio buttons
+ fileForm.querySelectorAll('input[name="operation"]').forEach(function (radio) {
radio.addEventListener('change', updateValueField);
-});
+ });
-// Initialize on page load
-updateValueField();
+ // Set up event listeners for selection mode radio buttons
+ fileForm.querySelectorAll('input[name="selection_mode"]').forEach(function (radio) {
+ radio.addEventListener('change', toggleSelectionMode);
+ });
- // Add form validation ONLY to the fileForm (won't affect the search form)
+ // Initialize on page load
+ updateValueField();
+ toggleSelectionMode();
+
+ // Add form validation with selection mode awareness
fileForm.addEventListener('submit', function (e) {
+ const selectionModeRadio = fileForm.querySelector('input[name="selection_mode"]:checked');
+ const selectionMode = selectionModeRadio ? selectionModeRadio.value : 'range';
+
const fileRange = (fileForm.querySelector('#file_range') || { value: '' }).value.trim();
+ const tagQuery = (fileForm.querySelector('#tag_query') || { value: '' }).value.trim();
const category = (fileForm.querySelector('#category') || { value: '' }).value.trim();
const value = (fileForm.querySelector('#value') || { value: '' }).value.trim();
const checkedOp = fileForm.querySelector('input[name="operation"]:checked');
const operation = checkedOp ? checkedOp.value : '';
- if (!fileRange) {
+ // Validate based on selection mode
+ if (selectionMode === 'range') {
+ if (!fileRange) {
alert('Please enter a file ID range');
e.preventDefault();
return;
- }
-
- if (!category) {
- alert('Please enter a category');
+ }
+ const rangePattern = /^[\d\s,-]+$/;
+ if (!rangePattern.test(fileRange)) {
+ alert('File range should only contain numbers, commas, dashes, and spaces');
e.preventDefault();
return;
- }
-
- if (operation === 'add' && !value) {
- alert('Please enter a tag value when adding tags');
+ }
+ } else if (selectionMode === 'tags') {
+ if (!tagQuery) {
+ alert('Please enter a tag query');
e.preventDefault();
return;
- }
-
- const rangePattern = /^[\d\s,-]+$/;
- if (!rangePattern.test(fileRange)) {
- alert('File range should only contain numbers, commas, dashes, and spaces');
+ }
+ // Basic validation for tag query format
+ const tagPattern = /^[^:]+:[^:]+(\s+OR\s+[^:]+:[^:]+|,[^:]+:[^:]+)*$/i;
+ if (!tagPattern.test(tagQuery)) {
+ alert('Tag query format should be "category:value" (e.g., "colour:blue" or "colour:blue,size:large")');
e.preventDefault();
return;
+ }
+ }
+
+ if (!category) {
+ alert('Please enter a category');
+ e.preventDefault();
+ return;
+ }
+
+ if (operation === 'add' && !value) {
+ alert('Please enter a tag value when adding tags');
+ e.preventDefault();
+ return;
}
-});
+ });
});
\ No newline at end of file
diff --git a/templates/bulk-tag.html b/templates/bulk-tag.html
@@ -1,37 +1,60 @@
{{template "_header" .}}
-
-
<h1>{{.Title}}</h1>
-
{{if .Data.Error}}
<div class="alert alert-danger">
<strong>Error:</strong> {{.Data.Error}}
</div>
{{end}}
-
{{if .Data.Success}}
<div class="alert alert-success">
<strong>Success:</strong> {{.Data.Success}}
</div>
{{end}}
-
<form method="POST">
<div class="form-section">
<h3>Select Files</h3>
-
<div class="form-group">
+ <label>Selection Method:</label>
+ <div class="radio-group">
+ <label>
+ <input type="radio" name="selection_mode" value="range"
+ {{if or (eq .Data.FormData.SelectionMode "range") (eq .Data.FormData.SelectionMode "")}}checked{{end}}
+ onchange="toggleSelectionMode()">
+ By File ID Range
+ </label><br>
+ <label>
+ <input type="radio" name="selection_mode" value="tags"
+ {{if eq .Data.FormData.SelectionMode "tags"}}checked{{end}}
+ onchange="toggleSelectionMode()">
+ By Tag Query
+ </label>
+ </div>
+ </div>
+
+ <div id="range-selection" class="form-group">
<label for="file_range">File ID Range:</label>
<input type="text" id="file_range" name="file_range"
- placeholder="e.g., 1-5,8,10-12" value="{{.Data.FormData.FileRange}}" required>
+ placeholder="e.g., 1-5,8,10-12" value="{{.Data.FormData.FileRange}}">
<div class="help-text">
Specify file IDs to tag. Use ranges (1-5) and individual IDs (8) separated by commas.
</div>
</div>
+
+ <div id="tag-selection" class="form-group" style="display: none;">
+ <label for="tag_query">Tag Query:</label>
+ <input type="text" id="tag_query" name="tag_query"
+ placeholder="e.g., colour:blue or colour:blue,size:large" value="{{.Data.FormData.TagQuery}}">
+ <div class="help-text">
+ <strong>Examples:</strong><br>
+ • <code>colour:blue</code> - Files with this exact tag<br>
+ • <code>colour:blue,size:large</code> - Files with BOTH tags (AND)<br>
+ • <code>colour:blue OR colour:red</code> - Files with EITHER tag (OR)
+ </div>
+ </div>
</div>
<div class="form-section">
<h3>Tag Operation</h3>
-
<div class="form-group">
<label for="category">Category:</label>
<input type="text" id="category" name="category" list="categories" value="{{.Data.FormData.Category}}" required>
@@ -42,7 +65,6 @@
</datalist>
<div class="help-text">Choose an existing category or create a new one.</div>
</div>
-
<div class="form-group">
<label for="value">Value:</label>
<input type="text" id="value" name="value" value="{{.Data.FormData.Value}}">
@@ -50,7 +72,6 @@
The tag value to add or remove. <strong>Leave empty when removing to delete all values in the category.</strong>
</div>
</div>
-
<div class="form-group">
<label>Operation:</label>
<div class="radio-group">
@@ -68,10 +89,8 @@
</div>
</div>
</div>
-
<button type="submit" class="text-button">Apply Tags</button>
</form>
-
<br>
<details><summary>Recent Files (for reference)</summary>
{{range .Data.RecentFiles}}
@@ -82,6 +101,5 @@
<div class="file-item">No files found</div>
{{end}}
</details>
-
<script src="/static/bulk-tag.js" defer></script>
-{{template "_footer"}}
+{{template "_footer"}}
+\ No newline at end of file