tagliatelle

Unnamed repository; edit this file 'description' to name the repository.
Log | Files | Refs

commit 8116c3476308e643eb98357611f689236e7c0b56
parent 4bd5a15f73dcbb21f4f33dbc0fa192b7b9a46477
Author: Peter <peter@minskio.co.uk>
Date:   Mon, 19 Oct 2026 15:09:34 +0100

Implement 'magic link' function

Diffstat:
Minternal/app/admin.go | 53+++++++++++++++++++++++++++++++++++++++++++++++++++++
Minternal/app/db.go | 37+++++++++++++++++++++++++++++++++++++
Ainternal/app/magiclinks.go | 131+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Minternal/app/viewer.go | 3++-
Mreadme.md | 1+
Astatic/admin-magic-links.js | 122+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mtemplates/admin.html | 47+++++++++++++++++++++++++++++++++++++++++++++++
Mtemplates/file.html | 11+++++++++++
8 files changed, 404 insertions(+), 1 deletion(-)

diff --git a/internal/app/admin.go b/internal/app/admin.go @@ -122,6 +122,9 @@ func adminHandler(w http.ResponseWriter, r *http.Request) { case "save_sed_rules": handleSaveSedRules(w, r, orphanData, missingThumbnails) + case "save_magic_links": + handleSaveMagicLinks(w, r, orphanData, missingThumbnails) + case "compute_properties": handleComputeProperties(w, r, orphanData, missingThumbnails) } @@ -175,6 +178,56 @@ func parseSedRulesFromForm(r *http.Request) ([]SedRule, error) { return rules, nil } +func parseMagicLinksFromForm(r *http.Request) ([]MagicLinkRule, error) { + var rules []MagicLinkRule + for i := 0; ; i++ { + name := strings.TrimSpace(r.FormValue(fmt.Sprintf("magic_links[%d][name]", i))) + if name == "" { + break + } + category := strings.TrimSpace(r.FormValue(fmt.Sprintf("magic_links[%d][category]", i))) + value := strings.TrimSpace(r.FormValue(fmt.Sprintf("magic_links[%d][value]", i))) + template := strings.TrimSpace(r.FormValue(fmt.Sprintf("magic_links[%d][template]", i))) + if category == "" || value == "" || template == "" { + return nil, fmt.Errorf("magic link rule %d is missing a tag category, tag value or URL template", i+1) + } + if !validMagicLinkTemplate(template) { + return nil, fmt.Errorf("magic link rule %d: URL template must start with http:// or https://", i+1) + } + rules = append(rules, MagicLinkRule{ + Name: name, + Category: category, + Value: value, + Template: template, + }) + } + return rules, nil +} + +func handleSaveMagicLinks(w http.ResponseWriter, r *http.Request, orphanData OrphanData, missingThumbnails []VideoFile) { + rules, err := parseMagicLinksFromForm(r) + if err != nil { + data := currentAdminState(r, orphanData, missingThumbnails) + data.Error = err.Error() + renderAdminPage(w, r, data) + return + } + + Cfg.MagicLinks = rules + + if err := SaveConfig(DB, Cfg); err != nil { + log.Printf("Error: handleSaveMagicLinks: failed to save configuration: %v", err) + data := currentAdminState(r, orphanData, missingThumbnails) + data.Error = "Failed to save configuration: " + err.Error() + renderAdminPage(w, r, data) + return + } + + data := currentAdminState(r, orphanData, missingThumbnails) + data.Success = "Magic link rules saved successfully!" + renderAdminPage(w, r, data) +} + func handleSaveAliases(w http.ResponseWriter, r *http.Request, orphanData OrphanData, missingThumbnails []VideoFile) { Cfg.TagAliases = parseAliasesFromForm(r) diff --git a/internal/app/db.go b/internal/app/db.go @@ -17,6 +17,7 @@ type Config struct { ItemsPerPage string TagAliases []TagAliasGroup SedRules []SedRule + MagicLinks []MagicLinkRule } type TagAliasGroup struct { @@ -89,6 +90,13 @@ func createTables(DB *sql.DB) error { description TEXT NOT NULL DEFAULT '', command TEXT NOT NULL ); + CREATE TABLE IF NOT EXISTS magic_links ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + category TEXT NOT NULL, + value TEXT NOT NULL, + template TEXT NOT NULL + ); ` _, err := DB.Exec(schema) @@ -101,6 +109,7 @@ func LoadConfig(DB *sql.DB) (Config, error) { ItemsPerPage: "100", TagAliases: []TagAliasGroup{}, SedRules: []SedRule{}, + MagicLinks: []MagicLinkRule{}, } rows, err := DB.Query(`SELECT key, value FROM settings`) @@ -163,6 +172,22 @@ func LoadConfig(DB *sql.DB) (Config, error) { return cfg, err } + magicRows, err := DB.Query(`SELECT name, category, value, template FROM magic_links ORDER BY id`) + if err != nil { + return cfg, err + } + defer magicRows.Close() + for magicRows.Next() { + var rule MagicLinkRule + if err := magicRows.Scan(&rule.Name, &rule.Category, &rule.Value, &rule.Template); err != nil { + return cfg, err + } + cfg.MagicLinks = append(cfg.MagicLinks, rule) + } + if err := magicRows.Err(); err != nil { + return cfg, err + } + return cfg, nil } @@ -210,5 +235,17 @@ func SaveConfig(DB *sql.DB, cfg Config) error { } } + if _, err := tx.Exec(`DELETE FROM magic_links`); err != nil { + return err + } + for _, rule := range cfg.MagicLinks { + if _, err := tx.Exec( + `INSERT INTO magic_links (name, category, value, template) VALUES (?, ?, ?, ?)`, + rule.Name, rule.Category, rule.Value, rule.Template, + ); err != nil { + return err + } + } + return tx.Commit() } diff --git a/internal/app/magiclinks.go b/internal/app/magiclinks.go @@ -0,0 +1,131 @@ +package app + +import ( + "net/url" + "regexp" + "sort" + "strings" +) + +const maxMagicLinksPerRule = 10 + +type MagicLinkRule struct { + Name string `json:"name"` + Category string `json:"category"` + Value string `json:"value"` + Template string `json:"template"` +} + +type MagicLink struct { + Label string + URL string +} + +var magicLinkVarPattern = regexp.MustCompile(`\$(?:\{([^}]+)\}|([A-Za-z0-9_]+))`) + +func magicLinkVarName(m []string) string { + if m[1] != "" { + return strings.TrimSpace(m[1]) + } + return m[2] +} + +// magic links must start with http(s) +func validMagicLinkTemplate(tmpl string) bool { + lower := strings.ToLower(tmpl) + return strings.HasPrefix(lower, "http://") || strings.HasPrefix(lower, "https://") +} + +func tagValuesFold(tags map[string][]string, name string) []string { + var values []string + for cat, vals := range tags { + if strings.EqualFold(cat, name) { + values = append(values, vals...) + } + } + sort.Strings(values) + return values +} + +func ruleMatches(rule MagicLinkRule, tags map[string][]string) bool { + accepted := expandTagWithAliases(rule.Category, rule.Value) + for _, have := range tagValuesFold(tags, rule.Category) { + for _, want := range accepted { + if strings.EqualFold(have, want) { + return true + } + } + } + return false +} + +// Skip when nothing matches, build multiple with multiple matches +func buildMagicLinks(rules []MagicLinkRule, tags map[string][]string) []MagicLink { + var links []MagicLink + seen := make(map[string]bool) + + for _, rule := range rules { + if !ruleMatches(rule, tags) { + continue + } + + // Collect each distinct variable used by the template, in order. + var varNames []string + varValues := make(map[string][]string) + missing := false + for _, m := range magicLinkVarPattern.FindAllStringSubmatch(rule.Template, -1) { + name := magicLinkVarName(m) + key := strings.ToLower(name) + if _, done := varValues[key]; done { + continue + } + vals := tagValuesFold(tags, name) + if len(vals) == 0 { + missing = true + break + } + varNames = append(varNames, key) + varValues[key] = vals + } + if missing { + continue + } + + // Expand the product of variable values. + combos := []map[string]string{{}} + for _, key := range varNames { + var next []map[string]string + for _, combo := range combos { + for _, v := range varValues[key] { + c := make(map[string]string, len(combo)+1) + for k, val := range combo { + c[k] = val + } + c[key] = v + next = append(next, c) + if len(next) >= maxMagicLinksPerRule { + break + } + } + if len(next) >= maxMagicLinksPerRule { + break + } + } + combos = next + } + + for _, combo := range combos { + built := magicLinkVarPattern.ReplaceAllStringFunc(rule.Template, func(s string) string { + m := magicLinkVarPattern.FindStringSubmatch(s) + return url.PathEscape(combo[strings.ToLower(magicLinkVarName(m))]) + }) + if seen[built] { + continue + } + seen[built] = true + links = append(links, MagicLink{Label: rule.Name, URL: built}) + } + } + + return links +} diff --git a/internal/app/viewer.go b/internal/app/viewer.go @@ -270,7 +270,8 @@ func fileHandler(w http.ResponseWriter, r *http.Request) { Categories []string EscapedFilename string Properties map[string]string - }{f, cats, url.PathEscape(f.Filename), fileProps}) + MagicLinks []MagicLink + }{f, cats, url.PathEscape(f.Filename), fileProps, buildMagicLinks(Cfg.MagicLinks, f.Tags)}) renderTemplate(w, "file.html", pageData) } diff --git a/readme.md b/readme.md @@ -28,6 +28,7 @@ Then access the server via a web browser, the default port is 8080. * Image, video, text and cbz gallery viewers * Will transcode incompatible video formats * Tag value aliases, e.g. `color:blue` and `color:navy` +* Configurable magic links, e.g. if `source=example` show `https://website.tld/$name` using the file's `name` tag * Regenerate video thumbnails via web interface * Add files via local filesystem, remote upload or `yt-dlp` directly * Clickable [rotate90](## "Rotates video/image contents by angle on click"), [l45](## "Jumps to line number in text viewer on click"), [01:23](## "Jumps video playback to specified timestamp on click") and [file/1234](## "Clickable link to that file ID") shortcodes in file descriptions diff --git a/static/admin-magic-links.js b/static/admin-magic-links.js @@ -0,0 +1,122 @@ +let magicLinkRules = []; + +// Init on page load +document.addEventListener('DOMContentLoaded', function() { + magicLinkRules = window.initialMagicLinks || []; + renderMagicLinkRules(); + setupMagicLinksForm(); +}); + +function renderMagicLinkRules() { + const container = document.getElementById('magic-link-rules'); + if (!container) return; + + container.innerHTML = ''; + + magicLinkRules.forEach((rule, index) => { + const ruleDiv = document.createElement('div'); + ruleDiv.style.cssText = 'border: 1px solid #ddd; padding: 15px; margin-bottom: 15px; border-radius: 5px;'; + + ruleDiv.innerHTML = ` + <div style="display: flex; justify-content: space-between; align-items: start; margin-bottom: 10px;"> + <h4 style="margin: 0;">Rule ${index + 1}</h4> + <button onclick="removeMagicLinkRule(${index})" style="background-color: #dc3545; color: white; padding: 5px 10px; border: none; border-radius: 3px; font-size: 12px; cursor: pointer;"> + Remove + </button> + </div> + + <div style="margin-bottom: 10px;"> + <label style="display: block; font-weight: bold; margin-bottom: 5px; font-size: 13px;">Name:</label> + <input type="text" value="${escapeHtml(rule.name)}" + onchange="updateMagicLinkRule(${index}, 'name', this.value)" + placeholder="e.g., Open on website.tld" + style="width: 100%; padding: 6px; font-size: 13px; border: 1px solid #ccc; border-radius: 3px;"> + <small>Shown as the label next to the link in the file viewer</small> + </div> + + <div style="display: flex; gap: 10px; margin-bottom: 10px;"> + <div style="flex: 1;"> + <label style="display: block; font-weight: bold; margin-bottom: 5px; font-size: 13px;">If tag category is:</label> + <input type="text" value="${escapeHtml(rule.category)}" + onchange="updateMagicLinkRule(${index}, 'category', this.value)" + placeholder="e.g., source" + style="width: 100%; padding: 6px; font-size: 13px; border: 1px solid #ccc; border-radius: 3px;"> + </div> + <div style="flex: 1;"> + <label style="display: block; font-weight: bold; margin-bottom: 5px; font-size: 13px;">equal to:</label> + <input type="text" value="${escapeHtml(rule.value)}" + onchange="updateMagicLinkRule(${index}, 'value', this.value)" + placeholder="e.g., example" + style="width: 100%; padding: 6px; font-size: 13px; border: 1px solid #ccc; border-radius: 3px;"> + </div> + </div> + + <div style="margin-bottom: 0;"> + <label style="display: block; font-weight: bold; margin-bottom: 5px; font-size: 13px;">URL Template:</label> + <input type="text" value="${escapeHtml(rule.template)}" + onchange="updateMagicLinkRule(${index}, 'template', this.value)" + placeholder="e.g., https://website.tld/$name" + style="width: 100%; padding: 6px; font-size: 13px; font-family: monospace; border: 1px solid #ccc; border-radius: 3px;"> + <small>Must start with http:// or https://. Use $category or \${category} to insert the file's tag value.</small> + </div> + `; + + container.appendChild(ruleDiv); + }); +} + +function addMagicLinkRule() { + magicLinkRules.push({ + name: '', + category: '', + value: '', + template: '' + }); + renderMagicLinkRules(); +} + +function removeMagicLinkRule(index) { + if (confirm('Remove this magic link rule?')) { + magicLinkRules.splice(index, 1); + renderMagicLinkRules(); + } +} + +function updateMagicLinkRule(index, field, value) { + if (magicLinkRules[index]) { + magicLinkRules[index][field] = value; + } +} + +function setupMagicLinksForm() { + const form = document.getElementById('magiclinks-form'); + if (!form) return; + + form.addEventListener('submit', function(e) { + // Validate rules + for (let i = 0; i < magicLinkRules.length; i++) { + const rule = magicLinkRules[i]; + if (!rule.name || !rule.category || !rule.value || !rule.template) { + e.preventDefault(); + alert(`Rule ${i + 1} is incomplete. Please fill in Name, tag category, tag value and URL Template.`); + return; + } + if (!/^https?:\/\//i.test(rule.template.trim())) { + e.preventDefault(); + alert(`Rule ${i + 1}: URL Template must start with http:// or https://`); + return; + } + } + + // Remove any previously-injected hidden fields from a prior submit + this.querySelectorAll('input[data-generated]').forEach(el => el.remove()); + + // Append one hidden field per value + magicLinkRules.forEach((rule, i) => { + appendHidden(this, `magic_links[${i}][name]`, rule.name); + appendHidden(this, `magic_links[${i}][category]`, rule.category); + appendHidden(this, `magic_links[${i}][value]`, rule.value); + appendHidden(this, `magic_links[${i}][template]`, rule.template); + }); + }); +} diff --git a/templates/admin.html b/templates/admin.html @@ -27,6 +27,9 @@ <button onclick="showAdminTab('sedrules')" id="admin-tab-sedrules" class="admin-tab-btn"> Sed Rules </button> + <button onclick="showAdminTab('magiclinks')" id="admin-tab-magiclinks" class="admin-tab-btn"> + Magic Links + </button> <button onclick="showAdminTab('orphans')" id="admin-tab-orphans" class="admin-tab-btn"> Orphans </button> @@ -184,6 +187,48 @@ </div> </div> +<!-- Magic Links Tab --> +<div id="admin-content-magiclinks" class="admin-tab-content"> + <div class="config-container"> + <div class="config-split"> + <h2>Magic Links</h2> + <p> + Build external links in the file viewer from a file's tags. + When a file has the given tag, the URL template is expanded using the file's other tags. + If no rule matches a file, the Magic Links section is not shown. + </p> + + <div id="magiclinks-section" class="admin-section"> + <div id="magic-link-rules"></div> + + <button onclick="addMagicLinkRule()" class="admin-btn admin-btn--success admin-btn--sm admin-btn--top"> + + Add Magic Link Rule + </button> + + <form method="post" action="/admin" id="magiclinks-form" class="admin-form-top"> + <input type="hidden" name="active_tab" value="magiclinks"> + <input type="hidden" name="action" value="save_magic_links"> + <button type="submit" class="admin-btn admin-btn--primary"> + Save Magic Links + </button> + </form> + </div> + </div> + + <div class="config-split"> + <h4 class="admin-example-heading">Example:</h4> + <ul class="admin-example-list"> + <li>If <code>source</code> = <code>example</code>, build <code>https://website.tld/$name</code></li> + <li><code>$name</code> is replaced by the value of the file's <code>name</code> tag</li> + <li>Use <code>${tag category}</code> for categories containing spaces</li> + <li>If a tag has several values, one link is shown per value</li> + <li>Rules whose variables the file has no tag for are skipped</li> + <li>Values are URL-escaped, and tag aliases are honoured when matching</li> + </ul> + </div> + </div> +</div> + <!-- Orphans Tab --> <div id="admin-content-orphans" class="admin-tab-content"> @@ -325,9 +370,11 @@ <script>window.initialAliasGroups = {{.Data.Config.TagAliases}};</script> <script>window.initialSedRules = {{.Data.Config.SedRules}};</script> +<script>window.initialMagicLinks = {{.Data.Config.MagicLinks}};</script> <script>window.activeAdminTab = "{{.Data.ActiveTab}}";</script> <script src="/static/admin-tag-alias.js" defer></script> <script src="/static/admin-sed-rules.js" defer></script> +<script src="/static/admin-magic-links.js" defer></script> <script src="/static/admin-tabs.js"></script> <script src="/static/common.js" defer></script> <script src="/static/hover-preview.js" defer></script> diff --git a/templates/file.html b/templates/file.html @@ -57,6 +57,17 @@ <script src="/static/copy-link.js" defer></script> </details> + {{if .Data.MagicLinks}} + <details open> + <summary>Magic Links</summary> + <ul id="magic-links"> + {{range .Data.MagicLinks}} + <li><a href="{{.URL}}" target="_blank" rel="noopener noreferrer">{{.Label}}</a></li> + {{end}} + </ul> + </details> + {{end}} + <details> <summary>File Actions</summary>