taggart

Simple golang tagging filesystem webapp
Log | Files | Refs

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:
Mmain.go | 253+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----
Mstatic/bulk-tag.js | 90+++++++++++++++++++++++++++++++++++++++++++++++++++++++++----------------------
Mtemplates/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