Compare commits
31 Commits
33180c8be8
...
c2c4960ee7
| Author | SHA1 | Date | |
|---|---|---|---|
| c2c4960ee7 | |||
| a30fa8b061 | |||
| 60ef401427 | |||
| 5a015d438b | |||
| 69037a77ed | |||
| 892e4860b4 | |||
| 73743ced78 | |||
| 80b93127dc | |||
| 1e54ec1f16 | |||
| 3d3a121fa6 | |||
| 2787c15d40 | |||
| 0a30325b96 | |||
| dc79557bdb | |||
| 6083a36824 | |||
| 4c55bd050f | |||
| 80f6abcbaa | |||
| 72edf7b258 | |||
| c016dcabaa | |||
| 762d86b84a | |||
| 903f21e7f1 | |||
| bb137e9c93 | |||
| c53701eb87 | |||
| 511b65df22 | |||
| dd3906855d | |||
| 6db0f0774c | |||
| 2d0ab6ae0e | |||
| adbe10fa41 | |||
| 4415a391e2 | |||
| 337ec9ef6e | |||
| df41cfa8c8 | |||
| 207c16ed29 |
@@ -1,5 +1,7 @@
|
|||||||
.claude/
|
.claude/
|
||||||
|
.zed/
|
||||||
wiki/
|
wiki/
|
||||||
|
cache/
|
||||||
|
|
||||||
# Binaries
|
# Binaries
|
||||||
datascape
|
datascape
|
||||||
|
|||||||
@@ -2,12 +2,17 @@
|
|||||||
|
|
||||||
Minimal self-hosted personal wiki. Folders are pages.
|
Minimal self-hosted personal wiki. Folders are pages.
|
||||||
|
|
||||||
## Run
|
## Features
|
||||||
|
|
||||||
```bash
|
- **Pages** every folder is a page. Place an `index.md` inside a folder and it renders as HTML. Drop any other files (PDFs, images, etc.) alongside it and they appear in the listing below the content. Navigating to a path that does not exist shows a **[CREATE]** prompt.
|
||||||
go run . -dir ./wiki -addr :8080
|
|
||||||
go run . -dir ./wiki -addr :8080 -user me -pass secret
|
- **Search** search across all page names (folder names) in the wiki, accessible from the navigation bar.
|
||||||
```
|
|
||||||
|
- **Wikilinks** link between pages with `[[Page Name]]` syntax. When a page is renamed or moved, all wikilinks pointing to it are rewritten automatically to reflect the new path.
|
||||||
|
|
||||||
|
- **Movie import** import movie entries via the OMDb API. Fetches title, year, runtime, genre, director, cast, plot, and poster, and pre-fills a new page with that metadata.
|
||||||
|
|
||||||
|
- **Special folder types** folders can opt into custom rendering (e.g. a photo diary with calendar navigation). See the [Special Folder Types](#special-folder-types) section for details.
|
||||||
|
|
||||||
## Build
|
## Build
|
||||||
|
|
||||||
@@ -21,16 +26,19 @@ GOOS=linux GOARCH=arm go build -o datascape .
|
|||||||
|
|
||||||
## Usage
|
## Usage
|
||||||
|
|
||||||
| Action | How |
|
```bash
|
||||||
|--------|-----|
|
go run . -dir ./wiki -addr :8080
|
||||||
| Browse | Navigate folders at `/` |
|
go run . -dir ./wiki -addr :8080 -user me -pass secret
|
||||||
| Read | Any folder with `index.md` renders it as HTML |
|
```
|
||||||
| Edit | Append `?edit` to any folder URL, or click **[EDIT]** (Alt+Shift+E) |
|
|
||||||
| Save | POST from the edit form writes `index.md` to disk; folder is created if needed |
|
|
||||||
| New page | Click **[NEW]** (Alt+Shift+N), enter a name — opens the new page in edit mode |
|
|
||||||
| Files | Drop PDFs, images, etc. next to `index.md` — they appear in the listing |
|
|
||||||
|
|
||||||
Navigating to a URL that does not exist shows an empty page with a **[CREATE]** prompt.
|
| Flag | Default | Description |
|
||||||
|
|------|---------|-------------|
|
||||||
|
| `-addr` | `:8080` | Listen address |
|
||||||
|
| `-dir` | `./wiki` | Wiki root directory |
|
||||||
|
| `-cache` | `./cache` | Thumbnail cache directory |
|
||||||
|
| `-user` | _(none)_ | Basic auth username — omit to disable auth |
|
||||||
|
| `-pass` | _(none)_ | Basic auth password |
|
||||||
|
| `-reindex-interval` | `30m` | Periodic search index rebuild interval (`0` disables) |
|
||||||
|
|
||||||
## Special Folder Types
|
## Special Folder Types
|
||||||
|
|
||||||
|
|||||||
@@ -4,15 +4,15 @@
|
|||||||
<a href="{{.MonthURL}}" class="diary-cal-heading">{{.MonthName}}</a>
|
<a href="{{.MonthURL}}" class="diary-cal-heading">{{.MonthName}}</a>
|
||||||
<div class="dropdown diary-cal-drop">
|
<div class="dropdown diary-cal-drop">
|
||||||
<button type="button" class="btn btn-small" data-action="cal-month-drop" aria-expanded="false" title="Monat wählen">▾</button>
|
<button type="button" class="btn btn-small" data-action="cal-month-drop" aria-expanded="false" title="Monat wählen">▾</button>
|
||||||
<div class="dropdown-menu">
|
<div class="dropdown-menu scrollable">
|
||||||
{{range .AllMonths}}<a class="dropdown-item{{if .IsCurrent}} cal-current{{end}}" href="{{.URL}}">{{.Name}}</a>{{end}}
|
{{range .AllMonths}}<a class="btn dropdown-item{{if .IsCurrent}} cal-current{{end}}" href="{{.URL}}">{{.Name}}</a>{{end}}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<a href="{{.YearURL}}" class="diary-cal-heading">{{.DisplayYear}}</a>
|
<a href="{{.YearURL}}" class="diary-cal-heading">{{.DisplayYear}}</a>
|
||||||
<div class="dropdown diary-cal-drop">
|
<div class="dropdown diary-cal-drop">
|
||||||
<button type="button" class="btn btn-small" data-action="cal-year-drop" aria-expanded="false" title="Jahr wählen">▾</button>
|
<button type="button" class="btn btn-small" data-action="cal-year-drop" aria-expanded="false" title="Jahr wählen">▾</button>
|
||||||
<div class="dropdown-menu align-right">
|
<div class="dropdown-menu align-right scrollable">
|
||||||
{{range .Years}}<a class="dropdown-item{{if .IsCurrent}} cal-current{{end}}" href="{{.URL}}">{{.Num}}</a>{{end}}
|
{{range .Years}}<a class="btn dropdown-item{{if .IsCurrent}} cal-current{{end}}" href="{{.URL}}">{{.Num}}</a>{{end}}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -26,4 +26,4 @@
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
<script src="/_/diary/diary-calendar.js"></script>
|
<script src="/_/diary/calendar.js"></script>
|
||||||
@@ -18,7 +18,7 @@
|
|||||||
main.parentNode.insertBefore(cal, main);
|
main.parentNode.insertBefore(cal, main);
|
||||||
}
|
}
|
||||||
|
|
||||||
cal.querySelectorAll(".diary-cal-drop > button").forEach(wireDropdown);
|
cal.querySelectorAll(".dropdown > button").forEach(wireDropdown);
|
||||||
|
|
||||||
var pageHeader = document.querySelector("header");
|
var pageHeader = document.querySelector("header");
|
||||||
function updateTop() {
|
function updateTop() {
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
{{if .Photos}}
|
||||||
|
<div class="photo-grid">
|
||||||
|
{{range .Photos}}
|
||||||
|
<a href="{{.URL}}" target="_blank"><img src="{{.ThumbURL}}" alt="" loading="lazy"></a>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
{{if .Photos}}
|
|
||||||
<div class="photo-grid">
|
|
||||||
{{range .Photos}}
|
|
||||||
<a href="{{.URL}}" target="_blank"><img src="{{.URL}}" alt="{{.Name}}" loading="lazy"></a>
|
|
||||||
{{end}}
|
|
||||||
</div>
|
|
||||||
{{end}}
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
<h2 id="months">Months</h2>
|
|
||||||
{{range .Months}}
|
|
||||||
<h3 id="{{.ID}}">
|
|
||||||
<a href="{{.URL}}">{{.Name}}</a>
|
|
||||||
{{if .PhotoCount}}<span class="muted">({{.PhotoCount}} photos)</span>{{end}}
|
|
||||||
</h3>
|
|
||||||
{{end}}
|
|
||||||
@@ -7,7 +7,7 @@
|
|||||||
{{if .Photos}}
|
{{if .Photos}}
|
||||||
<div class="photo-grid">
|
<div class="photo-grid">
|
||||||
{{range .Photos}}
|
{{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}}
|
{{end}}
|
||||||
</div>
|
</div>
|
||||||
{{end}}
|
{{end}}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
<h2 id="months">Monate</h2>
|
||||||
|
{{range .Months}}
|
||||||
|
<h3 id="{{.ID}}">
|
||||||
|
<a href="{{.URL}}">{{.Name}}</a>
|
||||||
|
</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,80 @@
|
|||||||
|
{{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"}}
|
||||||
|
<script>
|
||||||
|
if (sessionStorage.getItem('editor-wide') === '1') document.body.classList.add('editor-wide');
|
||||||
|
</script>
|
||||||
|
<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="dropdown">
|
||||||
|
<button type="button" class="btn btn-tool dropdown-toggle" title="Heading (1/2/3)">H▾</button>
|
||||||
|
<div class="dropdown-menu">
|
||||||
|
<button type="button" class="btn btn-tool dropdown-item" data-action="h1" data-key="1" title="Heading 1 (1)">Heading 1</button>
|
||||||
|
<button type="button" class="btn btn-tool dropdown-item" data-action="h2" data-key="2" title="Heading 2 (2)">Heading 2</button>
|
||||||
|
<button type="button" class="btn btn-tool dropdown-item" data-action="h3" data-key="3" title="Heading 3 (3)">Heading 3</button>
|
||||||
|
</div>
|
||||||
|
</span>
|
||||||
|
<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="dropdown">
|
||||||
|
<button type="button" class="btn btn-tool dropdown-toggle" title="Link (L/P)">L▾</button>
|
||||||
|
<div class="dropdown-menu">
|
||||||
|
<button type="button" class="btn btn-tool dropdown-item" data-action="link" data-key="L" title="Link (L)">Link</button>
|
||||||
|
<button type="button" class="btn btn-tool dropdown-item" data-action="wikilink" data-key="P" title="Wiki link (P)">Wiki link</button>
|
||||||
|
</div>
|
||||||
|
</span>
|
||||||
|
<span class="dropdown">
|
||||||
|
<button type="button" class="btn btn-tool dropdown-toggle" title="List (U/O/X)">≡▾</button>
|
||||||
|
<div class="dropdown-menu">
|
||||||
|
<button type="button" class="btn btn-tool dropdown-item" data-action="ul" data-key="U" title="Unordered list (U)">Unordered list</button>
|
||||||
|
<button type="button" class="btn btn-tool dropdown-item" data-action="ol" data-key="O" title="Ordered list (O)">Ordered list</button>
|
||||||
|
<button type="button" class="btn btn-tool dropdown-item" data-action="task" data-key="X" title="Task list (X)">Task list</button>
|
||||||
|
</div>
|
||||||
|
</span>
|
||||||
|
<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="hr" data-key="R" title="Horizontal rule (R)">---</button>
|
||||||
|
<span class="toolbar-sep"></span>
|
||||||
|
<span class="dropdown">
|
||||||
|
<button type="button" class="btn btn-tool dropdown-toggle" title="Table (T)">T▾</button>
|
||||||
|
<div class="dropdown-menu">
|
||||||
|
<button type="button" class="btn btn-tool dropdown-item" data-action="fmttable" data-key="T" title="Format table (T)">Format table</button>
|
||||||
|
<button type="button" class="btn btn-tool dropdown-item" data-action="tblalignleft" title="Align left">Align left</button>
|
||||||
|
<button type="button" class="btn btn-tool dropdown-item" data-action="tblaligncenter" title="Align center">Align center</button>
|
||||||
|
<button type="button" class="btn btn-tool dropdown-item" data-action="tblalignright" title="Align right">Align right</button>
|
||||||
|
<button type="button" class="btn btn-tool dropdown-item" data-action="tblinsertcol" title="Insert column">Insert column</button>
|
||||||
|
<button type="button" class="btn btn-tool dropdown-item" data-action="tbldeletecol" title="Delete column">Delete column</button>
|
||||||
|
<button type="button" class="btn btn-tool dropdown-item" data-action="tblinsertrow" title="Insert row">Insert row</button>
|
||||||
|
<button type="button" class="btn btn-tool dropdown-item" data-action="tbldeleterow" title="Delete row">Delete row</button>
|
||||||
|
</div>
|
||||||
|
</span>
|
||||||
|
<span class="dropdown">
|
||||||
|
<button type="button" class="btn btn-tool dropdown-toggle" title="Insert date (D/W)">D▾</button>
|
||||||
|
<div class="dropdown-menu">
|
||||||
|
<button type="button" class="btn btn-tool dropdown-item" data-action="dateiso" data-key="D" title="YYYY-MM-DD (D)">YYYY-MM-DD</button>
|
||||||
|
<button type="button" class="btn btn-tool dropdown-item" data-action="datelong" data-key="W" title="DE Long (W)">DE Long</button>
|
||||||
|
</div>
|
||||||
|
</span>
|
||||||
|
<span class="dropdown">
|
||||||
|
<button type="button" class="btn btn-tool dropdown-toggle" title="Special (V)">★▾</button>
|
||||||
|
<div class="dropdown-menu">
|
||||||
|
<button type="button" class="btn btn-tool dropdown-item" data-action="movie" data-key="V" title="Import movie (V)">Import movie</button>
|
||||||
|
</div>
|
||||||
|
</span>
|
||||||
|
<span class="toolbar-sep"></span>
|
||||||
|
<button type="button" class="btn btn-tool" data-action="wide" data-key="Z" title="Toggle wide mode (Z)">⇔</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/main.js"></script>
|
||||||
|
{{end}}
|
||||||
@@ -6,44 +6,55 @@
|
|||||||
|
|
||||||
// --- DOM helpers ---
|
// --- DOM helpers ---
|
||||||
|
|
||||||
|
// Route every edit through execCommand so the browser's native undo/redo
|
||||||
|
// stack is preserved. Direct assignment to textarea.value would wipe it.
|
||||||
|
function replaceRange(start, end, text) {
|
||||||
|
textarea.focus();
|
||||||
|
textarea.selectionStart = start;
|
||||||
|
textarea.selectionEnd = end;
|
||||||
|
document.execCommand('insertText', false, text);
|
||||||
|
}
|
||||||
|
|
||||||
function wrap(before, after, placeholder) {
|
function wrap(before, after, placeholder) {
|
||||||
var start = textarea.selectionStart;
|
var start = textarea.selectionStart;
|
||||||
var end = textarea.selectionEnd;
|
var end = textarea.selectionEnd;
|
||||||
var selected = textarea.value.slice(start, end) || placeholder;
|
var hadSelection = end > start;
|
||||||
var replacement = before + selected + after;
|
var selected = hadSelection ? textarea.value.slice(start, end) : placeholder;
|
||||||
textarea.value = textarea.value.slice(0, start) + replacement + textarea.value.slice(end);
|
replaceRange(start, end, before + selected + after);
|
||||||
if (selected === placeholder) {
|
if (!hadSelection) {
|
||||||
textarea.selectionStart = start + before.length;
|
textarea.selectionStart = start + before.length;
|
||||||
textarea.selectionEnd = start + before.length + placeholder.length;
|
textarea.selectionEnd = start + before.length + placeholder.length;
|
||||||
} else {
|
|
||||||
textarea.selectionStart = start + replacement.length;
|
|
||||||
textarea.selectionEnd = start + replacement.length;
|
|
||||||
}
|
}
|
||||||
textarea.focus();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function linePrefix(prefix) {
|
function linePrefix(prefix) {
|
||||||
var start = textarea.selectionStart;
|
var start = textarea.selectionStart;
|
||||||
var lineStart = textarea.value.lastIndexOf('\n', start - 1) + 1;
|
var lineStart = textarea.value.lastIndexOf('\n', start - 1) + 1;
|
||||||
textarea.value = textarea.value.slice(0, lineStart) + prefix + textarea.value.slice(lineStart);
|
replaceRange(lineStart, lineStart, prefix);
|
||||||
textarea.selectionStart = textarea.selectionEnd = start + prefix.length;
|
textarea.selectionStart = textarea.selectionEnd = start + prefix.length;
|
||||||
textarea.focus();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function insertAtCursor(s) {
|
function insertAtCursor(s) {
|
||||||
var start = textarea.selectionStart;
|
replaceRange(textarea.selectionStart, textarea.selectionEnd, s);
|
||||||
var end = textarea.selectionEnd;
|
|
||||||
textarea.value = textarea.value.slice(0, start) + s + textarea.value.slice(end);
|
|
||||||
textarea.selectionStart = textarea.selectionEnd = start + s.length;
|
|
||||||
textarea.dispatchEvent(new Event('input'));
|
|
||||||
textarea.focus();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function applyResult(result) {
|
function applyResult(result) {
|
||||||
textarea.value = result.text;
|
var oldText = textarea.value;
|
||||||
|
var newText = result.text;
|
||||||
|
var prefixLen = 0;
|
||||||
|
var maxPrefix = Math.min(oldText.length, newText.length);
|
||||||
|
while (prefixLen < maxPrefix && oldText.charCodeAt(prefixLen) === newText.charCodeAt(prefixLen)) {
|
||||||
|
prefixLen++;
|
||||||
|
}
|
||||||
|
var oldEnd = oldText.length;
|
||||||
|
var newEnd = newText.length;
|
||||||
|
while (oldEnd > prefixLen && newEnd > prefixLen
|
||||||
|
&& oldText.charCodeAt(oldEnd - 1) === newText.charCodeAt(newEnd - 1)) {
|
||||||
|
oldEnd--;
|
||||||
|
newEnd--;
|
||||||
|
}
|
||||||
|
replaceRange(prefixLen, oldEnd, newText.slice(prefixLen, newEnd));
|
||||||
textarea.selectionStart = textarea.selectionEnd = result.cursor;
|
textarea.selectionStart = textarea.selectionEnd = result.cursor;
|
||||||
textarea.dispatchEvent(new Event('input'));
|
|
||||||
textarea.focus();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function applyTableOp(fn, arg) {
|
function applyTableOp(fn, arg) {
|
||||||
@@ -53,11 +64,51 @@
|
|||||||
if (result) applyResult(result);
|
if (result) applyResult(result);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function promptDisplayText(initial, onDone) {
|
||||||
|
var input = document.createElement('input');
|
||||||
|
input.type = 'text';
|
||||||
|
input.className = 'modal-input';
|
||||||
|
input.placeholder = 'Display text (optional)';
|
||||||
|
if (initial) input.value = initial;
|
||||||
|
var handle = openModal({
|
||||||
|
title: 'Insert link — display text?',
|
||||||
|
body: input,
|
||||||
|
confirm: {
|
||||||
|
label: 'INSERT',
|
||||||
|
onConfirm: function () {
|
||||||
|
handle.close();
|
||||||
|
onDone(input.value.trim());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function insertWikilink() {
|
||||||
|
var sel = textarea.value.slice(textarea.selectionStart, textarea.selectionEnd);
|
||||||
|
openTreePicker({
|
||||||
|
title: 'Insert link',
|
||||||
|
mode: 'any',
|
||||||
|
initialPath: '/',
|
||||||
|
confirmLabel: 'NEXT',
|
||||||
|
onSelect: function (path, kind) {
|
||||||
|
if (kind === 'folder') {
|
||||||
|
promptDisplayText(sel, function (display) {
|
||||||
|
insertAtCursor(display ? '[[' + path + '::' + display + ']]' : '[[' + path + ']]');
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
var name = path.split('/').pop();
|
||||||
|
insertAtCursor('[' + (sel || name) + '](' + path + ')');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// --- Actions ---
|
// --- Actions ---
|
||||||
|
|
||||||
var T = EditorTables;
|
var T = EditorTables;
|
||||||
var L = EditorLists;
|
var L = EditorLists;
|
||||||
var D = EditorDates;
|
var D = EditorDates;
|
||||||
|
var M = EditorMovie;
|
||||||
|
|
||||||
var actions = {
|
var actions = {
|
||||||
save: function () { form.submit(); },
|
save: function () { form.submit(); },
|
||||||
@@ -70,25 +121,10 @@
|
|||||||
codeblock: function () { wrap('```\n', '\n```', 'code'); },
|
codeblock: function () { wrap('```\n', '\n```', 'code'); },
|
||||||
quote: function () { linePrefix('> '); },
|
quote: function () { linePrefix('> '); },
|
||||||
link: function () { wrap('[', '](url)', 'link text'); },
|
link: function () { wrap('[', '](url)', 'link text'); },
|
||||||
wikilink: function () {
|
wikilink: insertWikilink,
|
||||||
var sel = textarea.value.slice(textarea.selectionStart, textarea.selectionEnd);
|
|
||||||
openTreePicker({
|
|
||||||
title: 'Insert link',
|
|
||||||
mode: 'any',
|
|
||||||
initialPath: '/',
|
|
||||||
confirmLabel: 'INSERT',
|
|
||||||
onSelect: function (path, kind) {
|
|
||||||
if (kind === 'folder') {
|
|
||||||
insertAtCursor(sel ? '[[' + path + '|' + sel + ']]' : '[[' + path + ']]');
|
|
||||||
} else {
|
|
||||||
var name = path.split('/').pop();
|
|
||||||
insertAtCursor('[' + (sel || name) + '](' + path + ')');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
},
|
|
||||||
ul: function () { linePrefix('- '); },
|
ul: function () { linePrefix('- '); },
|
||||||
ol: function () { linePrefix('1. '); },
|
ol: function () { linePrefix('1. '); },
|
||||||
|
task: function () { linePrefix('- [ ] '); },
|
||||||
hr: function () { wrap('\n\n---\n\n', '', ''); },
|
hr: function () { wrap('\n\n---\n\n', '', ''); },
|
||||||
fmttable: function () { applyTableOp(T.formatTableText); },
|
fmttable: function () { applyTableOp(T.formatTableText); },
|
||||||
tblalignleft: function () { applyTableOp(T.setColumnAlignment, 'left'); },
|
tblalignleft: function () { applyTableOp(T.setColumnAlignment, 'left'); },
|
||||||
@@ -100,6 +136,12 @@
|
|||||||
tbldeleterow: function () { applyTableOp(T.deleteRow); },
|
tbldeleterow: function () { applyTableOp(T.deleteRow); },
|
||||||
dateiso: function () { insertAtCursor(D.isoDate()); },
|
dateiso: function () { insertAtCursor(D.isoDate()); },
|
||||||
datelong: function () { insertAtCursor(D.longDate()); },
|
datelong: function () { insertAtCursor(D.longDate()); },
|
||||||
|
movie: function () { M.run(textarea); },
|
||||||
|
wide: function () {
|
||||||
|
var enabled = !document.body.classList.contains('editor-wide');
|
||||||
|
document.body.classList.toggle('editor-wide', enabled);
|
||||||
|
sessionStorage.setItem('editor-wide', enabled ? '1' : '0');
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
// --- Keyboard shortcut registration ---
|
// --- Keyboard shortcut registration ---
|
||||||
@@ -115,13 +157,12 @@
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
keyMap['T'] = actions.fmttable;
|
|
||||||
keyMap['D'] = actions.dateiso;
|
|
||||||
keyMap['W'] = actions.datelong;
|
|
||||||
|
|
||||||
document.addEventListener('keydown', function (e) {
|
document.addEventListener('keydown', function (e) {
|
||||||
if (!e.altKey || !e.shiftKey) return;
|
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) {
|
if (action) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
action();
|
action();
|
||||||
@@ -153,69 +194,7 @@
|
|||||||
applyResult(result);
|
applyResult(result);
|
||||||
});
|
});
|
||||||
|
|
||||||
// --- Dropdown helper ---
|
// --- Dropdowns ---
|
||||||
|
|
||||||
var openMenus = [];
|
document.querySelectorAll('.dropdown-toggle').forEach(wireDropdown);
|
||||||
|
|
||||||
function makeDropdown(triggerBtn, items) {
|
|
||||||
var menu = document.createElement('div');
|
|
||||||
menu.className = 'dropdown-menu';
|
|
||||||
items.forEach(function (item) {
|
|
||||||
var btn = document.createElement('button');
|
|
||||||
btn.type = 'button';
|
|
||||||
btn.className = 'btn btn-tool dropdown-item';
|
|
||||||
btn.textContent = item.label;
|
|
||||||
btn.addEventListener('mousedown', function (e) {
|
|
||||||
e.preventDefault();
|
|
||||||
actions[item.action]();
|
|
||||||
menu.classList.remove('is-open');
|
|
||||||
});
|
|
||||||
menu.appendChild(btn);
|
|
||||||
});
|
|
||||||
triggerBtn.appendChild(menu);
|
|
||||||
openMenus.push(menu);
|
|
||||||
|
|
||||||
triggerBtn.addEventListener('click', function (e) {
|
|
||||||
if (e.target !== triggerBtn) return;
|
|
||||||
var wasOpen = menu.classList.contains('is-open');
|
|
||||||
openMenus.forEach(function (m) { m.classList.remove('is-open'); });
|
|
||||||
if (!wasOpen) menu.classList.add('is-open');
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
document.addEventListener('click', function (e) {
|
|
||||||
var insideAny = openMenus.some(function (m) {
|
|
||||||
return m.parentElement && m.parentElement.contains(e.target);
|
|
||||||
});
|
|
||||||
if (!insideAny) openMenus.forEach(function (m) { m.classList.remove('is-open'); });
|
|
||||||
});
|
|
||||||
document.addEventListener('keydown', function (e) {
|
|
||||||
if (e.key === 'Escape') openMenus.forEach(function (m) { m.classList.remove('is-open'); });
|
|
||||||
});
|
|
||||||
|
|
||||||
// --- Table dropdown ---
|
|
||||||
|
|
||||||
var tblDropBtn = document.querySelector('[data-action="tbldrop"]');
|
|
||||||
if (tblDropBtn) {
|
|
||||||
makeDropdown(tblDropBtn, [
|
|
||||||
{ label: 'Format table', action: 'fmttable' },
|
|
||||||
{ label: 'Align left', action: 'tblalignleft' },
|
|
||||||
{ label: 'Align center', action: 'tblaligncenter' },
|
|
||||||
{ label: 'Align right', action: 'tblalignright' },
|
|
||||||
{ label: 'Insert column', action: 'tblinsertcol' },
|
|
||||||
{ label: 'Delete column', action: 'tbldeletecol' },
|
|
||||||
{ label: 'Insert row', action: 'tblinsertrow' },
|
|
||||||
{ label: 'Delete row', action: 'tbldeleterow' },
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Date dropdown ---
|
|
||||||
|
|
||||||
var dateDropBtn = document.querySelector('[data-action="datedrop"]');
|
|
||||||
if (dateDropBtn) {
|
|
||||||
makeDropdown(dateDropBtn, [
|
|
||||||
{ label: 'YYYY-MM-DD', action: 'dateiso' },
|
|
||||||
{ label: 'DE Long', action: 'datelong' },
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
})();
|
})();
|
||||||
@@ -0,0 +1,183 @@
|
|||||||
|
window.EditorMovie = (function () {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
var STORAGE_KEY = 'omdb-api-key';
|
||||||
|
|
||||||
|
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 replaceRange(ta, start, end, text) {
|
||||||
|
ta.focus();
|
||||||
|
ta.selectionStart = start;
|
||||||
|
ta.selectionEnd = end;
|
||||||
|
document.execCommand('insertText', false, text);
|
||||||
|
}
|
||||||
|
|
||||||
|
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) {
|
||||||
|
replaceRange(ta, b, e + END.length, markup);
|
||||||
|
} else {
|
||||||
|
var h = t.match(/^#{1,6}\s+.+?\s*$/m);
|
||||||
|
if (h) {
|
||||||
|
var idx = t.indexOf(h[0]) + h[0].length;
|
||||||
|
replaceRange(ta, idx, idx, '\n\n' + markup);
|
||||||
|
} else {
|
||||||
|
replaceRange(ta, 0, 0, t ? markup + '\n\n' : markup);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function fetchMovie(key, title, year) {
|
||||||
|
var url = 'https://www.omdbapi.com/?apikey=' + encodeURIComponent(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 promptForKey(rejected, onSaved) {
|
||||||
|
var body = document.createDocumentFragment();
|
||||||
|
|
||||||
|
if (rejected) {
|
||||||
|
var notice = document.createElement('p');
|
||||||
|
notice.textContent = 'The previously stored key was rejected by OMDb.';
|
||||||
|
body.appendChild(notice);
|
||||||
|
}
|
||||||
|
|
||||||
|
var info = document.createElement('p');
|
||||||
|
info.appendChild(document.createTextNode('Enter your OMDb API key. Get one at '));
|
||||||
|
var link = document.createElement('a');
|
||||||
|
link.href = 'https://www.omdbapi.com/apikey.aspx';
|
||||||
|
link.target = '_blank';
|
||||||
|
link.rel = 'noopener';
|
||||||
|
link.textContent = 'omdbapi.com/apikey.aspx';
|
||||||
|
info.appendChild(link);
|
||||||
|
info.appendChild(document.createTextNode('.'));
|
||||||
|
body.appendChild(info);
|
||||||
|
|
||||||
|
var input = document.createElement('input');
|
||||||
|
input.type = 'text';
|
||||||
|
input.className = 'modal-input';
|
||||||
|
input.placeholder = 'OMDb API key';
|
||||||
|
body.appendChild(input);
|
||||||
|
|
||||||
|
openModal({
|
||||||
|
title: 'OMDb API key required',
|
||||||
|
body: body,
|
||||||
|
confirm: {
|
||||||
|
label: 'SAVE',
|
||||||
|
onConfirm: function () {
|
||||||
|
var key = input.value.trim();
|
||||||
|
if (!key) return;
|
||||||
|
localStorage.setItem(STORAGE_KEY, key);
|
||||||
|
closeModal();
|
||||||
|
onSaved(key);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function importWithKey(textarea, key, initialTitle) {
|
||||||
|
var input = document.createElement('input');
|
||||||
|
input.type = 'text';
|
||||||
|
input.className = 'modal-input';
|
||||||
|
input.placeholder = 'Title, optionally with (YYYY)';
|
||||||
|
input.value = initialTitle;
|
||||||
|
|
||||||
|
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(key, parsed.title, parsed.year)
|
||||||
|
.then(function (data) {
|
||||||
|
if (data && data.Response === 'False' &&
|
||||||
|
data.Error === 'Invalid API key!') {
|
||||||
|
localStorage.removeItem(STORAGE_KEY);
|
||||||
|
promptForKey(true, function (newKey) {
|
||||||
|
importWithKey(textarea, newKey, raw);
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
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.');
|
||||||
|
});
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function run(textarea) {
|
||||||
|
var initialTitle = firstHeading(textarea.value || '');
|
||||||
|
var key = localStorage.getItem(STORAGE_KEY);
|
||||||
|
if (!key) {
|
||||||
|
promptForKey(false, function (newKey) {
|
||||||
|
importWithKey(textarea, newKey, initialTitle);
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
importWithKey(textarea, key, initialTitle);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { run: run };
|
||||||
|
})();
|
||||||
@@ -14,6 +14,13 @@
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (window.location.pathname !== '/' && typeof movePage === 'function') movePage();
|
if (window.location.pathname !== '/' && typeof movePage === 'function') movePage();
|
||||||
break;
|
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,41 @@
|
|||||||
|
{{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="/" tabindex="-1"><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}}" tabindex="-1">{{.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>
|
||||||
|
<footer>
|
||||||
|
<span class="muted">Request: {{.RenderMS}} ms</span>
|
||||||
|
{{block "footerExtras" .}}{{end}}
|
||||||
|
</footer>
|
||||||
|
{{block "extras" .}}{{end}}
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
{{end}}
|
||||||
@@ -1,106 +0,0 @@
|
|||||||
<!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>
|
|
||||||
@@ -56,16 +56,46 @@ function movePage() {
|
|||||||
preselect: parent,
|
preselect: parent,
|
||||||
hideFiles: true,
|
hideFiles: true,
|
||||||
confirmLabel: 'NEXT',
|
confirmLabel: 'NEXT',
|
||||||
allowRoot: false,
|
|
||||||
onSelect: function (newParent) {
|
onSelect: function (newParent) {
|
||||||
promptPageName('Move — new name?', currentName, 'MOVE', function (name) {
|
var input = document.createElement('input');
|
||||||
|
input.type = 'text';
|
||||||
|
input.className = 'modal-input';
|
||||||
|
input.placeholder = 'Page name';
|
||||||
|
input.value = currentName;
|
||||||
|
|
||||||
|
var linksCheckbox = document.createElement('input');
|
||||||
|
linksCheckbox.type = 'checkbox';
|
||||||
|
linksCheckbox.id = 'move-update-links';
|
||||||
|
|
||||||
|
var linksLabel = document.createElement('label');
|
||||||
|
linksLabel.htmlFor = linksCheckbox.id;
|
||||||
|
linksLabel.className = 'modal-checkbox';
|
||||||
|
linksLabel.appendChild(linksCheckbox);
|
||||||
|
linksLabel.appendChild(document.createTextNode('Update links'));
|
||||||
|
|
||||||
|
var body = document.createDocumentFragment();
|
||||||
|
body.appendChild(input);
|
||||||
|
body.appendChild(linksLabel);
|
||||||
|
|
||||||
|
openModal({
|
||||||
|
title: 'Move — new name?',
|
||||||
|
body: body,
|
||||||
|
confirm: {
|
||||||
|
label: 'MOVE',
|
||||||
|
onConfirm: function () {
|
||||||
|
var name = input.value.trim();
|
||||||
|
if (!name) return;
|
||||||
var dest = (newParent === '/' ? '' : newParent) + '/' + name;
|
var dest = (newParent === '/' ? '' : newParent) + '/' + name;
|
||||||
|
var action = window.location.pathname + '?move=' +
|
||||||
|
encodeURIComponent(dest);
|
||||||
|
if (linksCheckbox.checked) action += '&links=1';
|
||||||
var form = document.createElement('form');
|
var form = document.createElement('form');
|
||||||
form.method = 'POST';
|
form.method = 'POST';
|
||||||
form.action = window.location.pathname + '?move=' +
|
form.action = action;
|
||||||
encodeURIComponent(dest);
|
|
||||||
document.body.appendChild(form);
|
document.body.appendChild(form);
|
||||||
form.submit();
|
form.submit();
|
||||||
|
}
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
(function () {
|
||||||
|
var content = document.querySelector("main");
|
||||||
|
if (!content) return;
|
||||||
|
|
||||||
|
var headings = content.querySelectorAll("h2, h3, h4");
|
||||||
|
if (!headings) return
|
||||||
|
|
||||||
|
headings.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);
|
||||||
|
});
|
||||||
|
}());
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
{{define "headScripts"}}<script src="/_/page/actions.js"></script>{{end}}
|
||||||
|
|
||||||
|
{{define "content"}}
|
||||||
|
{{if .Content}}
|
||||||
|
<div class="content">{{.Content}}</div>
|
||||||
|
{{end}}
|
||||||
|
{{if .SpecialContent}}
|
||||||
|
<div class="content">{{.SpecialContent}}</div>
|
||||||
|
{{end}}
|
||||||
|
{{if .Entries}}
|
||||||
|
<h2 id="files">Files</h2>
|
||||||
|
<div class="listing">
|
||||||
|
{{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}}
|
||||||
|
{{if or .Content .SpecialContent}}
|
||||||
|
<script src="/_/page/content.js"></script>
|
||||||
|
<script src="/_/page/anchors.js"></script>
|
||||||
|
<script src="/_/page/toc.js"></script>
|
||||||
|
<script src="/_/page/tasks.js"></script>
|
||||||
|
{{end}}
|
||||||
|
{{if .Content}}
|
||||||
|
<script src="/_/page/sections.js"></script>
|
||||||
|
{{end}}
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
{{define "extras"}}
|
||||||
|
{{if .SidebarWidget}}{{.SidebarWidget}}{{end}}
|
||||||
|
{{if .CanEdit}}
|
||||||
|
<div class="fab dropdown">
|
||||||
|
<button class="btn btn-fab" data-action="actions-drop" title="Actions" aria-label="Actions">≡</button>
|
||||||
|
<div class="dropdown-menu align-right open-up">
|
||||||
|
<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}}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
(function () {
|
||||||
|
document.querySelectorAll('input.task-checkbox[data-task-index]').forEach(function (cb) {
|
||||||
|
cb.addEventListener('change', function () {
|
||||||
|
var idx = cb.dataset.taskIndex;
|
||||||
|
var checked = cb.checked;
|
||||||
|
cb.disabled = true;
|
||||||
|
fetch(window.location.pathname + '?toggle=' + idx, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||||
|
body: 'checked=' + checked
|
||||||
|
}).then(function (res) {
|
||||||
|
if (!res.ok) {
|
||||||
|
cb.checked = !checked;
|
||||||
|
alert('Failed to save task state (' + res.status + ')');
|
||||||
|
}
|
||||||
|
}).catch(function () {
|
||||||
|
cb.checked = !checked;
|
||||||
|
alert('Failed to save task state');
|
||||||
|
}).finally(function () {
|
||||||
|
cb.disabled = false;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
})();
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
var content = document.querySelector("main");
|
var content = document.querySelector("main");
|
||||||
if (!content) return;
|
if (!content) return;
|
||||||
|
|
||||||
var headings = content.querySelectorAll("h1, h2, h3");
|
var headings = content.querySelectorAll("h2, h3, h4");
|
||||||
if (headings.length < 2) return;
|
if (headings.length < 2) return;
|
||||||
|
|
||||||
var nav = document.createElement("nav");
|
var nav = document.createElement("nav");
|
||||||
@@ -21,7 +21,7 @@
|
|||||||
var a = document.createElement("a");
|
var a = document.createElement("a");
|
||||||
a.href = "#" + h.id;
|
a.href = "#" + h.id;
|
||||||
var clone = h.cloneNode(true);
|
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();
|
a.textContent = clone.textContent.trim();
|
||||||
li.appendChild(a);
|
li.appendChild(a);
|
||||||
list.appendChild(li);
|
list.appendChild(li);
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
function rebuildIndex() {
|
||||||
|
openModal({ title: 'Rebuilding search index…', body: 'Walking the wiki tree.' });
|
||||||
|
fetch('/_reindex', { method: 'POST' })
|
||||||
|
.then(function (resp) {
|
||||||
|
if (!resp.ok) throw new Error('rebuild failed: ' + resp.status);
|
||||||
|
window.location.href = window.location.href;
|
||||||
|
})
|
||||||
|
.catch(function (err) {
|
||||||
|
closeModal();
|
||||||
|
openModal({ title: 'Rebuild failed', body: String(err), confirm: { label: 'OK' } });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('DOMContentLoaded', function () {
|
||||||
|
wireDropdown(document.querySelector('[data-action="actions-drop"]'));
|
||||||
|
|
||||||
|
// Focus the search input on results pages so Tab steps directly into the
|
||||||
|
// first match — the input sits immediately before the results in DOM
|
||||||
|
// order, so the natural tab sequence is input → first result → next, …
|
||||||
|
var input = document.querySelector('.search-input');
|
||||||
|
if (input && input.value) {
|
||||||
|
input.focus();
|
||||||
|
var end = input.value.length;
|
||||||
|
try { input.setSelectionRange(end, end); } catch (e) {}
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
{{define "headScripts"}}<script src="/_/search/actions.js"></script>{{end}}
|
||||||
|
|
||||||
|
{{define "searchQuery"}}{{.Query}}{{end}}
|
||||||
|
|
||||||
|
{{define "content"}}
|
||||||
|
{{if .Query}}
|
||||||
|
{{if .Results}}
|
||||||
|
<p class="muted">{{len .Results}} match{{if ne (len .Results) 1}}es{{end}} for “{{.Query}}”</p>
|
||||||
|
<hr/>
|
||||||
|
{{range .Results}}
|
||||||
|
<article class="search-card">
|
||||||
|
<a href="{{.URL}}">{{.Name}}</a>
|
||||||
|
<div class="muted">/{{.Path}}</div>
|
||||||
|
</article>
|
||||||
|
{{end}}
|
||||||
|
{{else}}
|
||||||
|
<p class="empty">No matches for “{{.Query}}”.</p>
|
||||||
|
{{end}}
|
||||||
|
{{else}}
|
||||||
|
<p class="empty">Enter a query above.</p>
|
||||||
|
{{end}}
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
{{define "footerExtras"}}
|
||||||
|
{{if not .IndexBuiltAt.IsZero}}<span class="muted">· Index: {{.IndexBuiltAt.Format "2006-01-02 15:04"}}</span>{{end}}
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
{{define "extras"}}
|
||||||
|
<div class="fab dropdown">
|
||||||
|
<button class="btn btn-fab" data-action="actions-drop" title="Actions" aria-label="Actions">≡</button>
|
||||||
|
<div class="dropdown-menu align-right open-up">
|
||||||
|
<button class="btn dropdown-item" onclick="rebuildIndex()" title="Rebuild search index">REBUILD INDEX</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
+169
-22
@@ -37,6 +37,8 @@ body {
|
|||||||
font:
|
font:
|
||||||
1rem "Iosevka Etoile",
|
1rem "Iosevka Etoile",
|
||||||
monospace;
|
monospace;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
|
|
||||||
* {
|
* {
|
||||||
@@ -148,6 +150,8 @@ main {
|
|||||||
max-width: 860px;
|
max-width: 860px;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
padding: 1.5rem 1rem;
|
padding: 1.5rem 1rem;
|
||||||
|
width: 100%;
|
||||||
|
flex: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* === Markdown content === */
|
/* === Markdown content === */
|
||||||
@@ -234,26 +238,35 @@ main {
|
|||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
a.heading-anchor {
|
||||||
|
color: var(--text-muted);
|
||||||
|
margin-right: 0.4em;
|
||||||
|
font-weight: normal;
|
||||||
|
}
|
||||||
|
a.heading-anchor:hover {
|
||||||
|
color: var(--primary-hover);
|
||||||
|
}
|
||||||
|
|
||||||
/* === File listing === */
|
/* === File listing === */
|
||||||
.listing {
|
.listing {
|
||||||
border: 1px solid var(--secondary);
|
border: 1px solid var(--secondary);
|
||||||
}
|
}
|
||||||
.listing-header {
|
main > h2 {
|
||||||
padding: 0.5rem 1rem;
|
|
||||||
font-size: 0.8rem;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.05em;
|
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
border-bottom: 1px solid var(--secondary);
|
margin: 1.25rem 0 0.5rem;
|
||||||
|
line-height: 1.3;
|
||||||
|
font-size: 1.4rem;
|
||||||
}
|
}
|
||||||
.listing-item {
|
.listing-item {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.75rem;
|
gap: 0.75rem;
|
||||||
padding: 0.6rem 1rem;
|
padding: 0.6rem 1rem;
|
||||||
border-top: 1px solid var(--secondary);
|
|
||||||
font-size: 0.95rem;
|
font-size: 0.95rem;
|
||||||
}
|
}
|
||||||
|
.listing-item + .listing-item {
|
||||||
|
border-top: 1px solid var(--secondary);
|
||||||
|
}
|
||||||
.listing-item:hover {
|
.listing-item:hover {
|
||||||
background: var(--bg-panel-hover);
|
background: var(--bg-panel-hover);
|
||||||
}
|
}
|
||||||
@@ -264,9 +277,7 @@ main {
|
|||||||
}
|
}
|
||||||
.listing-item a {
|
.listing-item a {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
overflow: hidden;
|
overflow-wrap: anywhere;
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
color: inherit;
|
color: inherit;
|
||||||
}
|
}
|
||||||
.listing-item .meta {
|
.listing-item .meta {
|
||||||
@@ -314,18 +325,59 @@ main {
|
|||||||
right: 0;
|
right: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.dropdown-menu.open-up {
|
||||||
|
top: auto;
|
||||||
|
bottom: 100%;
|
||||||
|
margin-bottom: 0.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
.dropdown-menu.is-open {
|
.dropdown-menu.is-open {
|
||||||
display: block;
|
display: block;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.dropdown-menu.scrollable {
|
||||||
|
max-height: 14rem;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* === Floating action button === */
|
||||||
|
.fab {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 1rem;
|
||||||
|
right: 1rem;
|
||||||
|
z-index: 50;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-fab {
|
||||||
|
background: var(--bg-panel);
|
||||||
|
border: 1px solid var(--secondary);
|
||||||
|
width: 3rem;
|
||||||
|
height: 3rem;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
line-height: 1;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
.btn-fab::before,
|
||||||
|
.btn-fab::after {
|
||||||
|
content: none;
|
||||||
|
}
|
||||||
|
.btn-fab:hover {
|
||||||
|
background: var(--bg-panel-hover);
|
||||||
|
color: var(--primary-hover);
|
||||||
|
}
|
||||||
|
|
||||||
.dropdown-item {
|
.dropdown-item {
|
||||||
display: block;
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: baseline;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
text-align: left;
|
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: 0;
|
border-radius: 0;
|
||||||
padding: 0.3rem 0.75rem;
|
padding: 0.3rem 0.75rem;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* === Edit form === */
|
/* === Edit form === */
|
||||||
@@ -334,6 +386,10 @@ main {
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
body.editor-wide main {
|
||||||
|
max-width: none;
|
||||||
|
}
|
||||||
|
|
||||||
textarea {
|
textarea {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
min-height: 60vh;
|
min-height: 60vh;
|
||||||
@@ -350,12 +406,68 @@ textarea {
|
|||||||
box-sizing: border-box;
|
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-card {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.25rem;
|
||||||
|
padding-bottom: 1rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
border-bottom: 1px dashed var(--secondary);
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
.search-card:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
.search-card a {
|
||||||
|
color: var(--link);
|
||||||
|
font-size: 1.1rem;
|
||||||
|
}
|
||||||
|
.search-card a:hover {
|
||||||
|
color: var(--link-hover);
|
||||||
|
}
|
||||||
|
|
||||||
/* === Muted text === */
|
/* === Muted text === */
|
||||||
.muted {
|
.muted {
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* === Page footer === */
|
||||||
|
footer {
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
border-top: 1px dashed var(--secondary);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* === Task lists === */
|
||||||
|
.content li:has(> input.task-checkbox:checked) {
|
||||||
|
color: var(--text-muted);
|
||||||
|
text-decoration: line-through;
|
||||||
|
}
|
||||||
|
|
||||||
/* === Photo grid === */
|
/* === Photo grid === */
|
||||||
.photo-grid {
|
.photo-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
@@ -374,6 +486,7 @@ textarea {
|
|||||||
height: 140px;
|
height: 140px;
|
||||||
object-fit: cover;
|
object-fit: cover;
|
||||||
display: block;
|
display: block;
|
||||||
|
background: var(--bg-panel) url("/_/icons/thumb-placeholder.svg") center/2rem no-repeat;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* === Empty state === */
|
/* === Empty state === */
|
||||||
@@ -409,7 +522,7 @@ hr {
|
|||||||
top: 1rem;
|
top: 1rem;
|
||||||
right: 1rem;
|
right: 1rem;
|
||||||
width: 14rem;
|
width: 14rem;
|
||||||
max-height: calc(100vh - 2rem);
|
max-height: calc(100vh - 6rem);
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
border: 1px solid var(--secondary);
|
border: 1px solid var(--secondary);
|
||||||
background: var(--bg);
|
background: var(--bg);
|
||||||
@@ -448,10 +561,10 @@ hr {
|
|||||||
color: var(--link-hover);
|
color: var(--link-hover);
|
||||||
}
|
}
|
||||||
|
|
||||||
.toc-h2 {
|
.toc-h3 {
|
||||||
padding-left: 0.8rem;
|
padding-left: 0.8rem;
|
||||||
}
|
}
|
||||||
.toc-h3 {
|
.toc-h4 {
|
||||||
padding-left: 1.6rem;
|
padding-left: 1.6rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -524,6 +637,12 @@ hr {
|
|||||||
outline: none;
|
outline: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.modal-checkbox {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
/* === Tree picker === */
|
/* === Tree picker === */
|
||||||
.tree-picker {
|
.tree-picker {
|
||||||
max-height: 60vh;
|
max-height: 60vh;
|
||||||
@@ -584,6 +703,31 @@ hr {
|
|||||||
word-break: break-all;
|
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 Calendar === */
|
||||||
.diary-cal {
|
.diary-cal {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
@@ -658,12 +802,7 @@ hr {
|
|||||||
color: var(--primary-hover);
|
color: var(--primary-hover);
|
||||||
}
|
}
|
||||||
|
|
||||||
.diary-cal-drop .dropdown-menu {
|
.dropdown-item.cal-current {
|
||||||
max-height: 14rem;
|
|
||||||
overflow-y: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.diary-cal-drop .dropdown-item.cal-current {
|
|
||||||
color: var(--primary-hover);
|
color: var(--primary-hover);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -707,7 +846,8 @@ hr {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 600px) {
|
@media (max-width: 600px) {
|
||||||
header {
|
header,
|
||||||
|
footer {
|
||||||
padding: 0.5rem 0.75rem;
|
padding: 0.5rem 0.75rem;
|
||||||
}
|
}
|
||||||
main {
|
main {
|
||||||
@@ -732,4 +872,11 @@ hr {
|
|||||||
.modal-header {
|
.modal-header {
|
||||||
cursor: default;
|
cursor: default;
|
||||||
}
|
}
|
||||||
|
.listing-item {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.listing-item .meta {
|
||||||
|
flex-basis: 100%;
|
||||||
|
padding-left: calc(1.25rem + 0.75rem);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -138,6 +138,13 @@
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (kind === 'folder') {
|
||||||
|
row.addEventListener('dblclick', function (e) {
|
||||||
|
e.preventDefault();
|
||||||
|
if (isOpen) collapse(); else expand();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
rowEl: row,
|
rowEl: row,
|
||||||
name: name,
|
name: name,
|
||||||
|
|||||||
@@ -102,7 +102,7 @@ type calendarData struct {
|
|||||||
AllMonths []calMonth
|
AllMonths []calMonth
|
||||||
}
|
}
|
||||||
|
|
||||||
var diaryCalTmpl = template.Must(template.ParseFS(assets, "assets/diary/diary-calendar.html"))
|
var diaryCalTmpl = template.Must(template.ParseFS(assets, "assets/diary/calendar.html"))
|
||||||
|
|
||||||
func computeCalendarWidget(diaryRootFS, diaryRootURL, fsPath string, depth int) template.HTML {
|
func computeCalendarWidget(diaryRootFS, diaryRootURL, fsPath string, depth int) template.HTML {
|
||||||
today := time.Now()
|
today := time.Now()
|
||||||
@@ -286,13 +286,14 @@ type diaryPhoto struct {
|
|||||||
Date time.Time
|
Date time.Time
|
||||||
Name string
|
Name string
|
||||||
URL string
|
URL string
|
||||||
|
ThumbURL string
|
||||||
}
|
}
|
||||||
|
|
||||||
type diaryMonthSummary struct {
|
type diaryMonthSummary struct {
|
||||||
ID string
|
ID string
|
||||||
Name string
|
Name string
|
||||||
URL string
|
URL string
|
||||||
PhotoCount int
|
Photos []diaryPhoto
|
||||||
}
|
}
|
||||||
|
|
||||||
type diaryDaySection struct {
|
type diaryDaySection struct {
|
||||||
@@ -311,9 +312,9 @@ type diaryYearData struct {
|
|||||||
type diaryMonthData struct{ Days []diaryDaySection }
|
type diaryMonthData struct{ Days []diaryDaySection }
|
||||||
type diaryDayData struct{ Photos []diaryPhoto }
|
type diaryDayData struct{ Photos []diaryPhoto }
|
||||||
|
|
||||||
var diaryYearTmpl = template.Must(template.ParseFS(assets, "assets/diary/diary-year.html"))
|
var diaryYearTmpl = template.Must(template.ParseFS(assets, "assets/diary/year.html"))
|
||||||
var diaryMonthTmpl = template.Must(template.ParseFS(assets, "assets/diary/diary-month.html"))
|
var diaryMonthTmpl = template.Must(template.ParseFS(assets, "assets/diary/month.html"))
|
||||||
var diaryDayTmpl = template.Must(template.ParseFS(assets, "assets/diary/diary-day.html"))
|
var diaryDayTmpl = template.Must(template.ParseFS(assets, "assets/diary/day.html"))
|
||||||
|
|
||||||
var germanWeekdays = map[time.Weekday]string{
|
var germanWeekdays = map[time.Weekday]string{
|
||||||
time.Sunday: "Sonntag",
|
time.Sunday: "Sonntag",
|
||||||
@@ -376,10 +377,16 @@ func yearPhotos(yearFsPath, yearURLPath string) []diaryPhoto {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
photoURL := path.Join(yearURLPath, url.PathEscape(name))
|
||||||
|
thumb := photoURL
|
||||||
|
if hasThumbnail(name) {
|
||||||
|
thumb = thumbURL(photoURL, 300)
|
||||||
|
}
|
||||||
photos = append(photos, diaryPhoto{
|
photos = append(photos, diaryPhoto{
|
||||||
Date: t,
|
Date: t,
|
||||||
Name: name,
|
Name: name,
|
||||||
URL: path.Join(yearURLPath, url.PathEscape(name)),
|
URL: photoURL,
|
||||||
|
ThumbURL: thumb,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
return photos
|
return photos
|
||||||
@@ -394,32 +401,52 @@ func renderDiaryYear(fsPath, urlPath string) template.HTML {
|
|||||||
|
|
||||||
photos := yearPhotos(fsPath, urlPath)
|
photos := yearPhotos(fsPath, urlPath)
|
||||||
|
|
||||||
entries, err := os.ReadDir(fsPath)
|
// Collect month numbers from both subdirectories and photo filenames so
|
||||||
if err != nil {
|
// years that contain only photos (no diary entries) still list months.
|
||||||
return ""
|
monthSet := map[int]bool{}
|
||||||
}
|
monthDirs := map[int]string{}
|
||||||
|
entries, _ := os.ReadDir(fsPath)
|
||||||
var months []diaryMonthSummary
|
|
||||||
for _, e := range entries {
|
for _, e := range entries {
|
||||||
if !e.IsDir() {
|
if !e.IsDir() {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
monthNum, err := strconv.Atoi(e.Name())
|
n, err := strconv.Atoi(e.Name())
|
||||||
if err != nil || monthNum < 1 || monthNum > 12 {
|
if err != nil || n < 1 || n > 12 {
|
||||||
continue
|
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 {
|
for _, p := range photos {
|
||||||
if p.Date.Year() == year && int(p.Date.Month()) == monthNum {
|
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)
|
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{
|
months = append(months, diaryMonthSummary{
|
||||||
ID: monthDate.Format("2006-01"),
|
ID: monthDate.Format("2006-01"),
|
||||||
Name: monthDate.Format("January 2006"),
|
Name: fmt.Sprintf("%s %d", germanMonths[monthDate.Month()], year),
|
||||||
URL: path.Join(urlPath, e.Name()) + "/",
|
URL: path.Join(urlPath, dirName) + "/",
|
||||||
PhotoCount: count,
|
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),
|
||||||
|
))
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"embed"
|
"embed"
|
||||||
"flag"
|
"flag"
|
||||||
"html/template"
|
"html/template"
|
||||||
@@ -12,12 +13,17 @@ import (
|
|||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
//go:embed assets
|
//go:embed assets
|
||||||
var assets embed.FS
|
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/main.html"))
|
||||||
|
editTmpl = template.Must(template.ParseFS(assets, "assets/layout.html", "assets/editor/main.html"))
|
||||||
|
searchTmpl = template.Must(template.ParseFS(assets, "assets/layout.html", "assets/search/main.html"))
|
||||||
|
)
|
||||||
|
|
||||||
// specialPage is the result returned by a pageTypeHandler.
|
// specialPage is the result returned by a pageTypeHandler.
|
||||||
// Content is injected into the page after the standard markdown content.
|
// Content is injected into the page after the standard markdown content.
|
||||||
@@ -41,8 +47,10 @@ var pageTypeHandlers []pageTypeHandler
|
|||||||
func main() {
|
func main() {
|
||||||
addr := flag.String("addr", ":8080", "listen address")
|
addr := flag.String("addr", ":8080", "listen address")
|
||||||
wikiDir := flag.String("dir", "./wiki", "wiki root directory")
|
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)")
|
user := flag.String("user", "", "basic auth username (empty = no auth)")
|
||||||
pass := flag.String("pass", "", "basic auth password")
|
pass := flag.String("pass", "", "basic auth password")
|
||||||
|
reindexInterval := flag.Duration("reindex-interval", 30*time.Minute, "periodic search index rebuild interval (0 disables)")
|
||||||
flag.Parse()
|
flag.Parse()
|
||||||
|
|
||||||
root, err := filepath.Abs(*wikiDir)
|
root, err := filepath.Abs(*wikiDir)
|
||||||
@@ -53,6 +61,14 @@ func main() {
|
|||||||
log.Fatal(err)
|
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)
|
initMarkdown(root)
|
||||||
|
|
||||||
authKey, err := loadOrCreateAuthKey(root)
|
authKey, err := loadOrCreateAuthKey(root)
|
||||||
@@ -70,8 +86,33 @@ func main() {
|
|||||||
static.ServeHTTP(w, r)
|
static.ServeHTTP(w, r)
|
||||||
}))
|
}))
|
||||||
http.HandleFunc("/_logout", h.handleLogout)
|
http.HandleFunc("/_logout", h.handleLogout)
|
||||||
|
http.HandleFunc("/_reindex", h.handleReindex)
|
||||||
http.Handle("/", h)
|
http.Handle("/", h)
|
||||||
|
|
||||||
|
// Build the folder index off the request path so the listener can start
|
||||||
|
// accepting connections immediately. searchWiki blocks on folderIndex.ready
|
||||||
|
// so the first search after a cold start still returns correct results.
|
||||||
|
go func() {
|
||||||
|
folderIndex.buildMu.Lock()
|
||||||
|
entries := buildFolderIndex(root)
|
||||||
|
folderIndex.Lock()
|
||||||
|
folderIndex.entries = entries
|
||||||
|
folderIndex.builtAt = time.Now()
|
||||||
|
folderIndex.Unlock()
|
||||||
|
folderIndex.buildMu.Unlock()
|
||||||
|
close(folderIndex.ready)
|
||||||
|
}()
|
||||||
|
|
||||||
|
if *reindexInterval > 0 {
|
||||||
|
go func(interval time.Duration) {
|
||||||
|
t := time.NewTicker(interval)
|
||||||
|
defer t.Stop()
|
||||||
|
for range t.C {
|
||||||
|
rebuildFolderIndex(root)
|
||||||
|
}
|
||||||
|
}(*reindexInterval)
|
||||||
|
}
|
||||||
|
|
||||||
log.Printf("datascape listening on %s, wiki at %s", *addr, root)
|
log.Printf("datascape listening on %s, wiki at %s", *addr, root)
|
||||||
log.Fatal(http.ListenAndServe(*addr, nil))
|
log.Fatal(http.ListenAndServe(*addr, nil))
|
||||||
}
|
}
|
||||||
@@ -81,11 +122,32 @@ type handler struct {
|
|||||||
authKey []byte
|
authKey []byte
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// reqStartKey marks the request start time stored in the request context
|
||||||
|
// so HTML templates can render total server-side processing time.
|
||||||
|
type reqStartKeyT struct{}
|
||||||
|
|
||||||
|
var reqStartKey = reqStartKeyT{}
|
||||||
|
|
||||||
|
// elapsedMS returns the milliseconds since the request entered ServeHTTP.
|
||||||
|
func elapsedMS(r *http.Request) int64 {
|
||||||
|
if start, ok := r.Context().Value(reqStartKey).(time.Time); ok {
|
||||||
|
return time.Since(start).Milliseconds()
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
func (h *handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
func (h *handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||||
|
r = r.WithContext(context.WithValue(r.Context(), reqStartKey, time.Now()))
|
||||||
|
|
||||||
if !h.checkAuth(w, r) {
|
if !h.checkAuth(w, r) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if strings.HasPrefix(r.URL.Path, thumbURLPrefix+"/") {
|
||||||
|
h.handleThumb(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
urlPath := path.Clean("/" + r.URL.Path)
|
urlPath := path.Clean("/" + r.URL.Path)
|
||||||
fsPath := filepath.Join(h.root, filepath.FromSlash(urlPath))
|
fsPath := filepath.Join(h.root, filepath.FromSlash(urlPath))
|
||||||
|
|
||||||
@@ -101,6 +163,11 @@ func (h *handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if r.Method == http.MethodGet && urlPath == "/" && r.URL.Query().Has("q") {
|
||||||
|
h.handleSearch(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
info, err := os.Stat(fsPath)
|
info, err := os.Stat(fsPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if os.IsNotExist(err) {
|
if os.IsNotExist(err) {
|
||||||
@@ -187,6 +254,8 @@ func (h *handler) serveDir(w http.ResponseWriter, r *http.Request, urlPath, fsPa
|
|||||||
if sectionIndex < len(sections) {
|
if sectionIndex < len(sections) {
|
||||||
rawContent = string(sections[sectionIndex])
|
rawContent = string(sections[sectionIndex])
|
||||||
}
|
}
|
||||||
|
} else if editMode && rawContent == "" && urlPath != "/" {
|
||||||
|
rawContent = "# " + pageTitle(urlPath) + "\n\n"
|
||||||
}
|
}
|
||||||
|
|
||||||
data := pageData{
|
data := pageData{
|
||||||
@@ -205,7 +274,12 @@ func (h *handler) serveDir(w http.ResponseWriter, r *http.Request, urlPath, fsPa
|
|||||||
}
|
}
|
||||||
|
|
||||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
if err := tmpl.Execute(w, data); err != nil {
|
t := pageTmpl
|
||||||
|
if editMode {
|
||||||
|
t = editTmpl
|
||||||
|
}
|
||||||
|
data.RenderMS = elapsedMS(r)
|
||||||
|
if err := t.ExecuteTemplate(w, "layout", data); err != nil {
|
||||||
log.Printf("template error: %v", err)
|
log.Printf("template error: %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -217,7 +291,11 @@ func (h *handler) handlePost(w http.ResponseWriter, r *http.Request, urlPath, fs
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
if _, ok := query["move"]; ok {
|
if _, ok := query["move"]; ok {
|
||||||
h.handleMove(w, r, urlPath, fsPath, query.Get("move"))
|
h.handleMove(w, r, urlPath, fsPath, query.Get("move"), query.Has("links"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if query.Has("toggle") {
|
||||||
|
h.handleToggle(w, r, fsPath)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -227,6 +305,7 @@ func (h *handler) handlePost(w http.ResponseWriter, r *http.Request, urlPath, fs
|
|||||||
}
|
}
|
||||||
content := r.FormValue("content")
|
content := r.FormValue("content")
|
||||||
indexPath := filepath.Join(fsPath, "index.md")
|
indexPath := filepath.Join(fsPath, "index.md")
|
||||||
|
redirectTarget := urlPath
|
||||||
|
|
||||||
// If a section index was submitted, splice the edited section back into
|
// If a section index was submitted, splice the edited section back into
|
||||||
// the full file rather than replacing the whole document.
|
// the full file rather than replacing the whole document.
|
||||||
@@ -242,6 +321,15 @@ func (h *handler) handlePost(w http.ResponseWriter, r *http.Request, urlPath, fs
|
|||||||
sections[sectionIndex] = []byte(content)
|
sections[sectionIndex] = []byte(content)
|
||||||
}
|
}
|
||||||
content = string(joinSections(sections))
|
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) == "" {
|
if strings.TrimSpace(content) == "" {
|
||||||
@@ -250,6 +338,10 @@ func (h *handler) handlePost(w http.ResponseWriter, r *http.Request, urlPath, fs
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
// Stat first so we know whether MkdirAll actually created the folder
|
||||||
|
// — if it did, the search index needs a new entry.
|
||||||
|
_, statErr := os.Stat(fsPath)
|
||||||
|
newlyCreated := os.IsNotExist(statErr)
|
||||||
if err := os.MkdirAll(fsPath, 0755); err != nil {
|
if err := os.MkdirAll(fsPath, 0755); err != nil {
|
||||||
http.Error(w, "mkdir failed: "+err.Error(), http.StatusInternalServerError)
|
http.Error(w, "mkdir failed: "+err.Error(), http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
@@ -258,8 +350,13 @@ func (h *handler) handlePost(w http.ResponseWriter, r *http.Request, urlPath, fs
|
|||||||
http.Error(w, "write failed: "+err.Error(), http.StatusInternalServerError)
|
http.Error(w, "write failed: "+err.Error(), http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if newlyCreated {
|
||||||
|
if rel, err := filepath.Rel(h.root, fsPath); err == nil {
|
||||||
|
folderIndexAdd(filepath.ToSlash(rel))
|
||||||
}
|
}
|
||||||
http.Redirect(w, r, urlPath, http.StatusSeeOther)
|
}
|
||||||
|
}
|
||||||
|
http.Redirect(w, r, redirectTarget, http.StatusSeeOther)
|
||||||
}
|
}
|
||||||
|
|
||||||
// readPageSettings parses a .page-settings file in dir.
|
// readPageSettings parses a .page-settings file in dir.
|
||||||
|
|||||||
@@ -10,10 +10,11 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
// handleMove moves the folder at srcFsPath (wiki URL srcURL) to dstURL and
|
// handleMove moves the folder at srcFsPath (wiki URL srcURL) to dstURL. When
|
||||||
// rewrites every [[...]] wiki link across the tree that targets the old path
|
// updateLinks is true it also rewrites every [[...]] wiki link across the
|
||||||
// or any descendant. All rewritten files are held in memory for rollback.
|
// tree that targets the old path or any descendant; rewritten files are held
|
||||||
func (h *handler) handleMove(w http.ResponseWriter, r *http.Request, srcURL, srcFsPath, dstURL string) {
|
// in memory for rollback.
|
||||||
|
func (h *handler) handleMove(w http.ResponseWriter, r *http.Request, srcURL, srcFsPath, dstURL string, updateLinks bool) {
|
||||||
oldPath := normalizeMovePath(srcURL)
|
oldPath := normalizeMovePath(srcURL)
|
||||||
if oldPath == "/" {
|
if oldPath == "/" {
|
||||||
http.Error(w, "cannot move wiki root", http.StatusBadRequest)
|
http.Error(w, "cannot move wiki root", http.StatusBadRequest)
|
||||||
@@ -44,12 +45,14 @@ func (h *handler) handleMove(w http.ResponseWriter, r *http.Request, srcURL, src
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Phase 1: walk the tree and rewrite every index.md that references the
|
// Phase 1: optionally walk the tree and rewrite every index.md that
|
||||||
// moved path. Keep the pre-rewrite bytes in memory so we can revert on
|
// references the moved path. Keep the pre-rewrite bytes in memory so we
|
||||||
// failure. The walker only reads directory listings and files literally
|
// can revert on failure. The walker only reads directory listings and
|
||||||
// named index.md; hidden directories are pruned. A cheap substring check
|
// files literally named index.md; hidden directories are pruned. A cheap
|
||||||
// skips parsing files that cannot contain a relevant link.
|
// substring check skips parsing files that cannot contain a relevant
|
||||||
|
// link.
|
||||||
rewritten := map[string][]byte{}
|
rewritten := map[string][]byte{}
|
||||||
|
if updateLinks {
|
||||||
needle := []byte("[[" + oldPath)
|
needle := []byte("[[" + oldPath)
|
||||||
walkErr := walkIndexFiles(h.root, func(fsPath string) error {
|
walkErr := walkIndexFiles(h.root, func(fsPath string) error {
|
||||||
orig, err := os.ReadFile(fsPath)
|
orig, err := os.ReadFile(fsPath)
|
||||||
@@ -74,6 +77,7 @@ func (h *handler) handleMove(w http.ResponseWriter, r *http.Request, srcURL, src
|
|||||||
http.Error(w, "rewrite failed: "+walkErr.Error(), http.StatusInternalServerError)
|
http.Error(w, "rewrite failed: "+walkErr.Error(), http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Phase 2: create intermediate parent folders for the destination.
|
// Phase 2: create intermediate parent folders for the destination.
|
||||||
if parent := filepath.Dir(dstFsPath); parent != "" {
|
if parent := filepath.Dir(dstFsPath); parent != "" {
|
||||||
@@ -90,6 +94,7 @@ func (h *handler) handleMove(w http.ResponseWriter, r *http.Request, srcURL, src
|
|||||||
http.Error(w, "rename failed: "+err.Error(), http.StatusInternalServerError)
|
http.Error(w, "rename failed: "+err.Error(), http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
folderIndexRenameSubtree(strings.TrimPrefix(oldPath, "/"), strings.TrimPrefix(newPath, "/"))
|
||||||
|
|
||||||
http.Redirect(w, r, wikiTargetHref(newPath), http.StatusSeeOther)
|
http.Redirect(w, r, wikiTargetHref(newPath), http.StatusSeeOther)
|
||||||
}
|
}
|
||||||
@@ -109,6 +114,7 @@ func (h *handler) handleDelete(w http.ResponseWriter, r *http.Request, urlPath,
|
|||||||
http.Error(w, "delete failed: "+err.Error(), http.StatusInternalServerError)
|
http.Error(w, "delete failed: "+err.Error(), http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
folderIndexRemoveSubtree(strings.TrimPrefix(normalizeMovePath(urlPath), "/"))
|
||||||
http.Redirect(w, r, parentURL(urlPath), http.StatusSeeOther)
|
http.Redirect(w, r, parentURL(urlPath), http.StatusSeeOther)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -147,7 +153,7 @@ func validateAndNormalizeNewPath(raw string) (string, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// rewriteWikiLinks returns (newContent, changed). Any [[target]] or
|
// rewriteWikiLinks returns (newContent, changed). Any [[target]] or
|
||||||
// [[target|display]] whose target equals oldPath or begins with oldPath+"/"
|
// [[target::display]] whose target equals oldPath or begins with oldPath+"/"
|
||||||
// has its target rewritten to the corresponding position under newPath.
|
// has its target rewritten to the corresponding position under newPath.
|
||||||
func rewriteWikiLinks(content []byte, oldPath, newPath string) ([]byte, bool) {
|
func rewriteWikiLinks(content []byte, oldPath, newPath string) ([]byte, bool) {
|
||||||
changed := false
|
changed := false
|
||||||
@@ -170,7 +176,7 @@ func rewriteWikiLinks(content []byte, oldPath, newPath string) ([]byte, bool) {
|
|||||||
changed = true
|
changed = true
|
||||||
suffix := ""
|
suffix := ""
|
||||||
if len(parts[2]) > 0 {
|
if len(parts[2]) > 0 {
|
||||||
suffix = string(parts[2])
|
suffix = "::" + string(parts[2])
|
||||||
}
|
}
|
||||||
return []byte("[[" + newTarget + suffix + "]]")
|
return []byte("[[" + newTarget + suffix + "]]")
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ var md goldmark.Markdown
|
|||||||
// targets against the filesystem.
|
// targets against the filesystem.
|
||||||
func initMarkdown(root string) {
|
func initMarkdown(root string) {
|
||||||
md = goldmark.New(
|
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.WithParserOptions(parser.WithAutoHeadingID()),
|
||||||
goldmark.WithRendererOptions(html.WithUnsafe()),
|
goldmark.WithRendererOptions(html.WithUnsafe()),
|
||||||
)
|
)
|
||||||
@@ -47,6 +47,7 @@ type pageData struct {
|
|||||||
Entries []entry
|
Entries []entry
|
||||||
SpecialContent template.HTML
|
SpecialContent template.HTML
|
||||||
SidebarWidget template.HTML
|
SidebarWidget template.HTML
|
||||||
|
RenderMS int64
|
||||||
}
|
}
|
||||||
|
|
||||||
// pageSettings holds the parsed contents of a .page-settings file.
|
// pageSettings holds the parsed contents of a .page-settings file.
|
||||||
@@ -70,7 +71,7 @@ func renderMarkdown(raw []byte) template.HTML {
|
|||||||
if err := md.Convert(raw, &buf); err != nil {
|
if err := md.Convert(raw, &buf); err != nil {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
return template.HTML(buf.String())
|
return template.HTML(rewriteTaskCheckboxes(buf.Bytes()))
|
||||||
}
|
}
|
||||||
|
|
||||||
// extractFirstHeading returns the text of the first ATX heading in raw markdown,
|
// extractFirstHeading returns the text of the first ATX heading in raw markdown,
|
||||||
|
|||||||
@@ -0,0 +1,381 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"io/fs"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
"unicode"
|
||||||
|
)
|
||||||
|
|
||||||
|
type searchResult struct {
|
||||||
|
Name string
|
||||||
|
URL string
|
||||||
|
Path string
|
||||||
|
Score int
|
||||||
|
}
|
||||||
|
|
||||||
|
type searchPageData struct {
|
||||||
|
Title string
|
||||||
|
Crumbs []crumb
|
||||||
|
EditMode bool
|
||||||
|
Query string
|
||||||
|
Results []searchResult
|
||||||
|
IndexBuiltAt time.Time
|
||||||
|
RenderMS int64
|
||||||
|
}
|
||||||
|
|
||||||
|
// folderEntry is a single indexed directory: its forward-slash relative path
|
||||||
|
// plus pre-tokenized basename so the per-query scoring loop avoids redoing
|
||||||
|
// the lowercasing and tokenization on every keystroke.
|
||||||
|
type folderEntry struct {
|
||||||
|
Path string
|
||||||
|
NameLower string
|
||||||
|
NameTokens []string
|
||||||
|
}
|
||||||
|
|
||||||
|
// folderIndex holds the in-memory directory index used by search. Writers
|
||||||
|
// always replace the entries slice wholesale so a reader that snapshots the
|
||||||
|
// header under RLock can score without holding the lock.
|
||||||
|
var folderIndex struct {
|
||||||
|
sync.RWMutex
|
||||||
|
entries []folderEntry
|
||||||
|
builtAt time.Time
|
||||||
|
buildMu sync.Mutex
|
||||||
|
ready chan struct{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
folderIndex.ready = make(chan struct{})
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleSearch renders the 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, builtAt := searchWiki(query)
|
||||||
|
|
||||||
|
title := "Search"
|
||||||
|
if query != "" {
|
||||||
|
title = "Search: " + query
|
||||||
|
}
|
||||||
|
data := searchPageData{
|
||||||
|
Title: title,
|
||||||
|
Crumbs: []crumb{{Name: "search", URL: "/?q=" + query}},
|
||||||
|
Query: query,
|
||||||
|
Results: results,
|
||||||
|
IndexBuiltAt: builtAt,
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
|
data.RenderMS = elapsedMS(r)
|
||||||
|
if err := searchTmpl.ExecuteTemplate(w, "layout", data); err != nil {
|
||||||
|
log.Printf("search template error: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// searchWiki scores the cached folder index against query. Blocks on the
|
||||||
|
// initial build so the very first request after startup serves correct
|
||||||
|
// results rather than an empty list. Returns the snapshot's builtAt so the
|
||||||
|
// UI can show how fresh the index is.
|
||||||
|
func searchWiki(query string) ([]searchResult, time.Time) {
|
||||||
|
<-folderIndex.ready
|
||||||
|
folderIndex.RLock()
|
||||||
|
entries := folderIndex.entries
|
||||||
|
builtAt := folderIndex.builtAt
|
||||||
|
folderIndex.RUnlock()
|
||||||
|
|
||||||
|
if query == "" {
|
||||||
|
return nil, builtAt
|
||||||
|
}
|
||||||
|
qLower := strings.ToLower(query)
|
||||||
|
qTokens := tokenize(qLower)
|
||||||
|
if len(qTokens) == 0 {
|
||||||
|
return nil, builtAt
|
||||||
|
}
|
||||||
|
|
||||||
|
var results []searchResult
|
||||||
|
for _, e := range entries {
|
||||||
|
score := scoreName(e.NameLower, e.NameTokens, qLower, qTokens)
|
||||||
|
if score == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
results = append(results, searchResult{
|
||||||
|
Name: filepath.Base(e.Path),
|
||||||
|
URL: "/" + e.Path + "/",
|
||||||
|
Path: e.Path,
|
||||||
|
Score: score,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
sort.SliceStable(results, func(i, j int) bool {
|
||||||
|
if results[i].Score != results[j].Score {
|
||||||
|
return results[i].Score > results[j].Score
|
||||||
|
}
|
||||||
|
di, dj := strings.Count(results[i].Path, "/"), strings.Count(results[j].Path, "/")
|
||||||
|
if di != dj {
|
||||||
|
return di < dj
|
||||||
|
}
|
||||||
|
return strings.ToLower(results[i].Name) < strings.ToLower(results[j].Name)
|
||||||
|
})
|
||||||
|
return results, builtAt
|
||||||
|
}
|
||||||
|
|
||||||
|
// scoreName ranks how well nameLower matches the query. Whole-name exact
|
||||||
|
// match dominates; otherwise score is the sum of each token's best match
|
||||||
|
// against the words in the name. nameTokens is precomputed by the index.
|
||||||
|
func scoreName(nameLower string, nameTokens []string, qLower string, qTokens []string) int {
|
||||||
|
if nameLower == qLower {
|
||||||
|
return 1000
|
||||||
|
}
|
||||||
|
score := 0
|
||||||
|
for _, qt := range qTokens {
|
||||||
|
best := 0
|
||||||
|
for _, w := range nameTokens {
|
||||||
|
switch {
|
||||||
|
case w == qt:
|
||||||
|
if best < 100 {
|
||||||
|
best = 100
|
||||||
|
}
|
||||||
|
case strings.HasPrefix(w, qt):
|
||||||
|
if best < 50 {
|
||||||
|
best = 50
|
||||||
|
}
|
||||||
|
case strings.Contains(w, qt):
|
||||||
|
if best < 20 {
|
||||||
|
best = 20
|
||||||
|
}
|
||||||
|
case levenshtein(w, qt) <= 2:
|
||||||
|
if best < 5 {
|
||||||
|
best = 5
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
score += best
|
||||||
|
}
|
||||||
|
return score
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleReindex rebuilds the folder index synchronously and returns 204.
|
||||||
|
// The frontend reloads the page on success. Serialized via buildMu so a
|
||||||
|
// double-click waits rather than running two walks in parallel.
|
||||||
|
func (h *handler) handleReindex(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if !h.checkAuth(w, r) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if r.Method != http.MethodPost {
|
||||||
|
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
rebuildFolderIndex(h.root)
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildFolderIndex walks root and returns a fresh slice of folder entries.
|
||||||
|
// Hidden directories (`.git`, `.thumbs`, …) are pruned; the root itself is
|
||||||
|
// excluded since it cannot be a search match.
|
||||||
|
func buildFolderIndex(root string) []folderEntry {
|
||||||
|
walkRoot := resolveWalkRoot(root)
|
||||||
|
var entries []folderEntry
|
||||||
|
_ = 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
|
||||||
|
}
|
||||||
|
rel, relErr := filepath.Rel(walkRoot, fsPath)
|
||||||
|
if relErr != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
entries = append(entries, newFolderEntry(filepath.ToSlash(rel)))
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
return entries
|
||||||
|
}
|
||||||
|
|
||||||
|
// newFolderEntry builds a folderEntry from a forward-slash relative path,
|
||||||
|
// computing the lowercased basename and its tokens once so search scoring
|
||||||
|
// doesn't have to redo it per query.
|
||||||
|
func newFolderEntry(relPath string) folderEntry {
|
||||||
|
name := relPath
|
||||||
|
if i := strings.LastIndex(relPath, "/"); i >= 0 {
|
||||||
|
name = relPath[i+1:]
|
||||||
|
}
|
||||||
|
nameLower := strings.ToLower(name)
|
||||||
|
return folderEntry{
|
||||||
|
Path: relPath,
|
||||||
|
NameLower: nameLower,
|
||||||
|
NameTokens: tokenize(nameLower),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// rebuildFolderIndex walks root and replaces the index entries atomically.
|
||||||
|
// buildMu serializes overlapping rebuilds (manual + ticker + startup) so
|
||||||
|
// the WalkDir cost is paid once even under contention.
|
||||||
|
func rebuildFolderIndex(root string) {
|
||||||
|
folderIndex.buildMu.Lock()
|
||||||
|
defer folderIndex.buildMu.Unlock()
|
||||||
|
entries := buildFolderIndex(root)
|
||||||
|
folderIndex.Lock()
|
||||||
|
folderIndex.entries = entries
|
||||||
|
folderIndex.builtAt = time.Now()
|
||||||
|
folderIndex.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// folderIndexAdd appends relPath as a new entry. No-op for empty/root paths.
|
||||||
|
func folderIndexAdd(relPath string) {
|
||||||
|
relPath = strings.Trim(relPath, "/")
|
||||||
|
if relPath == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
folderIndex.Lock()
|
||||||
|
folderIndex.entries = append(folderIndex.entries, newFolderEntry(relPath))
|
||||||
|
folderIndex.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// folderIndexRemoveSubtree drops the entry at relPath plus every descendant.
|
||||||
|
// Replaces the slice rather than mutating in place so any in-flight search
|
||||||
|
// reader keeps a valid snapshot.
|
||||||
|
func folderIndexRemoveSubtree(relPath string) {
|
||||||
|
relPath = strings.Trim(relPath, "/")
|
||||||
|
if relPath == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
prefix := relPath + "/"
|
||||||
|
folderIndex.Lock()
|
||||||
|
defer folderIndex.Unlock()
|
||||||
|
old := folderIndex.entries
|
||||||
|
out := make([]folderEntry, 0, len(old))
|
||||||
|
for _, e := range old {
|
||||||
|
if e.Path == relPath || strings.HasPrefix(e.Path, prefix) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out = append(out, e)
|
||||||
|
}
|
||||||
|
folderIndex.entries = out
|
||||||
|
}
|
||||||
|
|
||||||
|
// folderIndexRenameSubtree rewrites the path prefix for every entry under
|
||||||
|
// oldRel. The renamed root entry's basename may have changed so its
|
||||||
|
// NameLower/NameTokens are recomputed; descendants keep their basenames.
|
||||||
|
func folderIndexRenameSubtree(oldRel, newRel string) {
|
||||||
|
oldRel = strings.Trim(oldRel, "/")
|
||||||
|
newRel = strings.Trim(newRel, "/")
|
||||||
|
if oldRel == "" || newRel == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
oldPrefix := oldRel + "/"
|
||||||
|
folderIndex.Lock()
|
||||||
|
defer folderIndex.Unlock()
|
||||||
|
old := folderIndex.entries
|
||||||
|
out := make([]folderEntry, len(old))
|
||||||
|
for i, e := range old {
|
||||||
|
switch {
|
||||||
|
case e.Path == oldRel:
|
||||||
|
out[i] = newFolderEntry(newRel)
|
||||||
|
case strings.HasPrefix(e.Path, oldPrefix):
|
||||||
|
out[i] = folderEntry{
|
||||||
|
Path: newRel + "/" + strings.TrimPrefix(e.Path, oldPrefix),
|
||||||
|
NameLower: e.NameLower,
|
||||||
|
NameTokens: e.NameTokens,
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
out[i] = e
|
||||||
|
}
|
||||||
|
}
|
||||||
|
folderIndex.entries = out
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"regexp"
|
"regexp"
|
||||||
|
|
||||||
|
"github.com/yuin/goldmark/ast"
|
||||||
|
"github.com/yuin/goldmark/text"
|
||||||
)
|
)
|
||||||
|
|
||||||
var sectionHeadingRe = regexp.MustCompile(`(?m)^#{1,6} `)
|
var sectionHeadingRe = regexp.MustCompile(`(?m)^#{1,6} `)
|
||||||
@@ -25,6 +28,29 @@ func splitSections(raw []byte) [][]byte {
|
|||||||
return sections
|
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.
|
// joinSections reassembles sections produced by splitSections.
|
||||||
// Inserts a newline between sections when a non-empty section lacks a
|
// Inserts a newline between sections when a non-empty section lacks a
|
||||||
// trailing newline, so an edited section cannot inline the next heading.
|
// trailing newline, so an edited section cannot inline the next heading.
|
||||||
|
|||||||
@@ -0,0 +1,112 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
|
"strconv"
|
||||||
|
)
|
||||||
|
|
||||||
|
// taskCheckboxRe matches the <input> tag goldmark's GFM extension emits for a
|
||||||
|
// task list checkbox. Used to enumerate and rewrite checkboxes in rendered HTML.
|
||||||
|
var taskCheckboxRe = regexp.MustCompile(`<input(?: checked="")? disabled="" type="checkbox">`)
|
||||||
|
|
||||||
|
// taskLineRe matches a markdown task list line: leading whitespace, a bullet,
|
||||||
|
// then a `[ ]` / `[x]` / `[X]` checkbox marker.
|
||||||
|
var taskLineRe = regexp.MustCompile(`^(\s*[-*+]\s+)\[([ xX])\]`)
|
||||||
|
|
||||||
|
// rewriteTaskCheckboxes enables and indexes the task checkboxes in rendered
|
||||||
|
// HTML so JS can wire them up. Each checkbox gains a data-task-index matching
|
||||||
|
// its position among task list items in source order; the disabled attribute
|
||||||
|
// is removed so the user can toggle them.
|
||||||
|
func rewriteTaskCheckboxes(in []byte) []byte {
|
||||||
|
idx := 0
|
||||||
|
return taskCheckboxRe.ReplaceAllFunc(in, func(match []byte) []byte {
|
||||||
|
checked := bytes.Contains(match, []byte("checked"))
|
||||||
|
var out bytes.Buffer
|
||||||
|
out.WriteString(`<input type="checkbox" class="task-checkbox" data-task-index="`)
|
||||||
|
out.WriteString(strconv.Itoa(idx))
|
||||||
|
out.WriteByte('"')
|
||||||
|
if checked {
|
||||||
|
out.WriteString(` checked=""`)
|
||||||
|
}
|
||||||
|
out.WriteByte('>')
|
||||||
|
idx++
|
||||||
|
return out.Bytes()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleToggle flips the Nth task list checkbox in index.md based on the
|
||||||
|
// `toggle` query param and `checked` form value. Indices match the order in
|
||||||
|
// which goldmark emits checkboxes, which is source order excluding fenced
|
||||||
|
// code blocks.
|
||||||
|
func (h *handler) handleToggle(w http.ResponseWriter, r *http.Request, fsPath string) {
|
||||||
|
n, err := strconv.Atoi(r.URL.Query().Get("toggle"))
|
||||||
|
if err != nil || n < 0 {
|
||||||
|
http.Error(w, "bad toggle index", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := r.ParseForm(); err != nil {
|
||||||
|
http.Error(w, "bad request", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
checked := r.FormValue("checked") == "true"
|
||||||
|
|
||||||
|
indexPath := filepath.Join(fsPath, "index.md")
|
||||||
|
raw, err := os.ReadFile(indexPath)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "read failed: "+err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
updated, ok := flipTaskLine(raw, n, checked)
|
||||||
|
if !ok {
|
||||||
|
http.Error(w, "task not found", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := writeFileAtomic(indexPath, updated, 0644); err != nil {
|
||||||
|
http.Error(w, "write failed: "+err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
// flipTaskLine returns raw with the Nth task list bullet's `[ ]`/`[x]` marker
|
||||||
|
// set according to checked. Lines inside fenced code blocks are skipped so
|
||||||
|
// they do not consume an index. Returns ok=false when there is no Nth task.
|
||||||
|
func flipTaskLine(raw []byte, n int, checked bool) ([]byte, bool) {
|
||||||
|
lines := bytes.Split(raw, []byte("\n"))
|
||||||
|
inFence := false
|
||||||
|
count := 0
|
||||||
|
target := -1
|
||||||
|
for i, line := range lines {
|
||||||
|
trimmed := bytes.TrimLeft(line, " \t")
|
||||||
|
if bytes.HasPrefix(trimmed, []byte("```")) || bytes.HasPrefix(trimmed, []byte("~~~")) {
|
||||||
|
inFence = !inFence
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if inFence {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !taskLineRe.Match(line) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if count == n {
|
||||||
|
target = i
|
||||||
|
break
|
||||||
|
}
|
||||||
|
count++
|
||||||
|
}
|
||||||
|
if target == -1 {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
replacement := []byte("${1}[ ]")
|
||||||
|
if checked {
|
||||||
|
replacement = []byte("${1}[x]")
|
||||||
|
}
|
||||||
|
lines[target] = taskLineRe.ReplaceAll(lines[target], replacement)
|
||||||
|
return bytes.Join(lines, []byte("\n")), true
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
+5
-4
@@ -16,14 +16,15 @@ import (
|
|||||||
"github.com/yuin/goldmark/util"
|
"github.com/yuin/goldmark/util"
|
||||||
)
|
)
|
||||||
|
|
||||||
// wikiLinkRe matches [[target]] and [[target|display]] anchored at the start
|
// wikiLinkRe matches [[target]] and [[target::display]] anchored at the start
|
||||||
// of the current inline reader. Target and display forbid newlines and
|
// of the current inline reader. Target and display forbid newlines and
|
||||||
// brackets; target additionally forbids the pipe separator.
|
// brackets; the target is non-greedy so the first `::` separates target from
|
||||||
var wikiLinkRe = regexp.MustCompile(`^\[\[([^\[\]\|\n]+)(?:\|([^\[\]\n]+))?\]\]`)
|
// display when both are present.
|
||||||
|
var wikiLinkRe = regexp.MustCompile(`^\[\[([^\[\]\n]+?)(?:::([^\[\]\n]+))?\]\]`)
|
||||||
|
|
||||||
// wikiLinkPattern matches wiki-link tokens anywhere in a markdown source.
|
// wikiLinkPattern matches wiki-link tokens anywhere in a markdown source.
|
||||||
// Used by the move-endpoint rewriter; not by the goldmark parser.
|
// Used by the move-endpoint rewriter; not by the goldmark parser.
|
||||||
var wikiLinkPattern = regexp.MustCompile(`\[\[([^\[\]\n\|]+)(\|[^\[\]\n]+)?\]\]`)
|
var wikiLinkPattern = regexp.MustCompile(`\[\[([^\[\]\n]+?)(?:::([^\[\]\n]+))?\]\]`)
|
||||||
|
|
||||||
// wikiLinkNode is the AST node produced by wikiLinkParser.
|
// wikiLinkNode is the AST node produced by wikiLinkParser.
|
||||||
type wikiLinkNode struct {
|
type wikiLinkNode struct {
|
||||||
|
|||||||
Reference in New Issue
Block a user