commit 8116c3476308e643eb98357611f689236e7c0b56
parent 4bd5a15f73dcbb21f4f33dbc0fa192b7b9a46477
Author: Peter <peter@minskio.co.uk>
Date: Mon, 19 Oct 2026 15:09:34 +0100
Implement 'magic link' function
Diffstat:
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>