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:
| M | main.go | | | 140 | ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------- |
| M | templates/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>