Compare commits
12 Commits
master
...
86f2b7c34f
| Author | SHA1 | Date | |
|---|---|---|---|
| 86f2b7c34f | |||
| c688761e89 | |||
| 174e2dd1cd | |||
| a9ca40c2bd | |||
| eae5d1cc25 | |||
| 1d8dfdb1da | |||
| 6c268aa829 | |||
| 73a8b4f78f | |||
| 1f7cfd637a | |||
| 02a1482789 | |||
| 60b514eae7 | |||
| dedeeb77a8 |
@@ -1,5 +1,7 @@
|
||||
.claude/
|
||||
.zed/
|
||||
wiki/
|
||||
cache/
|
||||
|
||||
# Binaries
|
||||
datascape
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
(function () {
|
||||
document.querySelectorAll('.content h1, .content h2, .content h3, .content h4, .content h5, .content h6').forEach(function (h) {
|
||||
if (!h.id) return;
|
||||
var a = document.createElement('a');
|
||||
a.href = '#' + h.id;
|
||||
a.className = 'heading-anchor';
|
||||
a.setAttribute('aria-label', 'Link to this section');
|
||||
a.textContent = '#';
|
||||
h.insertBefore(a, h.firstChild);
|
||||
});
|
||||
}());
|
||||
@@ -1,7 +1,7 @@
|
||||
{{if .Photos}}
|
||||
<div class="photo-grid">
|
||||
{{range .Photos}}
|
||||
<a href="{{.URL}}" target="_blank"><img src="{{.URL}}" alt="{{.Name}}" loading="lazy"></a>
|
||||
<a href="{{.URL}}" target="_blank"><img src="{{.ThumbURL}}" alt="" loading="lazy"></a>
|
||||
{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
{{if .Photos}}
|
||||
<div class="photo-grid">
|
||||
{{range .Photos}}
|
||||
<a href="{{.URL}}" target="_blank"><img src="{{.URL}}" alt="{{.Name}}" loading="lazy"></a>
|
||||
<a href="{{.URL}}" target="_blank"><img src="{{.ThumbURL}}" alt="" loading="lazy"></a>
|
||||
{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
<h2 id="months">Months</h2>
|
||||
<h2 id="months">Monate</h2>
|
||||
{{range .Months}}
|
||||
<h3 id="{{.ID}}">
|
||||
<a href="{{.URL}}">{{.Name}}</a>
|
||||
{{if .PhotoCount}}<span class="muted">({{.PhotoCount}} photos)</span>{{end}}
|
||||
</h3>
|
||||
{{if .Photos}}
|
||||
<div class="photo-grid">
|
||||
{{range .Photos}}
|
||||
<a href="{{.URL}}" target="_blank"><img src="{{.ThumbURL}}" alt="" loading="lazy"></a>
|
||||
{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
{{end}}
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
{{define "headerActions"}}
|
||||
<a class="btn" href="{{.PostURL}}">CANCEL</a>
|
||||
<button class="btn" type="submit" form="edit-form" data-action="save" data-key="S" title="Save (S)">SAVE</button>
|
||||
{{end}}
|
||||
|
||||
{{define "content"}}
|
||||
<form id="edit-form" class="edit-form" method="POST" action="{{.PostURL}}">
|
||||
{{if ge .SectionIndex 0}}<input type="hidden" name="section" value="{{.SectionIndex}}">{{end}}
|
||||
<div class="editor-toolbar">
|
||||
<button type="button" class="btn btn-tool" data-action="bold" data-key="B" title="Bold (B)">**</button>
|
||||
<button type="button" class="btn btn-tool" data-action="italic" data-key="I" title="Italic (I)">*</button>
|
||||
<span class="toolbar-sep"></span>
|
||||
<button type="button" class="btn btn-tool" data-action="h1" data-key="1" title="Heading 1 (1)">#</button>
|
||||
<button type="button" class="btn btn-tool" data-action="h2" data-key="2" title="Heading 2 (2)">##</button>
|
||||
<button type="button" class="btn btn-tool" data-action="h3" data-key="3" title="Heading 3 (3)">###</button>
|
||||
<span class="toolbar-sep"></span>
|
||||
<button type="button" class="btn btn-tool" data-action="code" data-key="C" title="Inline code (C)">`</button>
|
||||
<button type="button" class="btn btn-tool" data-action="codeblock" data-key="K" title="Code block (K)">```</button>
|
||||
<span class="toolbar-sep"></span>
|
||||
<button type="button" class="btn btn-tool" data-action="link" data-key="L" title="Link (L)">[]</button>
|
||||
<button type="button" class="btn btn-tool" data-action="wikilink" data-key="P" title="Insert wiki link (P)">[[]]</button>
|
||||
<button type="button" class="btn btn-tool" data-action="quote" data-key="Q" title="Blockquote (Q)">></button>
|
||||
<button type="button" class="btn btn-tool" data-action="ul" data-key="U" title="Unordered list (U)">-</button>
|
||||
<button type="button" class="btn btn-tool" data-action="ol" data-key="O" title="Ordered list (O)">1.</button>
|
||||
<button type="button" class="btn btn-tool" data-action="hr" data-key="R" title="Horizontal rule (R)">---</button>
|
||||
<span class="toolbar-sep"></span>
|
||||
<button type="button" class="btn btn-tool dropdown" data-action="tbldrop" title="Table (T)">T▾</button>
|
||||
<button type="button" class="btn btn-tool dropdown" data-action="datedrop" title="Insert date (D/W)">D▾</button>
|
||||
<span class="toolbar-sep"></span>
|
||||
<button type="button" class="btn btn-tool" data-action="movie" data-key="V" title="Import movie (V)">MV</button>
|
||||
</div>
|
||||
<textarea name="content" id="editor" autofocus>{{.RawContent}}</textarea>
|
||||
</form>
|
||||
<script src="/_/editor/lists.js"></script>
|
||||
<script src="/_/editor/tables.js"></script>
|
||||
<script src="/_/editor/dates.js"></script>
|
||||
<script src="/_/editor/movie.js"></script>
|
||||
<script src="/_/editor.js"></script>
|
||||
{{end}}
|
||||
+6
-1
@@ -58,6 +58,7 @@
|
||||
var T = EditorTables;
|
||||
var L = EditorLists;
|
||||
var D = EditorDates;
|
||||
var M = EditorMovie;
|
||||
|
||||
var actions = {
|
||||
save: function () { form.submit(); },
|
||||
@@ -100,6 +101,7 @@
|
||||
tbldeleterow: function () { applyTableOp(T.deleteRow); },
|
||||
dateiso: function () { insertAtCursor(D.isoDate()); },
|
||||
datelong: function () { insertAtCursor(D.longDate()); },
|
||||
movie: function () { M.run(textarea); },
|
||||
};
|
||||
|
||||
// --- Keyboard shortcut registration ---
|
||||
@@ -121,7 +123,10 @@
|
||||
|
||||
document.addEventListener('keydown', function (e) {
|
||||
if (!e.altKey || !e.shiftKey) return;
|
||||
var action = keyMap[e.key];
|
||||
// Shift+digit produces a layout-dependent character in e.key (e.g. "!"
|
||||
// on US, "!" on DE), so fall back to e.code for digit rows.
|
||||
var key = /^Digit[0-9]$/.test(e.code) ? e.code.slice(5) : e.key;
|
||||
var action = keyMap[key];
|
||||
if (action) {
|
||||
e.preventDefault();
|
||||
action();
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
window.EditorMovie = (function () {
|
||||
'use strict';
|
||||
|
||||
// OMDb API key. Shipped to the browser; acceptable for a single-user LAN tool.
|
||||
var OMDB_API_KEY = 'c906744f';
|
||||
|
||||
var BEGIN = '<!-- BEGIN MOVIE -->';
|
||||
var END = '<!-- END MOVIE -->';
|
||||
|
||||
function firstHeading(text) {
|
||||
var m = text.match(/^#{1,6}\s+(.+?)\s*$/m);
|
||||
return m ? m[1].trim() : '';
|
||||
}
|
||||
|
||||
function parseTitleYear(raw) {
|
||||
var m = raw.match(/^(.+?)\s*\((\d{4})\)\s*$/);
|
||||
return m ? { title: m[1].trim(), year: m[2] } : { title: raw.trim(), year: null };
|
||||
}
|
||||
|
||||
function safe(v) { return (!v || v === 'N/A') ? '' : String(v); }
|
||||
|
||||
function esc(s) {
|
||||
return String(s)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
|
||||
function buildBlock(m) {
|
||||
var out = [BEGIN, '<aside class="movie-info">'];
|
||||
if (m.Poster && m.Poster !== 'N/A') {
|
||||
out.push('<img class="movie-poster" src="' + esc(m.Poster) +
|
||||
'" alt="' + esc(safe(m.Title)) + ' poster">');
|
||||
}
|
||||
out.push('<table>');
|
||||
[
|
||||
['Title', m.Title],
|
||||
['Year', m.Year],
|
||||
['Runtime', m.Runtime],
|
||||
['Genre', m.Genre],
|
||||
['Director', m.Director],
|
||||
['Cast', m.Actors],
|
||||
['Plot', m.Plot],
|
||||
].forEach(function (r) {
|
||||
out.push('<tr><th>' + r[0] + '</th><td>' + esc(safe(r[1])) + '</td></tr>');
|
||||
});
|
||||
out.push('</table>', '</aside>', END);
|
||||
return out.join('\n');
|
||||
}
|
||||
|
||||
function insertOrReplace(ta, markup) {
|
||||
var t = ta.value || '';
|
||||
var b = t.indexOf(BEGIN);
|
||||
var e = t.indexOf(END);
|
||||
if (b !== -1 && e !== -1 && e > b) {
|
||||
ta.value = t.slice(0, b) + markup + t.slice(e + END.length);
|
||||
} else {
|
||||
var h = t.match(/^#{1,6}\s+.+?\s*$/m);
|
||||
if (h) {
|
||||
var idx = t.indexOf(h[0]) + h[0].length;
|
||||
ta.value = t.slice(0, idx) + '\n\n' + markup + t.slice(idx);
|
||||
} else {
|
||||
ta.value = markup + (t ? '\n\n' + t : '');
|
||||
}
|
||||
}
|
||||
ta.dispatchEvent(new Event('input'));
|
||||
}
|
||||
|
||||
function fetchMovie(title, year) {
|
||||
var url = 'https://www.omdbapi.com/?apikey=' + encodeURIComponent(OMDB_API_KEY) +
|
||||
'&type=movie&t=' + encodeURIComponent(title);
|
||||
if (year) url += '&y=' + encodeURIComponent(year);
|
||||
return fetch(url).then(function (r) { return r.json(); });
|
||||
}
|
||||
|
||||
function showMessage(title, msg) {
|
||||
openModal({ title: title, body: msg, confirm: { label: 'OK' } });
|
||||
}
|
||||
|
||||
function run(textarea) {
|
||||
if (!OMDB_API_KEY) {
|
||||
showMessage('Movie import', 'OMDb API key is not set. Edit assets/editor/movie.js.');
|
||||
return;
|
||||
}
|
||||
|
||||
var input = document.createElement('input');
|
||||
input.type = 'text';
|
||||
input.className = 'modal-input';
|
||||
input.placeholder = 'Title, optionally with (YYYY)';
|
||||
input.value = firstHeading(textarea.value || '');
|
||||
|
||||
openModal({
|
||||
title: 'Import movie',
|
||||
body: input,
|
||||
confirm: {
|
||||
label: 'IMPORT',
|
||||
onConfirm: function () {
|
||||
var raw = input.value.trim();
|
||||
if (!raw) return;
|
||||
var parsed = parseTitleYear(raw);
|
||||
closeModal();
|
||||
fetchMovie(parsed.title, parsed.year)
|
||||
.then(function (data) {
|
||||
if (!data || data.Response === 'False') {
|
||||
showMessage('Not found',
|
||||
(data && data.Error) || 'Movie not found.');
|
||||
return;
|
||||
}
|
||||
insertOrReplace(textarea, buildBlock(data));
|
||||
})
|
||||
.catch(function () {
|
||||
showMessage('Import failed', 'OMDb lookup failed.');
|
||||
});
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return { run: run };
|
||||
})();
|
||||
@@ -14,6 +14,13 @@
|
||||
e.preventDefault();
|
||||
if (window.location.pathname !== '/' && typeof movePage === 'function') movePage();
|
||||
break;
|
||||
case 'F':
|
||||
var input = document.querySelector('.search-input');
|
||||
if (!input) return;
|
||||
e.preventDefault();
|
||||
input.focus();
|
||||
input.select();
|
||||
break;
|
||||
}
|
||||
});
|
||||
})();
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"
|
||||
fill="none" stroke="#cfcfcf" stroke-width="1.5" stroke-linejoin="miter" shape-rendering="crispEdges">
|
||||
<rect x="1" y="2" width="14" height="12"/>
|
||||
<path d="M1 11l4-4 3 3 2-2 5 5"/>
|
||||
<rect x="10" y="4" width="2" height="2" fill="#cfcfcf" stroke="none"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 328 B |
@@ -0,0 +1,37 @@
|
||||
{{define "layout"}}<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>{{.Title}}</title>
|
||||
<link rel="icon" href="/_/favicon.ico" />
|
||||
<link rel="preload" href="/_/fonts/IosevkaEtoile.woff2" as="font" type="font/woff2" crossorigin />
|
||||
<link rel="preload" href="/_/fonts/IosevkaSlab.woff2" as="font" type="font/woff2" crossorigin />
|
||||
<link rel="stylesheet" href="/_/style.css" />
|
||||
<script src="/_/modal.js"></script>
|
||||
<script src="/_/global-shortcuts.js"></script>
|
||||
<script src="/_/tree-picker.js"></script>
|
||||
{{block "headScripts" .}}{{end}}
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<nav class="breadcrumb">
|
||||
<a href="/"><svg class="logo" viewBox="0 0 26.052269 26.052269" xmlns="http://www.w3.org/2000/svg"><g fill="none" stroke="currentColor" stroke-linejoin="miter" transform="matrix(0.05463483,8.1519706e-6,-8.1519706e-6,0.05463483,-64.560546,-24.6949)"><rect x="1188.537" y="457.92056" width="461.87488" height="462.15189" stroke-width="20.2288"/><path d="m1348.9955 456.59572.046 309.36839" stroke-width="19.6849"/><path d="m1200.3996 765.80237 441.8362-.0659" stroke-width="19.6849"/><path d="m1648.2897 620.244-299.2012.0446" stroke-width="20.5676"/><path d="m1491.6148 909.24806-.021-136.93117" stroke-width="19.6849"/><rect x="1191.6504" y="461.66092" width="457.09634" height="457.09634" stroke-width="19.6761"/></g></svg></a>
|
||||
{{range .Crumbs}}
|
||||
<span class="sep">/</span><a href="{{.URL}}">{{.Name}}</a>
|
||||
{{end}}
|
||||
</nav>
|
||||
{{if not .EditMode}}
|
||||
<form class="search-form" action="/" method="get">
|
||||
<input class="search-input" type="search" name="q" value="{{block "searchQuery" .}}{{end}}" placeholder="Search…" title="Search (F)" autocomplete="off" autocorrect="off" autocapitalize="off" spellcheck="false" />
|
||||
</form>
|
||||
{{end}}
|
||||
{{block "headerActions" .}}{{end}}
|
||||
</header>
|
||||
<main>
|
||||
{{block "content" .}}{{end}}
|
||||
</main>
|
||||
{{block "extras" .}}{{end}}
|
||||
</body>
|
||||
</html>
|
||||
{{end}}
|
||||
+52
-106
@@ -1,106 +1,52 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>{{.Title}}</title>
|
||||
<link rel="icon" href="/_/favicon.ico" />
|
||||
<link rel="preload" href="/_/fonts/IosevkaEtoile.woff2" as="font" type="font/woff2" crossorigin />
|
||||
<link rel="preload" href="/_/fonts/IosevkaSlab.woff2" as="font" type="font/woff2" crossorigin />
|
||||
<link rel="stylesheet" href="/_/style.css" />
|
||||
<script src="/_/modal.js"></script>
|
||||
<script src="/_/global-shortcuts.js"></script>
|
||||
<script src="/_/tree-picker.js"></script>
|
||||
<script src="/_/page-actions.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<nav class="breadcrumb">
|
||||
<a href="/"><svg class="logo" viewBox="0 0 26.052269 26.052269" xmlns="http://www.w3.org/2000/svg"><g fill="none" stroke="currentColor" stroke-linejoin="miter" transform="matrix(0.05463483,8.1519706e-6,-8.1519706e-6,0.05463483,-64.560546,-24.6949)"><rect x="1188.537" y="457.92056" width="461.87488" height="462.15189" stroke-width="20.2288"/><path d="m1348.9955 456.59572.046 309.36839" stroke-width="19.6849"/><path d="m1200.3996 765.80237 441.8362-.0659" stroke-width="19.6849"/><path d="m1648.2897 620.244-299.2012.0446" stroke-width="20.5676"/><path d="m1491.6148 909.24806-.021-136.93117" stroke-width="19.6849"/><rect x="1191.6504" y="461.66092" width="457.09634" height="457.09634" stroke-width="19.6761"/></g></svg></a>
|
||||
{{range .Crumbs}}
|
||||
<span class="sep">/</span><a href="{{.URL}}">{{.Name}}</a>
|
||||
{{end}}
|
||||
</nav>
|
||||
{{if .EditMode}}
|
||||
<a class="btn" href="{{.PostURL}}">CANCEL</a>
|
||||
<button class="btn" type="submit" form="edit-form" data-action="save" data-key="S" title="Save (S)">SAVE</button>
|
||||
{{else if .CanEdit}}
|
||||
<div class="dropdown">
|
||||
<button class="btn" data-action="actions-drop" title="Actions">ACTIONS ▾</button>
|
||||
<div class="dropdown-menu align-right">
|
||||
<button class="btn dropdown-item" onclick="newPage()" title="New page (N)">NEW</button>
|
||||
<a class="btn dropdown-item" href="?edit" title="Edit page (E)">EDIT</a>
|
||||
{{if not .IsRoot}}
|
||||
<button class="btn dropdown-item" onclick="movePage()" title="Move page (M)">MOVE</button>
|
||||
<button class="btn dropdown-item danger" onclick="deletePage()" title="Delete page">DELETE</button>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
</header>
|
||||
<main>
|
||||
{{if .EditMode}}
|
||||
<form id="edit-form" class="edit-form" method="POST" action="{{.PostURL}}">
|
||||
{{if ge .SectionIndex 0}}<input type="hidden" name="section" value="{{.SectionIndex}}">{{end}}
|
||||
<div class="editor-toolbar">
|
||||
<button type="button" class="btn btn-tool" data-action="bold" data-key="B" title="Bold (B)">**</button>
|
||||
<button type="button" class="btn btn-tool" data-action="italic" data-key="I" title="Italic (I)">*</button>
|
||||
<span class="toolbar-sep"></span>
|
||||
<button type="button" class="btn btn-tool" data-action="h1" data-key="1" title="Heading 1 (1)">#</button>
|
||||
<button type="button" class="btn btn-tool" data-action="h2" data-key="2" title="Heading 2 (2)">##</button>
|
||||
<button type="button" class="btn btn-tool" data-action="h3" data-key="3" title="Heading 3 (3)">###</button>
|
||||
<span class="toolbar-sep"></span>
|
||||
<button type="button" class="btn btn-tool" data-action="code" data-key="C" title="Inline code (C)">`</button>
|
||||
<button type="button" class="btn btn-tool" data-action="codeblock" data-key="K" title="Code block (K)">```</button>
|
||||
<span class="toolbar-sep"></span>
|
||||
<button type="button" class="btn btn-tool" data-action="link" data-key="L" title="Link (L)">[]</button>
|
||||
<button type="button" class="btn btn-tool" data-action="wikilink" data-key="P" title="Insert wiki link (P)">[[]]</button>
|
||||
<button type="button" class="btn btn-tool" data-action="quote" data-key="Q" title="Blockquote (Q)">></button>
|
||||
<button type="button" class="btn btn-tool" data-action="ul" data-key="U" title="Unordered list (U)">-</button>
|
||||
<button type="button" class="btn btn-tool" data-action="ol" data-key="O" title="Ordered list (O)">1.</button>
|
||||
<button type="button" class="btn btn-tool" data-action="hr" data-key="R" title="Horizontal rule (R)">---</button>
|
||||
<span class="toolbar-sep"></span>
|
||||
<button type="button" class="btn btn-tool dropdown" data-action="tbldrop" title="Table (T)">T▾</button>
|
||||
<button type="button" class="btn btn-tool dropdown" data-action="datedrop" title="Insert date (D/W)">D▾</button>
|
||||
</div>
|
||||
<textarea name="content" id="editor" autofocus>{{.RawContent}}</textarea>
|
||||
</form>
|
||||
<script src="/_/editor/lists.js"></script>
|
||||
<script src="/_/editor/tables.js"></script>
|
||||
<script src="/_/editor/dates.js"></script>
|
||||
<script src="/_/editor.js"></script>
|
||||
{{else}}
|
||||
{{if .Content}}
|
||||
<div class="content">{{.Content}}</div>
|
||||
{{end}}
|
||||
{{if .SpecialContent}}
|
||||
<div class="content">{{.SpecialContent}}</div>
|
||||
{{end}}
|
||||
{{if or .Content .SpecialContent}}
|
||||
<script src="/_/content.js"></script>
|
||||
<script src="/_/toc.js"></script>
|
||||
{{end}}
|
||||
{{if .Content}}
|
||||
<script src="/_/sections.js"></script>
|
||||
{{end}}
|
||||
{{if .Entries}}
|
||||
<div class="listing">
|
||||
<div class="listing-header">Contents</div>
|
||||
{{range .Entries}}
|
||||
<div class="listing-item">
|
||||
<span class="icon">{{.Icon}}</span>
|
||||
<a href="{{.URL}}">{{.Name}}</a>
|
||||
<span class="meta">{{.Meta}}</span>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
{{else if not .Content}}
|
||||
{{if not .SpecialContent}}
|
||||
<p class="empty">Empty folder — <a href="?edit">[CREATE]</a></p>
|
||||
{{end}}
|
||||
{{end}}
|
||||
{{end}}
|
||||
</main>
|
||||
{{if .SidebarWidget}}{{.SidebarWidget}}{{end}}
|
||||
</body>
|
||||
</html>
|
||||
{{define "headScripts"}}<script src="/_/page-actions.js"></script>{{end}}
|
||||
|
||||
{{define "headerActions"}}
|
||||
{{if .CanEdit}}
|
||||
<div class="dropdown">
|
||||
<button class="btn" data-action="actions-drop" title="Actions">ACTIONS ▾</button>
|
||||
<div class="dropdown-menu align-right">
|
||||
<button class="btn dropdown-item" onclick="newPage()" title="New page (N)">NEW</button>
|
||||
<a class="btn dropdown-item" href="?edit" title="Edit page (E)">EDIT</a>
|
||||
{{if not .IsRoot}}
|
||||
<button class="btn dropdown-item" onclick="movePage()" title="Move page (M)">MOVE</button>
|
||||
<button class="btn dropdown-item danger" onclick="deletePage()" title="Delete page">DELETE</button>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
{{end}}
|
||||
|
||||
{{define "content"}}
|
||||
{{if .Content}}
|
||||
<div class="content">{{.Content}}</div>
|
||||
{{end}}
|
||||
{{if .SpecialContent}}
|
||||
<div class="content">{{.SpecialContent}}</div>
|
||||
{{end}}
|
||||
{{if or .Content .SpecialContent}}
|
||||
<script src="/_/content.js"></script>
|
||||
<script src="/_/anchors.js"></script>
|
||||
<script src="/_/toc.js"></script>
|
||||
{{end}}
|
||||
{{if .Content}}
|
||||
<script src="/_/sections.js"></script>
|
||||
{{end}}
|
||||
{{if .Entries}}
|
||||
<div class="listing">
|
||||
<div class="listing-header">Contents</div>
|
||||
{{range .Entries}}
|
||||
<div class="listing-item">
|
||||
<span class="icon">{{.Icon}}</span>
|
||||
<a href="{{.URL}}">{{.Name}}</a>
|
||||
<span class="meta">{{.Meta}}</span>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
{{else if not .Content}}
|
||||
{{if not .SpecialContent}}
|
||||
<p class="empty">Empty folder — <a href="?edit">[CREATE]</a></p>
|
||||
{{end}}
|
||||
{{end}}
|
||||
{{end}}
|
||||
|
||||
{{define "extras"}}{{if .SidebarWidget}}{{.SidebarWidget}}{{end}}{{end}}
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
{{define "searchQuery"}}{{.Query}}{{end}}
|
||||
|
||||
{{define "content"}}
|
||||
{{if .Query}}
|
||||
{{if .Results}}
|
||||
<h2 class="muted search-summary">{{len .Results}} match{{if ne (len .Results) 1}}es{{end}} for “{{.Query}}”</h2>
|
||||
<div class="search-results">
|
||||
{{range .Results}}
|
||||
<article class="search-card">
|
||||
<a class="search-card-name" href="{{.URL}}">{{.Name}}</a>
|
||||
<div class="search-card-path muted">/{{.Path}}</div>
|
||||
{{if .Snippet}}<div class="search-card-snippet">{{.Snippet}}</div>{{end}}
|
||||
</article>
|
||||
{{end}}
|
||||
</div>
|
||||
{{else}}
|
||||
<p class="empty">No matches for “{{.Query}}”.</p>
|
||||
{{end}}
|
||||
{{else}}
|
||||
<p class="empty">Enter a query above.</p>
|
||||
{{end}}
|
||||
{{end}}
|
||||
@@ -234,6 +234,15 @@ main {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.content a.heading-anchor {
|
||||
color: var(--text-muted);
|
||||
margin-right: 0.4em;
|
||||
font-weight: normal;
|
||||
}
|
||||
.content a.heading-anchor:hover {
|
||||
color: var(--primary-hover);
|
||||
}
|
||||
|
||||
/* === File listing === */
|
||||
.listing {
|
||||
border: 1px solid var(--secondary);
|
||||
@@ -350,6 +359,62 @@ textarea {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
/* === Search === */
|
||||
.search-form {
|
||||
display: flex;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
.search-input {
|
||||
background: var(--bg-panel);
|
||||
border: 1px solid var(--secondary);
|
||||
color: var(--text);
|
||||
font: inherit;
|
||||
font-size: 0.9rem;
|
||||
padding: 0.3rem 0.5rem;
|
||||
min-width: 0;
|
||||
width: 12rem;
|
||||
max-width: 100%;
|
||||
outline: none;
|
||||
}
|
||||
.search-input:focus {
|
||||
border-color: var(--primary-hover);
|
||||
}
|
||||
.search-summary {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.search-results {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
.search-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
padding-bottom: 1rem;
|
||||
border-bottom: 1px dashed var(--secondary);
|
||||
}
|
||||
.search-card:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
.search-card-name {
|
||||
color: var(--link);
|
||||
font-size: 1.1rem;
|
||||
word-break: break-word;
|
||||
}
|
||||
.search-card-name:hover {
|
||||
color: var(--link-hover);
|
||||
}
|
||||
.search-card-path {
|
||||
word-break: break-all;
|
||||
}
|
||||
.search-card-snippet {
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.5;
|
||||
color: var(--text-muted);
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
/* === Muted text === */
|
||||
.muted {
|
||||
color: var(--text-muted);
|
||||
@@ -374,6 +439,7 @@ textarea {
|
||||
height: 140px;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
background: var(--bg-panel) url("/_/icons/thumb-placeholder.svg") center/2rem no-repeat;
|
||||
}
|
||||
|
||||
/* === Empty state === */
|
||||
@@ -584,6 +650,31 @@ hr {
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
/* === Movie info box === */
|
||||
.movie-info {
|
||||
margin: 0.75rem 0;
|
||||
}
|
||||
.movie-info::after {
|
||||
content: "";
|
||||
display: block;
|
||||
clear: both;
|
||||
}
|
||||
.movie-info .movie-poster {
|
||||
float: right;
|
||||
max-width: 200px;
|
||||
margin: 0 0 0.75rem 1rem;
|
||||
}
|
||||
.movie-info table {
|
||||
width: auto;
|
||||
}
|
||||
@media (max-width: 600px) {
|
||||
.movie-info .movie-poster {
|
||||
float: none;
|
||||
display: block;
|
||||
margin: 0 auto 0.75rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* === Diary Calendar === */
|
||||
.diary-cal {
|
||||
position: fixed;
|
||||
|
||||
+1
-1
@@ -21,7 +21,7 @@
|
||||
var a = document.createElement("a");
|
||||
a.href = "#" + h.id;
|
||||
var clone = h.cloneNode(true);
|
||||
clone.querySelectorAll(".btn, .muted").forEach(function (el) { el.remove(); });
|
||||
clone.querySelectorAll(".btn, .muted, .heading-anchor").forEach(function (el) { el.remove(); });
|
||||
a.textContent = clone.textContent.trim();
|
||||
li.appendChild(a);
|
||||
list.appendChild(li);
|
||||
|
||||
@@ -283,16 +283,17 @@ func computeCalendarWidget(diaryRootFS, diaryRootURL, fsPath string, depth int)
|
||||
|
||||
// diaryPhoto is a photo file whose name starts with a YYYY-MM-DD date prefix.
|
||||
type diaryPhoto struct {
|
||||
Date time.Time
|
||||
Name string
|
||||
URL string
|
||||
Date time.Time
|
||||
Name string
|
||||
URL string
|
||||
ThumbURL string
|
||||
}
|
||||
|
||||
type diaryMonthSummary struct {
|
||||
ID string
|
||||
Name string
|
||||
URL string
|
||||
PhotoCount int
|
||||
ID string
|
||||
Name string
|
||||
URL string
|
||||
Photos []diaryPhoto
|
||||
}
|
||||
|
||||
type diaryDaySection struct {
|
||||
@@ -376,10 +377,16 @@ func yearPhotos(yearFsPath, yearURLPath string) []diaryPhoto {
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
photoURL := path.Join(yearURLPath, url.PathEscape(name))
|
||||
thumb := photoURL
|
||||
if hasThumbnail(name) {
|
||||
thumb = thumbURL(photoURL, 300)
|
||||
}
|
||||
photos = append(photos, diaryPhoto{
|
||||
Date: t,
|
||||
Name: name,
|
||||
URL: path.Join(yearURLPath, url.PathEscape(name)),
|
||||
Date: t,
|
||||
Name: name,
|
||||
URL: photoURL,
|
||||
ThumbURL: thumb,
|
||||
})
|
||||
}
|
||||
return photos
|
||||
@@ -394,32 +401,52 @@ func renderDiaryYear(fsPath, urlPath string) template.HTML {
|
||||
|
||||
photos := yearPhotos(fsPath, urlPath)
|
||||
|
||||
entries, err := os.ReadDir(fsPath)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
var months []diaryMonthSummary
|
||||
// Collect month numbers from both subdirectories and photo filenames so
|
||||
// years that contain only photos (no diary entries) still list months.
|
||||
monthSet := map[int]bool{}
|
||||
monthDirs := map[int]string{}
|
||||
entries, _ := os.ReadDir(fsPath)
|
||||
for _, e := range entries {
|
||||
if !e.IsDir() {
|
||||
continue
|
||||
}
|
||||
monthNum, err := strconv.Atoi(e.Name())
|
||||
if err != nil || monthNum < 1 || monthNum > 12 {
|
||||
n, err := strconv.Atoi(e.Name())
|
||||
if err != nil || n < 1 || n > 12 {
|
||||
continue
|
||||
}
|
||||
count := 0
|
||||
monthSet[n] = true
|
||||
monthDirs[n] = e.Name()
|
||||
}
|
||||
for _, p := range photos {
|
||||
if p.Date.Year() == year {
|
||||
monthSet[int(p.Date.Month())] = true
|
||||
}
|
||||
}
|
||||
|
||||
monthNums := make([]int, 0, len(monthSet))
|
||||
for m := range monthSet {
|
||||
monthNums = append(monthNums, m)
|
||||
}
|
||||
sort.Ints(monthNums)
|
||||
|
||||
var months []diaryMonthSummary
|
||||
for _, monthNum := range monthNums {
|
||||
var monthPhotos []diaryPhoto
|
||||
for _, p := range photos {
|
||||
if p.Date.Year() == year && int(p.Date.Month()) == monthNum {
|
||||
count++
|
||||
monthPhotos = append(monthPhotos, p)
|
||||
}
|
||||
}
|
||||
monthDate := time.Date(year, time.Month(monthNum), 1, 0, 0, 0, 0, time.UTC)
|
||||
dirName, ok := monthDirs[monthNum]
|
||||
if !ok {
|
||||
dirName = fmt.Sprintf("%02d", monthNum)
|
||||
}
|
||||
months = append(months, diaryMonthSummary{
|
||||
ID: monthDate.Format("2006-01"),
|
||||
Name: monthDate.Format("January 2006"),
|
||||
URL: path.Join(urlPath, e.Name()) + "/",
|
||||
PhotoCount: count,
|
||||
ID: monthDate.Format("2006-01"),
|
||||
Name: fmt.Sprintf("%s %d", germanMonths[monthDate.Month()], year),
|
||||
URL: path.Join(urlPath, dirName) + "/",
|
||||
Photos: monthPhotos,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
+57
@@ -0,0 +1,57 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/yuin/goldmark"
|
||||
"github.com/yuin/goldmark/ast"
|
||||
"github.com/yuin/goldmark/parser"
|
||||
"github.com/yuin/goldmark/text"
|
||||
"github.com/yuin/goldmark/util"
|
||||
)
|
||||
|
||||
type extLinksTransformer struct{}
|
||||
|
||||
func (extLinksTransformer) Transform(doc *ast.Document, reader text.Reader, pc parser.Context) {
|
||||
ast.Walk(doc, func(n ast.Node, entering bool) (ast.WalkStatus, error) {
|
||||
if !entering {
|
||||
return ast.WalkContinue, nil
|
||||
}
|
||||
link, ok := n.(*ast.Link)
|
||||
if !ok {
|
||||
return ast.WalkContinue, nil
|
||||
}
|
||||
if isExternalURL(string(link.Destination)) {
|
||||
link.SetAttribute([]byte("target"), []byte("_blank"))
|
||||
link.SetAttribute([]byte("rel"), []byte("noopener noreferrer"))
|
||||
}
|
||||
return ast.WalkContinue, nil
|
||||
})
|
||||
}
|
||||
|
||||
func isExternalURL(dest string) bool {
|
||||
if strings.HasPrefix(dest, "//") {
|
||||
return true
|
||||
}
|
||||
i := strings.Index(dest, ":")
|
||||
if i <= 0 {
|
||||
return false
|
||||
}
|
||||
for _, c := range dest[:i] {
|
||||
if !(c >= 'a' && c <= 'z') && !(c >= 'A' && c <= 'Z') &&
|
||||
!(c >= '0' && c <= '9') && c != '+' && c != '-' && c != '.' {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
type extLinksExt struct{}
|
||||
|
||||
func newExtLinksExt() goldmark.Extender { return &extLinksExt{} }
|
||||
|
||||
func (e *extLinksExt) Extend(m goldmark.Markdown) {
|
||||
m.Parser().AddOptions(parser.WithASTTransformers(
|
||||
util.Prioritized(extLinksTransformer{}, 999),
|
||||
))
|
||||
}
|
||||
@@ -17,7 +17,11 @@ import (
|
||||
//go:embed assets
|
||||
var assets embed.FS
|
||||
|
||||
var tmpl = template.Must(template.New("page.html").ParseFS(assets, "assets/page.html"))
|
||||
var (
|
||||
pageTmpl = template.Must(template.ParseFS(assets, "assets/layout.html", "assets/page.html"))
|
||||
editTmpl = template.Must(template.ParseFS(assets, "assets/layout.html", "assets/edit.html"))
|
||||
searchTmpl = template.Must(template.ParseFS(assets, "assets/layout.html", "assets/search.html"))
|
||||
)
|
||||
|
||||
// specialPage is the result returned by a pageTypeHandler.
|
||||
// Content is injected into the page after the standard markdown content.
|
||||
@@ -41,6 +45,7 @@ var pageTypeHandlers []pageTypeHandler
|
||||
func main() {
|
||||
addr := flag.String("addr", ":8080", "listen address")
|
||||
wikiDir := flag.String("dir", "./wiki", "wiki root directory")
|
||||
cacheDir := flag.String("cache", "./cache", "thumbnail cache directory")
|
||||
user := flag.String("user", "", "basic auth username (empty = no auth)")
|
||||
pass := flag.String("pass", "", "basic auth password")
|
||||
flag.Parse()
|
||||
@@ -53,6 +58,14 @@ func main() {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
thumbCacheDir, err = filepath.Abs(*cacheDir)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
if err := os.MkdirAll(thumbCacheDir, 0755); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
initMarkdown(root)
|
||||
|
||||
authKey, err := loadOrCreateAuthKey(root)
|
||||
@@ -86,6 +99,11 @@ func (h *handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
if strings.HasPrefix(r.URL.Path, thumbURLPrefix+"/") {
|
||||
h.handleThumb(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
urlPath := path.Clean("/" + r.URL.Path)
|
||||
fsPath := filepath.Join(h.root, filepath.FromSlash(urlPath))
|
||||
|
||||
@@ -101,6 +119,11 @@ func (h *handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
if r.Method == http.MethodGet && urlPath == "/" && r.URL.Query().Has("q") {
|
||||
h.handleSearch(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
info, err := os.Stat(fsPath)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
@@ -187,6 +210,8 @@ func (h *handler) serveDir(w http.ResponseWriter, r *http.Request, urlPath, fsPa
|
||||
if sectionIndex < len(sections) {
|
||||
rawContent = string(sections[sectionIndex])
|
||||
}
|
||||
} else if editMode && rawContent == "" && urlPath != "/" {
|
||||
rawContent = "# " + pageTitle(urlPath) + "\n\n"
|
||||
}
|
||||
|
||||
data := pageData{
|
||||
@@ -205,7 +230,11 @@ func (h *handler) serveDir(w http.ResponseWriter, r *http.Request, urlPath, fsPa
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
if err := tmpl.Execute(w, data); err != nil {
|
||||
t := pageTmpl
|
||||
if editMode {
|
||||
t = editTmpl
|
||||
}
|
||||
if err := t.ExecuteTemplate(w, "layout", data); err != nil {
|
||||
log.Printf("template error: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -227,6 +256,7 @@ func (h *handler) handlePost(w http.ResponseWriter, r *http.Request, urlPath, fs
|
||||
}
|
||||
content := r.FormValue("content")
|
||||
indexPath := filepath.Join(fsPath, "index.md")
|
||||
redirectTarget := urlPath
|
||||
|
||||
// If a section index was submitted, splice the edited section back into
|
||||
// the full file rather than replacing the whole document.
|
||||
@@ -242,6 +272,15 @@ func (h *handler) handlePost(w http.ResponseWriter, r *http.Request, urlPath, fs
|
||||
sections[sectionIndex] = []byte(content)
|
||||
}
|
||||
content = string(joinSections(sections))
|
||||
// Section index ≥ 1 is a heading-anchored section. Redirect to its
|
||||
// anchor so the user lands on the section they just saved, even if
|
||||
// the heading text changed.
|
||||
if sectionIndex >= 1 {
|
||||
ids := headingIDs([]byte(content))
|
||||
if sectionIndex-1 < len(ids) {
|
||||
redirectTarget = urlPath + "#" + ids[sectionIndex-1]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if strings.TrimSpace(content) == "" {
|
||||
@@ -259,7 +298,7 @@ func (h *handler) handlePost(w http.ResponseWriter, r *http.Request, urlPath, fs
|
||||
return
|
||||
}
|
||||
}
|
||||
http.Redirect(w, r, urlPath, http.StatusSeeOther)
|
||||
http.Redirect(w, r, redirectTarget, http.StatusSeeOther)
|
||||
}
|
||||
|
||||
// readPageSettings parses a .page-settings file in dir.
|
||||
|
||||
@@ -22,7 +22,7 @@ var md goldmark.Markdown
|
||||
// targets against the filesystem.
|
||||
func initMarkdown(root string) {
|
||||
md = goldmark.New(
|
||||
goldmark.WithExtensions(extension.GFM, extension.Table, newWikiLinkExt(root)),
|
||||
goldmark.WithExtensions(extension.GFM, extension.Table, newWikiLinkExt(root), newExtLinksExt()),
|
||||
goldmark.WithParserOptions(parser.WithAutoHeadingID()),
|
||||
goldmark.WithRendererOptions(html.WithUnsafe()),
|
||||
)
|
||||
|
||||
@@ -0,0 +1,327 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"io/fs"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
"unicode"
|
||||
)
|
||||
|
||||
type searchResult struct {
|
||||
Name string
|
||||
URL string
|
||||
Path string
|
||||
Score int // number of query tokens that hit
|
||||
NameHit bool // at least one hit came from the folder name
|
||||
Snippet string // ~300 chars around first body hit, or page stub for name-only hits
|
||||
}
|
||||
|
||||
type searchPageData struct {
|
||||
Title string
|
||||
Crumbs []crumb
|
||||
EditMode bool
|
||||
Query string
|
||||
Results []searchResult
|
||||
}
|
||||
|
||||
// handleSearch walks the wiki root and renders a search results page for the
|
||||
// query in r.URL.Query().Get("q"). Only invoked when path is "/" and "q" is
|
||||
// present.
|
||||
func (h *handler) handleSearch(w http.ResponseWriter, r *http.Request) {
|
||||
query := strings.TrimSpace(r.URL.Query().Get("q"))
|
||||
results := searchWiki(h.root, query)
|
||||
|
||||
title := "Search"
|
||||
if query != "" {
|
||||
title = "Search: " + query
|
||||
}
|
||||
data := searchPageData{
|
||||
Title: title,
|
||||
Crumbs: []crumb{{Name: "search", URL: "/?q=" + query}},
|
||||
Query: query,
|
||||
Results: results,
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
if err := searchTmpl.ExecuteTemplate(w, "layout", data); err != nil {
|
||||
log.Printf("search template error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// searchWiki walks root and scores each directory by how many whitespace-split
|
||||
// query tokens hit a word in either the folder name or its index.md body.
|
||||
// A word "hits" a token via case-insensitive equality or Levenshtein ≤ 2.
|
||||
// Folder-name hits break score ties above content-only hits.
|
||||
func searchWiki(root, query string) []searchResult {
|
||||
if query == "" {
|
||||
return nil
|
||||
}
|
||||
qTokens := tokenize(query)
|
||||
if len(qTokens) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
walkRoot := resolveWalkRoot(root)
|
||||
var results []searchResult
|
||||
_ = filepath.WalkDir(walkRoot, func(fsPath string, d fs.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
if skip, walkErr := hiddenSkip(fsPath, walkRoot, d); skip {
|
||||
return walkErr
|
||||
}
|
||||
if !d.IsDir() || fsPath == walkRoot {
|
||||
return nil
|
||||
}
|
||||
name := d.Name()
|
||||
body, _ := os.ReadFile(filepath.Join(fsPath, "index.md"))
|
||||
|
||||
nameWords := tokenize(name)
|
||||
bodyStr := string(body)
|
||||
bodyLower := strings.ToLower(bodyStr)
|
||||
bodyWords := tokenize(bodyLower)
|
||||
|
||||
score := 0
|
||||
nameHit := false
|
||||
for _, qt := range qTokens {
|
||||
inName := tokenInWords(qt, nameWords)
|
||||
inBody := tokenInWords(qt, bodyWords)
|
||||
if inName || inBody {
|
||||
score++
|
||||
}
|
||||
if inName {
|
||||
nameHit = true
|
||||
}
|
||||
}
|
||||
if score == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
rel, relErr := filepath.Rel(walkRoot, fsPath)
|
||||
if relErr != nil {
|
||||
return nil
|
||||
}
|
||||
results = append(results, searchResult{
|
||||
Name: name,
|
||||
URL: "/" + filepath.ToSlash(rel) + "/",
|
||||
Path: filepath.ToSlash(rel),
|
||||
Score: score,
|
||||
NameHit: nameHit,
|
||||
Snippet: makeSnippet(bodyStr, bodyLower, qTokens),
|
||||
})
|
||||
return nil
|
||||
})
|
||||
|
||||
sort.SliceStable(results, func(i, j int) bool {
|
||||
if results[i].Score != results[j].Score {
|
||||
return results[i].Score > results[j].Score
|
||||
}
|
||||
if results[i].NameHit != results[j].NameHit {
|
||||
return results[i].NameHit
|
||||
}
|
||||
return strings.ToLower(results[i].Name) < strings.ToLower(results[j].Name)
|
||||
})
|
||||
return results
|
||||
}
|
||||
|
||||
// resolveWalkRoot resolves symlinks so WalkDir descends into the real tree
|
||||
// even when the configured wiki root is itself a symlink (as on the NAS).
|
||||
func resolveWalkRoot(root string) string {
|
||||
if r, err := filepath.EvalSymlinks(root); err == nil {
|
||||
return r
|
||||
}
|
||||
return root
|
||||
}
|
||||
|
||||
// hiddenSkip handles dotfile/dot-dir entries during a WalkDir. It returns
|
||||
// (skipped, walkErr): skipped=true means the caller should `return walkErr`
|
||||
// to either prune the subtree (hidden dir) or move past the entry (hidden
|
||||
// file). When skipped=false the entry should be processed normally.
|
||||
func hiddenSkip(fsPath, walkRoot string, d fs.DirEntry) (bool, error) {
|
||||
if !strings.HasPrefix(d.Name(), ".") {
|
||||
return false, nil
|
||||
}
|
||||
if d.IsDir() && fsPath != walkRoot {
|
||||
return true, filepath.SkipDir
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// tokenize splits s into lowercase word tokens, breaking on any rune that is
|
||||
// not a letter or digit. Unicode-aware so umlauts etc. survive intact.
|
||||
func tokenize(s string) []string {
|
||||
var tokens []string
|
||||
var b strings.Builder
|
||||
for _, r := range s {
|
||||
if unicode.IsLetter(r) || unicode.IsDigit(r) {
|
||||
b.WriteRune(unicode.ToLower(r))
|
||||
continue
|
||||
}
|
||||
if b.Len() > 0 {
|
||||
tokens = append(tokens, b.String())
|
||||
b.Reset()
|
||||
}
|
||||
}
|
||||
if b.Len() > 0 {
|
||||
tokens = append(tokens, b.String())
|
||||
}
|
||||
return tokens
|
||||
}
|
||||
|
||||
// tokenInWords reports whether qt matches any word exactly or within
|
||||
// Levenshtein distance 2. qt and words must already be lowercase.
|
||||
func tokenInWords(qt string, words []string) bool {
|
||||
for _, w := range words {
|
||||
if w == qt {
|
||||
return true
|
||||
}
|
||||
if levenshtein(w, qt) <= 2 {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
var snippetWS = regexp.MustCompile(`\s+`)
|
||||
|
||||
const snippetWindow = 300
|
||||
|
||||
// makeSnippet returns ~300 characters of body around the earliest substring
|
||||
// match of any query token. When no token has an exact substring span (e.g.
|
||||
// matched only via Levenshtein, or the hit was folder-name-only), it falls
|
||||
// back to the first ~300 chars of the body with the leading heading stripped.
|
||||
// Returns "" only when the body itself is empty.
|
||||
func makeSnippet(body, bodyLower string, tokens []string) string {
|
||||
pos := -1
|
||||
for _, t := range tokens {
|
||||
i := strings.Index(bodyLower, t)
|
||||
if i < 0 {
|
||||
continue
|
||||
}
|
||||
if pos < 0 || i < pos {
|
||||
pos = i
|
||||
}
|
||||
}
|
||||
if pos < 0 {
|
||||
return makeStub(body)
|
||||
}
|
||||
|
||||
half := snippetWindow / 2
|
||||
start := pos - half
|
||||
if start < 0 {
|
||||
start = 0
|
||||
}
|
||||
end := pos + half
|
||||
if end > len(body) {
|
||||
end = len(body)
|
||||
}
|
||||
start, end = expandToWordBoundaries(body, start, end)
|
||||
out := snippetWS.ReplaceAllString(body[start:end], " ")
|
||||
out = strings.TrimSpace(out)
|
||||
if start > 0 {
|
||||
out = "…" + out
|
||||
}
|
||||
if end < len(body) {
|
||||
out = out + "…"
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// makeStub returns ~snippetWindow chars from the start of body, with the
|
||||
// leading "# Heading" line stripped. Returns "" for an empty body.
|
||||
func makeStub(body string) string {
|
||||
stripped := string(stripFirstHeading([]byte(body)))
|
||||
stripped = strings.TrimSpace(stripped)
|
||||
if stripped == "" {
|
||||
return ""
|
||||
}
|
||||
end := snippetWindow
|
||||
if end > len(stripped) {
|
||||
end = len(stripped)
|
||||
}
|
||||
_, end = expandToWordBoundaries(stripped, 0, end)
|
||||
out := snippetWS.ReplaceAllString(stripped[:end], " ")
|
||||
out = strings.TrimSpace(out)
|
||||
if end < len(stripped) {
|
||||
out = out + "…"
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// expandToWordBoundaries adjusts start/end so they don't split a word and
|
||||
// don't fall in the middle of a UTF-8 sequence. start moves forward past
|
||||
// any partial word at the beginning; end moves backward to the previous
|
||||
// word boundary.
|
||||
func expandToWordBoundaries(s string, start, end int) (int, int) {
|
||||
for start > 0 && start < len(s) && s[start]&0xC0 == 0x80 {
|
||||
start--
|
||||
}
|
||||
for end < len(s) && s[end]&0xC0 == 0x80 {
|
||||
end++
|
||||
}
|
||||
if start > 0 && start < len(s) && isWordByte(s[start-1]) && isWordByte(s[start]) {
|
||||
for start < end && isWordByte(s[start]) {
|
||||
start++
|
||||
}
|
||||
}
|
||||
if end < len(s) && isWordByte(s[end-1]) && isWordByte(s[end]) {
|
||||
for end > start && isWordByte(s[end-1]) {
|
||||
end--
|
||||
}
|
||||
}
|
||||
return start, end
|
||||
}
|
||||
|
||||
func isWordByte(b byte) bool {
|
||||
if b&0x80 != 0 {
|
||||
return true // assume any multibyte char is part of a word
|
||||
}
|
||||
return (b >= 'a' && b <= 'z') || (b >= 'A' && b <= 'Z') || (b >= '0' && b <= '9')
|
||||
}
|
||||
|
||||
// levenshtein returns the edit distance between a and b. Operates on runes so
|
||||
// multi-byte characters count as one edit.
|
||||
func levenshtein(a, b string) int {
|
||||
ar, br := []rune(a), []rune(b)
|
||||
if len(ar) == 0 {
|
||||
return len(br)
|
||||
}
|
||||
if len(br) == 0 {
|
||||
return len(ar)
|
||||
}
|
||||
prev := make([]int, len(br)+1)
|
||||
curr := make([]int, len(br)+1)
|
||||
for j := range prev {
|
||||
prev[j] = j
|
||||
}
|
||||
for i := 1; i <= len(ar); i++ {
|
||||
curr[0] = i
|
||||
for j := 1; j <= len(br); j++ {
|
||||
cost := 1
|
||||
if ar[i-1] == br[j-1] {
|
||||
cost = 0
|
||||
}
|
||||
del := prev[j] + 1
|
||||
ins := curr[j-1] + 1
|
||||
sub := prev[j-1] + cost
|
||||
curr[j] = min3(del, ins, sub)
|
||||
}
|
||||
prev, curr = curr, prev
|
||||
}
|
||||
return prev[len(br)]
|
||||
}
|
||||
|
||||
func min3(a, b, c int) int {
|
||||
m := a
|
||||
if b < m {
|
||||
m = b
|
||||
}
|
||||
if c < m {
|
||||
m = c
|
||||
}
|
||||
return m
|
||||
}
|
||||
+26
@@ -3,6 +3,9 @@ package main
|
||||
import (
|
||||
"bytes"
|
||||
"regexp"
|
||||
|
||||
"github.com/yuin/goldmark/ast"
|
||||
"github.com/yuin/goldmark/text"
|
||||
)
|
||||
|
||||
var sectionHeadingRe = regexp.MustCompile(`(?m)^#{1,6} `)
|
||||
@@ -25,6 +28,29 @@ func splitSections(raw []byte) [][]byte {
|
||||
return sections
|
||||
}
|
||||
|
||||
// headingIDs returns the auto-generated id of every heading in raw markdown,
|
||||
// in document order. The kth heading (1-indexed) corresponds to section k from
|
||||
// splitSections. Uses the package-level goldmark parser so duplicate-id
|
||||
// numbering matches what the renderer emits.
|
||||
func headingIDs(raw []byte) []string {
|
||||
doc := md.Parser().Parse(text.NewReader(raw))
|
||||
var ids []string
|
||||
ast.Walk(doc, func(n ast.Node, entering bool) (ast.WalkStatus, error) {
|
||||
if !entering {
|
||||
return ast.WalkContinue, nil
|
||||
}
|
||||
if _, ok := n.(*ast.Heading); ok {
|
||||
if v, ok := n.AttributeString("id"); ok {
|
||||
if b, ok := v.([]byte); ok {
|
||||
ids = append(ids, string(b))
|
||||
}
|
||||
}
|
||||
}
|
||||
return ast.WalkContinue, nil
|
||||
})
|
||||
return ids
|
||||
}
|
||||
|
||||
// joinSections reassembles sections produced by splitSections.
|
||||
// Inserts a newline between sections when a non-empty section lacks a
|
||||
// trailing newline, so an edited section cannot inline the next heading.
|
||||
|
||||
@@ -0,0 +1,224 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Thumbnailer produces a thumbnail of a source file. Implementations register
|
||||
// themselves in init() by appending to thumbnailers. The first registered
|
||||
// handler whose CanHandle returns true is used.
|
||||
type Thumbnailer interface {
|
||||
CanHandle(ext string) bool
|
||||
Generate(src io.Reader, dst io.Writer, width int) error
|
||||
}
|
||||
|
||||
var thumbnailers []Thumbnailer
|
||||
|
||||
// thumbCacheDir is set from the -cache flag at startup.
|
||||
var thumbCacheDir string
|
||||
|
||||
const thumbURLPrefix = "/_thumb"
|
||||
|
||||
// Cache is content-addressed: the cache path is derived from the SHA-256 of
|
||||
// the source file. Renames and moves reuse the same cache entry; overwriting
|
||||
// a file with new content produces a new digest and regenerates.
|
||||
var (
|
||||
thumbLocks = map[string]*sync.Mutex{}
|
||||
thumbLocksMu sync.Mutex
|
||||
|
||||
digestCache = map[string]digestEntry{}
|
||||
digestCacheMu sync.Mutex
|
||||
)
|
||||
|
||||
// digestEntry remembers the digest of a source file so repeated requests do
|
||||
// not re-hash the whole file. The (mtime, size) pair invalidates the cache
|
||||
// when the file is overwritten in place.
|
||||
type digestEntry struct {
|
||||
mtime time.Time
|
||||
size int64
|
||||
hex string
|
||||
}
|
||||
|
||||
func findThumbnailer(name string) Thumbnailer {
|
||||
ext := strings.ToLower(filepath.Ext(name))
|
||||
if ext == "" {
|
||||
return nil
|
||||
}
|
||||
for _, t := range thumbnailers {
|
||||
if t.CanHandle(ext) {
|
||||
return t
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// hasThumbnail reports whether a file name has a registered thumbnailer.
|
||||
func hasThumbnail(name string) bool {
|
||||
return findThumbnailer(name) != nil
|
||||
}
|
||||
|
||||
// thumbURL builds a thumbnail URL for a wiki file. filePath must be URL-style
|
||||
// (slash-separated, leading slash), as already used on page links.
|
||||
func thumbURL(filePath string, width int) string {
|
||||
return thumbURLPrefix + filePath + "?w=" + strconv.Itoa(width)
|
||||
}
|
||||
|
||||
func (h *handler) handleThumb(w http.ResponseWriter, r *http.Request) {
|
||||
raw := strings.TrimPrefix(r.URL.Path, thumbURLPrefix)
|
||||
if raw == "" || raw == "/" {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
decoded, err := url.PathUnescape(raw)
|
||||
if err != nil {
|
||||
http.Error(w, "bad path", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
cleanPath := path.Clean(decoded)
|
||||
|
||||
srcFS := filepath.Join(h.root, filepath.FromSlash(cleanPath))
|
||||
rel, err := filepath.Rel(h.root, srcFS)
|
||||
if err != nil || strings.HasPrefix(rel, "..") {
|
||||
http.Error(w, "Forbidden", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
srcInfo, err := os.Stat(srcFS)
|
||||
if err != nil || srcInfo.IsDir() {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
t := findThumbnailer(srcFS)
|
||||
if t == nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
width := 300
|
||||
if s := r.URL.Query().Get("w"); s != "" {
|
||||
if n, err := strconv.Atoi(s); err == nil && n > 0 && n <= 2000 {
|
||||
width = n
|
||||
}
|
||||
}
|
||||
|
||||
digest, data, err := sourceDigest(srcFS, srcInfo)
|
||||
if err != nil {
|
||||
log.Printf("thumb digest %s: %v", rel, err)
|
||||
http.Error(w, "thumbnail failed", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
cacheFS := filepath.Join(thumbCacheDir, digest[:2], fmt.Sprintf("%s.%d.jpg", digest, width))
|
||||
if _, err := os.Stat(cacheFS); err == nil {
|
||||
serveThumb(w, r, cacheFS)
|
||||
return
|
||||
}
|
||||
|
||||
lock := thumbLock(cacheFS)
|
||||
lock.Lock()
|
||||
defer lock.Unlock()
|
||||
|
||||
if _, err := os.Stat(cacheFS); err == nil {
|
||||
serveThumb(w, r, cacheFS)
|
||||
return
|
||||
}
|
||||
|
||||
var src io.Reader
|
||||
if data != nil {
|
||||
src = bytes.NewReader(data)
|
||||
} else {
|
||||
f, err := os.Open(srcFS)
|
||||
if err != nil {
|
||||
log.Printf("thumb open %s: %v", rel, err)
|
||||
http.Error(w, "thumbnail failed", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
defer f.Close()
|
||||
src = f
|
||||
}
|
||||
|
||||
if err := generateThumb(t, src, cacheFS, width); err != nil {
|
||||
log.Printf("thumb %s: %v", rel, err)
|
||||
http.Error(w, "thumbnail failed", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
serveThumb(w, r, cacheFS)
|
||||
}
|
||||
|
||||
// sourceDigest returns the SHA-256 hex digest of a source file's content.
|
||||
// On a cache hit (path + mtime + size unchanged) the returned data is nil,
|
||||
// so the caller knows to open the file itself. On a miss the file is read
|
||||
// once and the contents are returned for the caller to reuse.
|
||||
func sourceDigest(srcFS string, info os.FileInfo) (string, []byte, error) {
|
||||
digestCacheMu.Lock()
|
||||
d, ok := digestCache[srcFS]
|
||||
digestCacheMu.Unlock()
|
||||
if ok && d.mtime.Equal(info.ModTime()) && d.size == info.Size() {
|
||||
return d.hex, nil, nil
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(srcFS)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
sum := sha256.Sum256(data)
|
||||
h := hex.EncodeToString(sum[:])
|
||||
|
||||
digestCacheMu.Lock()
|
||||
digestCache[srcFS] = digestEntry{mtime: info.ModTime(), size: info.Size(), hex: h}
|
||||
digestCacheMu.Unlock()
|
||||
|
||||
return h, data, nil
|
||||
}
|
||||
|
||||
func serveThumb(w http.ResponseWriter, r *http.Request, cacheFS string) {
|
||||
w.Header().Set("Cache-Control", "public, max-age=86400")
|
||||
http.ServeFile(w, r, cacheFS)
|
||||
}
|
||||
|
||||
func generateThumb(t Thumbnailer, src io.Reader, cacheFS string, width int) error {
|
||||
if err := os.MkdirAll(filepath.Dir(cacheFS), 0755); err != nil {
|
||||
return err
|
||||
}
|
||||
tmp, err := os.CreateTemp(filepath.Dir(cacheFS), ".thumb-*")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tmpName := tmp.Name()
|
||||
if err := t.Generate(src, tmp, width); err != nil {
|
||||
tmp.Close()
|
||||
os.Remove(tmpName)
|
||||
return err
|
||||
}
|
||||
if err := tmp.Close(); err != nil {
|
||||
os.Remove(tmpName)
|
||||
return err
|
||||
}
|
||||
return os.Rename(tmpName, cacheFS)
|
||||
}
|
||||
|
||||
func thumbLock(key string) *sync.Mutex {
|
||||
thumbLocksMu.Lock()
|
||||
defer thumbLocksMu.Unlock()
|
||||
m, ok := thumbLocks[key]
|
||||
if !ok {
|
||||
m = &sync.Mutex{}
|
||||
thumbLocks[key] = m
|
||||
}
|
||||
return m
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"image"
|
||||
"image/color"
|
||||
_ "image/gif"
|
||||
"image/jpeg"
|
||||
_ "image/png"
|
||||
"io"
|
||||
)
|
||||
|
||||
func init() {
|
||||
thumbnailers = append(thumbnailers, &imageThumbnailer{})
|
||||
}
|
||||
|
||||
type imageThumbnailer struct{}
|
||||
|
||||
func (it *imageThumbnailer) CanHandle(ext string) bool {
|
||||
switch ext {
|
||||
case ".jpg", ".jpeg", ".png", ".gif":
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (it *imageThumbnailer) Generate(src io.Reader, dst io.Writer, width int) error {
|
||||
img, _, err := image.Decode(src)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return jpeg.Encode(dst, resizeBox(img, width), &jpeg.Options{Quality: 80})
|
||||
}
|
||||
|
||||
// resizeBox downsamples src to the requested width using a box filter.
|
||||
// Aspect ratio is preserved. Upscaling is a no-op (returns src unchanged).
|
||||
// Each source pixel is visited exactly once; alpha is discarded.
|
||||
func resizeBox(src image.Image, width int) image.Image {
|
||||
b := src.Bounds()
|
||||
srcW, srcH := b.Dx(), b.Dy()
|
||||
if srcW <= width {
|
||||
return src
|
||||
}
|
||||
dstW := width
|
||||
dstH := srcH * width / srcW
|
||||
if dstH < 1 {
|
||||
dstH = 1
|
||||
}
|
||||
dst := image.NewRGBA(image.Rect(0, 0, dstW, dstH))
|
||||
|
||||
for y := 0; y < dstH; y++ {
|
||||
sy0 := y * srcH / dstH
|
||||
sy1 := (y + 1) * srcH / dstH
|
||||
if sy1 == sy0 {
|
||||
sy1 = sy0 + 1
|
||||
}
|
||||
for x := 0; x < dstW; x++ {
|
||||
sx0 := x * srcW / dstW
|
||||
sx1 := (x + 1) * srcW / dstW
|
||||
if sx1 == sx0 {
|
||||
sx1 = sx0 + 1
|
||||
}
|
||||
var r, g, bl, n uint64
|
||||
for sy := sy0; sy < sy1; sy++ {
|
||||
for sx := sx0; sx < sx1; sx++ {
|
||||
sr, sg, sb, _ := src.At(b.Min.X+sx, b.Min.Y+sy).RGBA()
|
||||
r += uint64(sr >> 8)
|
||||
g += uint64(sg >> 8)
|
||||
bl += uint64(sb >> 8)
|
||||
n++
|
||||
}
|
||||
}
|
||||
dst.SetRGBA(x, y, color.RGBA{
|
||||
R: uint8(r / n),
|
||||
G: uint8(g / n),
|
||||
B: uint8(bl / n),
|
||||
A: 255,
|
||||
})
|
||||
}
|
||||
}
|
||||
return dst
|
||||
}
|
||||
Reference in New Issue
Block a user