taggart

Simple golang tagging filesystem webapp
Log | Files | Refs

commit 899f76e4746abd65eddee26e1c31fc55c07d730b
parent f4ebe92672dc0beccb45daf3d3f9a5bd2387b82e
Author: breadcat <breadcat@users.noreply.github.com>
Date:   Tue, 11 Nov 2025 22:32:23 +0000

Add tag alias function

Diffstat:
Mmain.go | 140++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---------
Mtemplates/settings.html | 162++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---
2 files changed, 280 insertions(+), 22 deletions(-)

diff --git a/main.go b/main.go @@ -43,6 +43,12 @@ type Config struct { InstanceName string `json:"instance_name"` GallerySize string `json:"gallery_size"` ItemsPerPage string `json:"items_per_page"` + TagAliases []TagAliasGroup `json:"tag_aliases"` +} + +type TagAliasGroup struct { + Category string `json:"category"` + Aliases []string `json:"aliases"` } type TagDisplay struct { @@ -71,6 +77,37 @@ type Pagination struct { PerPage int } +func expandTagWithAliases(category, value string) []string { + values := []string{value} + + for _, group := range config.TagAliases { + if group.Category != category { + continue + } + + // Check if the value is in this alias group + found := false + for _, alias := range group.Aliases { + if strings.EqualFold(alias, value) { + found = true + break + } + } + + if found { + // Add all aliases from this group + for _, alias := range group.Aliases { + if !strings.EqualFold(alias, value) { + values = append(values, alias) + } + } + break + } + } + + return values +} + func getOrCreateCategoryAndTag(category, value string) (int, int, error) { category = strings.TrimSpace(category) value = strings.TrimSpace(value) @@ -877,7 +914,6 @@ func tagsHandler(w http.ResponseWriter, r *http.Request) { } func tagFilterHandler(w http.ResponseWriter, r *http.Request) { - // Get page number from query params pageStr := r.URL.Query().Get("page") page := 1 if pageStr != "" { @@ -886,7 +922,6 @@ func tagFilterHandler(w http.ResponseWriter, r *http.Request) { } } - // Get per page from config perPage := 50 if config.ItemsPerPage != "" { if pp, err := strconv.Atoi(config.ItemsPerPage); err == nil && pp > 0 { @@ -894,13 +929,13 @@ func tagFilterHandler(w http.ResponseWriter, r *http.Request) { } } - // Split by /and/tag/ to get individual tag pairs fullPath := strings.TrimPrefix(r.URL.Path, "/tag/") tagPairs := strings.Split(fullPath, "/and/tag/") type filter struct { Category string Value string + Values []string // Expanded values including aliases } var filters []filter @@ -910,12 +945,24 @@ func tagFilterHandler(w http.ResponseWriter, r *http.Request) { renderError(w, "Invalid tag filter path", http.StatusBadRequest) return } - filters = append(filters, filter{parts[0], parts[1]}) + + f := filter{ + Category: parts[0], + Value: parts[1], + } + + // Expand with aliases + if parts[1] != "unassigned" { + f.Values = expandTagWithAliases(parts[0], parts[1]) + } + + filters = append(filters, f) } - // Build count query first + // Build count query countQuery := `SELECT COUNT(DISTINCT f.id) FROM files f WHERE 1=1` countArgs := []interface{}{} + for _, f := range filters { if f.Value == "unassigned" { countQuery += ` @@ -928,19 +975,28 @@ func tagFilterHandler(w http.ResponseWriter, r *http.Request) { )` countArgs = append(countArgs, f.Category) } else { - countQuery += ` + // Build OR clause for aliases + placeholders := make([]string, len(f.Values)) + for i := range f.Values { + placeholders[i] = "?" + } + + countQuery += fmt.Sprintf(` AND EXISTS ( SELECT 1 FROM file_tags ft JOIN tags t ON ft.tag_id = t.id JOIN categories c ON c.id = t.category_id - WHERE ft.file_id = f.id AND c.name = ? AND t.value = ? - )` - countArgs = append(countArgs, f.Category, f.Value) + WHERE ft.file_id = f.id AND c.name = ? AND t.value IN (%s) + )`, strings.Join(placeholders, ",")) + + countArgs = append(countArgs, f.Category) + for _, v := range f.Values { + countArgs = append(countArgs, v) + } } } - // Get total count var total int err := db.QueryRow(countQuery, countArgs...).Scan(&total) if err != nil { @@ -951,6 +1007,7 @@ func tagFilterHandler(w http.ResponseWriter, r *http.Request) { // Build main query with pagination query := `SELECT f.id, f.filename, f.path, COALESCE(f.description, '') as description FROM files f WHERE 1=1` args := []interface{}{} + for _, f := range filters { if f.Value == "unassigned" { query += ` @@ -963,19 +1020,28 @@ func tagFilterHandler(w http.ResponseWriter, r *http.Request) { )` args = append(args, f.Category) } else { - query += ` + // Build OR clause for aliases + placeholders := make([]string, len(f.Values)) + for i := range f.Values { + placeholders[i] = "?" + } + + query += fmt.Sprintf(` AND EXISTS ( SELECT 1 FROM file_tags ft JOIN tags t ON ft.tag_id = t.id JOIN categories c ON c.id = t.category_id - WHERE ft.file_id = f.id AND c.name = ? AND t.value = ? - )` - args = append(args, f.Category, f.Value) + WHERE ft.file_id = f.id AND c.name = ? AND t.value IN (%s) + )`, strings.Join(placeholders, ",")) + + args = append(args, f.Category) + for _, v := range f.Values { + args = append(args, v) + } } } - // Add pagination offset := (page - 1) * perPage query += ` ORDER BY f.id DESC LIMIT ? OFFSET ?` args = append(args, perPage, offset) @@ -1008,6 +1074,7 @@ func loadConfig() error { InstanceName: "Taggart", GallerySize: "400px", ItemsPerPage: "100", + TagAliases: []TagAliasGroup{}, } if data, err := ioutil.ReadFile("config.json"); err == nil { @@ -1053,7 +1120,7 @@ func settingsHandler(w http.ResponseWriter, r *http.Request) { action := r.FormValue("action") switch action { - case "save", "": // support both explicit and legacy save form + case "save", "": handleSaveSettings(w, r) return @@ -1084,6 +1151,10 @@ func settingsHandler(w http.ResponseWriter, r *http.Request) { }) renderTemplate(w, "settings.html", pageData) return + + case "save_aliases": + handleSaveAliases(w, r) + return } default: @@ -1096,6 +1167,42 @@ func settingsHandler(w http.ResponseWriter, r *http.Request) { } } +func handleSaveAliases(w http.ResponseWriter, r *http.Request) { + aliasesJSON := r.FormValue("aliases_json") + + var aliases []TagAliasGroup + if aliasesJSON != "" { + if err := json.Unmarshal([]byte(aliasesJSON), &aliases); err != nil { + pageData := buildPageData("Settings", struct { + Config Config + Error string + Success string + }{config, "Invalid aliases JSON: " + err.Error(), ""}) + renderTemplate(w, "settings.html", pageData) + return + } + } + + config.TagAliases = aliases + + if err := saveConfig(); err != nil { + pageData := buildPageData("Settings", struct { + Config Config + Error string + Success string + }{config, "Failed to save configuration: " + err.Error(), ""}) + renderTemplate(w, "settings.html", pageData) + return + } + + pageData := buildPageData("Settings", struct { + Config Config + Error string + Success string + }{config, "", "Tag aliases saved successfully!"}) + renderTemplate(w, "settings.html", pageData) +} + func handleSaveSettings(w http.ResponseWriter, r *http.Request) { newConfig := Config{ DatabasePath: strings.TrimSpace(r.FormValue("database_path")), @@ -1104,6 +1211,7 @@ func handleSaveSettings(w http.ResponseWriter, r *http.Request) { InstanceName: strings.TrimSpace(r.FormValue("instance_name")), GallerySize: strings.TrimSpace(r.FormValue("gallery_size")), ItemsPerPage: strings.TrimSpace(r.FormValue("items_per_page")), + TagAliases: config.TagAliases, // Preserve existing aliases } if err := validateConfig(newConfig); err != nil { diff --git a/templates/settings.html b/templates/settings.html @@ -42,7 +42,7 @@ <label for="instance_name" style="display: block; font-weight: bold; margin-bottom: 5px;">Instance Name:</label> <input type="text" id="instance_name" name="instance_name" value="{{.Data.Config.InstanceName}}" required style="width: 100%; padding: 8px; font-size: 14px;" - placeholder=":8080"> + placeholder="Taggart"> <small style="color: #666;">Instance Name, used in header and title bar</small> </div> @@ -50,7 +50,7 @@ <label for="gallery_size" style="display: block; font-weight: bold; margin-bottom: 5px;">Gallery Size:</label> <input type="text" id="gallery_size" name="gallery_size" value="{{.Data.Config.GallerySize}}" required style="width: 100%; padding: 8px; font-size: 14px;" - placeholder=":8080"> + placeholder="400px"> <small style="color: #666;">Size of previews used in galleries</small> </div> @@ -58,15 +58,165 @@ <label for="items_per_page" style="display: block; font-weight: bold; margin-bottom: 5px;">Items per Page:</label> <input type="text" id="items_per_page" name="items_per_page" value="{{.Data.Config.ItemsPerPage}}" required style="width: 100%; padding: 8px; font-size: 14px;" - placeholder=":8080"> + placeholder="100"> <small style="color: #666;">Items per page in galleries</small> </div> - <button type="submit" style="background-color: #007bff; color: white; padding: 10px 20px; border: none; border-radius: 4px; font-size: 16px;"> + <button type="submit" style="background-color: #007bff; color: white; padding: 10px 20px; border: none; border-radius: 4px; font-size: 16px; cursor: pointer;"> Save Settings </button> </form> +<hr style="margin: 40px 0;"> + +<h2>Tag Aliases</h2> +<p style="color: #666; margin-bottom: 20px;"> + Define tag aliases so that multiple tag values are treated as equivalent when searching or filtering. + For example, if you alias "colour/blue" with "colour/navy", searching for either will show files tagged with both. +</p> + +<div id="aliases-section" style="max-width: 800px;"> + <div id="alias-groups"></div> + + <button onclick="addAliasGroup()" style="background-color: #28a745; color: white; padding: 8px 16px; border: none; border-radius: 4px; font-size: 14px; cursor: pointer; margin-top: 10px;"> + + Add Alias Group + </button> + + <form method="post" id="aliases-form" style="margin-top: 20px;"> + <input type="hidden" name="action" value="save_aliases"> + <input type="hidden" name="aliases_json" id="aliases_json"> + <button type="submit" style="background-color: #007bff; color: white; padding: 10px 20px; border: none; border-radius: 4px; font-size: 16px; cursor: pointer;"> + Save Aliases + </button> + </form> +</div> + +<script> +let aliasGroups = {{.Data.Config.TagAliases}}; +if (!aliasGroups) { + aliasGroups = []; +} + +function renderAliasGroups() { + const container = document.getElementById('alias-groups'); + container.innerHTML = ''; + + aliasGroups.forEach((group, groupIndex) => { + const groupDiv = document.createElement('div'); + groupDiv.style.cssText = 'border: 1px solid #ddd; padding: 15px; margin-bottom: 15px; border-radius: 4px; background-color: #f8f9fa;'; + + groupDiv.innerHTML = ` + <div style="margin-bottom: 10px;"> + <label style="display: block; font-weight: bold; margin-bottom: 5px;">Category:</label> + <input type="text" + value="${escapeHtml(group.category)}" + onchange="updateCategory(${groupIndex}, this.value)" + style="width: 200px; padding: 6px; font-size: 14px;" + placeholder="e.g., colour"> + </div> + + <div style="margin-bottom: 10px;"> + <label style="display: block; font-weight: bold; margin-bottom: 5px;">Aliased Values:</label> + <div id="aliases-${groupIndex}"></div> + <button onclick="addAlias(${groupIndex})" type="button" style="background-color: #17a2b8; color: white; padding: 4px 12px; border: none; border-radius: 3px; font-size: 13px; cursor: pointer; margin-top: 5px;"> + + Add Value + </button> + </div> + + <button onclick="removeAliasGroup(${groupIndex})" type="button" style="background-color: #dc3545; color: white; padding: 6px 12px; border: none; border-radius: 3px; font-size: 13px; cursor: pointer;"> + Remove Group + </button> + `; + + container.appendChild(groupDiv); + renderAliases(groupIndex); + }); +} + +function renderAliases(groupIndex) { + const container = document.getElementById(`aliases-${groupIndex}`); + container.innerHTML = ''; + + const group = aliasGroups[groupIndex]; + if (!group.aliases) { + group.aliases = []; + } + + group.aliases.forEach((alias, aliasIndex) => { + const aliasDiv = document.createElement('div'); + aliasDiv.style.cssText = 'display: flex; gap: 10px; margin-bottom: 5px; align-items: center;'; + + aliasDiv.innerHTML = ` + <input type="text" + value="${escapeHtml(alias)}" + onchange="updateAlias(${groupIndex}, ${aliasIndex}, this.value)" + style="flex: 1; padding: 6px; font-size: 14px;" + placeholder="e.g., blue"> + <button onclick="removeAlias(${groupIndex}, ${aliasIndex})" type="button" style="background-color: #dc3545; color: white; padding: 4px 8px; border: none; border-radius: 3px; font-size: 12px; cursor: pointer;"> + Remove + </button> + `; + + container.appendChild(aliasDiv); + }); +} + +function addAliasGroup() { + aliasGroups.push({ + category: '', + aliases: ['', ''] + }); + renderAliasGroups(); +} + +function removeAliasGroup(groupIndex) { + if (confirm('Remove this alias group?')) { + aliasGroups.splice(groupIndex, 1); + renderAliasGroups(); + } +} + +function updateCategory(groupIndex, value) { + aliasGroups[groupIndex].category = value; +} + +function addAlias(groupIndex) { + aliasGroups[groupIndex].aliases.push(''); + renderAliases(groupIndex); +} + +function removeAlias(groupIndex, aliasIndex) { + aliasGroups[groupIndex].aliases.splice(aliasIndex, 1); + renderAliases(groupIndex); +} + +function updateAlias(groupIndex, aliasIndex, value) { + aliasGroups[groupIndex].aliases[aliasIndex] = value; +} + +function escapeHtml(text) { + const div = document.createElement('div'); + div.textContent = text; + return div.innerHTML; +} + +document.getElementById('aliases-form').addEventListener('submit', function(e) { + // Filter out empty groups and aliases + const cleanedGroups = aliasGroups + .filter(group => group.category && group.aliases && group.aliases.length > 0) + .map(group => ({ + category: group.category.trim(), + aliases: group.aliases.filter(a => a && a.trim()).map(a => a.trim()) + })) + .filter(group => group.aliases.length >= 2); // Need at least 2 values to be an alias + + document.getElementById('aliases_json').value = JSON.stringify(cleanedGroups); +}); + +// Initial render +renderAliasGroups(); +</script> + <div style="margin-top: 40px; padding: 20px; background-color: #f8f9fa; border-radius: 5px;"> <h3>Current Configuration:</h3> <ul> @@ -88,7 +238,7 @@ <form method="post" style="margin-bottom: 20px;"> <input type="hidden" name="action" value="backup"> - <button type="submit" style="background-color: #28a745; color: white; padding: 10px 20px; border: none; border-radius: 4px; font-size: 16px;"> + <button type="submit" style="background-color: #28a745; color: white; padding: 10px 20px; border: none; border-radius: 4px; font-size: 16px; cursor: pointer;"> Backup Database </button> <small style="color: #666; margin-left: 10px;">Creates a timestamped backup of the database file</small> @@ -96,7 +246,7 @@ <form method="post"> <input type="hidden" name="action" value="vacuum"> - <button type="submit" style="background-color: #6f42c1; color: white; padding: 10px 20px; border: none; border-radius: 4px; font-size: 16px;"> + <button type="submit" style="background-color: #6f42c1; color: white; padding: 10px 20px; border: none; border-radius: 4px; font-size: 16px; cursor: pointer;"> Vacuum Database </button> <small style="color: #666; margin-left: 10px;">Reclaims unused space and optimizes database performance</small>