Compare commits
62
Commits
11cae7df36
..
canvas
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b48d476b39 | ||
|
|
7b451bae86 | ||
|
|
1925987459 | ||
|
|
b57e5d454b | ||
|
|
b14941b3e0 | ||
|
|
adf1540cf9 | ||
|
|
c0b7ebf0c2 | ||
|
|
07bb3b002e | ||
|
|
96a58ca472 | ||
|
|
5ad715426d | ||
|
|
c612cdd28d | ||
|
|
a0b4f49915 | ||
|
|
dddacf9262 | ||
|
|
f78ebe7d5d | ||
|
|
d943fc5596 | ||
|
|
e9061a221c | ||
|
|
5a5305dd42 | ||
|
|
ab57377034 | ||
|
|
65983ec6be | ||
|
|
52bc694ffa | ||
|
|
e37dde1e40 | ||
|
|
3abcc94eca | ||
|
|
8b9f47f793 | ||
|
|
85527cfde8 | ||
|
|
5f835a8a4e | ||
|
|
1071dbaa09 | ||
|
|
da30bb8fc3 | ||
|
|
a849474b26 | ||
|
|
49c6240416 | ||
|
|
ddfd29ea0e | ||
|
|
3f2beafd94 | ||
|
|
28d22c040f | ||
|
|
34f750beff | ||
|
|
5303645173 | ||
|
|
d0325fdec5 | ||
|
|
22059a0b06 | ||
|
|
aa95d04d9e | ||
|
|
08be8c68fe | ||
|
|
a407a2eeaa | ||
|
|
75d6c4d430 | ||
|
|
5e72b073b8 | ||
|
|
8c9b448bdc | ||
|
|
ecaf3f4e12 | ||
|
|
1055c110f4 | ||
|
|
5d069683c4 | ||
|
|
9f6a611dff | ||
|
|
fa22244709 | ||
|
|
d92c2f008d | ||
|
|
7346050b51 | ||
|
|
1619240905 | ||
|
|
db17f94627 | ||
|
|
9ed6475775 | ||
|
|
7fe0013a5c | ||
|
|
f8b09e18bd | ||
|
|
404ce088f3 | ||
|
|
d719b53404 | ||
|
|
f870a12cd5 | ||
|
|
0b62cd50f3 | ||
|
|
fde4eff12d | ||
|
|
4f14b39d16 | ||
|
|
204e89dbce | ||
|
|
5525a03179 |
@@ -11,3 +11,6 @@ companion/datascape-companion-*
|
|||||||
|
|
||||||
# Editor build tooling deps (the built bundle is committed; node_modules is not)
|
# Editor build tooling deps (the built bundle is committed; node_modules is not)
|
||||||
editor-build/node_modules/
|
editor-build/node_modules/
|
||||||
|
|
||||||
|
AGENTS.md
|
||||||
|
CLAUDE.md
|
||||||
|
|||||||
@@ -1,130 +0,0 @@
|
|||||||
# CLAUDE.md
|
|
||||||
|
|
||||||
## Project Overview
|
|
||||||
|
|
||||||
`datascape` is a minimal personal wiki where **the folder structure is the wiki**.
|
|
||||||
No database, no CMS, no abstraction layer — every folder is a page, and `index.md`
|
|
||||||
in a folder is that page's content.
|
|
||||||
|
|
||||||
## Build & Deploy
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Local build (host architecture)
|
|
||||||
go build .
|
|
||||||
|
|
||||||
# Deploy to NAS
|
|
||||||
make deploy
|
|
||||||
```
|
|
||||||
|
|
||||||
### Editor bundle (the one build-pipeline exception)
|
|
||||||
|
|
||||||
The page editor uses CodeMirror 6, vendored as a single pre-built IIFE at
|
|
||||||
`assets/editor/vendor/codemirror.bundle.js` and embedded via `embed.FS`. This is
|
|
||||||
the **only** deliberate exception to the "no build pipeline" rule below — it is a
|
|
||||||
one-time, committed artifact, not a runtime build. `go build` / `make deploy`
|
|
||||||
never touch Node and only consume the committed bundle.
|
|
||||||
|
|
||||||
Regenerate the bundle **only** when upgrading the `@codemirror/*` versions:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# bump versions in editor-build/package.json first, then:
|
|
||||||
make editor # runs `npm ci && npm run build` in editor-build/, rewrites the vendored bundle
|
|
||||||
```
|
|
||||||
|
|
||||||
Commit the regenerated `codemirror.bundle.js` and the updated
|
|
||||||
`editor-build/package-lock.json`. `editor-build/node_modules/` is gitignored.
|
|
||||||
|
|
||||||
The bundle is served immutable under a stable filename, so the edit template
|
|
||||||
appends `?v=<content-hash>` to its `<script>` src (`editorBundleVersion` in
|
|
||||||
`main.go`). The hash changes whenever the bundle bytes change, so a rebuilt
|
|
||||||
bundle busts client caches automatically — no manual version bump needed.
|
|
||||||
|
|
||||||
## HTTP API Surface
|
|
||||||
|
|
||||||
| Method | Path | Behaviour |
|
|
||||||
|--------|------|-----------|
|
|
||||||
| GET | `/{path}/` | If folder exists: render `index.md` + list contents. If not: show empty create prompt. |
|
|
||||||
| GET | `/{path}/?edit` | CodeMirror 6 editor initialized with `index.md` content |
|
|
||||||
| POST | `/{path}` | Write `index.md` to disk; creates the folder if it does not exist yet |
|
|
||||||
|
|
||||||
Non-existent paths without a trailing slash redirect to the slash form (GET only — POSTs
|
|
||||||
are not redirected because `path.Clean` strips the trailing slash from `PostURL` and the
|
|
||||||
content would be lost).
|
|
||||||
|
|
||||||
Do not add new endpoints without a concrete stated need.
|
|
||||||
|
|
||||||
## Code Structure
|
|
||||||
|
|
||||||
When adding a new special folder type, create a new `.go` file. Do not add type-specific logic to `main.go` or `render.go`.
|
|
||||||
|
|
||||||
Prefer separate, human-readable `.html` files over inlined HTML strings in Go. Embed them via `embed.FS` if needed.
|
|
||||||
|
|
||||||
|
|
||||||
## Architecture Rules
|
|
||||||
|
|
||||||
- **Single binary** — no installer, no runtime dependencies, no Docker
|
|
||||||
- **Go stdlib `net/http`** only — no web framework
|
|
||||||
- **`goldmark`** for Markdown rendering — no other Markdown libraries
|
|
||||||
- **`embed.FS`** for all assets — no external serving, no CDN
|
|
||||||
- **No database** of any kind
|
|
||||||
- **No indexing or caching** unless explicitly requested and justified
|
|
||||||
- Keep dependencies to an absolute minimum; if stdlib can do it, use stdlib
|
|
||||||
|
|
||||||
## Frontend Rules
|
|
||||||
|
|
||||||
- Vanilla JS only — no frameworks, no build pipeline (the single exception is the vendored CodeMirror editor bundle; see Build & Deploy)
|
|
||||||
- Each feature gets its own JS file; global behaviour goes in `global-shortcuts.js`
|
|
||||||
- Do not inline JS in templates or merge unrelated features into one file
|
|
||||||
- `ALT+SHIFT` is the modifier for all keyboard shortcuts — do not introduce others
|
|
||||||
- Editor toolbar buttons use `data-action` + `data-key`; adding `data-key` auto-registers the shortcut
|
|
||||||
- For mutating modals (anything that POSTs and then navigates), call `closeModal()` and then `postReplace(action, body, target)` from `page/actions.js`. Do NOT use `<form>.submit()`. Two reasons:
|
|
||||||
1. The modal must be removed from the DOM before navigation, or the browser's bfcache snapshots it open and back-nav restores the modal.
|
|
||||||
2. `postReplace` uses `window.location.replace` so the action + result occupy a single history entry. A naive POST → 303 → GET creates two entries, and back-nav lands on a stale pre-mutation snapshot of the same page.
|
|
||||||
|
|
||||||
## CSS
|
|
||||||
|
|
||||||
Follow **SMACSS** conventions (Scalable and Modular Architecture for CSS). The stylesheet is organized into five categories:
|
|
||||||
|
|
||||||
- **Base** — element resets and global defaults only. Never style `header`, `textarea`, `input`, `aside`, `footer`, etc. directly for visual treatment — always via a class.
|
|
||||||
- **Layout** — `.row`, `.col`, `.page-wrap`. Use these for flex layout; do not inline `display: flex` on feature classes.
|
|
||||||
- **Modules** — reusable components: `.panel`, `.panel-header`, `.menu-row`, `.btn`, `.input`, `.muted`, `.truncate`, etc. New visual patterns should reuse these. Before adding a new module, check whether an existing one + a modifier already covers the case.
|
|
||||||
- **State** — `.is-*` prefix only (`.is-open`, `.is-selected`, `.is-active`, `.is-disabled`, `.is-empty`). State is the only place a class describes a moment in time rather than a structural role.
|
|
||||||
- **Theme** — colors, borders, spacing, and font sizes come from CSS variables defined in `:root` (`--bg`, `--secondary`, `--border`, `--border-dashed`, `--space-*`, `--font-*`). No hardcoded `1px solid #...`, no hardcoded rem spacing in component rules.
|
|
||||||
|
|
||||||
Naming: flat-dash (`.panel-header`, `.btn-small`), not BEM (`.panel__header--small`). Modifiers attach as additional classes (`<div class="btn btn-small">`), not as new standalone classes.
|
|
||||||
|
|
||||||
Anti-patterns to reject:
|
|
||||||
- One-off classes that duplicate an existing module (`.save-button` when `.btn` exists, `.form-name-input` when `.input` exists).
|
|
||||||
- Element selectors (`textarea { ... }`, `header { ... }`) for visual treatment — add a class instead.
|
|
||||||
- Inlining `display: flex; gap: X` on a feature class instead of composing with `.row` / `.col`.
|
|
||||||
- Adding a new module for a single use site — prefer a modifier on an existing module first.
|
|
||||||
- Hardcoded colors, border widths, or spacing values inside component rules — pull a variable, or add one to `:root` if it's missing.
|
|
||||||
|
|
||||||
## Development Priorities
|
|
||||||
|
|
||||||
When building features, apply this order:
|
|
||||||
1. Correctness on the filesystem — never corrupt or lose files
|
|
||||||
2. Mobile usability (primary editing device is Android over Wireguard VPN)
|
|
||||||
3. Simplicity of implementation, adhere to KISS
|
|
||||||
4. Performance
|
|
||||||
|
|
||||||
## Date Formatting
|
|
||||||
|
|
||||||
- General UI dates (file listings, metadata): ISO `YYYY-MM-DD`
|
|
||||||
- Diary headings (year/month/day) are also ISO short form: `# 2026`, `## 2026-05`, `### 2026-05-28`. No long-form rendering.
|
|
||||||
- Calendar widget month names are German; the `germanMonths` map in `diary.go` keeps the labels keyed by `time.Month` since Go's `time.Format` is English-only.
|
|
||||||
|
|
||||||
## What to Avoid
|
|
||||||
|
|
||||||
- Any parallel folder structure (e.g. a separate `media/` tree mirroring `pages/`)
|
|
||||||
- Over-engineering auth — Basic auth is sufficient for a personal VPN tool
|
|
||||||
- Heavy payloads or expensive rendering (target CPU: ARMv7 32-bit NAS)
|
|
||||||
- Suggesting Docker (plain binary is preferred)
|
|
||||||
|
|
||||||
## Out of Scope (do not implement unless explicitly asked)
|
|
||||||
|
|
||||||
- Full-text search
|
|
||||||
- Browser-based file upload
|
|
||||||
- Version history / git integration
|
|
||||||
- Multi-user support
|
|
||||||
- Tagging or metadata beyond `index.md` content
|
|
||||||
@@ -10,7 +10,9 @@ Minimal self-hosted personal wiki. Folders are pages.
|
|||||||
|
|
||||||
- **Search** search across all page names (folder names) in the wiki, accessible from the navigation bar.
|
- **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.
|
- **Wikilinks** link between pages with `[[search query]]` syntax, or `[[query::display text]]` to override the label. The link opens the search page for that query, so it keeps resolving after a page is renamed or moved. Files embed with standard Markdown image syntax — `` — relative to the page they sit on.
|
||||||
|
|
||||||
|
- **Canvases** an infinite pan/zoom board stored as a [JSON Canvas](https://jsoncanvas.org/) `.canvas` file next to the page's `index.md`. Canvases in a folder appear as tabs above the page content, so a canvas is an alternative *view* of the page rather than a separate document. See the [Canvases](#canvases) section.
|
||||||
|
|
||||||
- **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.
|
- **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.
|
||||||
|
|
||||||
@@ -106,6 +108,77 @@ They resolve against the year page rather than separate per-day URLs:
|
|||||||
|
|
||||||
Legacy `YYYY/MM/` and `YYYY/MM/DD/` URLs (no longer the canonical form) redirect to the matching anchor on the year page.
|
Legacy `YYYY/MM/` and `YYYY/MM/DD/` URLs (no longer the canonical form) redirect to the matching anchor on the year page.
|
||||||
|
|
||||||
|
## Canvases
|
||||||
|
|
||||||
|
A canvas is a `<Name>.canvas` file in a page folder holding a
|
||||||
|
[JSON Canvas 1.0](https://jsoncanvas.org/spec/1.0/) document. Every canvas in a
|
||||||
|
folder renders as a tab above that page's content, and the page itself is the
|
||||||
|
first tab — so a page and its diagrams are one thing with several views.
|
||||||
|
|
||||||
|
| URL | What it does |
|
||||||
|
|------|-------------|
|
||||||
|
| `<page>/?canvas=<Name>` | the canvas view (canonical) |
|
||||||
|
| `<page>/<Name>.canvas` | redirects to the canonical form |
|
||||||
|
| `<page>/<Name>.canvas?raw` | the file itself |
|
||||||
|
|
||||||
|
Create one from the **NEW CANVAS** action or the `+` in the tab strip.
|
||||||
|
|
||||||
|
### Nodes
|
||||||
|
|
||||||
|
All four spec node types render:
|
||||||
|
|
||||||
|
| Type | Rendered as |
|
||||||
|
|------|-------------|
|
||||||
|
| `text` | markdown, rendered server-side — wikilinks, `![[embeds]]`, tables and task lists all behave as they do on a page |
|
||||||
|
| `file` | a transclusion of another wiki path: markdown pages (narrowed to a heading when `subpath` is set), images, video, PDFs |
|
||||||
|
| `link` | a card for an external URL |
|
||||||
|
| `group` | a labelled frame; dragging it carries the nodes inside it |
|
||||||
|
|
||||||
|
Node content is rendered by the server and cached in the browser by *content*,
|
||||||
|
so the whole canvas arrives pre-rendered in the initial page load (no per-node
|
||||||
|
requests) and undo/redo of a text edit costs nothing.
|
||||||
|
|
||||||
|
### Controls
|
||||||
|
|
||||||
|
| Gesture | Action |
|
||||||
|
|---------|--------|
|
||||||
|
| drag background | pan |
|
||||||
|
| ctrl/⌘ + wheel, or pinch | zoom |
|
||||||
|
| shift + drag background | marquee select |
|
||||||
|
| drag a node | move it (snaps to a 20px grid; hold alt to bypass) |
|
||||||
|
| drag a corner handle | resize |
|
||||||
|
| drag a side port | draw an edge to another node |
|
||||||
|
| double-click a node | edit it |
|
||||||
|
| double-click empty space | new text node there |
|
||||||
|
|
||||||
|
Keys: `Ctrl+S` save · `Ctrl+Z` / `Ctrl+Shift+Z` undo/redo · `Del` delete
|
||||||
|
selection · `Ctrl+A` select all · arrows nudge (shift for single pixels) ·
|
||||||
|
`T`/`F`/`L`/`G` add a node · `+`/`-`/`0` zoom, `9` fit · `Esc` cancel.
|
||||||
|
|
||||||
|
Undo covers the whole session; a drag or resize is one entry no matter how many
|
||||||
|
events it spanned.
|
||||||
|
|
||||||
|
### Saving
|
||||||
|
|
||||||
|
Saving is explicit (**SAVE**, or `Ctrl+S`) and uploads the whole document. The
|
||||||
|
server parses and validates it before writing anything, so a malformed payload
|
||||||
|
can never land on disk, and four things are checked in order:
|
||||||
|
|
||||||
|
1. the JSON parses into the spec's shape;
|
||||||
|
2. node ids are unique, every edge endpoint resolves, and enums/colours/sizes are in range;
|
||||||
|
3. the `X-Canvas-Version` token matches the file on disk — a mismatch is a `409` and you are asked whether to reload or overwrite;
|
||||||
|
4. a save that would empty a populated canvas is refused until you confirm it.
|
||||||
|
|
||||||
|
The previous contents are kept beside the file as `.<Name>.canvas.bak`, and the
|
||||||
|
write itself is atomic. Leaving the page with unsaved changes prompts first.
|
||||||
|
|
||||||
|
`[[wikilinks]]` inside text nodes are search queries, like on a page, so they
|
||||||
|
keep resolving after a move. File nodes and group backgrounds hold literal
|
||||||
|
paths and are not rewritten: moving or renaming their target leaves them
|
||||||
|
rendered as broken references.
|
||||||
|
|
||||||
|
Canvas *contents* are not searchable; only the file name is indexed.
|
||||||
|
|
||||||
## Quick-Add Bookmarklet
|
## Quick-Add Bookmarklet
|
||||||
|
|
||||||
Replace `wiki.host` with your wiki host and `/Topics/Bookmarks/` with the destination page (one bookmarklet per target):
|
Replace `wiki.host` with your wiki host and `/Topics/Bookmarks/` with the destination page (one bookmarklet per target):
|
||||||
|
|||||||
@@ -0,0 +1,182 @@
|
|||||||
|
// doc.js — the canvas document plus its undo stack.
|
||||||
|
//
|
||||||
|
// Every mutation passes through begin()/commit(). A gesture (a drag, a resize,
|
||||||
|
// a nudge-repeat) spans hundreds of pointer events but has to collapse into a
|
||||||
|
// single undo entry, so the snapshot is taken once at begin() and pushed once
|
||||||
|
// at commit() — and only when the document actually changed, so a click that
|
||||||
|
// moves nothing leaves no trace.
|
||||||
|
//
|
||||||
|
// Undo is snapshot-based rather than a stack of inverse operations: the
|
||||||
|
// document is a small pure-JSON object, cloning it costs microseconds, and a
|
||||||
|
// snapshot cannot be subtly wrong the way a hand-written inverse can. The
|
||||||
|
// stack is capped so a long session cannot grow without bound.
|
||||||
|
window.Canvas = window.Canvas || {};
|
||||||
|
|
||||||
|
Canvas.Doc = (function () {
|
||||||
|
var MAX_UNDO = 100;
|
||||||
|
|
||||||
|
var doc = { nodes: [], edges: [] };
|
||||||
|
var undoStack = [];
|
||||||
|
var redoStack = [];
|
||||||
|
var pending = null;
|
||||||
|
var dirty = false;
|
||||||
|
var version = '';
|
||||||
|
var listeners = [];
|
||||||
|
|
||||||
|
function clone(v) { return JSON.parse(JSON.stringify(v)); }
|
||||||
|
|
||||||
|
function emit() {
|
||||||
|
for (var i = 0; i < listeners.length; i++) listeners[i]();
|
||||||
|
}
|
||||||
|
|
||||||
|
// newId mints a 16-hex-character node/edge id, the shape the format uses in
|
||||||
|
// the wild. Collisions are checked against the live document anyway.
|
||||||
|
function newId() {
|
||||||
|
var bytes = new Uint8Array(8);
|
||||||
|
window.crypto.getRandomValues(bytes);
|
||||||
|
var out = '';
|
||||||
|
for (var i = 0; i < bytes.length; i++) {
|
||||||
|
out += (bytes[i] + 0x100).toString(16).slice(1);
|
||||||
|
}
|
||||||
|
if (find(doc.nodes, out) || find(doc.edges, out)) return newId();
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function find(list, id) {
|
||||||
|
for (var i = 0; i < list.length; i++) {
|
||||||
|
if (list[i].id === id) return list[i];
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
init: function (d, hash) {
|
||||||
|
doc = (d && typeof d === 'object') ? d : {};
|
||||||
|
if (!Array.isArray(doc.nodes)) doc.nodes = [];
|
||||||
|
if (!Array.isArray(doc.edges)) doc.edges = [];
|
||||||
|
undoStack = [];
|
||||||
|
redoStack = [];
|
||||||
|
pending = null;
|
||||||
|
dirty = false;
|
||||||
|
version = hash || '';
|
||||||
|
},
|
||||||
|
|
||||||
|
get: function () { return doc; },
|
||||||
|
nodes: function () { return doc.nodes; },
|
||||||
|
edges: function () { return doc.edges; },
|
||||||
|
node: function (id) { return find(doc.nodes, id); },
|
||||||
|
edge: function (id) { return find(doc.edges, id); },
|
||||||
|
newId: newId,
|
||||||
|
|
||||||
|
// edgesFor returns every edge touching any of the given node ids.
|
||||||
|
edgesFor: function (nodeIds) {
|
||||||
|
var want = {};
|
||||||
|
nodeIds.forEach(function (id) { want[id] = true; });
|
||||||
|
return doc.edges.filter(function (e) {
|
||||||
|
return want[e.fromNode] || want[e.toNode];
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
// --- gesture bracketing ---
|
||||||
|
//
|
||||||
|
// Nested begin() calls join the outer gesture rather than starting a
|
||||||
|
// new one, so a composite action (drag a group, which drags its
|
||||||
|
// members) still produces exactly one undo entry.
|
||||||
|
begin: function (label) {
|
||||||
|
if (pending) { pending.depth++; return; }
|
||||||
|
pending = { label: label || '', before: clone(doc), depth: 0 };
|
||||||
|
},
|
||||||
|
|
||||||
|
commit: function () {
|
||||||
|
if (!pending) return;
|
||||||
|
if (pending.depth > 0) { pending.depth--; return; }
|
||||||
|
var before = pending.before;
|
||||||
|
var label = pending.label;
|
||||||
|
pending = null;
|
||||||
|
// A gesture that changed nothing (a click that selected without
|
||||||
|
// moving) must not push an undo entry the user would have to press
|
||||||
|
// Ctrl+Z through.
|
||||||
|
if (JSON.stringify(before) === JSON.stringify(doc)) return;
|
||||||
|
undoStack.push({ label: label, doc: before });
|
||||||
|
if (undoStack.length > MAX_UNDO) undoStack.shift();
|
||||||
|
redoStack.length = 0;
|
||||||
|
dirty = true;
|
||||||
|
emit();
|
||||||
|
},
|
||||||
|
|
||||||
|
// abort rolls the document back to the gesture's starting state, for
|
||||||
|
// Escape mid-drag.
|
||||||
|
abort: function () {
|
||||||
|
if (!pending) return;
|
||||||
|
if (pending.depth > 0) { pending.depth--; return; }
|
||||||
|
doc = pending.before;
|
||||||
|
pending = null;
|
||||||
|
emit();
|
||||||
|
},
|
||||||
|
|
||||||
|
inGesture: function () { return pending !== null; },
|
||||||
|
|
||||||
|
undo: function () {
|
||||||
|
if (!undoStack.length) return false;
|
||||||
|
var entry = undoStack.pop();
|
||||||
|
redoStack.push({ label: entry.label, doc: clone(doc) });
|
||||||
|
doc = entry.doc;
|
||||||
|
dirty = true;
|
||||||
|
emit();
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
|
||||||
|
redo: function () {
|
||||||
|
if (!redoStack.length) return false;
|
||||||
|
var entry = redoStack.pop();
|
||||||
|
undoStack.push({ label: entry.label, doc: clone(doc) });
|
||||||
|
doc = entry.doc;
|
||||||
|
dirty = true;
|
||||||
|
emit();
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
|
||||||
|
canUndo: function () { return undoStack.length > 0; },
|
||||||
|
canRedo: function () { return redoStack.length > 0; },
|
||||||
|
|
||||||
|
// --- structural helpers ---
|
||||||
|
|
||||||
|
addNode: function (node) {
|
||||||
|
doc.nodes.push(node);
|
||||||
|
return node;
|
||||||
|
},
|
||||||
|
|
||||||
|
addEdge: function (edge) {
|
||||||
|
doc.edges.push(edge);
|
||||||
|
return edge;
|
||||||
|
},
|
||||||
|
|
||||||
|
// remove drops the named nodes and edges. Every edge touching a removed
|
||||||
|
// node goes too — a dangling endpoint would fail validation on save, so
|
||||||
|
// it can never be allowed to exist even transiently.
|
||||||
|
remove: function (nodeIds, edgeIds) {
|
||||||
|
var deadNodes = {};
|
||||||
|
(nodeIds || []).forEach(function (id) { deadNodes[id] = true; });
|
||||||
|
var deadEdges = {};
|
||||||
|
(edgeIds || []).forEach(function (id) { deadEdges[id] = true; });
|
||||||
|
|
||||||
|
doc.nodes = doc.nodes.filter(function (n) { return !deadNodes[n.id]; });
|
||||||
|
doc.edges = doc.edges.filter(function (e) {
|
||||||
|
return !deadEdges[e.id] && !deadNodes[e.fromNode] && !deadNodes[e.toNode];
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
// --- save state ---
|
||||||
|
|
||||||
|
isDirty: function () { return dirty; },
|
||||||
|
markSaved: function (hash) {
|
||||||
|
dirty = false;
|
||||||
|
if (hash) version = hash;
|
||||||
|
emit();
|
||||||
|
},
|
||||||
|
version: function () { return version; },
|
||||||
|
|
||||||
|
onChange: function (fn) { listeners.push(fn); },
|
||||||
|
notify: emit
|
||||||
|
};
|
||||||
|
})();
|
||||||
@@ -0,0 +1,237 @@
|
|||||||
|
// edges.js — the SVG edge layer.
|
||||||
|
//
|
||||||
|
// Edges are cubic beziers anchored at the midpoint of a node side, with control
|
||||||
|
// points pushed out along that side's normal. When a side is unspecified the
|
||||||
|
// dominant axis between the two centres picks one, which keeps auto-routed
|
||||||
|
// edges predictable instead of hunting for a global minimum.
|
||||||
|
//
|
||||||
|
// The SVG sits inside the transformed world and draws in world coordinates, so
|
||||||
|
// it pans and zooms with the nodes for free.
|
||||||
|
window.Canvas = window.Canvas || {};
|
||||||
|
|
||||||
|
Canvas.Edges = (function () {
|
||||||
|
var Doc = Canvas.Doc;
|
||||||
|
var Select = Canvas.Select;
|
||||||
|
|
||||||
|
var SVGNS = 'http://www.w3.org/2000/svg';
|
||||||
|
var ARROW = 11;
|
||||||
|
|
||||||
|
var svg = null;
|
||||||
|
var groups = {}; // edge id -> <g>
|
||||||
|
var temp = null; // the rubber-band path while drawing a new edge
|
||||||
|
|
||||||
|
function el(name, attrs) {
|
||||||
|
var node = document.createElementNS(SVGNS, name);
|
||||||
|
for (var k in attrs) {
|
||||||
|
if (Object.prototype.hasOwnProperty.call(attrs, k)) node.setAttribute(k, attrs[k]);
|
||||||
|
}
|
||||||
|
return node;
|
||||||
|
}
|
||||||
|
|
||||||
|
// sideAnchor returns the connection point on a node side together with that
|
||||||
|
// side's outward normal, which the control points and arrowheads both need.
|
||||||
|
function sideAnchor(n, side) {
|
||||||
|
switch (side) {
|
||||||
|
case 'top': return { x: n.x + n.width / 2, y: n.y, nx: 0, ny: -1 };
|
||||||
|
case 'bottom': return { x: n.x + n.width / 2, y: n.y + n.height, nx: 0, ny: 1 };
|
||||||
|
case 'left': return { x: n.x, y: n.y + n.height / 2, nx: -1, ny: 0 };
|
||||||
|
default: return { x: n.x + n.width, y: n.y + n.height / 2, nx: 1, ny: 0 };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// autoSides picks the facing sides for an edge with no explicit ones: the
|
||||||
|
// dominant axis between the node centres wins. Simple and stable — the edge
|
||||||
|
// does not flip sides every time a node moves a few pixels.
|
||||||
|
function autoSides(a, b) {
|
||||||
|
var dx = (b.x + b.width / 2) - (a.x + a.width / 2);
|
||||||
|
var dy = (b.y + b.height / 2) - (a.y + a.height / 2);
|
||||||
|
if (Math.abs(dx) >= Math.abs(dy)) {
|
||||||
|
return dx >= 0 ? ['right', 'left'] : ['left', 'right'];
|
||||||
|
}
|
||||||
|
return dy >= 0 ? ['bottom', 'top'] : ['top', 'bottom'];
|
||||||
|
}
|
||||||
|
|
||||||
|
function geometry(e) {
|
||||||
|
var a = Doc.node(e.fromNode);
|
||||||
|
var b = Doc.node(e.toNode);
|
||||||
|
if (!a || !b) return null;
|
||||||
|
|
||||||
|
var auto = autoSides(a, b);
|
||||||
|
var fromSide = e.fromSide || auto[0];
|
||||||
|
// A self-loop has no meaningful "facing" side, so it gets a fixed pair
|
||||||
|
// that always produces a visible arc out of and back into the node.
|
||||||
|
var toSide = e.toSide || (a === b ? 'top' : auto[1]);
|
||||||
|
|
||||||
|
var p0 = sideAnchor(a, fromSide);
|
||||||
|
var p3 = sideAnchor(b, toSide);
|
||||||
|
var dist = Math.sqrt((p3.x - p0.x) * (p3.x - p0.x) + (p3.y - p0.y) * (p3.y - p0.y));
|
||||||
|
var k = Math.max(40, dist / 3);
|
||||||
|
return {
|
||||||
|
p0: p0,
|
||||||
|
p3: p3,
|
||||||
|
c1: { x: p0.x + p0.nx * k, y: p0.y + p0.ny * k },
|
||||||
|
c2: { x: p3.x + p3.nx * k, y: p3.y + p3.ny * k }
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function curve(g) {
|
||||||
|
return 'M' + g.p0.x + ',' + g.p0.y +
|
||||||
|
'C' + g.c1.x + ',' + g.c1.y + ' ' + g.c2.x + ',' + g.c2.y +
|
||||||
|
' ' + g.p3.x + ',' + g.p3.y;
|
||||||
|
}
|
||||||
|
|
||||||
|
// midpoint of a cubic at t=0.5, closed form — no DOM measurement needed to
|
||||||
|
// place a label.
|
||||||
|
function midpoint(g) {
|
||||||
|
return {
|
||||||
|
x: (g.p0.x + 3 * g.c1.x + 3 * g.c2.x + g.p3.x) / 8,
|
||||||
|
y: (g.p0.y + 3 * g.c1.y + 3 * g.c2.y + g.p3.y) / 8
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// arrowhead draws a triangle with its tip at pt, its base offset along the
|
||||||
|
// outward normal — so the arrow points into the node it terminates on.
|
||||||
|
function arrowhead(pt) {
|
||||||
|
var bx = pt.x + pt.nx * ARROW;
|
||||||
|
var by = pt.y + pt.ny * ARROW;
|
||||||
|
var px = -pt.ny * ARROW * 0.42;
|
||||||
|
var py = pt.nx * ARROW * 0.42;
|
||||||
|
return 'M' + pt.x + ',' + pt.y +
|
||||||
|
'L' + (bx + px) + ',' + (by + py) +
|
||||||
|
'L' + (bx - px) + ',' + (by - py) + 'Z';
|
||||||
|
}
|
||||||
|
|
||||||
|
function colorVar(c) {
|
||||||
|
if (!c) return 'var(--canvas-edge)';
|
||||||
|
if (c.charAt(0) === '#') return c;
|
||||||
|
return 'var(--canvas-' + c + ')';
|
||||||
|
}
|
||||||
|
|
||||||
|
function build(e) {
|
||||||
|
var g = el('g', { 'class': 'canvas-edge' });
|
||||||
|
g.dataset.id = e.id;
|
||||||
|
// A transparent fat stroke under the visible line is the hit target:
|
||||||
|
// a 2px curve is essentially unclickable otherwise.
|
||||||
|
g.appendChild(el('path', { 'class': 'canvas-edge-hit', fill: 'none' }));
|
||||||
|
g.appendChild(el('path', { 'class': 'canvas-edge-line', fill: 'none' }));
|
||||||
|
g.appendChild(el('path', { 'class': 'canvas-edge-head canvas-edge-head-to' }));
|
||||||
|
g.appendChild(el('path', { 'class': 'canvas-edge-head canvas-edge-head-from' }));
|
||||||
|
var label = el('text', { 'class': 'canvas-edge-label', 'text-anchor': 'middle', 'dominant-baseline': 'middle' });
|
||||||
|
g.appendChild(label);
|
||||||
|
return g;
|
||||||
|
}
|
||||||
|
|
||||||
|
function update(g, e) {
|
||||||
|
var geo = geometry(e);
|
||||||
|
if (!geo) { g.setAttribute('display', 'none'); return; }
|
||||||
|
g.removeAttribute('display');
|
||||||
|
|
||||||
|
var d = curve(geo);
|
||||||
|
var stroke = colorVar(e.color);
|
||||||
|
g.querySelector('.canvas-edge-hit').setAttribute('d', d);
|
||||||
|
|
||||||
|
var line = g.querySelector('.canvas-edge-line');
|
||||||
|
line.setAttribute('d', d);
|
||||||
|
line.setAttribute('stroke', stroke);
|
||||||
|
|
||||||
|
// Spec defaults: fromEnd is "none", toEnd is "arrow".
|
||||||
|
var toHead = g.querySelector('.canvas-edge-head-to');
|
||||||
|
if ((e.toEnd || 'arrow') === 'arrow') {
|
||||||
|
toHead.setAttribute('d', arrowhead(geo.p3));
|
||||||
|
toHead.setAttribute('fill', stroke);
|
||||||
|
toHead.removeAttribute('display');
|
||||||
|
} else {
|
||||||
|
toHead.setAttribute('display', 'none');
|
||||||
|
}
|
||||||
|
|
||||||
|
var fromHead = g.querySelector('.canvas-edge-head-from');
|
||||||
|
if ((e.fromEnd || 'none') === 'arrow') {
|
||||||
|
fromHead.setAttribute('d', arrowhead(geo.p0));
|
||||||
|
fromHead.setAttribute('fill', stroke);
|
||||||
|
fromHead.removeAttribute('display');
|
||||||
|
} else {
|
||||||
|
fromHead.setAttribute('display', 'none');
|
||||||
|
}
|
||||||
|
|
||||||
|
var label = g.querySelector('.canvas-edge-label');
|
||||||
|
if (e.label) {
|
||||||
|
var m = midpoint(geo);
|
||||||
|
label.setAttribute('x', m.x);
|
||||||
|
label.setAttribute('y', m.y);
|
||||||
|
label.textContent = e.label;
|
||||||
|
label.removeAttribute('display');
|
||||||
|
} else {
|
||||||
|
label.setAttribute('display', 'none');
|
||||||
|
}
|
||||||
|
|
||||||
|
g.classList.toggle('is-selected', Select.hasEdge(e.id));
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
init: function (svgEl) { svg = svgEl; },
|
||||||
|
|
||||||
|
sideAnchor: sideAnchor,
|
||||||
|
autoSides: autoSides,
|
||||||
|
|
||||||
|
render: function () {
|
||||||
|
var seen = {};
|
||||||
|
Doc.edges().forEach(function (e) {
|
||||||
|
seen[e.id] = true;
|
||||||
|
var g = groups[e.id];
|
||||||
|
if (!g) {
|
||||||
|
g = build(e);
|
||||||
|
groups[e.id] = g;
|
||||||
|
svg.appendChild(g);
|
||||||
|
}
|
||||||
|
update(g, e);
|
||||||
|
});
|
||||||
|
Object.keys(groups).forEach(function (id) {
|
||||||
|
if (seen[id]) return;
|
||||||
|
groups[id].remove();
|
||||||
|
delete groups[id];
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
// syncFor repaints only the edges touching the given nodes. Called on
|
||||||
|
// every frame of a drag, where re-running the full reconcile would be
|
||||||
|
// pure overhead.
|
||||||
|
syncFor: function (nodeIds) {
|
||||||
|
Doc.edgesFor(nodeIds).forEach(function (e) {
|
||||||
|
var g = groups[e.id];
|
||||||
|
if (g) update(g, e);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
syncSelection: function () {
|
||||||
|
Doc.edges().forEach(function (e) {
|
||||||
|
var g = groups[e.id];
|
||||||
|
if (g) g.classList.toggle('is-selected', Select.hasEdge(e.id));
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
// --- rubber band shown while dragging a new edge out of a port ---
|
||||||
|
|
||||||
|
beginTemp: function () {
|
||||||
|
if (temp) temp.remove();
|
||||||
|
temp = el('path', { 'class': 'canvas-edge-temp', fill: 'none' });
|
||||||
|
svg.appendChild(temp);
|
||||||
|
},
|
||||||
|
|
||||||
|
updateTemp: function (node, side, to) {
|
||||||
|
if (!temp) return;
|
||||||
|
var p0 = sideAnchor(node, side);
|
||||||
|
var dist = Math.sqrt((to.x - p0.x) * (to.x - p0.x) + (to.y - p0.y) * (to.y - p0.y));
|
||||||
|
var k = Math.max(40, dist / 3);
|
||||||
|
temp.setAttribute('d',
|
||||||
|
'M' + p0.x + ',' + p0.y +
|
||||||
|
'C' + (p0.x + p0.nx * k) + ',' + (p0.y + p0.ny * k) +
|
||||||
|
' ' + to.x + ',' + to.y + ' ' + to.x + ',' + to.y);
|
||||||
|
},
|
||||||
|
|
||||||
|
endTemp: function () {
|
||||||
|
if (!temp) return;
|
||||||
|
temp.remove();
|
||||||
|
temp = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
})();
|
||||||
@@ -0,0 +1,204 @@
|
|||||||
|
// edit.js — editing a node's content.
|
||||||
|
//
|
||||||
|
// Text nodes get the vendored CodeMirror editor in place, positioned over the
|
||||||
|
// node in *screen* coordinates rather than mounted inside the zoomed world.
|
||||||
|
// CodeMirror measures its own layout in device pixels, so a CSS-scaled parent
|
||||||
|
// wrecks caret placement and selection maths; a fixed-position panel sidesteps
|
||||||
|
// that entirely and stays sharp at every zoom level.
|
||||||
|
//
|
||||||
|
// The other node types have nothing to type into, so they reuse the existing
|
||||||
|
// tree picker and modal instead of an editor.
|
||||||
|
window.Canvas = window.Canvas || {};
|
||||||
|
|
||||||
|
Canvas.Edit = (function () {
|
||||||
|
var Doc = Canvas.Doc;
|
||||||
|
var View = Canvas.View;
|
||||||
|
|
||||||
|
var MIN_WIDTH = 360;
|
||||||
|
var MIN_HEIGHT = 200;
|
||||||
|
|
||||||
|
var panel = null;
|
||||||
|
var view = null;
|
||||||
|
var currentId = null;
|
||||||
|
var original = '';
|
||||||
|
|
||||||
|
function destroy() {
|
||||||
|
if (view) { view.destroy(); view = null; }
|
||||||
|
if (panel) { panel.remove(); panel = null; }
|
||||||
|
currentId = null;
|
||||||
|
original = '';
|
||||||
|
document.removeEventListener('pointerdown', onOutside, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
function commit() {
|
||||||
|
if (!panel || !view) return;
|
||||||
|
var text = view.state.doc.toString();
|
||||||
|
var id = currentId;
|
||||||
|
destroy();
|
||||||
|
var n = Doc.node(id);
|
||||||
|
if (!n || text === original) return;
|
||||||
|
Doc.begin('edit text');
|
||||||
|
n.text = text;
|
||||||
|
Doc.commit();
|
||||||
|
}
|
||||||
|
|
||||||
|
function onOutside(e) {
|
||||||
|
if (!panel || panel.contains(e.target)) return;
|
||||||
|
commit();
|
||||||
|
}
|
||||||
|
|
||||||
|
// screenRect places the editor over the node it edits, clamped to a usable
|
||||||
|
// minimum and kept inside the viewport — a 60x60 node still needs a panel
|
||||||
|
// big enough to type in.
|
||||||
|
function screenRect(n) {
|
||||||
|
var s = View.scale();
|
||||||
|
var tl = View.toScreen(n.x, n.y);
|
||||||
|
var w = Math.max(MIN_WIDTH, n.width * s);
|
||||||
|
var h = Math.max(MIN_HEIGHT, n.height * s);
|
||||||
|
w = Math.min(w, window.innerWidth - 16);
|
||||||
|
h = Math.min(h, window.innerHeight - 16);
|
||||||
|
var left = Math.max(8, Math.min(tl.x, window.innerWidth - w - 8));
|
||||||
|
var top = Math.max(8, Math.min(tl.y, window.innerHeight - h - 8));
|
||||||
|
return { left: left, top: top, width: w, height: h };
|
||||||
|
}
|
||||||
|
|
||||||
|
function openText(n) {
|
||||||
|
var rect = screenRect(n);
|
||||||
|
panel = document.createElement('div');
|
||||||
|
panel.className = 'canvas-editor panel panel-floating';
|
||||||
|
panel.style.left = rect.left + 'px';
|
||||||
|
panel.style.top = rect.top + 'px';
|
||||||
|
panel.style.width = rect.width + 'px';
|
||||||
|
panel.style.height = rect.height + 'px';
|
||||||
|
|
||||||
|
var hint = document.createElement('div');
|
||||||
|
hint.className = 'panel-header';
|
||||||
|
hint.textContent = 'Ctrl+Enter or click away to apply · Escape to cancel';
|
||||||
|
panel.appendChild(hint);
|
||||||
|
|
||||||
|
var mount = document.createElement('div');
|
||||||
|
mount.className = 'canvas-editor-mount';
|
||||||
|
panel.appendChild(mount);
|
||||||
|
document.body.appendChild(panel);
|
||||||
|
|
||||||
|
currentId = n.id;
|
||||||
|
original = n.text || '';
|
||||||
|
|
||||||
|
view = new CM.EditorView({
|
||||||
|
state: CM.EditorState.create({
|
||||||
|
doc: original,
|
||||||
|
selection: { anchor: original.length },
|
||||||
|
extensions: [
|
||||||
|
CM.history(),
|
||||||
|
CM.drawSelection(),
|
||||||
|
CM.indentOnInput(),
|
||||||
|
CM.EditorView.lineWrapping,
|
||||||
|
CM.EditorView.contentAttributes.of({ spellcheck: 'true', autocapitalize: 'sentences' }),
|
||||||
|
CM.markdown({ base: CM.markdownLanguage }),
|
||||||
|
CM.syntaxHighlighting(CM.highlightStyle),
|
||||||
|
CM.closeBrackets(),
|
||||||
|
CM.autocompletion({ override: [WikiComplete.source] }),
|
||||||
|
CM.theme,
|
||||||
|
// Mod-Enter goes first (nothing else claims it); Escape goes
|
||||||
|
// last so an open completion popup still gets first refusal
|
||||||
|
// on it and closes itself instead of discarding the edit.
|
||||||
|
CM.keymap.of([{ key: 'Mod-Enter', run: function () { commit(); return true; } }]
|
||||||
|
.concat(
|
||||||
|
CM.closeBracketsKeymap,
|
||||||
|
CM.completionKeymap,
|
||||||
|
CM.markdownKeymap,
|
||||||
|
CM.defaultKeymap,
|
||||||
|
CM.historyKeymap,
|
||||||
|
[CM.indentWithTab],
|
||||||
|
[{ key: 'Escape', run: function () { destroy(); return true; } }]
|
||||||
|
))
|
||||||
|
]
|
||||||
|
}),
|
||||||
|
parent: mount
|
||||||
|
});
|
||||||
|
view.focus();
|
||||||
|
// Capture phase: a click on a toolbar button should apply the edit
|
||||||
|
// before that button's own handler reads the document.
|
||||||
|
document.addEventListener('pointerdown', onOutside, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
function openFile(n) {
|
||||||
|
var start = (n.file || '/').replace(/\/+$/, '') || '/';
|
||||||
|
openTreePicker({
|
||||||
|
title: 'File node — pick a target',
|
||||||
|
mode: 'any',
|
||||||
|
initialPath: start,
|
||||||
|
preselect: start,
|
||||||
|
confirmLabel: 'USE',
|
||||||
|
onSelect: function (p) {
|
||||||
|
if (!p || p === n.file) return;
|
||||||
|
Doc.begin('retarget file node');
|
||||||
|
n.file = p;
|
||||||
|
n.subpath = '';
|
||||||
|
Doc.commit();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function promptValue(title, label, value, onValue) {
|
||||||
|
var input = document.createElement('input');
|
||||||
|
input.type = 'text';
|
||||||
|
input.className = 'input';
|
||||||
|
input.placeholder = label;
|
||||||
|
input.value = value || '';
|
||||||
|
openModal({
|
||||||
|
title: title,
|
||||||
|
body: input,
|
||||||
|
confirm: {
|
||||||
|
label: 'OK',
|
||||||
|
onConfirm: function () {
|
||||||
|
closeModal();
|
||||||
|
onValue(input.value.trim());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function openLink(n) {
|
||||||
|
promptValue('Link node', 'https://example.com', n.url, function (v) {
|
||||||
|
if (!v || v === n.url) return;
|
||||||
|
Doc.begin('edit link');
|
||||||
|
n.url = v;
|
||||||
|
Doc.commit();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function openGroup(n) {
|
||||||
|
promptValue('Group label', 'Label', n.label, function (v) {
|
||||||
|
if (v === (n.label || '')) return;
|
||||||
|
Doc.begin('edit group label');
|
||||||
|
if (v) n.label = v; else delete n.label;
|
||||||
|
Doc.commit();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// close applies whatever is currently open, if anything.
|
||||||
|
function close() {
|
||||||
|
if (panel) commit();
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
open: function (id) {
|
||||||
|
var n = Doc.node(id);
|
||||||
|
if (!n) return;
|
||||||
|
close();
|
||||||
|
switch (n.type) {
|
||||||
|
case 'text': openText(n); break;
|
||||||
|
case 'file': openFile(n); break;
|
||||||
|
case 'link': openLink(n); break;
|
||||||
|
case 'group': openGroup(n); break;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// Called from the gesture layer on any pointerdown on the surface, so
|
||||||
|
// starting a drag never silently drops an edit.
|
||||||
|
close: close,
|
||||||
|
|
||||||
|
isOpen: function () { return panel !== null; }
|
||||||
|
};
|
||||||
|
})();
|
||||||
@@ -0,0 +1,461 @@
|
|||||||
|
// interact.js — pointer and keyboard gestures.
|
||||||
|
//
|
||||||
|
// Every gesture is bracketed by Doc.begin()/Doc.commit(), so a drag that spans
|
||||||
|
// hundreds of pointermove events collapses into one undo entry. Within a
|
||||||
|
// gesture the document is mutated directly and only the affected elements are
|
||||||
|
// re-synced; the full reconcile happens once, on commit.
|
||||||
|
//
|
||||||
|
// Gesture routing on pointerdown, in priority order:
|
||||||
|
// resize handle -> resize port -> draw an edge
|
||||||
|
// node -> select + drag edge -> select
|
||||||
|
// background -> pan (shift: marquee select; two fingers: pinch zoom)
|
||||||
|
window.Canvas = window.Canvas || {};
|
||||||
|
|
||||||
|
Canvas.Interact = (function () {
|
||||||
|
var Doc = Canvas.Doc;
|
||||||
|
var View = Canvas.View;
|
||||||
|
var Select = Canvas.Select;
|
||||||
|
var Nodes = Canvas.Nodes;
|
||||||
|
var Edges = Canvas.Edges;
|
||||||
|
|
||||||
|
var MIN_SIZE = 40;
|
||||||
|
var DRAG_SLOP = 3;
|
||||||
|
|
||||||
|
var surface = null;
|
||||||
|
var marquee = null;
|
||||||
|
var gesture = null;
|
||||||
|
var pointers = {};
|
||||||
|
|
||||||
|
function snap(v) {
|
||||||
|
return Math.round(v / View.GRID) * View.GRID;
|
||||||
|
}
|
||||||
|
|
||||||
|
function pointerCount() {
|
||||||
|
return Object.keys(pointers).length;
|
||||||
|
}
|
||||||
|
|
||||||
|
// interactive reports whether the pointer landed on something the browser
|
||||||
|
// should handle itself (a link, a video control), which must not become the
|
||||||
|
// start of a node drag.
|
||||||
|
function interactive(target) {
|
||||||
|
return !!target.closest('a, button, input, select, textarea, video, embed, iframe');
|
||||||
|
}
|
||||||
|
|
||||||
|
// groupMembers returns the nodes whose centre sits inside a group, which is
|
||||||
|
// what "inside" means for dragging: a node straddling the border belongs to
|
||||||
|
// whichever side holds most of it.
|
||||||
|
function groupMembers(g) {
|
||||||
|
return Doc.nodes().filter(function (n) {
|
||||||
|
if (n.id === g.id) return false;
|
||||||
|
var cx = n.x + n.width / 2;
|
||||||
|
var cy = n.y + n.height / 2;
|
||||||
|
return cx >= g.x && cx <= g.x + g.width && cy >= g.y && cy <= g.y + g.height;
|
||||||
|
}).map(function (n) { return n.id; });
|
||||||
|
}
|
||||||
|
|
||||||
|
// withGroupMembers expands a selection so dragging a group carries whatever
|
||||||
|
// it contains.
|
||||||
|
function withGroupMembers(ids) {
|
||||||
|
var out = {};
|
||||||
|
ids.forEach(function (id) {
|
||||||
|
out[id] = true;
|
||||||
|
var n = Doc.node(id);
|
||||||
|
if (n && n.type === 'group') {
|
||||||
|
groupMembers(n).forEach(function (m) { out[m] = true; });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return Object.keys(out);
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectOnPress(id, additive) {
|
||||||
|
if (additive) {
|
||||||
|
Select.toggleNode(id);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!Select.hasNode(id)) Select.set([id], []);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- gesture starts ---
|
||||||
|
|
||||||
|
function startDrag(id, e) {
|
||||||
|
selectOnPress(id, e.shiftKey);
|
||||||
|
if (!Select.hasNode(id)) return; // shift-click deselected it
|
||||||
|
var ids = withGroupMembers(Select.nodes());
|
||||||
|
Doc.begin('move');
|
||||||
|
gesture = {
|
||||||
|
kind: 'drag',
|
||||||
|
ids: ids,
|
||||||
|
start: View.toWorld(e.clientX, e.clientY),
|
||||||
|
origin: ids.map(function (nid) {
|
||||||
|
var n = Doc.node(nid);
|
||||||
|
return { id: nid, x: n.x, y: n.y };
|
||||||
|
}),
|
||||||
|
moved: false
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function startResize(id, corner, e) {
|
||||||
|
Select.set([id], []);
|
||||||
|
var n = Doc.node(id);
|
||||||
|
Doc.begin('resize');
|
||||||
|
gesture = {
|
||||||
|
kind: 'resize',
|
||||||
|
id: id,
|
||||||
|
corner: corner,
|
||||||
|
start: View.toWorld(e.clientX, e.clientY),
|
||||||
|
origin: { x: n.x, y: n.y, width: n.width, height: n.height },
|
||||||
|
moved: false
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function startEdge(id, side) {
|
||||||
|
Edges.beginTemp();
|
||||||
|
gesture = { kind: 'edge', fromId: id, fromSide: side, target: null, moved: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
function startPan(e) {
|
||||||
|
gesture = { kind: 'pan', last: { x: e.clientX, y: e.clientY }, moved: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
function startMarquee(e) {
|
||||||
|
var r = surface.getBoundingClientRect();
|
||||||
|
gesture = {
|
||||||
|
kind: 'marquee',
|
||||||
|
origin: { x: e.clientX - r.left, y: e.clientY - r.top },
|
||||||
|
additive: e.ctrlKey || e.metaKey,
|
||||||
|
moved: false
|
||||||
|
};
|
||||||
|
marquee.hidden = false;
|
||||||
|
updateMarquee(e);
|
||||||
|
}
|
||||||
|
|
||||||
|
function startPinch() {
|
||||||
|
var ids = Object.keys(pointers);
|
||||||
|
var a = pointers[ids[0]];
|
||||||
|
var b = pointers[ids[1]];
|
||||||
|
gesture = {
|
||||||
|
kind: 'pinch',
|
||||||
|
dist: Math.hypot(b.x - a.x, b.y - a.y),
|
||||||
|
mid: { x: (a.x + b.x) / 2, y: (a.y + b.y) / 2 },
|
||||||
|
moved: true
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- gesture updates ---
|
||||||
|
|
||||||
|
function moveDrag(e) {
|
||||||
|
var p = View.toWorld(e.clientX, e.clientY);
|
||||||
|
var lead = gesture.origin[0];
|
||||||
|
if (!lead) return;
|
||||||
|
var dx = p.x - gesture.start.x;
|
||||||
|
var dy = p.y - gesture.start.y;
|
||||||
|
// Snap the lead node to the grid and shift the rest by the same amount,
|
||||||
|
// so a multi-node drag keeps its relative layout. Alt opts out.
|
||||||
|
if (!e.altKey) {
|
||||||
|
dx = snap(lead.x + dx) - lead.x;
|
||||||
|
dy = snap(lead.y + dy) - lead.y;
|
||||||
|
}
|
||||||
|
gesture.origin.forEach(function (o) {
|
||||||
|
var n = Doc.node(o.id);
|
||||||
|
if (!n) return;
|
||||||
|
n.x = Math.round(o.x + dx);
|
||||||
|
n.y = Math.round(o.y + dy);
|
||||||
|
});
|
||||||
|
gesture.ids.forEach(Nodes.syncGeom);
|
||||||
|
Edges.syncFor(gesture.ids);
|
||||||
|
}
|
||||||
|
|
||||||
|
function moveResize(e) {
|
||||||
|
var n = Doc.node(gesture.id);
|
||||||
|
if (!n) return;
|
||||||
|
var p = View.toWorld(e.clientX, e.clientY);
|
||||||
|
var o = gesture.origin;
|
||||||
|
var dx = p.x - gesture.start.x;
|
||||||
|
var dy = p.y - gesture.start.y;
|
||||||
|
var right = o.x + o.width;
|
||||||
|
var bottom = o.y + o.height;
|
||||||
|
var x = o.x, y = o.y, w = o.width, h = o.height;
|
||||||
|
|
||||||
|
if (gesture.corner.indexOf('w') !== -1) {
|
||||||
|
x = e.altKey ? o.x + dx : snap(o.x + dx);
|
||||||
|
w = right - x;
|
||||||
|
} else {
|
||||||
|
w = e.altKey ? o.width + dx : snap(right + dx) - o.x;
|
||||||
|
}
|
||||||
|
if (gesture.corner.indexOf('n') !== -1) {
|
||||||
|
y = e.altKey ? o.y + dy : snap(o.y + dy);
|
||||||
|
h = bottom - y;
|
||||||
|
} else {
|
||||||
|
h = e.altKey ? o.height + dy : snap(bottom + dy) - o.y;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clamp against the fixed edge so shrinking past the minimum pins the
|
||||||
|
// node instead of flipping it inside out.
|
||||||
|
if (w < MIN_SIZE) {
|
||||||
|
if (gesture.corner.indexOf('w') !== -1) x = right - MIN_SIZE;
|
||||||
|
w = MIN_SIZE;
|
||||||
|
}
|
||||||
|
if (h < MIN_SIZE) {
|
||||||
|
if (gesture.corner.indexOf('n') !== -1) y = bottom - MIN_SIZE;
|
||||||
|
h = MIN_SIZE;
|
||||||
|
}
|
||||||
|
|
||||||
|
n.x = Math.round(x);
|
||||||
|
n.y = Math.round(y);
|
||||||
|
n.width = Math.round(w);
|
||||||
|
n.height = Math.round(h);
|
||||||
|
Nodes.syncGeom(n.id);
|
||||||
|
Edges.syncFor([n.id]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function moveEdge(e) {
|
||||||
|
var from = Doc.node(gesture.fromId);
|
||||||
|
if (!from) return;
|
||||||
|
Edges.updateTemp(from, gesture.fromSide, View.toWorld(e.clientX, e.clientY));
|
||||||
|
|
||||||
|
var under = document.elementFromPoint(e.clientX, e.clientY);
|
||||||
|
var nodeEl = under && under.closest ? under.closest('.canvas-node') : null;
|
||||||
|
var id = nodeEl ? nodeEl.dataset.id : null;
|
||||||
|
if (id === gesture.target) return;
|
||||||
|
if (gesture.target) {
|
||||||
|
var prev = Nodes.el(gesture.target);
|
||||||
|
if (prev) prev.classList.remove('is-edge-target');
|
||||||
|
}
|
||||||
|
gesture.target = id;
|
||||||
|
if (id && nodeEl) nodeEl.classList.add('is-edge-target');
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateMarquee(e) {
|
||||||
|
var r = surface.getBoundingClientRect();
|
||||||
|
var x = e.clientX - r.left;
|
||||||
|
var y = e.clientY - r.top;
|
||||||
|
marquee.style.left = Math.min(x, gesture.origin.x) + 'px';
|
||||||
|
marquee.style.top = Math.min(y, gesture.origin.y) + 'px';
|
||||||
|
marquee.style.width = Math.abs(x - gesture.origin.x) + 'px';
|
||||||
|
marquee.style.height = Math.abs(y - gesture.origin.y) + 'px';
|
||||||
|
}
|
||||||
|
|
||||||
|
function movePinch() {
|
||||||
|
var ids = Object.keys(pointers);
|
||||||
|
if (ids.length < 2) return;
|
||||||
|
var a = pointers[ids[0]];
|
||||||
|
var b = pointers[ids[1]];
|
||||||
|
var dist = Math.hypot(b.x - a.x, b.y - a.y);
|
||||||
|
var mid = { x: (a.x + b.x) / 2, y: (a.y + b.y) / 2 };
|
||||||
|
if (gesture.dist > 0) View.zoomAt(mid.x, mid.y, dist / gesture.dist);
|
||||||
|
View.pan(mid.x - gesture.mid.x, mid.y - gesture.mid.y);
|
||||||
|
gesture.dist = dist;
|
||||||
|
gesture.mid = mid;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- gesture ends ---
|
||||||
|
|
||||||
|
// Both finishers take the gesture as an argument rather than reading the
|
||||||
|
// module-level one: endGesture clears it before dispatching so a finisher
|
||||||
|
// cannot accidentally leave a half-torn-down gesture in place.
|
||||||
|
function finishMarquee(g) {
|
||||||
|
marquee.hidden = true;
|
||||||
|
var r = surface.getBoundingClientRect();
|
||||||
|
var a = View.toWorld(r.left + parseFloat(marquee.style.left),
|
||||||
|
r.top + parseFloat(marquee.style.top));
|
||||||
|
var b = View.toWorld(r.left + parseFloat(marquee.style.left) + parseFloat(marquee.style.width),
|
||||||
|
r.top + parseFloat(marquee.style.top) + parseFloat(marquee.style.height));
|
||||||
|
var hit = Doc.nodes().filter(function (n) {
|
||||||
|
return n.x < b.x && n.x + n.width > a.x && n.y < b.y && n.y + n.height > a.y;
|
||||||
|
}).map(function (n) { return n.id; });
|
||||||
|
if (g.additive) {
|
||||||
|
hit.forEach(Select.addNode);
|
||||||
|
} else {
|
||||||
|
Select.set(hit, []);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function finishEdge(g) {
|
||||||
|
if (g.target) {
|
||||||
|
var el = Nodes.el(g.target);
|
||||||
|
if (el) el.classList.remove('is-edge-target');
|
||||||
|
}
|
||||||
|
Edges.endTemp();
|
||||||
|
if (!g.target) return;
|
||||||
|
Doc.begin('connect');
|
||||||
|
Doc.addEdge({
|
||||||
|
id: Doc.newId(),
|
||||||
|
fromNode: g.fromId,
|
||||||
|
fromSide: g.fromSide,
|
||||||
|
toNode: g.target
|
||||||
|
});
|
||||||
|
Doc.commit();
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- event wiring ---
|
||||||
|
|
||||||
|
function onDown(e) {
|
||||||
|
if (Canvas.Edit) Canvas.Edit.close();
|
||||||
|
pointers[e.pointerId] = { x: e.clientX, y: e.clientY };
|
||||||
|
|
||||||
|
if (pointerCount() === 2 && (!gesture || gesture.kind === 'pan')) {
|
||||||
|
if (gesture) endGesture(true);
|
||||||
|
startPinch();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (gesture) return; // one gesture at a time
|
||||||
|
|
||||||
|
var t = e.target;
|
||||||
|
var handle = t.closest('.canvas-handle');
|
||||||
|
var port = t.closest('.canvas-port');
|
||||||
|
var nodeEl = t.closest('.canvas-node');
|
||||||
|
var edgeEl = t.closest('.canvas-edge');
|
||||||
|
|
||||||
|
if (handle && nodeEl) {
|
||||||
|
startResize(nodeEl.dataset.id, handle.dataset.corner, e);
|
||||||
|
} else if (port && nodeEl) {
|
||||||
|
startEdge(nodeEl.dataset.id, port.dataset.side);
|
||||||
|
} else if (nodeEl) {
|
||||||
|
if (interactive(t)) return;
|
||||||
|
startDrag(nodeEl.dataset.id, e);
|
||||||
|
} else if (edgeEl) {
|
||||||
|
if (e.shiftKey) Select.toggleEdge(edgeEl.dataset.id);
|
||||||
|
else Select.set([], [edgeEl.dataset.id]);
|
||||||
|
return;
|
||||||
|
} else if (e.shiftKey) {
|
||||||
|
startMarquee(e);
|
||||||
|
} else {
|
||||||
|
startPan(e);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (gesture) {
|
||||||
|
// Capture keeps the gesture alive when the pointer leaves the
|
||||||
|
// surface mid-drag. It throws when the pointer is already gone (a
|
||||||
|
// release that raced the handler), which must not abort the
|
||||||
|
// gesture — that would leave it stuck with no pointerup to end it.
|
||||||
|
try { surface.setPointerCapture(e.pointerId); } catch (err) { /* pointer already released */ }
|
||||||
|
e.preventDefault();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onMove(e) {
|
||||||
|
if (pointers[e.pointerId]) {
|
||||||
|
pointers[e.pointerId].x = e.clientX;
|
||||||
|
pointers[e.pointerId].y = e.clientY;
|
||||||
|
}
|
||||||
|
if (!gesture) return;
|
||||||
|
|
||||||
|
if (gesture.kind === 'pinch') { movePinch(); return; }
|
||||||
|
|
||||||
|
if (!gesture.moved) {
|
||||||
|
// Ignore sub-pixel jitter so a plain click does not register as a
|
||||||
|
// drag (and so a click on the background still clears the selection).
|
||||||
|
var dx = Math.abs(e.movementX || 0);
|
||||||
|
var dy = Math.abs(e.movementY || 0);
|
||||||
|
if (dx + dy < DRAG_SLOP && gesture.kind !== 'edge') return;
|
||||||
|
gesture.moved = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (gesture.kind) {
|
||||||
|
case 'drag': moveDrag(e); break;
|
||||||
|
case 'resize': moveResize(e); break;
|
||||||
|
case 'edge': moveEdge(e); break;
|
||||||
|
case 'marquee': updateMarquee(e); break;
|
||||||
|
case 'pan':
|
||||||
|
View.pan(e.clientX - gesture.last.x, e.clientY - gesture.last.y);
|
||||||
|
gesture.last = { x: e.clientX, y: e.clientY };
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function endGesture(silent) {
|
||||||
|
if (!gesture) return;
|
||||||
|
var g = gesture;
|
||||||
|
gesture = null;
|
||||||
|
switch (g.kind) {
|
||||||
|
case 'drag':
|
||||||
|
case 'resize':
|
||||||
|
Doc.commit();
|
||||||
|
break;
|
||||||
|
case 'edge':
|
||||||
|
if (!silent) finishEdge(g); else Edges.endTemp();
|
||||||
|
break;
|
||||||
|
case 'marquee':
|
||||||
|
if (!silent) finishMarquee(g); else marquee.hidden = true;
|
||||||
|
break;
|
||||||
|
case 'pan':
|
||||||
|
// A press on the background that never moved is a click, and a
|
||||||
|
// click on empty space clears the selection.
|
||||||
|
if (!silent && !g.moved) Select.clear();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onUp(e) {
|
||||||
|
delete pointers[e.pointerId];
|
||||||
|
if (gesture && gesture.kind === 'pinch' && pointerCount() < 2) {
|
||||||
|
gesture = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
endGesture(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onWheel(e) {
|
||||||
|
// Ctrl/meta+wheel is the zoom gesture every drawing tool uses, and is
|
||||||
|
// also what a trackpad pinch reports. A plain wheel scrolls the canvas.
|
||||||
|
if (e.ctrlKey || e.metaKey) {
|
||||||
|
e.preventDefault();
|
||||||
|
View.zoomAt(e.clientX, e.clientY, Math.pow(0.999, e.deltaY));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
e.preventDefault();
|
||||||
|
if (e.shiftKey) View.pan(-e.deltaY, 0);
|
||||||
|
else View.pan(-e.deltaX, -e.deltaY);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onDblClick(e) {
|
||||||
|
var nodeEl = e.target.closest('.canvas-node');
|
||||||
|
if (nodeEl) {
|
||||||
|
if (interactive(e.target)) return;
|
||||||
|
e.preventDefault();
|
||||||
|
Canvas.Edit.open(nodeEl.dataset.id);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (e.target.closest('.canvas-edge')) return;
|
||||||
|
// Double-clicking empty space drops a new text node there and opens it,
|
||||||
|
// which is how a canvas normally gets its first node.
|
||||||
|
var p = View.toWorld(e.clientX, e.clientY);
|
||||||
|
var node = Canvas.Actions.addText(p);
|
||||||
|
if (node) Canvas.Edit.open(node.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
init: function (surfaceEl, marqueeEl) {
|
||||||
|
surface = surfaceEl;
|
||||||
|
marquee = marqueeEl;
|
||||||
|
|
||||||
|
surface.addEventListener('pointerdown', onDown);
|
||||||
|
surface.addEventListener('pointermove', onMove);
|
||||||
|
surface.addEventListener('pointerup', onUp);
|
||||||
|
surface.addEventListener('pointercancel', onUp);
|
||||||
|
surface.addEventListener('wheel', onWheel, { passive: false });
|
||||||
|
surface.addEventListener('dblclick', onDblClick);
|
||||||
|
// Native drag would fight the pointer gestures on images and links.
|
||||||
|
surface.addEventListener('dragstart', function (e) { e.preventDefault(); });
|
||||||
|
surface.addEventListener('contextmenu', function (e) {
|
||||||
|
if (e.target.closest('.canvas-node, .canvas-edge')) e.preventDefault();
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
// cancel aborts an in-flight gesture and rolls the document back, for
|
||||||
|
// the Escape key.
|
||||||
|
cancel: function () {
|
||||||
|
if (!gesture) return false;
|
||||||
|
var kind = gesture.kind;
|
||||||
|
if (kind === 'drag' || kind === 'resize') {
|
||||||
|
gesture = null;
|
||||||
|
Doc.abort();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
endGesture(true);
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
|
||||||
|
active: function () { return gesture !== null; }
|
||||||
|
};
|
||||||
|
})();
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
{{define "headerActions"}}
|
||||||
|
<a class="btn" href="{{.PostURL}}" title="Back to the page">PAGE</a>
|
||||||
|
<span class="dropdown">
|
||||||
|
<button type="button" class="btn dropdown-toggle" data-action="canvas-menu" title="Canvas actions">CANVAS▾</button>
|
||||||
|
<div class="dropdown-menu align-right">
|
||||||
|
<button type="button" class="btn btn-block" data-action="canvas-rename">Rename…</button>
|
||||||
|
<button type="button" class="btn btn-block danger" data-action="canvas-delete">Delete canvas</button>
|
||||||
|
</div>
|
||||||
|
</span>
|
||||||
|
<button class="btn" type="button" data-action="canvas-save" title="Save (Ctrl+S)">SAVE</button>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
{{define "content"}}
|
||||||
|
<script>
|
||||||
|
// Full-bleed view mode: the canvas surface owns the viewport instead of the
|
||||||
|
// centred reading column. Set here rather than server-side for the same
|
||||||
|
// reason the editor does it — one class, no extra template plumbing.
|
||||||
|
document.body.classList.add('canvas-mode');
|
||||||
|
</script>
|
||||||
|
{{if .CanvasError}}
|
||||||
|
<div class="content">
|
||||||
|
<h1>{{.ActiveCanvas}}</h1>
|
||||||
|
<p class="canvas-broken">This canvas could not be opened — {{.CanvasError}}</p>
|
||||||
|
<p>Fix it in the file itself, then reload.
|
||||||
|
<a href="{{.ActiveCanvas}}.canvas?raw">view the raw file</a> ·
|
||||||
|
<a href="{{.PostURL}}">back to the page</a></p>
|
||||||
|
</div>
|
||||||
|
{{else}}
|
||||||
|
<div class="canvas-app">
|
||||||
|
<nav class="canvas-tabs row" aria-label="Page views">
|
||||||
|
<a class="canvas-tab" href="{{.PostURL}}">PAGE</a>
|
||||||
|
{{range .Canvases}}<a class="canvas-tab{{if .Active}} is-active{{end}}" href="{{.URL}}">{{.Name}}</a>{{end}}
|
||||||
|
</nav>
|
||||||
|
<div class="canvas-toolbar row">
|
||||||
|
<button type="button" class="btn btn-tool" data-action="undo" title="Undo (Ctrl+Z)" disabled>↶</button>
|
||||||
|
<button type="button" class="btn btn-tool" data-action="redo" title="Redo (Ctrl+Shift+Z)" disabled>↷</button>
|
||||||
|
<span class="toolbar-sep"></span>
|
||||||
|
<button type="button" class="btn btn-tool" data-action="add-text" title="Add text node (T)">TEXT</button>
|
||||||
|
<button type="button" class="btn btn-tool" data-action="add-file" title="Add file node (F)">FILE</button>
|
||||||
|
<button type="button" class="btn btn-tool" data-action="add-link" title="Add link node (L)">LINK</button>
|
||||||
|
<button type="button" class="btn btn-tool" data-action="add-group" title="Add group (G)">GROUP</button>
|
||||||
|
<span class="toolbar-sep"></span>
|
||||||
|
<span class="dropdown">
|
||||||
|
<button type="button" class="btn btn-tool dropdown-toggle" data-action="color-menu" title="Colour of the selection">■▾</button>
|
||||||
|
<div class="dropdown-menu canvas-color-menu">
|
||||||
|
<button type="button" class="btn btn-tool btn-block" data-color="">Default</button>
|
||||||
|
<button type="button" class="btn btn-tool btn-block" data-color="1"><span class="canvas-swatch" data-swatch="1"></span> Red</button>
|
||||||
|
<button type="button" class="btn btn-tool btn-block" data-color="2"><span class="canvas-swatch" data-swatch="2"></span> Orange</button>
|
||||||
|
<button type="button" class="btn btn-tool btn-block" data-color="3"><span class="canvas-swatch" data-swatch="3"></span> Yellow</button>
|
||||||
|
<button type="button" class="btn btn-tool btn-block" data-color="4"><span class="canvas-swatch" data-swatch="4"></span> Green</button>
|
||||||
|
<button type="button" class="btn btn-tool btn-block" data-color="5"><span class="canvas-swatch" data-swatch="5"></span> Cyan</button>
|
||||||
|
<button type="button" class="btn btn-tool btn-block" data-color="6"><span class="canvas-swatch" data-swatch="6"></span> Purple</button>
|
||||||
|
</div>
|
||||||
|
</span>
|
||||||
|
<button type="button" class="btn btn-tool" data-action="delete-selection" title="Delete selection (Del)">×</button>
|
||||||
|
<span class="toolbar-sep"></span>
|
||||||
|
<button type="button" class="btn btn-tool" data-action="zoom-out" title="Zoom out (−)">−</button>
|
||||||
|
<button type="button" class="btn btn-tool" data-action="zoom-reset" title="Reset zoom to 100% (0)"><span data-canvas-zoom>100%</span></button>
|
||||||
|
<button type="button" class="btn btn-tool" data-action="zoom-in" title="Zoom in (+)">+</button>
|
||||||
|
<button type="button" class="btn btn-tool" data-action="fit" title="Fit to content (9)">FIT</button>
|
||||||
|
<span class="canvas-status muted" data-canvas-status></span>
|
||||||
|
</div>
|
||||||
|
<div class="canvas-surface" id="canvas-surface" tabindex="0">
|
||||||
|
<div class="canvas-world" id="canvas-world">
|
||||||
|
<div class="canvas-layer" id="canvas-groups"></div>
|
||||||
|
<svg class="canvas-edges" id="canvas-edges" xmlns="http://www.w3.org/2000/svg"></svg>
|
||||||
|
<div class="canvas-layer" id="canvas-nodes"></div>
|
||||||
|
</div>
|
||||||
|
<div class="canvas-marquee" id="canvas-marquee" hidden></div>
|
||||||
|
<div class="canvas-hint muted" data-canvas-hint hidden>
|
||||||
|
Empty canvas — use the toolbar to add a node. Drag the background to pan, ctrl+wheel to zoom.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<script>window.CANVAS_DATA = {{.CanvasJSON}};</script>
|
||||||
|
<script src="/_/editor/vendor/codemirror.bundle.js?v={{editorBundleVersion}}"></script>
|
||||||
|
<script src="/_/editor/wikicomplete.js"></script>
|
||||||
|
<script src="/_/canvas/doc.js"></script>
|
||||||
|
<script src="/_/canvas/view.js"></script>
|
||||||
|
<script src="/_/canvas/select.js"></script>
|
||||||
|
<script src="/_/canvas/nodes.js"></script>
|
||||||
|
<script src="/_/canvas/edges.js"></script>
|
||||||
|
<script src="/_/canvas/interact.js"></script>
|
||||||
|
<script src="/_/canvas/edit.js"></script>
|
||||||
|
<script src="/_/canvas/save.js"></script>
|
||||||
|
<script src="/_/canvas/main.js"></script>
|
||||||
|
{{end}}
|
||||||
|
{{end}}
|
||||||
@@ -0,0 +1,389 @@
|
|||||||
|
// main.js — bootstrap, toolbar, and keyboard shortcuts.
|
||||||
|
//
|
||||||
|
// The payload the server inlined (document + pre-rendered node HTML + the
|
||||||
|
// version token) is the only input; nothing else is fetched on load.
|
||||||
|
(function () {
|
||||||
|
var data = window.CANVAS_DATA;
|
||||||
|
var surface = document.getElementById('canvas-surface');
|
||||||
|
if (!data || !surface) return;
|
||||||
|
|
||||||
|
var Doc = Canvas.Doc;
|
||||||
|
var View = Canvas.View;
|
||||||
|
var Select = Canvas.Select;
|
||||||
|
var Nodes = Canvas.Nodes;
|
||||||
|
var Edges = Canvas.Edges;
|
||||||
|
var Save = Canvas.Save;
|
||||||
|
|
||||||
|
var world = document.getElementById('canvas-world');
|
||||||
|
var marquee = document.getElementById('canvas-marquee');
|
||||||
|
var hint = document.querySelector('[data-canvas-hint]');
|
||||||
|
var statusEl = document.querySelector('[data-canvas-status]');
|
||||||
|
var zoomEl = document.querySelector('[data-canvas-zoom]');
|
||||||
|
|
||||||
|
// Default sizes for new nodes, in world pixels. Multiples of the 20px grid
|
||||||
|
// so a fresh node lands aligned.
|
||||||
|
var SIZES = {
|
||||||
|
text: { width: 260, height: 120 },
|
||||||
|
file: { width: 400, height: 400 },
|
||||||
|
link: { width: 400, height: 140 },
|
||||||
|
group: { width: 520, height: 400 }
|
||||||
|
};
|
||||||
|
|
||||||
|
function snap(v) { return Math.round(v / View.GRID) * View.GRID; }
|
||||||
|
|
||||||
|
// --- document actions ---
|
||||||
|
|
||||||
|
Canvas.Actions = {
|
||||||
|
// add drops a node centred on a world point (the viewport centre when
|
||||||
|
// called from the toolbar, the cursor when called from a double-click).
|
||||||
|
add: function (type, extra, at) {
|
||||||
|
var size = SIZES[type];
|
||||||
|
var p = at || View.centreOfViewport();
|
||||||
|
var node = {
|
||||||
|
id: Doc.newId(),
|
||||||
|
type: type,
|
||||||
|
x: snap(p.x - size.width / 2),
|
||||||
|
y: snap(p.y - size.height / 2),
|
||||||
|
width: size.width,
|
||||||
|
height: size.height
|
||||||
|
};
|
||||||
|
for (var k in extra) {
|
||||||
|
if (Object.prototype.hasOwnProperty.call(extra, k)) node[k] = extra[k];
|
||||||
|
}
|
||||||
|
Doc.begin('add ' + type);
|
||||||
|
Doc.addNode(node);
|
||||||
|
Doc.commit();
|
||||||
|
Select.set([node.id], []);
|
||||||
|
return node;
|
||||||
|
},
|
||||||
|
|
||||||
|
addText: function (at) {
|
||||||
|
return Canvas.Actions.add('text', { text: '' }, at);
|
||||||
|
},
|
||||||
|
|
||||||
|
addGroup: function (at) {
|
||||||
|
return Canvas.Actions.add('group', { label: 'Group' }, at);
|
||||||
|
},
|
||||||
|
|
||||||
|
// A file node is meaningless without a target and a link node without a
|
||||||
|
// url, so both ask first and only then create — validation on the server
|
||||||
|
// rejects the empty forms anyway.
|
||||||
|
addFile: function () {
|
||||||
|
openTreePicker({
|
||||||
|
title: 'File node — pick a target',
|
||||||
|
mode: 'any',
|
||||||
|
initialPath: data.postUrl,
|
||||||
|
confirmLabel: 'ADD',
|
||||||
|
onSelect: function (p) {
|
||||||
|
if (p) Canvas.Actions.add('file', { file: p });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
addLink: function () {
|
||||||
|
var input = document.createElement('input');
|
||||||
|
input.type = 'url';
|
||||||
|
input.className = 'input';
|
||||||
|
input.placeholder = 'https://example.com';
|
||||||
|
openModal({
|
||||||
|
title: 'Link node',
|
||||||
|
body: input,
|
||||||
|
confirm: {
|
||||||
|
label: 'ADD',
|
||||||
|
onConfirm: function () {
|
||||||
|
var v = input.value.trim();
|
||||||
|
if (!v) return;
|
||||||
|
closeModal();
|
||||||
|
Canvas.Actions.add('link', { url: v });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
deleteSelection: function () {
|
||||||
|
if (Select.isEmpty()) return;
|
||||||
|
Doc.begin('delete');
|
||||||
|
Doc.remove(Select.nodes(), Select.edges());
|
||||||
|
Doc.commit();
|
||||||
|
Select.clear();
|
||||||
|
},
|
||||||
|
|
||||||
|
setColor: function (color) {
|
||||||
|
if (Select.isEmpty()) return;
|
||||||
|
Doc.begin('colour');
|
||||||
|
Select.nodes().forEach(function (id) {
|
||||||
|
var n = Doc.node(id);
|
||||||
|
if (!n) return;
|
||||||
|
if (color) n.color = color; else delete n.color;
|
||||||
|
});
|
||||||
|
Select.edges().forEach(function (id) {
|
||||||
|
var e = Doc.edge(id);
|
||||||
|
if (!e) return;
|
||||||
|
if (color) e.color = color; else delete e.color;
|
||||||
|
});
|
||||||
|
Doc.commit();
|
||||||
|
},
|
||||||
|
|
||||||
|
// nudge moves the selection by whole grid steps, or single pixels with
|
||||||
|
// shift held for the cases where the grid is in the way.
|
||||||
|
nudge: function (dx, dy, fine) {
|
||||||
|
var ids = Select.nodes();
|
||||||
|
if (!ids.length) return;
|
||||||
|
var step = fine ? 1 : View.GRID;
|
||||||
|
Doc.begin('nudge');
|
||||||
|
ids.forEach(function (id) {
|
||||||
|
var n = Doc.node(id);
|
||||||
|
if (!n) return;
|
||||||
|
n.x += dx * step;
|
||||||
|
n.y += dy * step;
|
||||||
|
});
|
||||||
|
Doc.commit();
|
||||||
|
},
|
||||||
|
|
||||||
|
selectAll: function () {
|
||||||
|
Select.set(
|
||||||
|
Doc.nodes().map(function (n) { return n.id; }),
|
||||||
|
Doc.edges().map(function (e) { return e.id; })
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- chrome ---
|
||||||
|
|
||||||
|
function setStatus(text) {
|
||||||
|
if (statusEl) statusEl.textContent = text || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateToolbar() {
|
||||||
|
var undoBtn = document.querySelector('[data-action="undo"]');
|
||||||
|
var redoBtn = document.querySelector('[data-action="redo"]');
|
||||||
|
var delBtn = document.querySelector('[data-action="delete-selection"]');
|
||||||
|
if (undoBtn) undoBtn.disabled = !Doc.canUndo();
|
||||||
|
if (redoBtn) redoBtn.disabled = !Doc.canRedo();
|
||||||
|
if (delBtn) delBtn.disabled = Select.isEmpty();
|
||||||
|
|
||||||
|
var saveBtn = document.querySelector('[data-action="canvas-save"]');
|
||||||
|
if (saveBtn) saveBtn.classList.toggle('is-active', Doc.isDirty());
|
||||||
|
if (hint) hint.hidden = Doc.nodes().length > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateZoom() {
|
||||||
|
if (zoomEl) zoomEl.textContent = Math.round(View.scale() * 100) + '%';
|
||||||
|
}
|
||||||
|
|
||||||
|
// refresh is the single re-render path: prune the selection against the
|
||||||
|
// (possibly undone) document, reconcile both layers, update the chrome.
|
||||||
|
function refresh() {
|
||||||
|
Select.prune(Doc.get());
|
||||||
|
Nodes.render();
|
||||||
|
Edges.render();
|
||||||
|
updateToolbar();
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- canvas-level actions (rename / delete the file itself) ---
|
||||||
|
|
||||||
|
function leaveGuard() {
|
||||||
|
if (!Doc.isDirty()) return true;
|
||||||
|
return window.confirm('This canvas has unsaved changes. Leave anyway?');
|
||||||
|
}
|
||||||
|
|
||||||
|
function canvasAction(url, then) {
|
||||||
|
fetch(url, { method: 'POST', credentials: 'same-origin', redirect: 'manual' })
|
||||||
|
.then(function (res) {
|
||||||
|
if (res.type === 'opaqueredirect' || res.ok) { then(); return; }
|
||||||
|
return res.text().then(function (msg) {
|
||||||
|
alert(msg || ('Request failed (' + res.status + ')'));
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.catch(function () { alert('Network error'); });
|
||||||
|
}
|
||||||
|
|
||||||
|
function renameCanvas() {
|
||||||
|
var input = document.createElement('input');
|
||||||
|
input.type = 'text';
|
||||||
|
input.className = 'input';
|
||||||
|
input.value = data.name;
|
||||||
|
openModal({
|
||||||
|
title: 'Rename canvas',
|
||||||
|
body: input,
|
||||||
|
confirm: {
|
||||||
|
label: 'RENAME',
|
||||||
|
onConfirm: function () {
|
||||||
|
var v = input.value.trim();
|
||||||
|
if (!v || v === data.name) { closeModal(); return; }
|
||||||
|
closeModal();
|
||||||
|
canvasAction(
|
||||||
|
data.postUrl + '?canvas=' + encodeURIComponent(data.name) +
|
||||||
|
'&rename=' + encodeURIComponent(v),
|
||||||
|
function () {
|
||||||
|
window.location.href = data.postUrl + '?canvas=' + encodeURIComponent(v);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function deleteCanvas() {
|
||||||
|
openModal({
|
||||||
|
title: 'Delete canvas',
|
||||||
|
body: 'Delete "' + data.name + '"? The page itself is not affected.',
|
||||||
|
confirm: {
|
||||||
|
label: 'DELETE',
|
||||||
|
danger: true,
|
||||||
|
onConfirm: function () {
|
||||||
|
closeModal();
|
||||||
|
canvasAction(
|
||||||
|
data.postUrl + '?canvas=' + encodeURIComponent(data.name) + '&delete=1',
|
||||||
|
function () { window.location.href = data.postUrl; }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- wiring ---
|
||||||
|
|
||||||
|
function wireToolbar() {
|
||||||
|
var handlers = {
|
||||||
|
'undo': function () { Doc.undo(); },
|
||||||
|
'redo': function () { Doc.redo(); },
|
||||||
|
'add-text': function () { Canvas.Edit.open(Canvas.Actions.addText().id); },
|
||||||
|
'add-file': function () { Canvas.Actions.addFile(); },
|
||||||
|
'add-link': function () { Canvas.Actions.addLink(); },
|
||||||
|
'add-group': function () { Canvas.Actions.addGroup(); },
|
||||||
|
'delete-selection': function () { Canvas.Actions.deleteSelection(); },
|
||||||
|
'zoom-in': function () { View.zoomBy(1.2); },
|
||||||
|
'zoom-out': function () { View.zoomBy(1 / 1.2); },
|
||||||
|
'zoom-reset': function () { View.resetZoom(); },
|
||||||
|
'fit': function () { View.fit(Doc.nodes()); },
|
||||||
|
'canvas-save': function () { Save.run(); },
|
||||||
|
'canvas-rename': renameCanvas,
|
||||||
|
'canvas-delete': deleteCanvas
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener('click', function (e) {
|
||||||
|
var btn = e.target.closest('[data-action]');
|
||||||
|
if (!btn) return;
|
||||||
|
var fn = handlers[btn.dataset.action];
|
||||||
|
if (!fn) return;
|
||||||
|
e.preventDefault();
|
||||||
|
fn();
|
||||||
|
});
|
||||||
|
|
||||||
|
document.querySelectorAll('.canvas-color-menu [data-color]').forEach(function (btn) {
|
||||||
|
btn.addEventListener('click', function () {
|
||||||
|
Canvas.Actions.setColor(btn.dataset.color);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// The header dropdowns are wired on demand: the auto-wiring in
|
||||||
|
// global-shortcuts.js only covers the page's ACTIONS menu, which this
|
||||||
|
// view replaces.
|
||||||
|
['canvas-menu', 'color-menu'].forEach(function (action) {
|
||||||
|
wireDropdown(document.querySelector('[data-action="' + action + '"]'));
|
||||||
|
});
|
||||||
|
|
||||||
|
document.querySelectorAll('.canvas-tabs a').forEach(function (a) {
|
||||||
|
a.addEventListener('click', function (e) {
|
||||||
|
if (!leaveGuard()) e.preventDefault();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// typing reports whether the keystroke belongs to a text field, an open
|
||||||
|
// modal, or the node editor — in which case the canvas must keep its hands
|
||||||
|
// off it.
|
||||||
|
function typing(e) {
|
||||||
|
var t = e.target;
|
||||||
|
if (!t || !t.closest) return false;
|
||||||
|
return !!t.closest('input, textarea, select, .cm-editor, .modal, [contenteditable="true"]');
|
||||||
|
}
|
||||||
|
|
||||||
|
function wireKeys() {
|
||||||
|
document.addEventListener('keydown', function (e) {
|
||||||
|
if (typing(e)) return;
|
||||||
|
|
||||||
|
var mod = e.ctrlKey || e.metaKey;
|
||||||
|
if (mod && e.key.toLowerCase() === 's') {
|
||||||
|
e.preventDefault();
|
||||||
|
Save.run();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (mod && e.key.toLowerCase() === 'z') {
|
||||||
|
e.preventDefault();
|
||||||
|
if (e.shiftKey) Doc.redo(); else Doc.undo();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (mod && e.key.toLowerCase() === 'y') {
|
||||||
|
e.preventDefault();
|
||||||
|
Doc.redo();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (mod && e.key.toLowerCase() === 'a') {
|
||||||
|
e.preventDefault();
|
||||||
|
Canvas.Actions.selectAll();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (mod) return;
|
||||||
|
|
||||||
|
switch (e.key) {
|
||||||
|
case 'Delete':
|
||||||
|
case 'Backspace':
|
||||||
|
e.preventDefault();
|
||||||
|
Canvas.Actions.deleteSelection();
|
||||||
|
break;
|
||||||
|
case 'Escape':
|
||||||
|
if (!Canvas.Interact.cancel()) Select.clear();
|
||||||
|
break;
|
||||||
|
case 'Enter':
|
||||||
|
var sel = Select.nodes();
|
||||||
|
if (sel.length === 1) {
|
||||||
|
e.preventDefault();
|
||||||
|
Canvas.Edit.open(sel[0]);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case 'ArrowUp': e.preventDefault(); Canvas.Actions.nudge(0, -1, e.shiftKey); break;
|
||||||
|
case 'ArrowDown': e.preventDefault(); Canvas.Actions.nudge(0, 1, e.shiftKey); break;
|
||||||
|
case 'ArrowLeft': e.preventDefault(); Canvas.Actions.nudge(-1, 0, e.shiftKey); break;
|
||||||
|
case 'ArrowRight': e.preventDefault(); Canvas.Actions.nudge(1, 0, e.shiftKey); break;
|
||||||
|
case 't': Canvas.Edit.open(Canvas.Actions.addText().id); break;
|
||||||
|
case 'f': Canvas.Actions.addFile(); break;
|
||||||
|
case 'l': Canvas.Actions.addLink(); break;
|
||||||
|
case 'g': Canvas.Actions.addGroup(); break;
|
||||||
|
case '9': View.fit(Doc.nodes()); break;
|
||||||
|
case '0': View.resetZoom(); break;
|
||||||
|
case '+':
|
||||||
|
case '=': View.zoomBy(1.2); break;
|
||||||
|
case '-': View.zoomBy(1 / 1.2); break;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- boot ---
|
||||||
|
|
||||||
|
Doc.init(data.doc, data.hash);
|
||||||
|
Nodes.seed(data.rendered, Doc.get());
|
||||||
|
Nodes.init(document.getElementById('canvas-nodes'), document.getElementById('canvas-groups'));
|
||||||
|
Edges.init(document.getElementById('canvas-edges'));
|
||||||
|
View.init(surface, world);
|
||||||
|
Canvas.Interact.init(surface, marquee);
|
||||||
|
Save.init(data.postUrl, data.name);
|
||||||
|
|
||||||
|
Doc.onChange(refresh);
|
||||||
|
Select.onChange(function () {
|
||||||
|
Nodes.syncSelection();
|
||||||
|
Edges.syncSelection();
|
||||||
|
updateToolbar();
|
||||||
|
});
|
||||||
|
View.onChange(updateZoom);
|
||||||
|
Save.onState(function (state, message) { setStatus(message); });
|
||||||
|
|
||||||
|
wireToolbar();
|
||||||
|
wireKeys();
|
||||||
|
|
||||||
|
refresh();
|
||||||
|
View.fit(Doc.nodes());
|
||||||
|
updateZoom();
|
||||||
|
surface.focus();
|
||||||
|
})();
|
||||||
Binary file not shown.
@@ -0,0 +1,125 @@
|
|||||||
|
// save.js — writing the canvas back.
|
||||||
|
//
|
||||||
|
// The whole document goes up in one request: a canvas is a single JSON object
|
||||||
|
// whose edges reference node ids, so a partial patch would have to reason about
|
||||||
|
// cross-references the server would then have to re-validate anyway.
|
||||||
|
//
|
||||||
|
// Two server rejections are worth handling rather than dumping on the user:
|
||||||
|
// a 409 (someone else changed the file) and the empty-canvas guard (which asks
|
||||||
|
// for explicit confirmation before letting a save blank a populated canvas).
|
||||||
|
window.Canvas = window.Canvas || {};
|
||||||
|
|
||||||
|
Canvas.Save = (function () {
|
||||||
|
var Doc = Canvas.Doc;
|
||||||
|
|
||||||
|
var action = '';
|
||||||
|
var saving = false;
|
||||||
|
var listeners = [];
|
||||||
|
|
||||||
|
function emit(state, message) {
|
||||||
|
for (var i = 0; i < listeners.length; i++) listeners[i](state, message);
|
||||||
|
}
|
||||||
|
|
||||||
|
function post(url) {
|
||||||
|
return fetch(url, {
|
||||||
|
method: 'POST',
|
||||||
|
credentials: 'same-origin',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'X-Canvas-Version': Doc.version()
|
||||||
|
},
|
||||||
|
body: JSON.stringify(Doc.get())
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function run(force) {
|
||||||
|
if (saving) return Promise.resolve(false);
|
||||||
|
if (!Doc.isDirty() && !force) {
|
||||||
|
emit('clean', 'no changes');
|
||||||
|
return Promise.resolve(true);
|
||||||
|
}
|
||||||
|
saving = true;
|
||||||
|
emit('saving', 'saving…');
|
||||||
|
|
||||||
|
var url = action + (force ? '&empty=1' : '');
|
||||||
|
return post(url).then(function (res) {
|
||||||
|
if (res.status === 204) {
|
||||||
|
Doc.markSaved(res.headers.get('X-Canvas-Version') || '');
|
||||||
|
emit('clean', 'saved');
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return res.text().then(function (msg) {
|
||||||
|
if (res.status === 409) {
|
||||||
|
conflict(msg);
|
||||||
|
} else if (res.status === 400 && msg.indexOf('empty canvas') !== -1) {
|
||||||
|
confirmEmpty(msg);
|
||||||
|
} else {
|
||||||
|
emit('error', msg || ('save failed (' + res.status + ')'));
|
||||||
|
alert(msg || ('Save failed (' + res.status + ')'));
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
}).catch(function () {
|
||||||
|
emit('error', 'network error');
|
||||||
|
alert('Could not reach the server — your changes are still here, try again.');
|
||||||
|
return false;
|
||||||
|
}).finally(function () {
|
||||||
|
saving = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// conflict: the file changed underneath us. Overwriting silently would
|
||||||
|
// discard whatever the other writer did, so the choice is the user's — and
|
||||||
|
// reloading would discard *their* work, so that is spelled out too.
|
||||||
|
function conflict(msg) {
|
||||||
|
emit('error', 'conflict');
|
||||||
|
openModal({
|
||||||
|
title: 'Canvas changed on disk',
|
||||||
|
body: msg + '\n\nReload to take the version on disk (your unsaved changes here are lost), ' +
|
||||||
|
'or keep editing and save again to overwrite it.',
|
||||||
|
confirm: {
|
||||||
|
label: 'RELOAD',
|
||||||
|
danger: true,
|
||||||
|
onConfirm: function () {
|
||||||
|
closeModal();
|
||||||
|
window.location.reload();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
cancel: { label: 'KEEP EDITING', autofocus: true }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function confirmEmpty(msg) {
|
||||||
|
emit('error', 'refused');
|
||||||
|
openModal({
|
||||||
|
title: 'Save an empty canvas?',
|
||||||
|
body: msg + '\n\nThis removes every node from the file.',
|
||||||
|
confirm: {
|
||||||
|
label: 'EMPTY IT',
|
||||||
|
danger: true,
|
||||||
|
onConfirm: function () {
|
||||||
|
closeModal();
|
||||||
|
run(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
init: function (postURL, name) {
|
||||||
|
action = postURL + '?canvas=' + encodeURIComponent(name);
|
||||||
|
// Unsaved work must not vanish on a stray Back or tab close. The
|
||||||
|
// browser shows its own generic wording; the return value is only a
|
||||||
|
// signal that there is something to lose.
|
||||||
|
window.addEventListener('beforeunload', function (e) {
|
||||||
|
if (!Doc.isDirty()) return;
|
||||||
|
e.preventDefault();
|
||||||
|
e.returnValue = '';
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
run: function () { return run(false); },
|
||||||
|
isSaving: function () { return saving; },
|
||||||
|
onState: function (fn) { listeners.push(fn); }
|
||||||
|
};
|
||||||
|
})();
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
// select.js — which nodes and edges are selected.
|
||||||
|
//
|
||||||
|
// Kept apart from the document so selection never lands in the undo stack or
|
||||||
|
// on disk: undoing an edit should restore geometry, not re-select whatever
|
||||||
|
// happened to be highlighted at the time.
|
||||||
|
window.Canvas = window.Canvas || {};
|
||||||
|
|
||||||
|
Canvas.Select = (function () {
|
||||||
|
var nodes = {};
|
||||||
|
var edges = {};
|
||||||
|
var listeners = [];
|
||||||
|
|
||||||
|
function emit() {
|
||||||
|
for (var i = 0; i < listeners.length; i++) listeners[i]();
|
||||||
|
}
|
||||||
|
|
||||||
|
function keys(obj) { return Object.keys(obj); }
|
||||||
|
|
||||||
|
return {
|
||||||
|
nodes: function () { return keys(nodes); },
|
||||||
|
edges: function () { return keys(edges); },
|
||||||
|
hasNode: function (id) { return !!nodes[id]; },
|
||||||
|
hasEdge: function (id) { return !!edges[id]; },
|
||||||
|
isEmpty: function () { return !keys(nodes).length && !keys(edges).length; },
|
||||||
|
count: function () { return keys(nodes).length + keys(edges).length; },
|
||||||
|
|
||||||
|
set: function (nodeIds, edgeIds) {
|
||||||
|
nodes = {};
|
||||||
|
edges = {};
|
||||||
|
(nodeIds || []).forEach(function (id) { nodes[id] = true; });
|
||||||
|
(edgeIds || []).forEach(function (id) { edges[id] = true; });
|
||||||
|
emit();
|
||||||
|
},
|
||||||
|
|
||||||
|
addNode: function (id) {
|
||||||
|
if (nodes[id]) return;
|
||||||
|
nodes[id] = true;
|
||||||
|
emit();
|
||||||
|
},
|
||||||
|
|
||||||
|
toggleNode: function (id) {
|
||||||
|
if (nodes[id]) delete nodes[id]; else nodes[id] = true;
|
||||||
|
emit();
|
||||||
|
},
|
||||||
|
|
||||||
|
toggleEdge: function (id) {
|
||||||
|
if (edges[id]) delete edges[id]; else edges[id] = true;
|
||||||
|
emit();
|
||||||
|
},
|
||||||
|
|
||||||
|
clear: function () {
|
||||||
|
if (!keys(nodes).length && !keys(edges).length) return;
|
||||||
|
nodes = {};
|
||||||
|
edges = {};
|
||||||
|
emit();
|
||||||
|
},
|
||||||
|
|
||||||
|
// prune drops ids that no longer exist. Called after undo/redo, where
|
||||||
|
// the document can lose nodes the selection still refers to.
|
||||||
|
prune: function (doc) {
|
||||||
|
var changed = false;
|
||||||
|
var live = {};
|
||||||
|
doc.nodes.forEach(function (n) { live[n.id] = true; });
|
||||||
|
keys(nodes).forEach(function (id) {
|
||||||
|
if (!live[id]) { delete nodes[id]; changed = true; }
|
||||||
|
});
|
||||||
|
var liveEdges = {};
|
||||||
|
doc.edges.forEach(function (e) { liveEdges[e.id] = true; });
|
||||||
|
keys(edges).forEach(function (id) {
|
||||||
|
if (!liveEdges[id]) { delete edges[id]; changed = true; }
|
||||||
|
});
|
||||||
|
if (changed) emit();
|
||||||
|
},
|
||||||
|
|
||||||
|
onChange: function (fn) { listeners.push(fn); }
|
||||||
|
};
|
||||||
|
})();
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
// view.js — pan and zoom.
|
||||||
|
//
|
||||||
|
// The whole scene lives in one transformed <div>, so panning and zooming cost a
|
||||||
|
// single CSS transform: no relayout, no re-render of node content, nothing that
|
||||||
|
// touches the DOM per node. That is what keeps a few hundred nodes usable on
|
||||||
|
// the mobile/VPN path.
|
||||||
|
//
|
||||||
|
// The dot grid is painted on the (untransformed) surface and its size/offset
|
||||||
|
// follow the transform, which keeps it crisp at every zoom level instead of
|
||||||
|
// scaling a bitmap.
|
||||||
|
window.Canvas = window.Canvas || {};
|
||||||
|
|
||||||
|
Canvas.View = (function () {
|
||||||
|
var MIN_SCALE = 0.05;
|
||||||
|
var MAX_SCALE = 4;
|
||||||
|
var GRID = 20;
|
||||||
|
|
||||||
|
var surface = null;
|
||||||
|
var world = null;
|
||||||
|
var tx = 0, ty = 0, scale = 1;
|
||||||
|
var listeners = [];
|
||||||
|
|
||||||
|
function apply() {
|
||||||
|
world.style.transform = 'translate(' + tx + 'px,' + ty + 'px) scale(' + scale + ')';
|
||||||
|
var step = GRID * scale;
|
||||||
|
surface.style.backgroundSize = step + 'px ' + step + 'px';
|
||||||
|
surface.style.backgroundPosition = tx + 'px ' + ty + 'px';
|
||||||
|
// Below a few pixels apart the dots stop reading as a grid and start
|
||||||
|
// reading as noise, so zooming far out drops them entirely.
|
||||||
|
surface.classList.toggle('is-grid-hidden', step < 6);
|
||||||
|
for (var i = 0; i < listeners.length; i++) listeners[i]();
|
||||||
|
}
|
||||||
|
|
||||||
|
function clampScale(s) {
|
||||||
|
return Math.max(MIN_SCALE, Math.min(MAX_SCALE, s));
|
||||||
|
}
|
||||||
|
|
||||||
|
// zoomAt scales about a screen point, so the world coordinate under the
|
||||||
|
// cursor stays put — the behaviour that makes wheel-zoom feel anchored
|
||||||
|
// rather than lurching toward the origin.
|
||||||
|
function zoomAt(clientX, clientY, factor) {
|
||||||
|
var next = clampScale(scale * factor);
|
||||||
|
if (next === scale) return;
|
||||||
|
var r = surface.getBoundingClientRect();
|
||||||
|
var px = clientX - r.left;
|
||||||
|
var py = clientY - r.top;
|
||||||
|
var ratio = next / scale;
|
||||||
|
tx = px - (px - tx) * ratio;
|
||||||
|
ty = py - (py - ty) * ratio;
|
||||||
|
scale = next;
|
||||||
|
apply();
|
||||||
|
}
|
||||||
|
|
||||||
|
function bounds(nodes) {
|
||||||
|
if (!nodes.length) return null;
|
||||||
|
var minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
|
||||||
|
nodes.forEach(function (n) {
|
||||||
|
minX = Math.min(minX, n.x);
|
||||||
|
minY = Math.min(minY, n.y);
|
||||||
|
maxX = Math.max(maxX, n.x + n.width);
|
||||||
|
maxY = Math.max(maxY, n.y + n.height);
|
||||||
|
});
|
||||||
|
return { x: minX, y: minY, width: maxX - minX, height: maxY - minY };
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
GRID: GRID,
|
||||||
|
|
||||||
|
init: function (surfaceEl, worldEl) {
|
||||||
|
surface = surfaceEl;
|
||||||
|
world = worldEl;
|
||||||
|
apply();
|
||||||
|
},
|
||||||
|
|
||||||
|
get: function () { return { x: tx, y: ty, scale: scale }; },
|
||||||
|
scale: function () { return scale; },
|
||||||
|
|
||||||
|
toWorld: function (clientX, clientY) {
|
||||||
|
var r = surface.getBoundingClientRect();
|
||||||
|
return {
|
||||||
|
x: (clientX - r.left - tx) / scale,
|
||||||
|
y: (clientY - r.top - ty) / scale
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
toScreen: function (x, y) {
|
||||||
|
var r = surface.getBoundingClientRect();
|
||||||
|
return { x: x * scale + tx + r.left, y: y * scale + ty + r.top };
|
||||||
|
},
|
||||||
|
|
||||||
|
pan: function (dx, dy) {
|
||||||
|
tx += dx;
|
||||||
|
ty += dy;
|
||||||
|
apply();
|
||||||
|
},
|
||||||
|
|
||||||
|
zoomAt: zoomAt,
|
||||||
|
|
||||||
|
// zoomBy scales about the viewport centre, for the toolbar buttons and
|
||||||
|
// the +/- shortcuts where there is no cursor to anchor to.
|
||||||
|
zoomBy: function (factor) {
|
||||||
|
var r = surface.getBoundingClientRect();
|
||||||
|
zoomAt(r.left + r.width / 2, r.top + r.height / 2, factor);
|
||||||
|
},
|
||||||
|
|
||||||
|
resetZoom: function () {
|
||||||
|
var r = surface.getBoundingClientRect();
|
||||||
|
zoomAt(r.left + r.width / 2, r.top + r.height / 2, 1 / scale);
|
||||||
|
},
|
||||||
|
|
||||||
|
// fit frames the whole document with a margin, never zooming past 1:1 —
|
||||||
|
// a canvas with three small nodes should not open comically magnified.
|
||||||
|
fit: function (nodes) {
|
||||||
|
var b = bounds(nodes);
|
||||||
|
var r = surface.getBoundingClientRect();
|
||||||
|
if (!b || !r.width || !r.height) {
|
||||||
|
tx = 0; ty = 0; scale = 1;
|
||||||
|
apply();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var pad = 60;
|
||||||
|
var s = clampScale(Math.min(
|
||||||
|
(r.width - pad * 2) / Math.max(b.width, 1),
|
||||||
|
(r.height - pad * 2) / Math.max(b.height, 1),
|
||||||
|
1
|
||||||
|
));
|
||||||
|
scale = s;
|
||||||
|
tx = r.width / 2 - (b.x + b.width / 2) * s;
|
||||||
|
ty = r.height / 2 - (b.y + b.height / 2) * s;
|
||||||
|
apply();
|
||||||
|
},
|
||||||
|
|
||||||
|
// centreOfViewport is where a newly added node goes: wherever the user
|
||||||
|
// is currently looking.
|
||||||
|
centreOfViewport: function () {
|
||||||
|
var r = surface.getBoundingClientRect();
|
||||||
|
return {
|
||||||
|
x: (r.width / 2 - tx) / scale,
|
||||||
|
y: (r.height / 2 - ty) / scale
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
bounds: bounds,
|
||||||
|
onChange: function (fn) { listeners.push(fn); },
|
||||||
|
refresh: apply
|
||||||
|
};
|
||||||
|
})();
|
||||||
+39
-3
@@ -116,9 +116,16 @@
|
|||||||
if (!state.available) return;
|
if (!state.available) return;
|
||||||
document.addEventListener('click', function (e) {
|
document.addEventListener('click', function (e) {
|
||||||
if (!e.target.closest) return;
|
if (!e.target.closest) return;
|
||||||
// Match both listing styles: table rows expose the file link inside
|
// Match every listing style: table rows expose the file link inside
|
||||||
// a .list-item row; thumbnail tiles are bare a.thumb-tile anchors.
|
// a .list-item row; thumbnail tiles are bare a.thumb-tile anchors;
|
||||||
var anchor = e.target.closest('.list-item a, a.thumb-tile');
|
// diary photo grids wrap each thumbnail in a .photo-grid anchor.
|
||||||
|
// The left tree rail's file anchors (a.tree-file) join in too;
|
||||||
|
// folder anchors there end in "/" and fall through to navigation.
|
||||||
|
// Search file-result anchors join in too: page results end in "/"
|
||||||
|
// and fall through to navigation, file results don't and open locally.
|
||||||
|
// Embedded images (a.embed-link inside rendered content) wrap the
|
||||||
|
// raw file href, so they open the full-size original locally too.
|
||||||
|
var anchor = e.target.closest('.list-item a, a.thumb-tile, .photo-grid a, aside.tree-sidebar a.tree-file, .search-card a, .content a.embed-link');
|
||||||
if (!anchor) return;
|
if (!anchor) return;
|
||||||
var item = anchor.closest('.list-item');
|
var item = anchor.closest('.list-item');
|
||||||
// Only intercept the primary file link, and only for files (not folders).
|
// Only intercept the primary file link, and only for files (not folders).
|
||||||
@@ -150,6 +157,22 @@
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function wireFileRevealButtons() {
|
||||||
|
// Per-result "open location" buttons on the search page. Unlike the
|
||||||
|
// page reveal button (which reveals window.location), each carries its
|
||||||
|
// own file path so it reveals that specific file in its folder.
|
||||||
|
if (!state.available) return;
|
||||||
|
var btns = document.querySelectorAll('[data-companion-file-reveal]');
|
||||||
|
btns.forEach(function (btn) {
|
||||||
|
btn.hidden = false;
|
||||||
|
btn.addEventListener('click', function () {
|
||||||
|
var rel = btn.getAttribute('data-companion-file-reveal');
|
||||||
|
if (!rel) return;
|
||||||
|
companionGET('/open-folder', { path: rel }).catch(function () { });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function probeStatus() {
|
function probeStatus() {
|
||||||
return companionGET('/status').then(function (r) {
|
return companionGET('/status').then(function (r) {
|
||||||
if (!r.ok) throw new Error('status ' + r.status);
|
if (!r.ok) throw new Error('status ' + r.status);
|
||||||
@@ -163,11 +186,24 @@
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isMobileDevice() {
|
||||||
|
// The companion only ships for Windows and Linux desktops, so skip it
|
||||||
|
// on phones/tablets. Checks the UA and falls back to "touch is the only
|
||||||
|
// pointer" so narrow desktop windows still get the companion.
|
||||||
|
if (/Android|iPhone|iPad|iPod|Mobi/i.test(navigator.userAgent)) return true;
|
||||||
|
return window.matchMedia('(pointer: coarse)').matches &&
|
||||||
|
!window.matchMedia('(any-pointer: fine)').matches;
|
||||||
|
}
|
||||||
|
|
||||||
function init() {
|
function init() {
|
||||||
|
// Every companion element is rendered hidden, so bailing out here
|
||||||
|
// leaves the status icon and reveal buttons invisible on mobile.
|
||||||
|
if (isMobileDevice()) return;
|
||||||
probeStatus().then(function () {
|
probeStatus().then(function () {
|
||||||
updateFooterIcon();
|
updateFooterIcon();
|
||||||
wireFileLinks();
|
wireFileLinks();
|
||||||
wireRevealButton();
|
wireRevealButton();
|
||||||
|
wireFileRevealButtons();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,29 +1,24 @@
|
|||||||
<div class="diary-cal panel panel-sidebar"
|
<div class="diary-cal panel panel-sidebar"
|
||||||
data-display-year="{{.DisplayYear}}"
|
data-display-year="{{.DisplayYear}}"
|
||||||
data-display-month="{{.DisplayMonth}}">
|
data-display-month="{{.DisplayMonth}}">
|
||||||
<div class="panel-header"><a href="{{.DiaryURL}}">Chronological</a></div>
|
<div class="panel-header row">
|
||||||
<div class="diary-cal-nav">
|
<a href="{{.DiaryURL}}">Chronological</a>
|
||||||
<div class="dropdown diary-cal-drop">
|
<div class="dropdown diary-cal-drop">
|
||||||
<button type="button" class="btn" data-cal-month-link data-action="cal-month-drop" aria-expanded="false" title="Monat wählen"> {{.DisplayMonthName}} </button>
|
<button type="button" class="btn" data-action="cal-year-drop" aria-expanded="false" title="Jahr wählen">{{.DisplayYear}} ▾</button>
|
||||||
<div class="dropdown-menu scrollable">
|
|
||||||
{{range .Months}}<a class="btn btn-block" data-cal-month-jump="{{.Num}}" href="{{.AnchorURL}}">{{.Name}}</a>{{end}}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<a href="{{.YearURL}}" class="diary-cal-heading">{{.DisplayYear}}</a>
|
|
||||||
<div class="dropdown diary-cal-drop">
|
|
||||||
<button type="button" class="btn" data-action="cal-year-drop" aria-expanded="false" title="Jahr wählen">▾</button>
|
|
||||||
<div class="dropdown-menu align-right scrollable">
|
<div class="dropdown-menu align-right scrollable">
|
||||||
{{range .Years}}<a class="btn btn-block{{if .IsCurrent}} cal-current{{end}}" href="{{.URL}}">{{.Num}}</a>{{end}}
|
{{range .Years}}<a class="btn btn-block{{if .IsCurrent}} cal-current{{end}}" href="{{.URL}}">{{.Num}}</a>{{end}}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{{range .Months}}
|
{{range $i, $m := .Months}}
|
||||||
<table class="diary-cal-grid" data-cal-month="{{.Num}}"{{if ne .Num $.DisplayMonth}} hidden{{end}}>
|
{{if $i}}<hr/>{{end}}
|
||||||
|
<div class="diary-cal-month"><a href="{{$m.AnchorURL}}">{{$m.Name}}</a></div>
|
||||||
|
<table class="diary-cal-grid" data-cal-month="{{$m.Num}}">
|
||||||
<thead>
|
<thead>
|
||||||
<tr><th>Mo</th><th>Di</th><th>Mi</th><th>Do</th><th>Fr</th><th>Sa</th><th>So</th></tr>
|
<tr><th>Mo</th><th>Di</th><th>Mi</th><th>Do</th><th>Fr</th><th>Sa</th><th>So</th></tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{{range .Weeks}}<tr>{{range .}}<td class="{{if .IsCurrent}}cal-current{{else if .IsToday}}cal-today{{end}}{{if and .Num (not .HasEntry)}} cal-empty{{end}}">{{if .Num}}<a href="{{.URL}}">{{.Num}}</a>{{end}}</td>{{end}}</tr>
|
{{range $m.Weeks}}<tr>{{range .}}<td class="{{if .IsCurrent}}cal-current{{else if .IsToday}}cal-today{{end}}{{if and .Num (not .HasEntry)}} cal-empty{{end}}">{{if .Num}}<a href="{{.URL}}">{{.Num}}</a>{{end}}</td>{{end}}</tr>
|
||||||
{{end}}
|
{{end}}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
|||||||
+30
-34
@@ -3,47 +3,43 @@
|
|||||||
if (!cal) return;
|
if (!cal) return;
|
||||||
cal.querySelectorAll(".dropdown > button").forEach(wireDropdown);
|
cal.querySelectorAll(".dropdown > button").forEach(wireDropdown);
|
||||||
|
|
||||||
var displayYear = parseInt(cal.dataset.displayYear, 10);
|
var displayMonth = parseInt(cal.dataset.displayMonth, 10);
|
||||||
var current = parseInt(cal.dataset.displayMonth, 10);
|
|
||||||
var monthLabel = cal.querySelector("[data-cal-month-link]");
|
|
||||||
var months = {};
|
var months = {};
|
||||||
cal.querySelectorAll("[data-cal-month]").forEach(function (t) {
|
cal.querySelectorAll("[data-cal-month]").forEach(function (t) {
|
||||||
months[parseInt(t.dataset.calMonth, 10)] = t;
|
months[parseInt(t.dataset.calMonth, 10)] = t;
|
||||||
});
|
});
|
||||||
var jumpLinks = {};
|
|
||||||
cal.querySelectorAll("[data-cal-month-jump]").forEach(function (a) {
|
|
||||||
jumpLinks[parseInt(a.dataset.calMonthJump, 10)] = a;
|
|
||||||
});
|
|
||||||
|
|
||||||
function show(m) {
|
// Centering only applies inside the persistent desktop rail (.sidebar). On
|
||||||
if (m === current) return;
|
// mobile the widget is re-parented into .overlay-body, where we leave the
|
||||||
if (!months[m]) return;
|
// grids stacked from January and do not scroll (design decision).
|
||||||
months[current].hidden = true;
|
function railContainer() {
|
||||||
months[m].hidden = false;
|
var el = cal.parentElement;
|
||||||
current = m;
|
while (el) {
|
||||||
if (monthLabel) {
|
if (el.classList) {
|
||||||
var label = jumpLinks[m];
|
if (el.classList.contains("overlay-body")) return null;
|
||||||
if (label) monthLabel.textContent = " " + label.textContent + " ";
|
if (el.classList.contains("sidebar")) return el;
|
||||||
}
|
}
|
||||||
|
el = el.parentElement;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Dropdown month picks scroll via the href anchor; we also swap the grid
|
// Scroll only the rail so the given month grid sits in its vertical center;
|
||||||
// so the calendar reflects what the user just navigated to.
|
// the main document/window position is untouched (priority: don't disturb
|
||||||
Object.keys(jumpLinks).forEach(function (key) {
|
// the reading position).
|
||||||
var a = jumpLinks[key];
|
function centerMonth(m) {
|
||||||
a.addEventListener("click", function () { show(parseInt(key, 10)); });
|
var grid = months[m];
|
||||||
});
|
if (!grid) return;
|
||||||
|
var container = railContainer();
|
||||||
// Any in-page anchor click (#YYYY-MM or #YYYY-MM-DD) updates the calendar
|
if (!container) return;
|
||||||
// so it tracks the user's focus through the year page.
|
var offset = grid.getBoundingClientRect().top -
|
||||||
function syncFromHash() {
|
container.getBoundingClientRect().top + container.scrollTop;
|
||||||
var h = window.location.hash;
|
container.scrollTop = offset - (container.clientHeight - grid.clientHeight) / 2;
|
||||||
if (!h) return;
|
|
||||||
var m = h.match(/^#(\d{4})-(\d{2})(?:-\d{2})?$/);
|
|
||||||
if (!m) return;
|
|
||||||
if (parseInt(m[1], 10) !== displayYear) return;
|
|
||||||
show(parseInt(m[2], 10));
|
|
||||||
}
|
}
|
||||||
window.addEventListener("hashchange", syncFromHash);
|
|
||||||
syncFromHash();
|
// Center the current month once, on initial render. Anchor navigation
|
||||||
|
// (#YYYY-MM / #YYYY-MM-DD) deliberately does NOT re-center the rail —
|
||||||
|
// re-scrolling on every hop is disorienting; the today/current cell
|
||||||
|
// highlighting is enough to keep bearings.
|
||||||
|
centerMonth(displayMonth);
|
||||||
})();
|
})();
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
{{range .Sections}}
|
{{range .Sections}}
|
||||||
{{if eq .Level 1}}<h1 id="{{.ID}}">{{.Heading}}{{if .EditURL}} <a href="{{.EditURL}}" class="btn btn-small">edit</a>{{end}}</h1>
|
{{if eq .Level 1}}<h1 id="{{.ID}}">{{.Heading}}{{if .EditURL}} <a href="{{.EditURL}}" class="btn btn-small">edit</a>{{end}}</h1>
|
||||||
{{else if eq .Level 2}}<h2 id="{{.ID}}">{{.Heading}}{{if .EditURL}} <a href="{{.EditURL}}" class="btn btn-small">edit</a>{{end}}</h2>
|
{{else if eq .Level 2}}<h2 id="{{.ID}}">{{.Heading}}{{if .EditURL}} <a href="{{.EditURL}}" class="btn btn-small">edit</a>{{end}}</h2>
|
||||||
{{else}}<h3 id="{{.ID}}">{{.Heading}}{{if .EditURL}} <a href="{{.EditURL}}" class="btn btn-small">edit</a>{{end}}</h3>{{end}}
|
{{else}}<h3 id="{{.ID}}">{{.Heading}}{{if .EditURL}} <a href="{{.EditURL}}" class="btn btn-small btn-float">edit</a>{{end}}</h3>{{end}}
|
||||||
{{if .Body}}{{.Body}}{{end}}
|
{{if .Body}}{{.Body}}{{end}}
|
||||||
{{if .Photos}}
|
{{if .Photos}}
|
||||||
<div class="photo-grid">
|
<div class="photo-grid">
|
||||||
{{range .Photos}}
|
{{range .Photos}}
|
||||||
<a href="{{.URL}}" target="_blank"><img src="{{.ThumbURL}}" alt="" loading="lazy"></a>
|
<a href="{{.URL}}"><img src="{{.ThumbURL}}" alt="" loading="lazy"></a>
|
||||||
{{end}}
|
{{end}}
|
||||||
</div>
|
</div>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{{define "headerActions"}}
|
{{define "headerActions"}}
|
||||||
<a class="btn" href="{{.PostURL}}">CANCEL</a>
|
<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>
|
<button class="btn" type="button" data-action="save" data-key="S" title="Save (S)">SAVE</button>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|
||||||
{{define "content"}}
|
{{define "content"}}
|
||||||
@@ -9,10 +9,12 @@
|
|||||||
</script>
|
</script>
|
||||||
<form id="edit-form" class="edit-form" method="POST" action="{{.PostURL}}">
|
<form id="edit-form" class="edit-form" method="POST" action="{{.PostURL}}">
|
||||||
{{if ge .SectionIndex 0}}<input type="hidden" name="section" value="{{.SectionIndex}}">{{end}}
|
{{if ge .SectionIndex 0}}<input type="hidden" name="section" value="{{.SectionIndex}}">{{end}}
|
||||||
|
{{if .Span}}<input type="hidden" name="span" value="1">{{end}}
|
||||||
{{if ge .InsertBefore 0}}<input type="hidden" name="insert_before" value="{{.InsertBefore}}">{{end}}
|
{{if ge .InsertBefore 0}}<input type="hidden" name="insert_before" value="{{.InsertBefore}}">{{end}}
|
||||||
<div class="editor-toolbar">
|
<div class="editor-toolbar">
|
||||||
<button type="button" class="btn btn-tool" data-action="undo" title="Undo">↶</button>
|
<button type="button" class="btn btn-tool" data-action="undo" title="Undo">↶</button>
|
||||||
<button type="button" class="btn btn-tool" data-action="redo" title="Redo">↷</button>
|
<button type="button" class="btn btn-tool" data-action="redo" title="Redo">↷</button>
|
||||||
|
<button type="button" class="btn btn-tool" data-action="deleteline" data-key="Y" title="Delete line (Y)">×</button>
|
||||||
<span class="toolbar-sep"></span>
|
<span class="toolbar-sep"></span>
|
||||||
<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="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>
|
<button type="button" class="btn btn-tool" data-action="italic" data-key="I" title="Italic (I)">*</button>
|
||||||
@@ -28,10 +30,11 @@
|
|||||||
<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="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>
|
<button type="button" class="btn btn-tool" data-action="codeblock" data-key="K" title="Code block (K)">```</button>
|
||||||
<span class="dropdown">
|
<span class="dropdown">
|
||||||
<button type="button" class="btn btn-tool dropdown-toggle" title="Link (L/P)">L▾</button>
|
<button type="button" class="btn btn-tool dropdown-toggle" title="Link (L/P/G)">L▾</button>
|
||||||
<div class="dropdown-menu">
|
<div class="dropdown-menu">
|
||||||
<button type="button" class="btn btn-tool btn-block" data-action="link" data-key="L" title="Link (L)">Link</button>
|
<button type="button" class="btn btn-tool btn-block" data-action="link" data-key="L" title="Link (L)">Link</button>
|
||||||
<button type="button" class="btn btn-tool btn-block" data-action="wikilink" data-key="P" title="Wiki link (P)">Wiki link</button>
|
<button type="button" class="btn btn-tool btn-block" data-action="wikilink" data-key="P" title="Wiki link (P)">Wiki link</button>
|
||||||
|
<button type="button" class="btn btn-tool btn-block" data-action="embed" data-key="G" title="Embed file (G)">Embed file</button>
|
||||||
</div>
|
</div>
|
||||||
</span>
|
</span>
|
||||||
<span class="dropdown">
|
<span class="dropdown">
|
||||||
@@ -44,7 +47,6 @@
|
|||||||
</span>
|
</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="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>
|
<button type="button" class="btn btn-tool" data-action="hr" data-key="R" title="Horizontal rule (R)">---</button>
|
||||||
<button type="button" class="btn btn-tool" data-action="deleteline" data-key="Y" title="Delete line (Y)">⌦</button>
|
|
||||||
<span class="toolbar-sep"></span>
|
<span class="toolbar-sep"></span>
|
||||||
<span class="dropdown">
|
<span class="dropdown">
|
||||||
<button type="button" class="btn btn-tool dropdown-toggle" title="Table (T)">T▾</button>
|
<button type="button" class="btn btn-tool dropdown-toggle" title="Table (T)">T▾</button>
|
||||||
|
|||||||
+231
-13
@@ -23,18 +23,47 @@
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Enter/Tab cell navigation must yield to an open autocomplete popup so those
|
||||||
|
// keys still accept a suggestion (e.g. a [[wiki-link]] typed inside a cell).
|
||||||
|
// The table keymap sits at Prec.highest, so without this guard it would
|
||||||
|
// swallow the key before the completion keymap ever sees it. Detect the popup
|
||||||
|
// via its tooltip element rather than exporting completionStatus from the
|
||||||
|
// vendored bundle (which would force a bundle rebuild).
|
||||||
|
function completionOpen() {
|
||||||
|
return !!document.querySelector('.cm-tooltip-autocomplete');
|
||||||
|
}
|
||||||
|
|
||||||
|
function tableNavKey(fn) {
|
||||||
|
var handler = tableKey(fn);
|
||||||
|
return function (view) {
|
||||||
|
if (completionOpen()) return false;
|
||||||
|
return handler(view);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
var tableKeymap = [
|
var tableKeymap = [
|
||||||
{ key: 'Shift-Enter', run: tableKey(T.insertRowBelow) },
|
{ key: 'Shift-Enter', run: tableKey(T.insertRowBelow) },
|
||||||
{ key: 'Shift-Delete', run: tableKey(T.deleteRow) },
|
{ key: 'Shift-Delete', run: tableKey(T.deleteRow) },
|
||||||
|
{ key: 'Enter', run: tableNavKey(T.nextRowSameColumn) },
|
||||||
|
{ key: 'Tab', run: tableNavKey(T.nextCell) },
|
||||||
|
{ key: 'Shift-Tab', run: tableNavKey(T.prevCell) },
|
||||||
];
|
];
|
||||||
|
|
||||||
var state = CM.EditorState.create({
|
var state = CM.EditorState.create({
|
||||||
doc: hidden.value,
|
doc: hidden.value,
|
||||||
|
// Start with the caret at the end of the existing content — editing an
|
||||||
|
// existing page almost always means appending, and the default (start
|
||||||
|
// of doc) buries the caret behind the leading heading.
|
||||||
|
selection: { anchor: hidden.value.length },
|
||||||
extensions: [
|
extensions: [
|
||||||
CM.history(),
|
CM.history(),
|
||||||
CM.drawSelection(),
|
CM.drawSelection(),
|
||||||
CM.indentOnInput(),
|
CM.indentOnInput(),
|
||||||
CM.EditorView.lineWrapping,
|
CM.EditorView.lineWrapping,
|
||||||
|
// Enable native browser spellcheck on the contenteditable surface
|
||||||
|
// (CM6 leaves it off by default). autocapitalize helps prose entry
|
||||||
|
// on the Android/mobile path; CM's DOM observer absorbs corrections.
|
||||||
|
CM.EditorView.contentAttributes.of({ spellcheck: 'true', autocapitalize: 'sentences' }),
|
||||||
CM.markdown({ base: CM.markdownLanguage }),
|
CM.markdown({ base: CM.markdownLanguage }),
|
||||||
CM.syntaxHighlighting(CM.highlightStyle),
|
CM.syntaxHighlighting(CM.highlightStyle),
|
||||||
CM.closeBrackets(),
|
CM.closeBrackets(),
|
||||||
@@ -136,7 +165,46 @@
|
|||||||
function syncContent() {
|
function syncContent() {
|
||||||
hidden.value = view.state.doc.toString();
|
hidden.value = view.state.doc.toString();
|
||||||
}
|
}
|
||||||
form.addEventListener('submit', syncContent);
|
|
||||||
|
// Save POSTs via fetch and then rewrites the *editor's* history entry with
|
||||||
|
// the resulting page, so the edit session and its result share a single
|
||||||
|
// entry (see history-nav.js). A plain form submit would push a second one
|
||||||
|
// and leave the editor sitting in history behind the saved page.
|
||||||
|
//
|
||||||
|
// The server answers 204 + X-Target instead of a 303 because the target may
|
||||||
|
// carry a #section anchor the client cannot compute, and fetch drops the
|
||||||
|
// fragment from a followed redirect.
|
||||||
|
function postSave() {
|
||||||
|
syncContent();
|
||||||
|
var body = new URLSearchParams(new FormData(form)).toString();
|
||||||
|
fetch(form.action, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/x-www-form-urlencoded',
|
||||||
|
'X-Save-Mode': 'replace',
|
||||||
|
},
|
||||||
|
body: body,
|
||||||
|
}).then(function (res) {
|
||||||
|
if (!res.ok) {
|
||||||
|
return res.text().then(function (msg) {
|
||||||
|
alert(msg || ('Save failed (' + res.status + ')'));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
var target = res.headers.get('X-Target') || form.action;
|
||||||
|
// replaceState + reload rather than location.replace: if target
|
||||||
|
// differs from the current URL only by fragment the browser would
|
||||||
|
// skip the re-fetch and show pre-save content.
|
||||||
|
window.history.replaceState(null, '', target);
|
||||||
|
window.location.reload();
|
||||||
|
}).catch(function () {
|
||||||
|
alert('Network error — the page was not saved');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
form.addEventListener('submit', function (e) {
|
||||||
|
e.preventDefault();
|
||||||
|
postSave();
|
||||||
|
});
|
||||||
|
|
||||||
// --- Actions ---
|
// --- Actions ---
|
||||||
|
|
||||||
@@ -155,6 +223,7 @@
|
|||||||
quote: function () { linePrefix('> '); },
|
quote: function () { linePrefix('> '); },
|
||||||
link: function () { wrap('[', '](url)', 'link text'); },
|
link: function () { wrap('[', '](url)', 'link text'); },
|
||||||
wikilink: insertWikilink,
|
wikilink: insertWikilink,
|
||||||
|
embed: insertEmbed,
|
||||||
ul: function () { linePrefix('- '); },
|
ul: function () { linePrefix('- '); },
|
||||||
ol: function () { linePrefix('1. '); },
|
ol: function () { linePrefix('1. '); },
|
||||||
task: function () { linePrefix('- [ ] '); },
|
task: function () { linePrefix('- [ ] '); },
|
||||||
@@ -172,17 +241,141 @@
|
|||||||
movie: function () { M.run(movieCtx); },
|
movie: function () { M.run(movieCtx); },
|
||||||
};
|
};
|
||||||
|
|
||||||
// Wiki link button: drop an empty [[]] at the cursor and open the `[[`
|
// Wiki link button (ALT+SHIFT+P): a query field backed by /_search typeahead
|
||||||
// completion popup so the same autocomplete flow handles target selection.
|
// plus an optional display-text field, inserting [[query]] or
|
||||||
|
// [[query::display]] at the cursor. Picking a suggestion only fills in that
|
||||||
|
// page's name — the link resolves through search at click time, so a path
|
||||||
|
// never enters the document. Inline `[[` typing uses the same suggestions
|
||||||
|
// via wikicomplete.js.
|
||||||
function insertWikilink() {
|
function insertWikilink() {
|
||||||
var sel = view.state.selection.main;
|
var sel = view.state.selection.main;
|
||||||
view.dispatch({
|
var selectedText = view.state.sliceDoc(sel.from, sel.to).trim();
|
||||||
changes: { from: sel.from, to: sel.to, insert: '[[]]' },
|
|
||||||
selection: { anchor: sel.from + 2 },
|
var container = document.createElement('div');
|
||||||
scrollIntoView: true,
|
|
||||||
|
var queryWrap = document.createElement('div');
|
||||||
|
var queryInput = document.createElement('input');
|
||||||
|
queryInput.type = 'text';
|
||||||
|
queryInput.className = 'input';
|
||||||
|
queryInput.placeholder = 'Search query';
|
||||||
|
// A selection is the query, not the label: [[selection]] renders with
|
||||||
|
// the same visible text anyway, and it seeds the typeahead.
|
||||||
|
queryInput.value = selectedText;
|
||||||
|
queryWrap.appendChild(queryInput);
|
||||||
|
|
||||||
|
var displayInput = document.createElement('input');
|
||||||
|
displayInput.type = 'text';
|
||||||
|
displayInput.className = 'input';
|
||||||
|
displayInput.placeholder = 'Display text (optional)';
|
||||||
|
|
||||||
|
container.appendChild(queryWrap);
|
||||||
|
container.appendChild(displayInput);
|
||||||
|
|
||||||
|
var handle = openModal({
|
||||||
|
title: 'Insert link',
|
||||||
|
body: container,
|
||||||
|
confirm: {
|
||||||
|
label: 'INSERT',
|
||||||
|
initiallyDisabled: !selectedText,
|
||||||
|
onConfirm: function () {
|
||||||
|
var query = queryInput.value.trim();
|
||||||
|
if (!query) return;
|
||||||
|
var display = displayInput.value.trim();
|
||||||
|
handle.close();
|
||||||
|
insertAtCursor(display ? '[[' + query + '::' + display + ']]' : '[[' + query + ']]');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function updateConfirm() {
|
||||||
|
handle.setConfirmDisabled(!queryInput.value.trim());
|
||||||
|
}
|
||||||
|
queryInput.addEventListener('input', updateConfirm);
|
||||||
|
|
||||||
|
window.attachSuggestions(queryInput, {
|
||||||
|
showFooter: false,
|
||||||
|
container: queryWrap,
|
||||||
|
onPick: function (r) {
|
||||||
|
queryInput.value = r.name;
|
||||||
|
updateConfirm();
|
||||||
|
displayInput.focus();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// embedDest turns the absolute wiki path the tree picker hands back into the
|
||||||
|
// destination written to the document: relative to the page being edited
|
||||||
|
// when the file lives under it, absolute otherwise. Pages render at a
|
||||||
|
// slash-terminated URL, so a relative destination resolves against the page
|
||||||
|
// in the browser.
|
||||||
|
function embedDest(absPath) {
|
||||||
|
var base = decodeURIComponent(window.location.pathname);
|
||||||
|
if (base.slice(-1) !== '/') base += '/';
|
||||||
|
return absPath.indexOf(base) === 0 ? absPath.slice(base.length) : absPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
// embedToken formats a Markdown image. A destination carrying spaces or
|
||||||
|
// brackets goes in <angle brackets> — CommonMark's way of keeping it in one
|
||||||
|
// piece — and the title slot holds the alignment keyword the renderer reads.
|
||||||
|
// Right is the renderer's default, so it is left off.
|
||||||
|
function embedToken(dest, caption, align) {
|
||||||
|
if (/[\s()<>]/.test(dest)) dest = '<' + dest + '>';
|
||||||
|
var title = (align && align !== 'right') ? ' "' + align + '"' : '';
|
||||||
|
return '![' + caption.replace(/([\[\]\\])/g, '\\$1') + '](' + dest + title + ')';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Embed button (ALT+SHIFT+G): pick a file from the tree, then give it a
|
||||||
|
// caption and an alignment. Images render as a floated figure; anything
|
||||||
|
// else degrades to a link.
|
||||||
|
function insertEmbed() {
|
||||||
|
var sel = view.state.selection.main;
|
||||||
|
var selectedText = view.state.sliceDoc(sel.from, sel.to).trim();
|
||||||
|
var here = decodeURIComponent(window.location.pathname).replace(/\/+$/, '') || '/';
|
||||||
|
|
||||||
|
openTreePicker({
|
||||||
|
title: 'Embed — pick a file',
|
||||||
|
mode: 'file',
|
||||||
|
initialPath: here,
|
||||||
|
confirmLabel: 'NEXT',
|
||||||
|
onSelect: function (absPath) {
|
||||||
|
var container = document.createElement('div');
|
||||||
|
|
||||||
|
var captionInput = document.createElement('input');
|
||||||
|
captionInput.type = 'text';
|
||||||
|
captionInput.className = 'input';
|
||||||
|
captionInput.placeholder = 'Caption (optional)';
|
||||||
|
captionInput.value = selectedText;
|
||||||
|
|
||||||
|
var alignSelect = document.createElement('select');
|
||||||
|
alignSelect.className = 'input';
|
||||||
|
[['right', 'Float right'], ['left', 'Float left'], ['center', 'Centered']]
|
||||||
|
.forEach(function (opt) {
|
||||||
|
var o = document.createElement('option');
|
||||||
|
o.value = opt[0];
|
||||||
|
o.textContent = opt[1];
|
||||||
|
alignSelect.appendChild(o);
|
||||||
|
});
|
||||||
|
|
||||||
|
container.appendChild(captionInput);
|
||||||
|
container.appendChild(alignSelect);
|
||||||
|
|
||||||
|
var handle = openModal({
|
||||||
|
title: 'Embed — caption?',
|
||||||
|
body: container,
|
||||||
|
confirm: {
|
||||||
|
label: 'INSERT',
|
||||||
|
onConfirm: function () {
|
||||||
|
handle.close();
|
||||||
|
insertAtCursor(embedToken(
|
||||||
|
embedDest(absPath),
|
||||||
|
captionInput.value.trim(),
|
||||||
|
alignSelect.value
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
});
|
});
|
||||||
view.focus();
|
|
||||||
CM.startCompletion(view);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Keyboard shortcut registration ---
|
// --- Keyboard shortcut registration ---
|
||||||
@@ -198,6 +391,19 @@
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Keep the editor focused when a toolbar button is tapped. Without this the
|
||||||
|
// button steals focus on mousedown, which dismisses the mobile soft keyboard
|
||||||
|
// before the action runs (and view.focus() can't reopen it without a direct
|
||||||
|
// gesture). preventDefault on mousedown blocks the focus shift; click still
|
||||||
|
// fires. Scoped to the toolbar so header SAVE/CANCEL are unaffected. Includes
|
||||||
|
// dropdown toggles, which also must not pull focus off the editor.
|
||||||
|
var toolbar = document.querySelector('.editor-toolbar');
|
||||||
|
if (toolbar) {
|
||||||
|
toolbar.addEventListener('mousedown', function (e) {
|
||||||
|
if (e.target.closest('.btn')) e.preventDefault();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
document.addEventListener('keydown', function (e) {
|
document.addEventListener('keydown', function (e) {
|
||||||
if (!e.altKey || !e.shiftKey) return;
|
if (!e.altKey || !e.shiftKey) return;
|
||||||
// Shift+digit produces a layout-dependent character in e.key (e.g. "!"
|
// Shift+digit produces a layout-dependent character in e.key (e.g. "!"
|
||||||
@@ -212,15 +418,27 @@
|
|||||||
|
|
||||||
// --- Dropdowns ---
|
// --- Dropdowns ---
|
||||||
|
|
||||||
// The toolbar scrolls horizontally on mobile, which makes it an overflow
|
// The toolbar scrolls horizontally (so it clips its absolutely-positioned
|
||||||
// container that would clip its absolutely-positioned dropdown menus. Pin an
|
// menus) and on mobile is fixed to the bottom of the viewport. Pin an open
|
||||||
// open menu to the viewport under its trigger so it escapes the clip.
|
// menu to the viewport so it escapes the clip, opening upward when there
|
||||||
var toolbar = document.querySelector('.editor-toolbar');
|
// isn't room below it (the bottom-toolbar case).
|
||||||
function pinMenu(toggle, menu) {
|
function pinMenu(toggle, menu) {
|
||||||
if (!menu.classList.contains('is-open')) return;
|
if (!menu.classList.contains('is-open')) return;
|
||||||
var r = toggle.getBoundingClientRect();
|
var r = toggle.getBoundingClientRect();
|
||||||
|
var vh = window.innerHeight;
|
||||||
menu.style.position = 'fixed';
|
menu.style.position = 'fixed';
|
||||||
|
menu.style.overflowY = 'auto';
|
||||||
|
var spaceBelow = vh - r.bottom;
|
||||||
|
var spaceAbove = r.top;
|
||||||
|
if (spaceBelow < menu.offsetHeight + 8 && spaceAbove > spaceBelow) {
|
||||||
|
menu.style.top = 'auto';
|
||||||
|
menu.style.bottom = (vh - r.top) + 'px';
|
||||||
|
menu.style.maxHeight = (spaceAbove - 8) + 'px';
|
||||||
|
} else {
|
||||||
|
menu.style.bottom = 'auto';
|
||||||
menu.style.top = r.bottom + 'px';
|
menu.style.top = r.bottom + 'px';
|
||||||
|
menu.style.maxHeight = (spaceBelow - 8) + 'px';
|
||||||
|
}
|
||||||
var left = Math.min(r.left, document.documentElement.clientWidth - menu.offsetWidth - 4);
|
var left = Math.min(r.left, document.documentElement.clientWidth - menu.offsetWidth - 4);
|
||||||
menu.style.left = Math.max(4, left) + 'px';
|
menu.style.left = Math.max(4, left) + 'px';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -262,8 +262,94 @@ window.EditorTables = (function () {
|
|||||||
return formatTableText(newLines.join('\n'), Math.min(newCursor, newLines.join('\n').length));
|
return formatTableText(newLines.join('\n'), Math.min(newCursor, newLines.join('\n').length));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Absolute cursor offset at the start of the `col`-th cell's content on
|
||||||
|
// `lineIdx` of a *formatted* table (i.e. just past the "| " / " | " that
|
||||||
|
// opens the cell). Clamps to the last cell if col overflows the row.
|
||||||
|
function cellCursor(text, lineIdx, col) {
|
||||||
|
var lines = text.split('\n');
|
||||||
|
var offset = 0;
|
||||||
|
for (var i = 0; i < lineIdx; i++) offset += lines[i].length + 1;
|
||||||
|
var line = lines[lineIdx] || '';
|
||||||
|
var pipes = [];
|
||||||
|
for (var c = 0; c < line.length; c++) if (line.charAt(c) === '|') pipes.push(c);
|
||||||
|
if (pipes.length === 0) return Math.min(offset, text.length);
|
||||||
|
var p = pipes[Math.min(col, pipes.length - 1)];
|
||||||
|
return Math.min(offset + p + 2, text.length);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Shared engine for the Enter / Tab / Shift-Tab cell navigation. Reformats
|
||||||
|
// the table and drops the cursor into the target cell, appending an empty
|
||||||
|
// row when navigation runs past the last row. Returns null when the cursor
|
||||||
|
// is not in a table (so the caller can fall back to the editor default).
|
||||||
|
function moveInTable(text, cursorPos, mode) {
|
||||||
|
var range = findTableRange(text, cursorPos);
|
||||||
|
if (!range) return null;
|
||||||
|
var colIdx = getCursorColumn(text, cursorPos);
|
||||||
|
if (colIdx === null) return null;
|
||||||
|
|
||||||
|
var sepRel = -1, colCount = 0;
|
||||||
|
for (var i = range.start; i <= range.end; i++) {
|
||||||
|
var cells = parseTableRow(range.lines[i]);
|
||||||
|
if (cells.length > colCount) colCount = cells.length;
|
||||||
|
if (sepRel === -1 && isSeparatorRow(cells)) sepRel = i - range.start;
|
||||||
|
}
|
||||||
|
if (sepRel === -1) return null;
|
||||||
|
|
||||||
|
var lastRel = range.end - range.start;
|
||||||
|
var rowRel = range.cursorLine - range.start;
|
||||||
|
var targetRow = rowRel, targetCol = colIdx;
|
||||||
|
|
||||||
|
if (mode === 'enter') {
|
||||||
|
targetRow = rowRel + 1;
|
||||||
|
if (targetRow === sepRel) targetRow++;
|
||||||
|
} else if (mode === 'tab') {
|
||||||
|
targetCol = colIdx + 1;
|
||||||
|
if (targetCol >= colCount) {
|
||||||
|
targetCol = 0;
|
||||||
|
targetRow = rowRel + 1;
|
||||||
|
if (targetRow === sepRel) targetRow++;
|
||||||
|
}
|
||||||
|
} else if (mode === 'shifttab') {
|
||||||
|
targetCol = colIdx - 1;
|
||||||
|
if (targetCol < 0) {
|
||||||
|
targetCol = colCount - 1;
|
||||||
|
targetRow = rowRel - 1;
|
||||||
|
if (targetRow === sepRel) targetRow--;
|
||||||
|
}
|
||||||
|
if (targetRow < 0) return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var lines = range.lines.slice();
|
||||||
|
if (targetRow > lastRel) {
|
||||||
|
var emptyCells = [];
|
||||||
|
for (var c = 0; c < colCount; c++) emptyCells.push('');
|
||||||
|
var emptyLine = '| ' + emptyCells.join(' | ') + ' |';
|
||||||
|
while (lastRel < targetRow) {
|
||||||
|
lines.splice(range.start + lastRel + 1, 0, emptyLine);
|
||||||
|
lastRel++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var tableStartOffset = 0;
|
||||||
|
for (var i = 0; i < range.start; i++) tableStartOffset += lines[i].length + 1;
|
||||||
|
var formatted = formatTableText(lines.join('\n'), tableStartOffset);
|
||||||
|
if (!formatted) return null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
text: formatted.text,
|
||||||
|
cursor: cellCursor(formatted.text, range.start + targetRow, targetCol),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function nextRowSameColumn(text, cursorPos) { return moveInTable(text, cursorPos, 'enter'); }
|
||||||
|
function nextCell(text, cursorPos) { return moveInTable(text, cursorPos, 'tab'); }
|
||||||
|
function prevCell(text, cursorPos) { return moveInTable(text, cursorPos, 'shifttab'); }
|
||||||
|
|
||||||
return {
|
return {
|
||||||
formatTableText: formatTableText,
|
formatTableText: formatTableText,
|
||||||
|
nextRowSameColumn: nextRowSameColumn,
|
||||||
|
nextCell: nextCell,
|
||||||
|
prevCell: prevCell,
|
||||||
setColumnAlignment: setColumnAlignment,
|
setColumnAlignment: setColumnAlignment,
|
||||||
insertColumn: insertColumn,
|
insertColumn: insertColumn,
|
||||||
deleteColumn: deleteColumn,
|
deleteColumn: deleteColumn,
|
||||||
|
|||||||
@@ -1,45 +1,18 @@
|
|||||||
// wikicomplete.js — the `[[` wikilink autocomplete source for CodeMirror.
|
// wikicomplete.js — the `[[` autocomplete source for CodeMirror.
|
||||||
//
|
//
|
||||||
// Triggers when the cursor sits in a `[[…` token (freshly typed or re-edited
|
// Wiki links are search queries, so the popup is the same typeahead the header
|
||||||
// inside an existing `[[…]]`), queries the existing /_search JSON endpoint
|
// search box uses: the text typed after `[[` goes to `/_search` and each match
|
||||||
// (debounced), and offers matching page paths. Selecting a result inserts the
|
// is offered by name. Picking one replaces the query segment with that page's
|
||||||
// absolute path and auto-closes `]]`, leaving the cursor before the close so a
|
// name — never a path — so what lands in the document is still a query. The
|
||||||
// `::display` alias can be typed (decision 7). Exposes window.WikiComplete.source
|
// trailing `]]` and any `::display` tail are left untouched.
|
||||||
// for main.js to register via CM's autocompletion().
|
//
|
||||||
|
// Exposes window.WikiComplete.source for main.js to register via CM's
|
||||||
|
// autocompletion().
|
||||||
window.WikiComplete = (function () {
|
window.WikiComplete = (function () {
|
||||||
var DEBOUNCE_MS = 100;
|
// Matches search-suggest.js so both surfaces fire at the same point.
|
||||||
var MIN_QUERY_LEN = 2;
|
var MIN_QUERY_LEN = 2;
|
||||||
|
|
||||||
// Mirrors the Go validator (wikilinks.go isValidWikiTarget) and the old
|
function fetchSuggestions(query) {
|
||||||
// client check: absolute path, no empty/'.'/'..' segments. Server results
|
|
||||||
// are wiki paths so this is mostly a guard against odd index entries.
|
|
||||||
function isValidWikiTarget(p) {
|
|
||||||
if (!p || p[0] !== '/') return false;
|
|
||||||
var trimmed = p.replace(/^\/+|\/+$/g, '');
|
|
||||||
if (trimmed === '') return true;
|
|
||||||
var segs = trimmed.split('/');
|
|
||||||
for (var i = 0; i < segs.length; i++) {
|
|
||||||
if (segs[i] === '' || segs[i] === '.' || segs[i] === '..') return false;
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build the apply() for a chosen result. Inserts the path over the typed
|
|
||||||
// query and ensures a single trailing `]]`, cursor parked before it. If the
|
|
||||||
// user (or closeBrackets) already produced `]]`, we don't double it up.
|
|
||||||
function makeApply(path) {
|
|
||||||
return function (view, completion, from, to) {
|
|
||||||
var closed = view.state.sliceDoc(to, to + 2) === ']]';
|
|
||||||
var insert = closed ? path : path + ']]';
|
|
||||||
view.dispatch({
|
|
||||||
changes: { from: from, to: to, insert: insert },
|
|
||||||
selection: { anchor: from + path.length },
|
|
||||||
scrollIntoView: true,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function fetchSuggest(query) {
|
|
||||||
return fetch('/_search?q=' + encodeURIComponent(query), {
|
return fetch('/_search?q=' + encodeURIComponent(query), {
|
||||||
credentials: 'same-origin',
|
credentials: 'same-origin',
|
||||||
headers: { 'Accept': 'application/json' },
|
headers: { 'Accept': 'application/json' },
|
||||||
@@ -49,45 +22,52 @@ window.WikiComplete = (function () {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// CM completion source. `[[` plus any run of non-`]`, non-newline chars
|
// queryEnd returns where the current query segment ends: the first `]`, the
|
||||||
// before the cursor is the trigger; the query is the text after `[[`. The
|
// `::` that starts the display text, or end of line. Replacing only up to
|
||||||
// replaced range extends past the cursor to the end of the inner token (up
|
// there keeps a re-edit from eating an existing alias or the closing `]]`.
|
||||||
// to `]]`/EOL) so re-editing inside an existing `[[…]]` replaces the whole
|
function queryEnd(context, pos) {
|
||||||
// target instead of duplicating the trailing text.
|
var line = context.state.doc.lineAt(pos);
|
||||||
|
var to = pos;
|
||||||
|
while (to < line.to) {
|
||||||
|
var ch = context.state.sliceDoc(to, to + 1);
|
||||||
|
if (ch === ']') break;
|
||||||
|
if (ch === ':' && context.state.sliceDoc(to, to + 2) === '::') break;
|
||||||
|
to++;
|
||||||
|
}
|
||||||
|
return to;
|
||||||
|
}
|
||||||
|
|
||||||
|
// CM completion source. Activates on `[[` followed by at least
|
||||||
|
// MIN_QUERY_LEN characters; a cursor past a `::` is in the display tail and
|
||||||
|
// gets nothing.
|
||||||
function source(context) {
|
function source(context) {
|
||||||
var match = context.matchBefore(/\[\[[^\]\n]*/);
|
var match = context.matchBefore(/\[\[[^\]\n]*/);
|
||||||
if (!match) return null;
|
if (!match) return null;
|
||||||
var query = context.state.sliceDoc(match.from + 2, context.pos);
|
var content = context.state.sliceDoc(match.from + 2, context.pos);
|
||||||
if (query.length < MIN_QUERY_LEN && !context.explicit) return null;
|
if (content.indexOf('::') !== -1) return null;
|
||||||
|
var query = content.trim();
|
||||||
|
if (query.length < MIN_QUERY_LEN) return null;
|
||||||
|
|
||||||
var line = context.state.doc.lineAt(context.pos);
|
var from = match.from + 2;
|
||||||
var to = context.pos;
|
var to = queryEnd(context, context.pos);
|
||||||
while (to < line.to && context.state.sliceDoc(to, to + 1) !== ']') to++;
|
|
||||||
|
|
||||||
return new Promise(function (resolve) {
|
return new Promise(function (resolve) {
|
||||||
var timer = setTimeout(function () {
|
|
||||||
if (context.aborted) { resolve(null); return; }
|
if (context.aborted) { resolve(null); return; }
|
||||||
fetchSuggest(query).then(function (resp) {
|
fetchSuggestions(query).then(function (resp) {
|
||||||
if (context.aborted) { resolve(null); return; }
|
if (context.aborted) { resolve(null); return; }
|
||||||
var options = (resp.results || []).reduce(function (acc, r) {
|
var options = (resp.results || []).map(function (r) {
|
||||||
var path = '/' + r.path;
|
return { label: r.name, detail: '/' + r.path, apply: r.name };
|
||||||
if (isValidWikiTarget(path)) {
|
});
|
||||||
acc.push({ label: path, detail: r.name, apply: makeApply(path) });
|
// filter:false — the server already ranked these fuzzily, and
|
||||||
}
|
// CM's own prefix filter would drop the near-misses (typos,
|
||||||
return acc;
|
// token reorderings) that make the fuzzy search worth having.
|
||||||
}, []);
|
// No validFor either: every keystroke re-queries from scratch.
|
||||||
resolve({ from: match.from + 2, to: to, options: options });
|
resolve({ from: from, to: to, options: options, filter: false });
|
||||||
}).catch(function () {
|
}).catch(function () {
|
||||||
resolve(null);
|
resolve(null);
|
||||||
});
|
});
|
||||||
}, DEBOUNCE_MS);
|
|
||||||
// CM doesn't cancel pending promises, but it sets context.aborted;
|
|
||||||
// clear the timer too if the doc moved on before it fired.
|
|
||||||
if (context.addEventListener) {
|
|
||||||
context.addEventListener('abort', function () { clearTimeout(timer); });
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return { source: source, isValidWikiTarget: isValidWikiTarget };
|
return { source: source };
|
||||||
})();
|
})();
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
// Fitness dashboard range dropdowns: changing one reloads the page with that
|
||||||
|
// chart's query parameter updated. Plain GET navigation — each range is a
|
||||||
|
// distinct, bookmarkable view, so no postReplace/history handling is needed.
|
||||||
|
document.addEventListener('change', function (e) {
|
||||||
|
var sel = e.target.closest('[data-fitness-range]');
|
||||||
|
if (!sel) return;
|
||||||
|
var url = new URL(window.location.href);
|
||||||
|
url.searchParams.set(sel.dataset.fitnessRange, sel.value);
|
||||||
|
window.location.href = url.toString();
|
||||||
|
});
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
{{define "fitnessChart"}}
|
||||||
|
<section class="fitness-chart panel">
|
||||||
|
<div class="fitness-chart-header row space-between">
|
||||||
|
<span class="caption">{{.Title}}</span>
|
||||||
|
<select class="input fitness-range" data-fitness-range="{{.Param}}" aria-label="{{.Title}} time range">
|
||||||
|
{{range .Options}}<option value="{{.Value}}"{{if .Selected}} selected{{end}}>{{.Label}}</option>{{end}}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
{{if .Empty}}
|
||||||
|
<p class="fitness-empty is-empty">No data in this range.</p>
|
||||||
|
{{else}}
|
||||||
|
<svg class="fitness-svg" viewBox="0 0 {{.ViewW}} {{.ViewH}}" role="img" aria-label="{{.Title}}">
|
||||||
|
{{range .YTicks}}
|
||||||
|
<line class="chart-grid" x1="{{$.PlotX}}" y1="{{.Pos}}" x2="{{$.PlotR}}" y2="{{.Pos}}"/>
|
||||||
|
<text class="chart-label" x="{{$.YLabelX}}" y="{{.Pos}}" text-anchor="end" dominant-baseline="middle">{{.Label}}</text>
|
||||||
|
{{end}}
|
||||||
|
{{range .XTicks}}
|
||||||
|
<text class="chart-label" x="{{.Pos}}" y="{{$.XLabelY}}" text-anchor="{{.Anchor}}">{{.Label}}</text>
|
||||||
|
{{end}}
|
||||||
|
<line class="chart-axis" x1="{{.PlotX}}" y1="{{.PlotY}}" x2="{{.PlotX}}" y2="{{.PlotB}}"/>
|
||||||
|
<line class="chart-axis" x1="{{.PlotX}}" y1="{{.PlotB}}" x2="{{.PlotR}}" y2="{{.PlotB}}"/>
|
||||||
|
{{range .Lines}}
|
||||||
|
<polyline class="chart-line" points="{{.}}"/>
|
||||||
|
{{end}}
|
||||||
|
{{range .Dots}}
|
||||||
|
<circle class="chart-dot" cx="{{.X}}" cy="{{.Y}}" r="2.5"><title>{{.Title}}</title></circle>
|
||||||
|
{{end}}
|
||||||
|
{{if .Goal}}
|
||||||
|
<line class="chart-goal" x1="{{.PlotX}}" y1="{{.Goal.Y}}" x2="{{.PlotR}}" y2="{{.Goal.Y}}"/>
|
||||||
|
<text class="chart-goal-label" x="{{.PlotR}}" y="{{.Goal.LabelY}}" text-anchor="end">{{.Goal.Label}}</text>
|
||||||
|
{{end}}
|
||||||
|
</svg>
|
||||||
|
{{end}}
|
||||||
|
</section>
|
||||||
|
{{end}}
|
||||||
|
<div class="fitness-dash col">
|
||||||
|
{{if .Notice}}
|
||||||
|
<p class="muted">{{.Notice}}</p>
|
||||||
|
{{else}}
|
||||||
|
{{range .Charts}}{{template "fitnessChart" .}}{{end}}
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
<script src="/_/fitness/fitness.js"></script>
|
||||||
@@ -4,6 +4,8 @@
|
|||||||
switch (e.key) {
|
switch (e.key) {
|
||||||
case 'E':
|
case 'E':
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
// assign, not replace — opening the editor pushes a history
|
||||||
|
// entry so Back cancels the edit (see history-nav.js).
|
||||||
window.location.href = window.location.pathname + '?edit';
|
window.location.href = window.location.pathname + '?edit';
|
||||||
break;
|
break;
|
||||||
case 'N':
|
case 'N':
|
||||||
@@ -54,3 +56,14 @@ document.addEventListener('keydown', function (e) {
|
|||||||
menu.classList.remove('is-open');
|
menu.classList.remove('is-open');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
document.addEventListener('DOMContentLoaded', function () {
|
||||||
|
var trigger = document.querySelector('[data-action="actions-menu"]');
|
||||||
|
if (!trigger) return;
|
||||||
|
wireDropdown(trigger);
|
||||||
|
var menu = trigger.parentElement.querySelector('.dropdown-menu');
|
||||||
|
if (!menu) return;
|
||||||
|
trigger.addEventListener('click', function () {
|
||||||
|
trigger.setAttribute('aria-expanded', menu.classList.contains('is-open') ? true : false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
// Keeps the editor from lingering in history once you leave it — while still
|
||||||
|
// letting the browser/Android Back button CANCEL an edit session.
|
||||||
|
//
|
||||||
|
// Opening the editor pushes a normal history entry, so Back exits the editor
|
||||||
|
// and returns to the page (the primary "back to cancel" gesture on mobile).
|
||||||
|
//
|
||||||
|
// Leaving the editor by CANCEL is the one transition we rewrite: the CANCEL
|
||||||
|
// link points back at the same page, so we replace the editor entry instead of
|
||||||
|
// pushing a second page entry on top of it. Without this, page -> edit -> CANCEL
|
||||||
|
// would leave [page, editor, page] and Back would walk straight back into the
|
||||||
|
// editor. SAVE does the equivalent from editor/main.js (replaceState + reload).
|
||||||
|
(function () {
|
||||||
|
function isEdit(loc) {
|
||||||
|
return new URLSearchParams(loc.search).has('edit');
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('click', function (e) {
|
||||||
|
if (e.defaultPrevented || e.button !== 0) return;
|
||||||
|
if (e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return;
|
||||||
|
var a = e.target.closest ? e.target.closest('a[href]') : null;
|
||||||
|
if (!a || a.target || a.hasAttribute('download')) return;
|
||||||
|
|
||||||
|
// Only act while inside the editor. Entering the editor stays a normal
|
||||||
|
// push so Back can cancel it.
|
||||||
|
if (!isEdit(window.location)) return;
|
||||||
|
|
||||||
|
var url = new URL(a.href, window.location.href);
|
||||||
|
if (url.origin !== window.location.origin) return;
|
||||||
|
if (url.pathname !== window.location.pathname) return;
|
||||||
|
// Leaving the editor to another page (e.g. a wikilink) keeps its normal
|
||||||
|
// push; only the same-page exit (CANCEL) is collapsed.
|
||||||
|
if (isEdit(url)) return;
|
||||||
|
|
||||||
|
e.preventDefault();
|
||||||
|
window.location.replace(url.href);
|
||||||
|
});
|
||||||
|
}());
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="1em" height="1em"
|
||||||
|
fill="none" stroke="currentColor" stroke-width="1.5" stroke-linejoin="miter" shape-rendering="crispEdges">
|
||||||
|
<path d="M1 2h6v5H1zM9 9h6v5H9zM4 7v4h5"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 249 B |
+26
-8
@@ -2,7 +2,7 @@
|
|||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<meta name="viewport" content="width=device-width, initial-scale=1, interactive-widget=resizes-content" />
|
||||||
<title>{{.Title}}</title>
|
<title>{{.Title}}</title>
|
||||||
<link rel="icon" href="/_/favicon.ico" />
|
<link rel="icon" href="/_/favicon.ico" />
|
||||||
<link rel="preload" href="/_/fonts/IosevkaEtoile.woff2" as="font" type="font/woff2" crossorigin />
|
<link rel="preload" href="/_/fonts/IosevkaEtoile.woff2" as="font" type="font/woff2" crossorigin />
|
||||||
@@ -10,30 +10,45 @@
|
|||||||
<link rel="stylesheet" href="/_/style.css" />
|
<link rel="stylesheet" href="/_/style.css" />
|
||||||
<script src="/_/modal.js"></script>
|
<script src="/_/modal.js"></script>
|
||||||
<script src="/_/global-shortcuts.js"></script>
|
<script src="/_/global-shortcuts.js"></script>
|
||||||
|
<script src="/_/history-nav.js"></script>
|
||||||
|
<script src="/_/scroll-sync.js" defer></script>
|
||||||
<script src="/_/search-suggest.js" defer></script>
|
<script src="/_/search-suggest.js" defer></script>
|
||||||
<script src="/_/tree-picker.js"></script>
|
<script src="/_/tree-picker.js"></script>
|
||||||
<script src="/_/companion.js" defer></script>
|
<script src="/_/companion.js" defer></script>
|
||||||
|
{{if .ShowPageChrome}}<script src="/_/overlay.js" defer></script>
|
||||||
|
<script src="/_/tree-sidebar.js" defer></script>{{end}}
|
||||||
{{block "headScripts" .}}{{end}}
|
{{block "headScripts" .}}{{end}}
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<header>
|
<header>
|
||||||
<nav class="breadcrumb row">
|
<nav class="breadcrumb row">
|
||||||
<a href="/" tabindex="-1" title="Home"><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>
|
<a href="/" tabindex="-1" title="Home"><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><span class="app-name"> datascape</span></a>
|
||||||
{{if .ParentURL}}<a class="nav-up" href="{{.ParentURL}}" tabindex="-1" title="Up" aria-label="Up">Up <svg viewBox="0 0 16 16" width="1em" height="1em" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linejoin="miter" stroke-linecap="square"><path d="M8 13V3M3 8l5-5 5 5"/></svg></a>{{end}}
|
|
||||||
</nav>
|
</nav>
|
||||||
{{if not .EditMode}}
|
{{if .ShowPageChrome}}
|
||||||
<form class="search-form" action="/" method="get">
|
<form class="search-form" action="/" method="get">
|
||||||
<input class="input search-input" type="search" name="q" value="{{block "searchQuery" .}}{{end}}" placeholder="Search…" title="Search (F)" autocomplete="off" autocorrect="off" autocapitalize="off" spellcheck="false" />
|
<input class="input search-input" type="search" name="q" value="{{block "searchQuery" .}}{{end}}" placeholder="Search…" title="Search (F)" autocomplete="off" autocorrect="off" autocapitalize="off" spellcheck="false" />
|
||||||
</form>
|
</form>
|
||||||
{{end}}
|
{{end}}
|
||||||
<div class="header-actions row">{{block "headerActions" .}}{{end}}</div>
|
<div class="header-actions row">
|
||||||
|
{{if not .ShowPageChrome}}{{block "headerActions" .}}{{end}}{{else}}
|
||||||
|
<span class="dropdown actions">
|
||||||
|
<button type="button" class="btn actions-toggle" data-action="actions-menu" aria-haspopup="true" aria-expanded="false" title="Actions">ACTIONS</button>
|
||||||
|
<div class="dropdown-menu align-right actions-menu">
|
||||||
|
<div class="actions-special"></div>
|
||||||
|
{{template "headerActions" . }}
|
||||||
|
</div>
|
||||||
|
</span>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
</header>
|
</header>
|
||||||
<div class="page-wrap">
|
<div class="shell">
|
||||||
|
{{if .ShowPageChrome}}<aside class="tree-sidebar col">
|
||||||
|
<div class="tree-scroll"></div>
|
||||||
|
</aside>{{end}}
|
||||||
|
<div class="center">
|
||||||
<main>
|
<main>
|
||||||
{{block "content" .}}{{end}}
|
{{block "content" .}}{{end}}
|
||||||
</main>
|
</main>
|
||||||
<aside class="sidebar">{{block "sidebar" .}}{{end}}</aside>
|
|
||||||
</div>
|
|
||||||
<footer>
|
<footer>
|
||||||
<span class="muted">Request: {{.RenderMS}} ms</span>
|
<span class="muted">Request: {{.RenderMS}} ms</span>
|
||||||
{{block "footerExtras" .}}{{end}}
|
{{block "footerExtras" .}}{{end}}
|
||||||
@@ -42,6 +57,9 @@
|
|||||||
<div class="dropdown-menu align-right open-up companion-flyout"></div>
|
<div class="dropdown-menu align-right open-up companion-flyout"></div>
|
||||||
</span>
|
</span>
|
||||||
</footer>
|
</footer>
|
||||||
|
</div>
|
||||||
|
{{if .ShowPageChrome}}{{block "aside" .}}{{end}}{{end}}
|
||||||
|
</div>
|
||||||
{{block "extras" .}}{{end}}
|
{{block "extras" .}}{{end}}
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -0,0 +1,96 @@
|
|||||||
|
// Full-viewport Overlay component (mobile). Distinct from the .modal dialog:
|
||||||
|
// this hosts a whole sidebar at full screen with its own scroll and a top-right close control.
|
||||||
|
// It reuses the low-level focus/Escape/bfcache patterns from modal.js rather than that component's panel chrome.
|
||||||
|
//
|
||||||
|
// openOverlay(node) MOVES `node` (an existing rail <aside>) into the overlay
|
||||||
|
// body and restores it to its original DOM position on close, so the rail keeps
|
||||||
|
// its state (expanded tree folders, rendered TOC). Only one overlay is open at
|
||||||
|
// a time.
|
||||||
|
(function () {
|
||||||
|
var overlay = null;
|
||||||
|
var bodyEl = null;
|
||||||
|
var closeBtn = null;
|
||||||
|
var hosted = null; // the moved node
|
||||||
|
var placeholder = null; // marks where to restore it
|
||||||
|
var prevFocus = null;
|
||||||
|
var onKeydown = null;
|
||||||
|
|
||||||
|
function build() {
|
||||||
|
overlay = document.createElement('div');
|
||||||
|
overlay.className = 'overlay';
|
||||||
|
overlay.setAttribute('role', 'dialog');
|
||||||
|
overlay.setAttribute('aria-modal', 'true');
|
||||||
|
|
||||||
|
var bar = document.createElement('div');
|
||||||
|
bar.className = 'overlay-bar';
|
||||||
|
|
||||||
|
closeBtn = document.createElement('button');
|
||||||
|
closeBtn.type = 'button';
|
||||||
|
closeBtn.className = 'btn overlay-close';
|
||||||
|
closeBtn.title = 'Close';
|
||||||
|
closeBtn.setAttribute('aria-label', 'Close');
|
||||||
|
closeBtn.textContent = '×';
|
||||||
|
closeBtn.addEventListener('click', close);
|
||||||
|
bar.appendChild(closeBtn);
|
||||||
|
|
||||||
|
bodyEl = document.createElement('div');
|
||||||
|
bodyEl.className = 'overlay-body';
|
||||||
|
bodyEl.addEventListener('click', function (e) {
|
||||||
|
if (e.target.closest('a')) close();
|
||||||
|
});
|
||||||
|
|
||||||
|
overlay.appendChild(bar);
|
||||||
|
overlay.appendChild(bodyEl);
|
||||||
|
}
|
||||||
|
|
||||||
|
function open(node) {
|
||||||
|
if (overlay && overlay.parentNode) close();
|
||||||
|
if (!overlay) build();
|
||||||
|
|
||||||
|
prevFocus = document.activeElement;
|
||||||
|
hosted = node;
|
||||||
|
placeholder = document.createComment('overlay-slot');
|
||||||
|
node.parentNode.insertBefore(placeholder, node);
|
||||||
|
bodyEl.appendChild(node);
|
||||||
|
|
||||||
|
document.body.appendChild(overlay);
|
||||||
|
overlay.classList.add('is-open');
|
||||||
|
|
||||||
|
onKeydown = function (e) {
|
||||||
|
if (e.key === 'Escape') { e.preventDefault(); close(); }
|
||||||
|
};
|
||||||
|
document.addEventListener('keydown', onKeydown);
|
||||||
|
|
||||||
|
setTimeout(function () { closeBtn.focus(); }, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function close() {
|
||||||
|
if (!overlay || !overlay.parentNode) return;
|
||||||
|
if (onKeydown) {
|
||||||
|
document.removeEventListener('keydown', onKeydown);
|
||||||
|
onKeydown = null;
|
||||||
|
}
|
||||||
|
// Restore the hosted rail to its original spot in the shell.
|
||||||
|
if (hosted && placeholder && placeholder.parentNode) {
|
||||||
|
placeholder.parentNode.insertBefore(hosted, placeholder);
|
||||||
|
placeholder.parentNode.removeChild(placeholder);
|
||||||
|
}
|
||||||
|
hosted = null;
|
||||||
|
placeholder = null;
|
||||||
|
overlay.classList.remove('is-open');
|
||||||
|
overlay.parentNode.removeChild(overlay);
|
||||||
|
|
||||||
|
var toRestore = prevFocus;
|
||||||
|
prevFocus = null;
|
||||||
|
if (toRestore && toRestore.focus) {
|
||||||
|
try { toRestore.focus(); } catch (e) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Belt-and-suspenders bfcache guard: force the overlay closed (restoring
|
||||||
|
// the rail) if the page is being hidden while it is still open.
|
||||||
|
window.addEventListener('pagehide', close);
|
||||||
|
|
||||||
|
window.openOverlay = open;
|
||||||
|
window.closeOverlay = close;
|
||||||
|
})();
|
||||||
+40
-19
@@ -13,6 +13,11 @@ function encodePickedPath(p) {
|
|||||||
// re-fetching, so a server-side mutation wouldn't be reflected. Instead,
|
// re-fetching, so a server-side mutation wouldn't be reflected. Instead,
|
||||||
// rewrite the current entry's URL via history.replaceState, then reload — the
|
// rewrite the current entry's URL via history.replaceState, then reload — the
|
||||||
// reload always re-fetches and preserves the (new) URL including its fragment.
|
// reload always re-fetches and preserves the (new) URL including its fragment.
|
||||||
|
function navigateReplace(target) {
|
||||||
|
window.history.replaceState(null, '', target);
|
||||||
|
window.location.reload();
|
||||||
|
}
|
||||||
|
|
||||||
function postReplace(action, body, target) {
|
function postReplace(action, body, target) {
|
||||||
var init = { method: 'POST', redirect: 'manual' };
|
var init = { method: 'POST', redirect: 'manual' };
|
||||||
if (body) {
|
if (body) {
|
||||||
@@ -21,8 +26,7 @@ function postReplace(action, body, target) {
|
|||||||
}
|
}
|
||||||
fetch(action, init).then(function (res) {
|
fetch(action, init).then(function (res) {
|
||||||
if (res.type === 'opaqueredirect' || res.ok) {
|
if (res.type === 'opaqueredirect' || res.ok) {
|
||||||
window.history.replaceState(null, '', target);
|
navigateReplace(target);
|
||||||
window.location.reload();
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
return res.text().then(function (msg) {
|
return res.text().then(function (msg) {
|
||||||
@@ -71,6 +75,38 @@ function newPage() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// submitMove POSTs a move and navigates on success. When the server reports
|
||||||
|
// the destination folder already exists but can be merged (the source carries
|
||||||
|
// a page and the destination has none), it asks the user to confirm and
|
||||||
|
// retries the same move with &merge=1.
|
||||||
|
function submitMove(action, target) {
|
||||||
|
fetch(action, { method: 'POST', redirect: 'manual' }).then(function (res) {
|
||||||
|
if (res.type === 'opaqueredirect' || res.ok) {
|
||||||
|
navigateReplace(target);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (res.status === 409 && res.headers.get('X-Merge-Available') === '1') {
|
||||||
|
openModal({
|
||||||
|
title: 'Merge folders?',
|
||||||
|
body: 'The destination folder already exists but has no page of its own. Merge this page and its contents into it?',
|
||||||
|
confirm: {
|
||||||
|
label: 'MERGE',
|
||||||
|
onConfirm: function () {
|
||||||
|
closeModal();
|
||||||
|
submitMove(action + '&merge=1', target);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
return res.text().then(function (msg) {
|
||||||
|
alert(msg || ('Request failed (' + res.status + ')'));
|
||||||
|
});
|
||||||
|
}).catch(function () {
|
||||||
|
alert('Network error');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function movePage() {
|
function movePage() {
|
||||||
var current = decodeURIComponent(window.location.pathname).replace(/\/+$/, '');
|
var current = decodeURIComponent(window.location.pathname).replace(/\/+$/, '');
|
||||||
if (!current) return;
|
if (!current) return;
|
||||||
@@ -93,23 +129,9 @@ function movePage() {
|
|||||||
input.placeholder = 'Page name';
|
input.placeholder = 'Page name';
|
||||||
input.value = currentName;
|
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 = 'row';
|
|
||||||
linksLabel.appendChild(linksCheckbox);
|
|
||||||
linksLabel.appendChild(document.createTextNode('Update links'));
|
|
||||||
|
|
||||||
var body = document.createDocumentFragment();
|
|
||||||
body.appendChild(input);
|
|
||||||
body.appendChild(linksLabel);
|
|
||||||
|
|
||||||
openModal({
|
openModal({
|
||||||
title: 'Move — new name?',
|
title: 'Move — new name?',
|
||||||
body: body,
|
body: input,
|
||||||
confirm: {
|
confirm: {
|
||||||
label: 'MOVE',
|
label: 'MOVE',
|
||||||
onConfirm: function () {
|
onConfirm: function () {
|
||||||
@@ -118,10 +140,9 @@ function movePage() {
|
|||||||
var dest = (newParent === '/' ? '' : newParent) + '/' + name;
|
var dest = (newParent === '/' ? '' : newParent) + '/' + name;
|
||||||
var action = window.location.pathname + '?move=' +
|
var action = window.location.pathname + '?move=' +
|
||||||
encodeURIComponent(dest);
|
encodeURIComponent(dest);
|
||||||
if (linksCheckbox.checked) action += '&links=1';
|
|
||||||
var target = encodePickedPath(dest) + '/';
|
var target = encodePickedPath(dest) + '/';
|
||||||
closeModal();
|
closeModal();
|
||||||
postReplace(action, null, target);
|
submitMove(action, target);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,114 +0,0 @@
|
|||||||
(function () {
|
|
||||||
var content = document.querySelector('.content');
|
|
||||||
if (!content) return;
|
|
||||||
|
|
||||||
var allHeadings = content.querySelectorAll('h1, h2, h3, h4, h5, h6');
|
|
||||||
if (!allHeadings.length) return;
|
|
||||||
|
|
||||||
function copyAnchor(id, item, label, menu) {
|
|
||||||
var url = window.location.origin + window.location.pathname + '#' + id;
|
|
||||||
function flash() {
|
|
||||||
item.textContent = 'Copied!';
|
|
||||||
setTimeout(function () {
|
|
||||||
item.textContent = label;
|
|
||||||
menu.classList.remove('is-open');
|
|
||||||
}, 1200);
|
|
||||||
}
|
|
||||||
function fallback() {
|
|
||||||
var ta = document.createElement('textarea');
|
|
||||||
ta.value = url;
|
|
||||||
ta.setAttribute('readonly', '');
|
|
||||||
ta.style.position = 'fixed';
|
|
||||||
ta.style.opacity = '0';
|
|
||||||
document.body.appendChild(ta);
|
|
||||||
ta.select();
|
|
||||||
var ok = false;
|
|
||||||
try { ok = document.execCommand('copy'); } catch (e) { ok = false; }
|
|
||||||
document.body.removeChild(ta);
|
|
||||||
if (ok) flash();
|
|
||||||
else openModal({
|
|
||||||
title: 'Copy anchor link',
|
|
||||||
body: 'Could not copy automatically. URL:\n' + url,
|
|
||||||
confirm: { label: 'OK', onConfirm: function () { closeModal(); } }
|
|
||||||
});
|
|
||||||
}
|
|
||||||
if (navigator.clipboard && navigator.clipboard.writeText) {
|
|
||||||
navigator.clipboard.writeText(url).then(flash, fallback);
|
|
||||||
} else {
|
|
||||||
fallback();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function addTask(sectionIndex, headingId) {
|
|
||||||
var input = document.createElement('input');
|
|
||||||
input.type = 'text';
|
|
||||||
input.className = 'input';
|
|
||||||
input.placeholder = 'Task description';
|
|
||||||
var ctrl = openModal({
|
|
||||||
title: 'Add task',
|
|
||||||
body: input,
|
|
||||||
confirm: {
|
|
||||||
label: 'ADD',
|
|
||||||
initiallyDisabled: true,
|
|
||||||
onConfirm: function () {
|
|
||||||
var text = input.value.trim();
|
|
||||||
if (!text) return;
|
|
||||||
var action = window.location.pathname + '?addtask=' + sectionIndex;
|
|
||||||
var target = window.location.pathname + '#' + headingId;
|
|
||||||
closeModal();
|
|
||||||
postReplace(action, 'text=' + encodeURIComponent(text), target);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
input.addEventListener('input', function () {
|
|
||||||
ctrl.setConfirmDisabled(input.value.trim() === '');
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
allHeadings.forEach(function (h, i) {
|
|
||||||
if (!h.id) return;
|
|
||||||
var tag = h.tagName.toLowerCase();
|
|
||||||
if (tag !== 'h2' && tag !== 'h3' && tag !== 'h4') return;
|
|
||||||
|
|
||||||
var sectionIndex = i + 1;
|
|
||||||
|
|
||||||
var wrap = document.createElement('span');
|
|
||||||
wrap.className = 'dropdown heading-anchor';
|
|
||||||
|
|
||||||
var trigger = document.createElement('button');
|
|
||||||
trigger.type = 'button';
|
|
||||||
trigger.className = 'dropdown-toggle';
|
|
||||||
trigger.setAttribute('aria-haspopup', 'menu');
|
|
||||||
trigger.setAttribute('aria-label', 'Section actions');
|
|
||||||
trigger.textContent = '#';
|
|
||||||
|
|
||||||
var menu = document.createElement('div');
|
|
||||||
menu.className = 'dropdown-menu';
|
|
||||||
|
|
||||||
var copyLabel = 'Copy anchor link';
|
|
||||||
var copyBtn = document.createElement('button');
|
|
||||||
copyBtn.type = 'button';
|
|
||||||
copyBtn.className = 'btn btn-tool btn-block';
|
|
||||||
copyBtn.dataset.action = 'copy-anchor';
|
|
||||||
copyBtn.textContent = copyLabel;
|
|
||||||
copyBtn.addEventListener('click', function (e) {
|
|
||||||
e.stopPropagation();
|
|
||||||
copyAnchor(h.id, copyBtn, copyLabel, menu);
|
|
||||||
});
|
|
||||||
|
|
||||||
var addBtn = document.createElement('button');
|
|
||||||
addBtn.type = 'button';
|
|
||||||
addBtn.className = 'btn btn-tool btn-block';
|
|
||||||
addBtn.dataset.action = 'add-task';
|
|
||||||
addBtn.textContent = 'Add task';
|
|
||||||
addBtn.addEventListener('click', function () { addTask(sectionIndex, h.id); });
|
|
||||||
|
|
||||||
menu.appendChild(copyBtn);
|
|
||||||
menu.appendChild(addBtn);
|
|
||||||
wrap.appendChild(trigger);
|
|
||||||
wrap.appendChild(menu);
|
|
||||||
h.insertBefore(wrap, h.firstChild);
|
|
||||||
|
|
||||||
wireDropdown(trigger);
|
|
||||||
});
|
|
||||||
}());
|
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
// canvas-actions.js — creating a canvas from a normal page.
|
||||||
|
//
|
||||||
|
// The canvas view itself owns everything else (rename, delete, editing); this
|
||||||
|
// only covers the one action that has to start from the page, since a folder
|
||||||
|
// with no canvases has no canvas view to reach.
|
||||||
|
//
|
||||||
|
// Reuses openModal and postReplace from page/actions.js, which the page
|
||||||
|
// template loads first.
|
||||||
|
|
||||||
|
// validCanvasName mirrors canvasNameRe in canvas.go. Checking here only buys a
|
||||||
|
// better error message — the server rejects the same set regardless.
|
||||||
|
function validCanvasName(name) {
|
||||||
|
if (!name || name.length > 100) return false;
|
||||||
|
if (name.charAt(0) === '.') return false;
|
||||||
|
return !/[\/\\]/.test(name);
|
||||||
|
}
|
||||||
|
|
||||||
|
function newCanvas() {
|
||||||
|
var input = document.createElement('input');
|
||||||
|
input.type = 'text';
|
||||||
|
input.className = 'input';
|
||||||
|
input.placeholder = 'Canvas name';
|
||||||
|
|
||||||
|
openModal({
|
||||||
|
title: 'New canvas',
|
||||||
|
body: input,
|
||||||
|
confirm: {
|
||||||
|
label: 'CREATE',
|
||||||
|
onConfirm: function () {
|
||||||
|
var name = input.value.trim();
|
||||||
|
if (!validCanvasName(name)) {
|
||||||
|
alert('A canvas name cannot be empty, start with a dot, or contain a slash.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
closeModal();
|
||||||
|
var base = window.location.pathname;
|
||||||
|
if (base.slice(-1) !== '/') base += '/';
|
||||||
|
var target = base + '?canvas=' + encodeURIComponent(name);
|
||||||
|
postReplace(target + '&create=1', null, target);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -5,7 +5,7 @@
|
|||||||
img.src = 'https://icons.duckduckgo.com/ip3/' + hostname + '.ico';
|
img.src = 'https://icons.duckduckgo.com/ip3/' + hostname + '.ico';
|
||||||
img.width = 16;
|
img.width = 16;
|
||||||
img.height = 16;
|
img.height = 16;
|
||||||
img.style.verticalAlign = 'middle';
|
img.style.verticalAlign = 'center';
|
||||||
img.style.marginRight = '3px';
|
img.style.marginRight = '3px';
|
||||||
a.prepend(img);
|
a.prepend(img);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
document.addEventListener("DOMContentLoaded", function () {
|
||||||
|
var aside = document.querySelector("aside.sidebar");
|
||||||
|
var specials = document.querySelector(".actions-special");
|
||||||
|
if (!aside || !aside.children.length || !specials) return;
|
||||||
|
|
||||||
|
var item = document.createElement("button");
|
||||||
|
item.type = "button";
|
||||||
|
item.className = "btn btn-block";
|
||||||
|
item.title = "Contents";
|
||||||
|
item.textContent = "CONTENTS";
|
||||||
|
item.addEventListener("click", function () {
|
||||||
|
if (typeof openOverlay === "function") openOverlay(aside);
|
||||||
|
});
|
||||||
|
specials.appendChild(item);
|
||||||
|
});
|
||||||
+26
-17
@@ -1,6 +1,28 @@
|
|||||||
{{define "headScripts"}}<script src="/_/page/actions.js"></script>{{end}}
|
{{define "headScripts"}}<script src="/_/page/actions.js"></script>
|
||||||
|
<script src="/_/page/canvas-actions.js"></script>{{end}}
|
||||||
|
|
||||||
|
{{define "headerActions"}}{{if .CanEdit}}
|
||||||
|
<button class="btn btn-block" onclick="newPage()" title="New page (N)">NEW</button>
|
||||||
|
<button class="btn btn-block" data-companion-reveal hidden title="Reveal in file manager">REVEAL</button>
|
||||||
|
{{if not .IsRoot}}
|
||||||
|
<button class="btn btn-block" onclick="movePage()" title="Move page (M)">MOVE</button>
|
||||||
|
<button class="btn btn-block danger" onclick="deletePage()" title="Delete page">DELETE</button>
|
||||||
|
{{end}}
|
||||||
|
<a class="btn btn-block" href="?edit" title="Edit page (E)">EDIT</a>
|
||||||
|
<button class="btn btn-block" onclick="newCanvas()" title="New canvas">NEW CANVAS</button>
|
||||||
|
{{end}}{{end}}
|
||||||
|
|
||||||
{{define "content"}}
|
{{define "content"}}
|
||||||
|
{{/* Canvases in this folder are alternative views of the same page, so they
|
||||||
|
read as a tab strip above the content rather than as rows in the file
|
||||||
|
listing (listEntries filters them out for exactly this reason). */}}
|
||||||
|
{{if .Canvases}}
|
||||||
|
<nav class="canvas-tabs row" aria-label="Page views">
|
||||||
|
<span class="canvas-tab is-active">PAGE</span>
|
||||||
|
{{range .Canvases}}<a class="canvas-tab" href="{{.URL}}">{{.Name}}</a>{{end}}
|
||||||
|
{{if .CanEdit}}<button type="button" class="canvas-tab canvas-tab-add" onclick="newCanvas()" title="New canvas">+</button>{{end}}
|
||||||
|
</nav>
|
||||||
|
{{end}}
|
||||||
{{if .Content}}
|
{{if .Content}}
|
||||||
<div class="content">{{.Content}}</div>
|
<div class="content">{{.Content}}</div>
|
||||||
{{end}}
|
{{end}}
|
||||||
@@ -13,7 +35,7 @@
|
|||||||
<div class="thumb-grid">
|
<div class="thumb-grid">
|
||||||
{{range .Entries}}
|
{{range .Entries}}
|
||||||
<a class="thumb-tile" href="{{.URL}}" title="{{.Name}}">
|
<a class="thumb-tile" href="{{.URL}}" title="{{.Name}}">
|
||||||
{{if .ThumbURL}}<img class="thumb-img" src="{{.ThumbURL}}" alt="" loading="lazy" width="300">{{else}}<span class="thumb-icon">{{.Icon}}</span>{{end}}
|
{{if .ThumbURL}}<span class="thumb-media"><img class="thumb-img" src="{{.ThumbURL}}" alt="" loading="lazy" width="300">{{if .IsVideo}}<span class="thumb-play" aria-hidden="true">▶</span>{{end}}</span>{{else}}<span class="thumb-icon">{{.Icon}}</span>{{end}}
|
||||||
<span class="thumb-label truncate">{{.Name}}</span>
|
<span class="thumb-label truncate">{{.Name}}</span>
|
||||||
</a>
|
</a>
|
||||||
{{end}}
|
{{end}}
|
||||||
@@ -39,26 +61,13 @@
|
|||||||
{{end}}
|
{{end}}
|
||||||
{{if or .Content .SpecialContent}}
|
{{if or .Content .SpecialContent}}
|
||||||
<script src="/_/page/content.js"></script>
|
<script src="/_/page/content.js"></script>
|
||||||
<script src="/_/page/anchors.js"></script>
|
|
||||||
{{if not .SuppressTOC}}<script src="/_/page/toc.js"></script>{{end}}
|
{{if not .SuppressTOC}}<script src="/_/page/toc.js"></script>{{end}}
|
||||||
<script src="/_/page/tasks.js"></script>
|
<script src="/_/page/tasks.js"></script>
|
||||||
{{end}}
|
{{end}}
|
||||||
{{if .Content}}
|
{{if .Content}}
|
||||||
<script src="/_/page/sections.js"></script>
|
<script src="/_/page/sections.js"></script>
|
||||||
{{end}}
|
{{end}}
|
||||||
<script src="/_/page/sidebar-fab.js"></script>
|
<script src="/_/page/contents.js"></script>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|
||||||
{{define "sidebar"}}{{if .CanEdit}}<nav class="actions panel panel-sidebar">
|
{{define "aside"}}{{if .SidebarWidget}}<aside class="sidebar">{{.SidebarWidget}}</aside>{{end}}{{end}}
|
||||||
<div class="panel-header">ACTIONS</div>
|
|
||||||
<button class="btn btn-block" onclick="newPage()" title="New page (N)">NEW PAGE</button>
|
|
||||||
<a class="btn btn-block" href="?edit" title="Edit page (E)">EDIT PAGE</a>
|
|
||||||
<button class="btn btn-block" data-companion-reveal hidden title="Reveal in file manager">REVEAL ON CLIENT</button>
|
|
||||||
{{if not .IsRoot}}
|
|
||||||
<button class="btn btn-block" onclick="movePage()" title="Move page (M)">MOVE PAGE</button>
|
|
||||||
{{end}}
|
|
||||||
<button class="btn btn-block" data-action="clean-tasks" onclick="cleanUpTasks()" title="Clean up finished tasks" hidden>CLEAN UP TASKS</button>
|
|
||||||
{{if not .IsRoot}}
|
|
||||||
<button class="btn btn-block danger" onclick="deletePage()" title="Delete page">DELETE PAGE</button>
|
|
||||||
{{end}}
|
|
||||||
</nav>{{end}}{{if .SidebarWidget}}{{.SidebarWidget}}{{end}}{{end}}
|
|
||||||
|
|||||||
+10
-5
@@ -4,14 +4,19 @@
|
|||||||
var headings = content.querySelectorAll('h1, h2, h3, h4, h5, h6');
|
var headings = content.querySelectorAll('h1, h2, h3, h4, h5, h6');
|
||||||
if (!headings.length) return;
|
if (!headings.length) return;
|
||||||
|
|
||||||
// Section 0 is pre-heading content, editable via full-page edit.
|
|
||||||
// Sections 1..N each start at a heading; that is the index sent to the server.
|
|
||||||
// Skip headings that already carry a server-rendered edit link.
|
|
||||||
headings.forEach(function (h, i) {
|
headings.forEach(function (h, i) {
|
||||||
if (h.querySelector('a.btn')) return;
|
if (h.querySelector('a.btn')) return;
|
||||||
var a = document.createElement('a');
|
var a = document.createElement('a');
|
||||||
a.href = '?edit§ion=' + (i + 1);
|
|
||||||
a.className = 'btn btn-small';
|
if (h.tagName === 'H1') {
|
||||||
|
a.href = '?edit';
|
||||||
|
} else if (h.tagName === 'H2') {
|
||||||
|
a.href = '?edit§ion=' + (i + 1) + '&span';
|
||||||
|
} else {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
a.className = 'btn btn-small btn-float';
|
||||||
a.textContent = 'edit';
|
a.textContent = 'edit';
|
||||||
h.appendChild(document.createTextNode(' '))
|
h.appendChild(document.createTextNode(' '))
|
||||||
h.appendChild(a);
|
h.appendChild(a);
|
||||||
|
|||||||
@@ -1,23 +0,0 @@
|
|||||||
document.addEventListener("DOMContentLoaded", function () {
|
|
||||||
var aside = document.querySelector("aside.sidebar");
|
|
||||||
if (!aside || !aside.children.length) return;
|
|
||||||
|
|
||||||
var fab = document.createElement("button");
|
|
||||||
fab.type = "button";
|
|
||||||
fab.className = "btn btn-fab fab";
|
|
||||||
fab.title = "Menu";
|
|
||||||
fab.setAttribute("aria-label", "Menu");
|
|
||||||
fab.setAttribute("aria-expanded", "false");
|
|
||||||
fab.textContent = "≡";
|
|
||||||
fab.addEventListener("click", function () {
|
|
||||||
var open = aside.classList.toggle("is-open");
|
|
||||||
fab.setAttribute("aria-expanded", open ? "true" : "false");
|
|
||||||
});
|
|
||||||
aside.addEventListener("click", function (e) {
|
|
||||||
if (e.target.tagName === "A") {
|
|
||||||
aside.classList.remove("is-open");
|
|
||||||
fab.setAttribute("aria-expanded", "false");
|
|
||||||
}
|
|
||||||
});
|
|
||||||
document.body.appendChild(fab);
|
|
||||||
});
|
|
||||||
@@ -21,25 +21,4 @@
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
var hasChecked = !!document.querySelector('input.task-checkbox:checked');
|
|
||||||
if (hasChecked) {
|
|
||||||
var btn = document.querySelector('[data-action="clean-tasks"]');
|
|
||||||
if (btn) btn.hidden = false;
|
|
||||||
}
|
|
||||||
})();
|
})();
|
||||||
|
|
||||||
function cleanUpTasks() {
|
|
||||||
openModal({
|
|
||||||
title: 'Clean up tasks',
|
|
||||||
body: 'Remove all completed tasks from this page?',
|
|
||||||
confirm: {
|
|
||||||
label: 'CLEAN UP',
|
|
||||||
danger: true,
|
|
||||||
onConfirm: function () {
|
|
||||||
closeModal();
|
|
||||||
postReplace(window.location.pathname + '?cleantasks=1', null, window.location.pathname);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|||||||
+3
-4
@@ -6,7 +6,7 @@ document.addEventListener("DOMContentLoaded", function () {
|
|||||||
if (headings.length < 2) return;
|
if (headings.length < 2) return;
|
||||||
|
|
||||||
var nav = document.createElement("nav");
|
var nav = document.createElement("nav");
|
||||||
nav.className = "toc panel panel-sidebar";
|
nav.className = "toc panel";
|
||||||
|
|
||||||
var header = document.createElement("div");
|
var header = document.createElement("div");
|
||||||
header.className = "panel-header";
|
header.className = "panel-header";
|
||||||
@@ -21,13 +21,12 @@ document.addEventListener("DOMContentLoaded", function () {
|
|||||||
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, .heading-anchor, .dropdown").forEach(function (el) { el.remove(); });
|
clone.querySelectorAll(".btn, .muted, .dropdown").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);
|
||||||
});
|
});
|
||||||
nav.appendChild(list);
|
nav.appendChild(list);
|
||||||
|
|
||||||
var rail = document.querySelector("aside.sidebar");
|
content.insertBefore(nav, content.firstChild);
|
||||||
rail.appendChild(nav);
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ function openViewSettings() {
|
|||||||
options.forEach(function (opt) {
|
options.forEach(function (opt) {
|
||||||
var b = document.createElement('button');
|
var b = document.createElement('button');
|
||||||
b.type = 'button';
|
b.type = 'button';
|
||||||
b.className = 'btn btn-small';
|
b.className = 'btn';
|
||||||
b.textContent = opt.label;
|
b.textContent = opt.label;
|
||||||
if (state[key] === opt.value) b.classList.add('is-active');
|
if (state[key] === opt.value) b.classList.add('is-active');
|
||||||
b.addEventListener('click', function () {
|
b.addEventListener('click', function () {
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
// Roughly preserve reading position when entering the editor.
|
||||||
|
//
|
||||||
|
// While reading a page we record how far the center column is scrolled (as a
|
||||||
|
// fraction of its scrollable height, keyed by path). When the same path opens
|
||||||
|
// in the editor we restore that fraction, so you land near where you were
|
||||||
|
// reading instead of at the top. It is fraction-based — the rendered page and
|
||||||
|
// the raw-markdown editor have different heights — so the match is deliberately
|
||||||
|
// approximate. This only moves the viewport; the caret default (end of doc) is
|
||||||
|
// unchanged, so a click still decides where you actually edit.
|
||||||
|
(function () {
|
||||||
|
var center = document.querySelector('.center');
|
||||||
|
if (!center) return;
|
||||||
|
|
||||||
|
var key = 'scrollsync:' + location.pathname;
|
||||||
|
// Only restore a freshly-recorded position: a stale entry from earlier in
|
||||||
|
// the tab session would jump a direct ?edit open to the wrong place.
|
||||||
|
var MAX_AGE_MS = 5 * 60 * 1000;
|
||||||
|
var params = new URLSearchParams(location.search);
|
||||||
|
var inEditor = params.has('edit');
|
||||||
|
// Section / insert edits load only a slice of the document into the editor,
|
||||||
|
// so a whole-page reading fraction does not map onto them — restore only for
|
||||||
|
// a full-page ?edit.
|
||||||
|
var fullPageEdit = inEditor && !params.has('section') && !params.has('insert_before');
|
||||||
|
|
||||||
|
function maxScroll() { return Math.max(0, center.scrollHeight - center.clientHeight); }
|
||||||
|
|
||||||
|
if (inEditor) {
|
||||||
|
if (!fullPageEdit) return;
|
||||||
|
var raw = sessionStorage.getItem(key);
|
||||||
|
if (!raw) return;
|
||||||
|
var data;
|
||||||
|
try { data = JSON.parse(raw); } catch (e) { return; }
|
||||||
|
if (!data || typeof data.f !== 'number' || Date.now() - (data.t || 0) > MAX_AGE_MS) return;
|
||||||
|
// Wait for CodeMirror to mount and the column to reach full height, then
|
||||||
|
// map the fraction onto it. A double rAF after load lets CM's initial
|
||||||
|
// layout and focus scroll settle so ours wins.
|
||||||
|
window.addEventListener('load', function () {
|
||||||
|
requestAnimationFrame(function () {
|
||||||
|
requestAnimationFrame(function () {
|
||||||
|
center.scrollTop = data.f * maxScroll();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reading mode: keep the latest scroll fraction recorded (throttled to one
|
||||||
|
// write per frame) so it is ready the moment the editor opens.
|
||||||
|
var pending = false;
|
||||||
|
center.addEventListener('scroll', function () {
|
||||||
|
if (pending) return;
|
||||||
|
pending = true;
|
||||||
|
requestAnimationFrame(function () {
|
||||||
|
pending = false;
|
||||||
|
var m = maxScroll();
|
||||||
|
try {
|
||||||
|
sessionStorage.setItem(key, JSON.stringify({ f: m > 0 ? center.scrollTop / m : 0, t: Date.now() }));
|
||||||
|
} catch (e) {}
|
||||||
|
});
|
||||||
|
}, { passive: true });
|
||||||
|
})();
|
||||||
@@ -11,8 +11,56 @@ function rebuildIndex() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function encodeSearchPath(p) {
|
||||||
|
if (p === '/' || p === '') return '/';
|
||||||
|
return '/' + p.replace(/^\/+/, '').split('/').map(encodeURIComponent).join('/');
|
||||||
|
}
|
||||||
|
|
||||||
|
// createSearchPage runs the new-page flow (pick a parent folder, then confirm
|
||||||
|
// the name) starting from the search query, so a query with no exact page can
|
||||||
|
// be turned into that page in one step. The name field is pre-filled with the
|
||||||
|
// query but stays editable.
|
||||||
|
function createSearchPage(name) {
|
||||||
|
var current = decodeURIComponent(window.location.pathname).replace(/\/+$/, '') || '/';
|
||||||
|
openTreePicker({
|
||||||
|
title: 'New page — where?',
|
||||||
|
mode: 'folder',
|
||||||
|
initialPath: current,
|
||||||
|
preselect: current,
|
||||||
|
hideFiles: true,
|
||||||
|
confirmLabel: 'NEXT',
|
||||||
|
onSelect: function (parentPath) {
|
||||||
|
var input = document.createElement('input');
|
||||||
|
input.type = 'text';
|
||||||
|
input.className = 'input';
|
||||||
|
input.placeholder = 'Page name';
|
||||||
|
input.value = name || '';
|
||||||
|
openModal({
|
||||||
|
title: 'New page — name?',
|
||||||
|
body: input,
|
||||||
|
confirm: {
|
||||||
|
label: 'CREATE',
|
||||||
|
onConfirm: function () {
|
||||||
|
var finalName = input.value.trim();
|
||||||
|
if (!finalName) return;
|
||||||
|
var base = parentPath === '/' ? '/' : encodeSearchPath(parentPath) + '/';
|
||||||
|
window.location.href = base + encodeURIComponent(finalName) + '/?edit';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
document.addEventListener('DOMContentLoaded', function () {
|
document.addEventListener('DOMContentLoaded', function () {
|
||||||
wireDropdown(document.querySelector('[data-action="actions-drop"]'));
|
|
||||||
|
var createLink = document.querySelector('[data-create-page]');
|
||||||
|
if (createLink) {
|
||||||
|
createLink.addEventListener('click', function (e) {
|
||||||
|
e.preventDefault();
|
||||||
|
createSearchPage(createLink.getAttribute('data-create-page'));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Focus the search input on results pages so Tab steps directly into the
|
// Focus the search input on results pages so Tab steps directly into the
|
||||||
// first match — the input sits immediately before the results in DOM
|
// first match — the input sits immediately before the results in DOM
|
||||||
|
|||||||
+30
-14
@@ -4,32 +4,48 @@
|
|||||||
|
|
||||||
{{define "content"}}
|
{{define "content"}}
|
||||||
{{if .Query}}
|
{{if .Query}}
|
||||||
{{if .Results}}
|
{{if .Exact}}
|
||||||
<p class="muted">{{len .Results}} match{{if ne (len .Results) 1}}es{{end}} for “{{.Query}}”</p>
|
<h2 class="search-section">Exact Match</h2>
|
||||||
<hr/>
|
{{range .Exact}}
|
||||||
{{range .Results}}
|
|
||||||
<article class="search-card">
|
<article class="search-card">
|
||||||
<a href="{{.URL}}">{{.Name}}</a>
|
<a href="{{.URL}}">{{.Name}}</a>
|
||||||
<div class="muted">/{{.Path}}</div>
|
<div class="muted">/{{.Path}}</div>
|
||||||
</article>
|
</article>
|
||||||
{{end}}
|
{{end}}
|
||||||
{{else}}
|
{{else}}
|
||||||
<p class="empty">No matches for “{{.Query}}”.</p>
|
<p class="muted">No page named “{{.Query}}” — <button class="btn btn-small" data-create-page="{{.Query}}">create it</button></p>
|
||||||
|
{{end}}
|
||||||
|
{{if .Pages}}
|
||||||
|
<h2 class="search-section">Matching Pages <span class="muted">{{.PageTotal}}</span></h2>
|
||||||
|
{{range .Pages}}
|
||||||
|
<article class="search-card">
|
||||||
|
<a href="{{.URL}}">{{.Name}}</a>
|
||||||
|
<div class="muted">/{{.Path}}</div>
|
||||||
|
</article>
|
||||||
|
{{end}}
|
||||||
|
{{end}}
|
||||||
|
{{if .Files}}
|
||||||
|
<h2 class="search-section">Matching Files <span class="muted">{{.FileTotal}}</span></h2>
|
||||||
|
{{range .Files}}
|
||||||
|
<article class="search-card">
|
||||||
|
<div class="row">
|
||||||
|
{{fileIcon .Name}}
|
||||||
|
<a href="{{.URL}}">{{.Name}}</a>
|
||||||
|
<button class="btn btn-small" data-companion-file-reveal="{{.Path}}" hidden title="Open location in file manager">open</button>
|
||||||
|
</div>
|
||||||
|
<div class="muted">/{{.Path}} · {{.Meta}}</div>
|
||||||
|
</article>
|
||||||
|
{{end}}
|
||||||
{{end}}
|
{{end}}
|
||||||
{{else}}
|
{{else}}
|
||||||
<p class="empty">Enter a query above.</p>
|
<p class="empty">Enter a query above.</p>
|
||||||
{{end}}
|
{{end}}
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|
||||||
|
{{define "headerActions"}}
|
||||||
|
<button class="btn btn-block" onclick="rebuildIndex()" title="Rebuild search index">REBUILD INDEX</button>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
{{define "footerExtras"}}
|
{{define "footerExtras"}}
|
||||||
{{if not .IndexBuiltAt.IsZero}}<span class="muted">· Index: {{.IndexBuiltAt.Format "2006-01-02 15:04"}}</span>{{end}}
|
{{if not .IndexBuiltAt.IsZero}}<span class="muted">· Index: {{.IndexBuiltAt.Format "2006-01-02 15:04"}}</span>{{end}}
|
||||||
{{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 btn-block" onclick="rebuildIndex()" title="Rebuild search index">REBUILD INDEX</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{{end}}
|
|
||||||
|
|||||||
+655
-154
File diff suppressed because it is too large
Load Diff
@@ -85,11 +85,6 @@
|
|||||||
}
|
}
|
||||||
row.appendChild(chevron);
|
row.appendChild(chevron);
|
||||||
|
|
||||||
var marker = document.createElement('span');
|
|
||||||
marker.className = 'tree-marker';
|
|
||||||
marker.textContent = kind === 'folder' ? '' : '\u00b7';
|
|
||||||
row.appendChild(marker);
|
|
||||||
|
|
||||||
var label = document.createElement('span');
|
var label = document.createElement('span');
|
||||||
label.className = 'tree-name';
|
label.className = 'tree-name';
|
||||||
label.textContent = name;
|
label.textContent = name;
|
||||||
@@ -199,9 +194,6 @@
|
|||||||
var rootChev = document.createElement('span');
|
var rootChev = document.createElement('span');
|
||||||
rootChev.className = 'tree-chevron is-leaf';
|
rootChev.className = 'tree-chevron is-leaf';
|
||||||
rootRow.appendChild(rootChev);
|
rootRow.appendChild(rootChev);
|
||||||
var rootMarker = document.createElement('span');
|
|
||||||
rootMarker.className = 'tree-marker';
|
|
||||||
rootRow.appendChild(rootMarker);
|
|
||||||
var rootLabel = document.createElement('span');
|
var rootLabel = document.createElement('span');
|
||||||
rootLabel.className = 'tree-name';
|
rootLabel.className = 'tree-name';
|
||||||
rootLabel.textContent = '/';
|
rootLabel.textContent = '/';
|
||||||
|
|||||||
@@ -0,0 +1,215 @@
|
|||||||
|
;(function () {
|
||||||
|
// This script owns the .tree-scroll child of the aside, not the aside itself.
|
||||||
|
var container = document.querySelector("aside.tree-sidebar .tree-scroll")
|
||||||
|
if (!container) return
|
||||||
|
|
||||||
|
function joinPath(parent, name) {
|
||||||
|
if (parent === "/" || parent === "") return "/" + name
|
||||||
|
return parent.replace(/\/+$/, "") + "/" + name
|
||||||
|
}
|
||||||
|
|
||||||
|
function encodeSegments(p) {
|
||||||
|
return p.replace(/^\/+/, "").split("/").map(encodeURIComponent).join("/")
|
||||||
|
}
|
||||||
|
|
||||||
|
function folderHref(p) {
|
||||||
|
if (p === "/" || p === "") return "/"
|
||||||
|
return "/" + encodeSegments(p) + "/"
|
||||||
|
}
|
||||||
|
|
||||||
|
function fetchFolder(path, expandTo) {
|
||||||
|
var url = folderHref(path) + "?tree=1&foldersOnly=1"
|
||||||
|
if (expandTo) url += "&expandTo=" + encodeURIComponent(expandTo)
|
||||||
|
return fetch(url, { credentials: "same-origin" }).then(function (r) {
|
||||||
|
if (!r.ok) throw new Error("HTTP " + r.status)
|
||||||
|
return r.json()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Current page as a canonical, DECODED wiki path ("/" for root, no trailing
|
||||||
|
// slash otherwise). pathname is percent-encoded; the tree's entry names (and
|
||||||
|
// thus fullPath) are decoded, so we must decode here too or paths containing
|
||||||
|
// spaces / non-ASCII never match a row and the chain never expands.
|
||||||
|
var activePath = (function () {
|
||||||
|
var p = window.location.pathname.replace(/\/+$/, "")
|
||||||
|
if (p === "") return "/"
|
||||||
|
try {
|
||||||
|
return decodeURIComponent(p)
|
||||||
|
} catch (e) {
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
var activeRow = null
|
||||||
|
|
||||||
|
function disabledRow(text) {
|
||||||
|
var row = document.createElement("div")
|
||||||
|
row.className = "tree-row is-disabled"
|
||||||
|
row.textContent = text
|
||||||
|
return row
|
||||||
|
}
|
||||||
|
|
||||||
|
// renderInto fills containerEl with rows for `entries` (children of
|
||||||
|
// parentPath). Folders carrying a `children` array (the pre-expanded
|
||||||
|
// ancestor chain from ?expandTo) are opened immediately and recurse.
|
||||||
|
function renderInto(containerEl, parentPath, entries) {
|
||||||
|
entries.forEach(function (entry) {
|
||||||
|
var built = buildRow(parentPath, entry)
|
||||||
|
containerEl.appendChild(built.row)
|
||||||
|
if (built.preExpand) built.open()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildRow(parentPath, entry) {
|
||||||
|
var fullPath = joinPath(parentPath, entry.name)
|
||||||
|
var row = document.createElement("div")
|
||||||
|
row.className = "tree-row"
|
||||||
|
|
||||||
|
var chevron = document.createElement("span")
|
||||||
|
chevron.className = "tree-chevron"
|
||||||
|
chevron.textContent = "▸"
|
||||||
|
row.appendChild(chevron)
|
||||||
|
|
||||||
|
var link = document.createElement("a")
|
||||||
|
link.className = "tree-name tree-folder"
|
||||||
|
link.textContent = entry.name
|
||||||
|
link.href = folderHref(fullPath)
|
||||||
|
row.appendChild(link)
|
||||||
|
|
||||||
|
if (fullPath === activePath) {
|
||||||
|
row.classList.add("is-active")
|
||||||
|
activeRow = row
|
||||||
|
}
|
||||||
|
|
||||||
|
var preChildren = entry.children ? entry.children : null
|
||||||
|
var childrenEl = null
|
||||||
|
var loaded = false
|
||||||
|
var isOpen = false
|
||||||
|
var isLeaf = false
|
||||||
|
|
||||||
|
function ensureChildrenEl() {
|
||||||
|
if (!childrenEl) {
|
||||||
|
childrenEl = document.createElement("div")
|
||||||
|
childrenEl.className = "tree-children"
|
||||||
|
}
|
||||||
|
return childrenEl
|
||||||
|
}
|
||||||
|
|
||||||
|
function markLeaf() {
|
||||||
|
isLeaf = true
|
||||||
|
isOpen = false
|
||||||
|
chevron.textContent = "▸"
|
||||||
|
chevron.classList.add("is-leaf")
|
||||||
|
if (childrenEl && childrenEl.parentNode) {
|
||||||
|
childrenEl.parentNode.removeChild(childrenEl)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadChildren() {
|
||||||
|
var el = ensureChildrenEl()
|
||||||
|
el.textContent = ""
|
||||||
|
el.appendChild(disabledRow("…"))
|
||||||
|
fetchFolder(fullPath)
|
||||||
|
.then(function (resp) {
|
||||||
|
el.textContent = ""
|
||||||
|
loaded = true
|
||||||
|
if (!resp.entries || resp.entries.length === 0) {
|
||||||
|
markLeaf()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
renderInto(el, fullPath, resp.entries)
|
||||||
|
})
|
||||||
|
.catch(function () {
|
||||||
|
el.textContent = ""
|
||||||
|
var err = document.createElement("div")
|
||||||
|
err.className = "tree-row"
|
||||||
|
err.textContent = "(failed — tap to retry)"
|
||||||
|
err.addEventListener("click", function (e) {
|
||||||
|
e.stopPropagation()
|
||||||
|
loadChildren()
|
||||||
|
})
|
||||||
|
el.appendChild(err)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function open() {
|
||||||
|
if (isOpen || isLeaf) return
|
||||||
|
var el = ensureChildrenEl()
|
||||||
|
row.parentNode.insertBefore(el, row.nextSibling)
|
||||||
|
chevron.textContent = "▾" // ▾
|
||||||
|
isOpen = true
|
||||||
|
if (!loaded) {
|
||||||
|
if (preChildren) {
|
||||||
|
renderInto(el, fullPath, preChildren)
|
||||||
|
loaded = true
|
||||||
|
} else {
|
||||||
|
loadChildren()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function close() {
|
||||||
|
if (!isOpen) return
|
||||||
|
if (childrenEl && childrenEl.parentNode) {
|
||||||
|
childrenEl.parentNode.removeChild(childrenEl)
|
||||||
|
}
|
||||||
|
chevron.textContent = "▸"
|
||||||
|
isOpen = false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clicking the name link navigates (folder) or opens the file; clicking
|
||||||
|
// anywhere else on the row toggles expansion for folders.
|
||||||
|
row.addEventListener("click", function (e) {
|
||||||
|
if (e.target.closest("a.tree-name")) return
|
||||||
|
if (isOpen) close()
|
||||||
|
else open()
|
||||||
|
})
|
||||||
|
|
||||||
|
return { row: row, open: open, preExpand: !!preChildren }
|
||||||
|
}
|
||||||
|
|
||||||
|
function render() {
|
||||||
|
container.textContent = ""
|
||||||
|
var listing = document.createElement("div")
|
||||||
|
container.appendChild(listing)
|
||||||
|
listing.appendChild(disabledRow("…"))
|
||||||
|
|
||||||
|
fetchFolder("/", activePath === "/" ? "" : activePath)
|
||||||
|
.then(function (resp) {
|
||||||
|
listing.textContent = ""
|
||||||
|
renderInto(listing, "/", resp.entries)
|
||||||
|
if (activeRow) {
|
||||||
|
try {
|
||||||
|
activeRow.scrollIntoView({ block: "nearest" })
|
||||||
|
} catch (e) {}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(function () {
|
||||||
|
listing.textContent = ""
|
||||||
|
var err = document.createElement("div")
|
||||||
|
err.className = "tree-row"
|
||||||
|
err.textContent = "(failed — tap to retry)"
|
||||||
|
err.addEventListener("click", function () {
|
||||||
|
render()
|
||||||
|
})
|
||||||
|
listing.appendChild(err)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function setupMenuItem() {
|
||||||
|
var specials = document.querySelector(".actions-special")
|
||||||
|
if (!specials) return
|
||||||
|
|
||||||
|
var item = document.createElement("button")
|
||||||
|
item.type = "button"
|
||||||
|
item.className = "btn btn-block"
|
||||||
|
item.title = "Folders"
|
||||||
|
item.textContent = "FOLDERS"
|
||||||
|
item.addEventListener("click", function () {
|
||||||
|
if (typeof openOverlay === "function") openOverlay(container)
|
||||||
|
})
|
||||||
|
specials.appendChild(item)
|
||||||
|
}
|
||||||
|
|
||||||
|
render()
|
||||||
|
setupMenuItem()
|
||||||
|
})()
|
||||||
@@ -0,0 +1,699 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"html"
|
||||||
|
"html/template"
|
||||||
|
"io"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"os"
|
||||||
|
"path"
|
||||||
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// JSON Canvas (https://jsoncanvas.org/spec/1.0/) support.
|
||||||
|
//
|
||||||
|
// A canvas is a `<Name>.canvas` file sitting in a page folder, addressed as an
|
||||||
|
// alternative *view* of that page rather than as a standalone file:
|
||||||
|
//
|
||||||
|
// /Topics/Ideas/?canvas=Architecture the canvas view
|
||||||
|
// /Topics/Ideas/Architecture.canvas 302 -> the line above
|
||||||
|
// /Topics/Ideas/Architecture.canvas?raw the file bytes
|
||||||
|
//
|
||||||
|
// Routing through the folder keeps the page's identity (breadcrumb, tree,
|
||||||
|
// header actions) and lets every canvas in a folder render as a tab strip above
|
||||||
|
// the page content. The redirect mirrors how diary.go collapses its virtual
|
||||||
|
// month/day URLs onto one canonical form.
|
||||||
|
//
|
||||||
|
// The server owns the format: it parses and validates on every read and write,
|
||||||
|
// re-marshals from its own structs on save, and never writes bytes it has not
|
||||||
|
// round-tripped through canvasDoc. Keys it does not model are dropped rather
|
||||||
|
// than preserved — this wiki is the only writer.
|
||||||
|
|
||||||
|
const canvasExt = ".canvas"
|
||||||
|
|
||||||
|
// canvasMaxBytes caps a save payload. A canvas is coordinates and prose; a
|
||||||
|
// megabyte is already far past any hand-built diagram and well short of
|
||||||
|
// anything that could pressure the NAS.
|
||||||
|
const canvasMaxBytes = 4 << 20
|
||||||
|
|
||||||
|
// canvasRenderMaxBytes caps a /_canvas/render body. Only one node's content
|
||||||
|
// travels at a time.
|
||||||
|
const canvasRenderMaxBytes = 1 << 20
|
||||||
|
|
||||||
|
// Structural caps. These exist so a looping client cannot grow a file until it
|
||||||
|
// stops loading, not because the format has limits.
|
||||||
|
const (
|
||||||
|
canvasMaxNodes = 5000
|
||||||
|
canvasMaxEdges = 10000
|
||||||
|
canvasMaxCoord = 1 << 20
|
||||||
|
)
|
||||||
|
|
||||||
|
// canvasNameRe restricts a ?canvas= value to a single safe path component: the
|
||||||
|
// name is joined onto the folder path, so separators, dot-segments, control
|
||||||
|
// characters, and leading dots (which would create a hidden file the listing
|
||||||
|
// filters away) must all be rejected before the filesystem sees it.
|
||||||
|
var canvasNameRe = regexp.MustCompile(`^[^./\\\x00-\x1f][^/\\\x00-\x1f]{0,99}$`)
|
||||||
|
|
||||||
|
// canvasHexRe matches the hex form of a canvasColor. The spec also allows the
|
||||||
|
// preset digits "1".."6"; see validCanvasColor.
|
||||||
|
var canvasHexRe = regexp.MustCompile(`^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$`)
|
||||||
|
|
||||||
|
// Node types defined by the spec.
|
||||||
|
const (
|
||||||
|
canvasNodeText = "text"
|
||||||
|
canvasNodeFile = "file"
|
||||||
|
canvasNodeLink = "link"
|
||||||
|
canvasNodeGroup = "group"
|
||||||
|
)
|
||||||
|
|
||||||
|
// canvasDoc is a whole `.canvas` file. Both arrays are optional per the spec,
|
||||||
|
// so an empty document marshals to `{}`.
|
||||||
|
type canvasDoc struct {
|
||||||
|
Nodes []canvasNode `json:"nodes,omitempty"`
|
||||||
|
Edges []canvasEdge `json:"edges,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// canvasNode carries every node variant in one struct. The spec's four types
|
||||||
|
// share six required attributes and add one to three of their own, so a flat
|
||||||
|
// struct with omitempty round-trips all of them without the ceremony of a
|
||||||
|
// custom unmarshaller; validate() enforces which extras a given Type may use.
|
||||||
|
type canvasNode struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
X int `json:"x"`
|
||||||
|
Y int `json:"y"`
|
||||||
|
Width int `json:"width"`
|
||||||
|
Height int `json:"height"`
|
||||||
|
Color string `json:"color,omitempty"`
|
||||||
|
|
||||||
|
Text string `json:"text,omitempty"` // type=text
|
||||||
|
File string `json:"file,omitempty"` // type=file
|
||||||
|
Subpath string `json:"subpath,omitempty"` // type=file
|
||||||
|
URL string `json:"url,omitempty"` // type=link
|
||||||
|
Label string `json:"label,omitempty"` // type=group
|
||||||
|
Background string `json:"background,omitempty"` // type=group
|
||||||
|
BackgroundStyle string `json:"backgroundStyle,omitempty"` // type=group
|
||||||
|
}
|
||||||
|
|
||||||
|
type canvasEdge struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
FromNode string `json:"fromNode"`
|
||||||
|
FromSide string `json:"fromSide,omitempty"`
|
||||||
|
FromEnd string `json:"fromEnd,omitempty"` // defaults to "none"
|
||||||
|
ToNode string `json:"toNode"`
|
||||||
|
ToSide string `json:"toSide,omitempty"`
|
||||||
|
ToEnd string `json:"toEnd,omitempty"` // defaults to "arrow"
|
||||||
|
Color string `json:"color,omitempty"`
|
||||||
|
Label string `json:"label,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// canvasRef is one entry in the page's view switcher.
|
||||||
|
type canvasRef struct {
|
||||||
|
Name string
|
||||||
|
URL string
|
||||||
|
Active bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// canvasPayload is the single blob inlined into the canvas page. Shipping the
|
||||||
|
// document *and* its server-rendered node HTML together means opening a canvas
|
||||||
|
// costs exactly one request no matter how many nodes it holds — the obvious
|
||||||
|
// alternative (one content request per node on load) fans out badly over the
|
||||||
|
// mobile/VPN path.
|
||||||
|
type canvasPayload struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
PostURL string `json:"postUrl"`
|
||||||
|
Hash string `json:"hash"`
|
||||||
|
Doc *canvasDoc `json:"doc"`
|
||||||
|
Rendered map[string]template.HTML `json:"rendered"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- parsing and validation ---
|
||||||
|
|
||||||
|
// parseCanvas unmarshals and validates raw canvas bytes. Unknown keys are
|
||||||
|
// ignored rather than rejected so a hand-edited file with a stray attribute
|
||||||
|
// still opens; they are dropped on the next save.
|
||||||
|
func parseCanvas(raw []byte) (*canvasDoc, error) {
|
||||||
|
doc := &canvasDoc{}
|
||||||
|
if len(strings.TrimSpace(string(raw))) == 0 {
|
||||||
|
return doc, nil
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(raw, doc); err != nil {
|
||||||
|
return nil, fmt.Errorf("invalid JSON: %w", err)
|
||||||
|
}
|
||||||
|
if err := doc.validate(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return doc, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// validate rejects anything that would render as a broken or invisible canvas.
|
||||||
|
// It runs on read as well as write: a file edited by hand outside the app gets
|
||||||
|
// the same diagnosis the editor would give, naming the offending node.
|
||||||
|
func (c *canvasDoc) validate() error {
|
||||||
|
if len(c.Nodes) > canvasMaxNodes {
|
||||||
|
return fmt.Errorf("too many nodes (%d, max %d)", len(c.Nodes), canvasMaxNodes)
|
||||||
|
}
|
||||||
|
if len(c.Edges) > canvasMaxEdges {
|
||||||
|
return fmt.Errorf("too many edges (%d, max %d)", len(c.Edges), canvasMaxEdges)
|
||||||
|
}
|
||||||
|
|
||||||
|
ids := make(map[string]bool, len(c.Nodes))
|
||||||
|
for i := range c.Nodes {
|
||||||
|
n := &c.Nodes[i]
|
||||||
|
if n.ID == "" {
|
||||||
|
return fmt.Errorf("node %d: missing id", i)
|
||||||
|
}
|
||||||
|
if ids[n.ID] {
|
||||||
|
return fmt.Errorf("duplicate node id %q", n.ID)
|
||||||
|
}
|
||||||
|
ids[n.ID] = true
|
||||||
|
|
||||||
|
switch n.Type {
|
||||||
|
case canvasNodeText:
|
||||||
|
// An empty text node is a node the user just created and has not
|
||||||
|
// typed into yet, so emptiness is allowed here even though the spec
|
||||||
|
// calls `text` required. A missing *file* or *url*, by contrast, is
|
||||||
|
// a node that can never render anything.
|
||||||
|
case canvasNodeFile:
|
||||||
|
if n.File == "" {
|
||||||
|
return fmt.Errorf("node %q: file node has no file", n.ID)
|
||||||
|
}
|
||||||
|
if n.Subpath != "" && !strings.HasPrefix(n.Subpath, "#") {
|
||||||
|
return fmt.Errorf("node %q: subpath must start with #", n.ID)
|
||||||
|
}
|
||||||
|
case canvasNodeLink:
|
||||||
|
if n.URL == "" {
|
||||||
|
return fmt.Errorf("node %q: link node has no url", n.ID)
|
||||||
|
}
|
||||||
|
case canvasNodeGroup:
|
||||||
|
if n.BackgroundStyle != "" {
|
||||||
|
switch n.BackgroundStyle {
|
||||||
|
case "cover", "ratio", "repeat":
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("node %q: bad backgroundStyle %q", n.ID, n.BackgroundStyle)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case "":
|
||||||
|
return fmt.Errorf("node %q: missing type", n.ID)
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("node %q: unknown type %q", n.ID, n.Type)
|
||||||
|
}
|
||||||
|
|
||||||
|
if n.Width <= 0 || n.Height <= 0 {
|
||||||
|
return fmt.Errorf("node %q: width and height must be positive", n.ID)
|
||||||
|
}
|
||||||
|
if n.Width > canvasMaxCoord || n.Height > canvasMaxCoord ||
|
||||||
|
abs(n.X) > canvasMaxCoord || abs(n.Y) > canvasMaxCoord {
|
||||||
|
return fmt.Errorf("node %q: coordinates out of range", n.ID)
|
||||||
|
}
|
||||||
|
if !validCanvasColor(n.Color) {
|
||||||
|
return fmt.Errorf("node %q: bad color %q", n.ID, n.Color)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
edgeIDs := make(map[string]bool, len(c.Edges))
|
||||||
|
for i := range c.Edges {
|
||||||
|
e := &c.Edges[i]
|
||||||
|
if e.ID == "" {
|
||||||
|
return fmt.Errorf("edge %d: missing id", i)
|
||||||
|
}
|
||||||
|
if edgeIDs[e.ID] {
|
||||||
|
return fmt.Errorf("duplicate edge id %q", e.ID)
|
||||||
|
}
|
||||||
|
edgeIDs[e.ID] = true
|
||||||
|
if !ids[e.FromNode] {
|
||||||
|
return fmt.Errorf("edge %q: fromNode %q does not exist", e.ID, e.FromNode)
|
||||||
|
}
|
||||||
|
if !ids[e.ToNode] {
|
||||||
|
return fmt.Errorf("edge %q: toNode %q does not exist", e.ID, e.ToNode)
|
||||||
|
}
|
||||||
|
if !validCanvasSide(e.FromSide) {
|
||||||
|
return fmt.Errorf("edge %q: bad fromSide %q", e.ID, e.FromSide)
|
||||||
|
}
|
||||||
|
if !validCanvasSide(e.ToSide) {
|
||||||
|
return fmt.Errorf("edge %q: bad toSide %q", e.ID, e.ToSide)
|
||||||
|
}
|
||||||
|
if !validCanvasEnd(e.FromEnd) {
|
||||||
|
return fmt.Errorf("edge %q: bad fromEnd %q", e.ID, e.FromEnd)
|
||||||
|
}
|
||||||
|
if !validCanvasEnd(e.ToEnd) {
|
||||||
|
return fmt.Errorf("edge %q: bad toEnd %q", e.ID, e.ToEnd)
|
||||||
|
}
|
||||||
|
if !validCanvasColor(e.Color) {
|
||||||
|
return fmt.Errorf("edge %q: bad color %q", e.ID, e.Color)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func abs(n int) int {
|
||||||
|
if n < 0 {
|
||||||
|
return -n
|
||||||
|
}
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
// validCanvasColor accepts an absent color, a preset digit "1".."6" (red,
|
||||||
|
// orange, yellow, green, cyan, purple), or a hex string.
|
||||||
|
func validCanvasColor(c string) bool {
|
||||||
|
switch c {
|
||||||
|
case "", "1", "2", "3", "4", "5", "6":
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return canvasHexRe.MatchString(c)
|
||||||
|
}
|
||||||
|
|
||||||
|
func validCanvasSide(s string) bool {
|
||||||
|
switch s {
|
||||||
|
case "", "top", "right", "bottom", "left":
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func validCanvasEnd(e string) bool {
|
||||||
|
switch e {
|
||||||
|
case "", "none", "arrow":
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// canvasHash is the conflict token exchanged with the client. It covers the
|
||||||
|
// exact bytes on disk, so any edit made outside this editor invalidates it.
|
||||||
|
func canvasHash(raw []byte) string {
|
||||||
|
sum := sha256.Sum256(raw)
|
||||||
|
return hex.EncodeToString(sum[:])[:16]
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- paths and discovery ---
|
||||||
|
|
||||||
|
func canvasPath(fsPath, name string) string {
|
||||||
|
return filepath.Join(fsPath, name+canvasExt)
|
||||||
|
}
|
||||||
|
|
||||||
|
// canvasBackupPath is the previous-contents copy kept beside a canvas. The dot
|
||||||
|
// prefix keeps it out of the file listing and the search index.
|
||||||
|
func canvasBackupPath(fsPath, name string) string {
|
||||||
|
return filepath.Join(fsPath, "."+name+canvasExt+".bak")
|
||||||
|
}
|
||||||
|
|
||||||
|
// canvasURL builds the canonical view URL for a canvas in the folder at urlPath.
|
||||||
|
func canvasURL(urlPath, name string) string {
|
||||||
|
if !strings.HasSuffix(urlPath, "/") {
|
||||||
|
urlPath += "/"
|
||||||
|
}
|
||||||
|
return urlPath + "?canvas=" + url.QueryEscape(name)
|
||||||
|
}
|
||||||
|
|
||||||
|
// canvasViewURL maps a direct `/A/B/Name.canvas` request onto the canonical
|
||||||
|
// `/A/B/?canvas=Name` form. Segments are re-encoded because urlPath arrives
|
||||||
|
// already percent-decoded.
|
||||||
|
func canvasViewURL(urlPath string) (string, bool) {
|
||||||
|
base := path.Base(urlPath)
|
||||||
|
name := strings.TrimSuffix(base, canvasExt)
|
||||||
|
if name == "" || name == base || !canvasNameRe.MatchString(name) {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
return canvasURL(fileURL(strings.Trim(path.Dir(urlPath), "/")), name), true
|
||||||
|
}
|
||||||
|
|
||||||
|
// listCanvases returns the canvases in a folder as view-switcher tabs, sorted
|
||||||
|
// case-insensitively by name. active marks the one currently being viewed.
|
||||||
|
func listCanvases(fsPath, urlPath, active string) []canvasRef {
|
||||||
|
entries, err := os.ReadDir(fsPath)
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var refs []canvasRef
|
||||||
|
for _, e := range entries {
|
||||||
|
name := e.Name()
|
||||||
|
if e.IsDir() || strings.HasPrefix(name, ".") || !strings.HasSuffix(name, canvasExt) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
base := strings.TrimSuffix(name, canvasExt)
|
||||||
|
if !canvasNameRe.MatchString(base) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
refs = append(refs, canvasRef{
|
||||||
|
Name: base,
|
||||||
|
URL: canvasURL(urlPath, base),
|
||||||
|
Active: base == active,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
sort.Slice(refs, func(i, j int) bool {
|
||||||
|
return strings.ToLower(refs[i].Name) < strings.ToLower(refs[j].Name)
|
||||||
|
})
|
||||||
|
return refs
|
||||||
|
}
|
||||||
|
|
||||||
|
// isCanvasFile reports whether a listing entry is a canvas. Used to keep
|
||||||
|
// canvases out of the Files listing — they are surfaced as tabs above the
|
||||||
|
// content instead, the same reasoning that hides index.md.
|
||||||
|
func isCanvasFile(name string) bool {
|
||||||
|
return strings.HasSuffix(name, canvasExt)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- server-side node rendering ---
|
||||||
|
|
||||||
|
// prerenderNodes renders every node whose content the server owns, keyed by
|
||||||
|
// node id. Link and group nodes are built entirely in the browser and are
|
||||||
|
// absent from the map.
|
||||||
|
func (h *handler) prerenderNodes(c *canvasDoc) map[string]template.HTML {
|
||||||
|
out := make(map[string]template.HTML, len(c.Nodes))
|
||||||
|
for i := range c.Nodes {
|
||||||
|
n := &c.Nodes[i]
|
||||||
|
switch n.Type {
|
||||||
|
case canvasNodeText:
|
||||||
|
out[n.ID] = renderMarkdown([]byte(n.Text))
|
||||||
|
case canvasNodeFile:
|
||||||
|
out[n.ID] = h.renderFileNode(n.File, n.Subpath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// renderFileNode renders a file node's transclusion. Markdown is rendered
|
||||||
|
// through the shared goldmark instance so wikilinks, embeds, tables, and task
|
||||||
|
// checkboxes behave exactly as they do on a page; media gets a player or an
|
||||||
|
// <img> pointed at the existing thumbnail endpoint; anything else degrades to
|
||||||
|
// a link. A missing target renders visibly broken rather than blank.
|
||||||
|
func (h *handler) renderFileNode(file, subpath string) template.HTML {
|
||||||
|
target := "/" + strings.Trim(file, "/")
|
||||||
|
fsPath := filepath.Join(h.root, filepath.FromSlash(strings.TrimPrefix(target, "/")))
|
||||||
|
if rel, err := filepath.Rel(h.root, fsPath); err != nil || strings.HasPrefix(rel, "..") {
|
||||||
|
return canvasBrokenRef(file, "outside the wiki")
|
||||||
|
}
|
||||||
|
|
||||||
|
info, err := os.Stat(fsPath)
|
||||||
|
if err != nil {
|
||||||
|
return canvasBrokenRef(file, "not found")
|
||||||
|
}
|
||||||
|
if info.IsDir() {
|
||||||
|
// A folder is a page; transclude its markdown.
|
||||||
|
fsPath = filepath.Join(fsPath, "index.md")
|
||||||
|
if _, err := os.Stat(fsPath); err != nil {
|
||||||
|
return canvasBrokenRef(file, "page has no content")
|
||||||
|
}
|
||||||
|
return h.renderMarkdownFile(fsPath, file, subpath)
|
||||||
|
}
|
||||||
|
|
||||||
|
name := path.Base(target)
|
||||||
|
href := html.EscapeString(fileURL(strings.TrimPrefix(target, "/")))
|
||||||
|
switch {
|
||||||
|
case isImageFile(name):
|
||||||
|
return template.HTML(`<a class="canvas-media" href="` + href + `"><img src="` +
|
||||||
|
html.EscapeString(thumbURL(fileURL(strings.TrimPrefix(target, "/")), 800)) + `" alt="" loading="lazy"></a>`)
|
||||||
|
case isVideoFile(name):
|
||||||
|
return template.HTML(`<video class="canvas-media" controls preload="metadata" src="` + href + `"></video>`)
|
||||||
|
case strings.EqualFold(path.Ext(name), ".pdf"):
|
||||||
|
return template.HTML(`<embed class="canvas-media" type="application/pdf" src="` + href + `">`)
|
||||||
|
case strings.EqualFold(path.Ext(name), ".md"):
|
||||||
|
return h.renderMarkdownFile(fsPath, file, subpath)
|
||||||
|
default:
|
||||||
|
return template.HTML(`<p class="canvas-file-fallback"><a href="` + href + `">` +
|
||||||
|
html.EscapeString(name) + `</a></p>`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// renderMarkdownFile renders a markdown file, optionally narrowed to the
|
||||||
|
// section named by subpath (`#Heading`). The narrowing reuses the same section
|
||||||
|
// machinery the page editor uses, so a heading spans its subsections.
|
||||||
|
func (h *handler) renderMarkdownFile(fsPath, file, subpath string) template.HTML {
|
||||||
|
raw, err := os.ReadFile(fsPath)
|
||||||
|
if err != nil {
|
||||||
|
return canvasBrokenRef(file, "unreadable")
|
||||||
|
}
|
||||||
|
if subpath != "" {
|
||||||
|
section, ok := canvasSubpathSection(raw, subpath)
|
||||||
|
if !ok {
|
||||||
|
return canvasBrokenRef(file+subpath, "no such heading")
|
||||||
|
}
|
||||||
|
raw = section
|
||||||
|
}
|
||||||
|
return renderMarkdown(raw)
|
||||||
|
}
|
||||||
|
|
||||||
|
// canvasSubpathSection narrows raw markdown to the section whose heading
|
||||||
|
// matches subpath. The match accepts either the heading text (what a human
|
||||||
|
// types) or goldmark's generated anchor id (what a copied link contains).
|
||||||
|
func canvasSubpathSection(raw []byte, subpath string) ([]byte, bool) {
|
||||||
|
want := strings.TrimSpace(strings.TrimPrefix(subpath, "#"))
|
||||||
|
if want == "" {
|
||||||
|
return raw, true
|
||||||
|
}
|
||||||
|
sections := splitSections(raw)
|
||||||
|
ids := headingIDs(raw)
|
||||||
|
for i := 1; i < len(sections); i++ {
|
||||||
|
_, text := sectionHeading(sections[i])
|
||||||
|
id := ""
|
||||||
|
if i-1 < len(ids) {
|
||||||
|
id = ids[i-1]
|
||||||
|
}
|
||||||
|
if strings.EqualFold(text, want) || id == want {
|
||||||
|
return joinSections(sections[i:secionSpanEnd(sections, i)]), true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
|
||||||
|
func canvasBrokenRef(target, reason string) template.HTML {
|
||||||
|
return template.HTML(`<p class="canvas-broken">` + html.EscapeString(target) +
|
||||||
|
` <span class="muted">(` + html.EscapeString(reason) + `)</span></p>`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- request handlers ---
|
||||||
|
|
||||||
|
// serveCanvas renders the canvas view of a page. Reached from serveDir when
|
||||||
|
// the request carries ?canvas=<name>.
|
||||||
|
func (h *handler) serveCanvas(w http.ResponseWriter, r *http.Request, urlPath, fsPath, name string) {
|
||||||
|
if !canvasNameRe.MatchString(name) {
|
||||||
|
http.Error(w, "bad canvas name", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
raw, err := os.ReadFile(canvasPath(fsPath, name))
|
||||||
|
if err != nil {
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
http.Error(w, "read failed: "+err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
data := pageData{
|
||||||
|
Title: name + " — " + pageTitle(urlPath),
|
||||||
|
CanEdit: true,
|
||||||
|
IsRoot: urlPath == "/",
|
||||||
|
SectionIndex: -1,
|
||||||
|
InsertBefore: -1,
|
||||||
|
PostURL: urlPath,
|
||||||
|
ActiveCanvas: name,
|
||||||
|
Canvases: listCanvases(fsPath, urlPath, name),
|
||||||
|
}
|
||||||
|
|
||||||
|
// A parse failure is reported in-page rather than as a bare error: the file
|
||||||
|
// is hand-editable, so the user needs to see which node is wrong and still
|
||||||
|
// reach the raw bytes.
|
||||||
|
doc, parseErr := parseCanvas(raw)
|
||||||
|
if parseErr != nil {
|
||||||
|
data.CanvasError = parseErr.Error()
|
||||||
|
} else {
|
||||||
|
payload := canvasPayload{
|
||||||
|
Name: name,
|
||||||
|
PostURL: urlPath,
|
||||||
|
Hash: canvasHash(raw),
|
||||||
|
Doc: doc,
|
||||||
|
Rendered: h.prerenderNodes(doc),
|
||||||
|
}
|
||||||
|
// encoding/json escapes <, >, & and the U+2028/U+2029 line separators,
|
||||||
|
// so the result is safe to inline verbatim in a <script> block.
|
||||||
|
blob, err := json.Marshal(payload)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "encode failed: "+err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
data.CanvasJSON = template.JS(blob)
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
|
data.RenderMS = elapsedMS(r)
|
||||||
|
if err := canvasTmpl.ExecuteTemplate(w, "layout", data); err != nil {
|
||||||
|
log.Printf("template error: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleCanvasPost dispatches every POST carrying ?canvas=. It is checked
|
||||||
|
// before the page-level delete/move actions in handlePost so `?canvas=X&delete`
|
||||||
|
// removes the canvas rather than the whole page.
|
||||||
|
func (h *handler) handleCanvasPost(w http.ResponseWriter, r *http.Request, urlPath, fsPath, name string) {
|
||||||
|
if !canvasNameRe.MatchString(name) {
|
||||||
|
http.Error(w, "bad canvas name", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
query := r.URL.Query()
|
||||||
|
switch {
|
||||||
|
case query.Has("create"):
|
||||||
|
h.handleCanvasCreate(w, r, urlPath, fsPath, name)
|
||||||
|
case query.Has("delete"):
|
||||||
|
h.handleCanvasDelete(w, r, urlPath, fsPath, name)
|
||||||
|
case query.Has("rename"):
|
||||||
|
h.handleCanvasRename(w, r, urlPath, fsPath, name, query.Get("rename"))
|
||||||
|
default:
|
||||||
|
h.handleCanvasSave(w, r, urlPath, fsPath, name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleCanvasSave replaces a canvas with the posted document. The write is
|
||||||
|
// gated on four checks in order — parse, validate, version, non-empty — so a
|
||||||
|
// malformed or stale payload never reaches the file.
|
||||||
|
func (h *handler) handleCanvasSave(w http.ResponseWriter, r *http.Request, urlPath, fsPath, name string) {
|
||||||
|
body, err := io.ReadAll(http.MaxBytesReader(w, r.Body, canvasMaxBytes))
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "request too large or truncated", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
doc, err := parseCanvas(body)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "rejected: "+err.Error(), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
p := canvasPath(fsPath, name)
|
||||||
|
current, readErr := os.ReadFile(p)
|
||||||
|
if readErr != nil && !os.IsNotExist(readErr) {
|
||||||
|
http.Error(w, "read failed: "+readErr.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if want := r.Header.Get("X-Canvas-Version"); want != "" && want != canvasHash(current) {
|
||||||
|
http.Error(w, "the canvas changed since you opened it — reload to get the current version",
|
||||||
|
http.StatusConflict)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// A save must never blank a canvas. An empty document is indistinguishable
|
||||||
|
// from a client that lost its state, so emptying is only allowed when the
|
||||||
|
// caller says so explicitly — the same reasoning as the empty-content guard
|
||||||
|
// on page saves.
|
||||||
|
if len(doc.Nodes) == 0 && !r.URL.Query().Has("empty") {
|
||||||
|
if cur, err := parseCanvas(current); err == nil && len(cur.Nodes) > 0 {
|
||||||
|
http.Error(w, fmt.Sprintf("refusing to replace %d nodes with an empty canvas", len(cur.Nodes)),
|
||||||
|
http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
out, err := json.MarshalIndent(doc, "", "\t")
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "encode failed: "+err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
out = append(out, '\n')
|
||||||
|
|
||||||
|
if len(current) > 0 {
|
||||||
|
if err := writeFileAtomic(canvasBackupPath(fsPath, name), current, 0644); err != nil {
|
||||||
|
log.Printf("canvas backup %s: %v", p, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := writeFileAtomic(p, out, 0644); err != nil {
|
||||||
|
http.Error(w, "write failed: "+err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hand back the new token so the client can keep editing without reloading.
|
||||||
|
w.Header().Set("X-Canvas-Version", canvasHash(out))
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *handler) handleCanvasCreate(w http.ResponseWriter, r *http.Request, urlPath, fsPath, name string) {
|
||||||
|
if err := os.MkdirAll(fsPath, 0755); err != nil {
|
||||||
|
http.Error(w, "mkdir failed: "+err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
p := canvasPath(fsPath, name)
|
||||||
|
if _, err := os.Stat(p); err == nil {
|
||||||
|
http.Error(w, "a canvas with that name already exists", http.StatusConflict)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := writeFileAtomic(p, []byte("{}\n"), 0644); err != nil {
|
||||||
|
http.Error(w, "write failed: "+err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
http.Redirect(w, r, canvasURL(urlPath, name), http.StatusSeeOther)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *handler) handleCanvasDelete(w http.ResponseWriter, r *http.Request, urlPath, fsPath, name string) {
|
||||||
|
if err := os.Remove(canvasPath(fsPath, name)); err != nil {
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
http.Error(w, "delete failed: "+err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_ = os.Remove(canvasBackupPath(fsPath, name))
|
||||||
|
http.Redirect(w, r, urlPath, http.StatusSeeOther)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *handler) handleCanvasRename(w http.ResponseWriter, r *http.Request, urlPath, fsPath, name, newName string) {
|
||||||
|
if !canvasNameRe.MatchString(newName) {
|
||||||
|
http.Error(w, "bad canvas name", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if newName == name {
|
||||||
|
http.Redirect(w, r, canvasURL(urlPath, name), http.StatusSeeOther)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
dst := canvasPath(fsPath, newName)
|
||||||
|
if _, err := os.Stat(dst); err == nil {
|
||||||
|
http.Error(w, "a canvas with that name already exists", http.StatusConflict)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := os.Rename(canvasPath(fsPath, name), dst); err != nil {
|
||||||
|
http.Error(w, "rename failed: "+err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_ = os.Remove(canvasBackupPath(fsPath, name))
|
||||||
|
http.Redirect(w, r, canvasURL(urlPath, newName), http.StatusSeeOther)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleCanvasRender renders one node's content on demand. It is stateless and
|
||||||
|
// takes the content in the request rather than reading it from the canvas file:
|
||||||
|
// the node being re-rendered has just been edited in the browser and is not on
|
||||||
|
// disk yet. Everything already on disk arrives pre-rendered with the page.
|
||||||
|
func (h *handler) handleCanvasRender(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
|
||||||
|
}
|
||||||
|
r.Body = http.MaxBytesReader(w, r.Body, canvasRenderMaxBytes)
|
||||||
|
if err := r.ParseForm(); err != nil {
|
||||||
|
http.Error(w, "bad request", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var out template.HTML
|
||||||
|
switch r.FormValue("type") {
|
||||||
|
case canvasNodeFile:
|
||||||
|
out = h.renderFileNode(r.FormValue("file"), r.FormValue("subpath"))
|
||||||
|
case canvasNodeText:
|
||||||
|
out = renderMarkdown([]byte(r.FormValue("text")))
|
||||||
|
default:
|
||||||
|
http.Error(w, "bad node type", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
|
_, _ = io.WriteString(w, string(out))
|
||||||
|
}
|
||||||
@@ -57,7 +57,9 @@ func runOpenCommand(template, path string) error {
|
|||||||
if !sawPath {
|
if !sawPath {
|
||||||
tokens = append(tokens, path)
|
tokens = append(tokens, path)
|
||||||
}
|
}
|
||||||
return exec.Command(tokens[0], tokens[1:]...).Start()
|
cmd := exec.Command(tokens[0], tokens[1:]...)
|
||||||
|
hideConsole(cmd)
|
||||||
|
return cmd.Start()
|
||||||
}
|
}
|
||||||
|
|
||||||
// tokenizeCommand splits a command-line string into argv tokens, honouring
|
// tokenizeCommand splits a command-line string into argv tokens, honouring
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
//go:build !windows
|
||||||
|
|
||||||
|
package main
|
||||||
|
|
||||||
|
import "os/exec"
|
||||||
|
|
||||||
|
// Allows running commands without showing a terminal window on startup when not running on windows
|
||||||
|
func hideConsole(*exec.Cmd) {}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os/exec"
|
||||||
|
"syscall"
|
||||||
|
)
|
||||||
|
|
||||||
|
// This is the windows CREATE_NO_WINDOW syscall.
|
||||||
|
// We have no golang.org/x/sys dependency, so this will suffice
|
||||||
|
const createNoWindow = 0x08000000
|
||||||
|
|
||||||
|
// Allows running commands without showing a terminal window on startup on windows
|
||||||
|
func hideConsole(cmd *exec.Cmd) {
|
||||||
|
cmd.SysProcAttr = &syscall.SysProcAttr{
|
||||||
|
HideWindow: true,
|
||||||
|
CreationFlags: createNoWindow,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,7 +6,7 @@ import (
|
|||||||
"path/filepath"
|
"path/filepath"
|
||||||
)
|
)
|
||||||
|
|
||||||
const version = "1"
|
const version = "2"
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
flag.Parse()
|
flag.Parse()
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"path"
|
"path"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"regexp"
|
|
||||||
"sort"
|
"sort"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -28,7 +27,8 @@ type diaryHandler struct{}
|
|||||||
// page anchor (or to the year-file editor when ?edit is set).
|
// page anchor (or to the year-file editor when ?edit is set).
|
||||||
//
|
//
|
||||||
// 1. `today` / `this-month` / `this-year` shortcuts resolve directly to a
|
// 1. `today` / `this-month` / `this-year` shortcuts resolve directly to a
|
||||||
// year+anchor target (or insert flow when today's section is missing).
|
// year+anchor target (never the editor, even when today's section does
|
||||||
|
// not exist yet).
|
||||||
// 2. A virtual month URL (/diary/<root>/YYYY/MM/) redirects to
|
// 2. A virtual month URL (/diary/<root>/YYYY/MM/) redirects to
|
||||||
// /diary/<root>/YYYY/#YYYY-MM (or to ?edit§ion=N when ?edit is set).
|
// /diary/<root>/YYYY/#YYYY-MM (or to ?edit§ion=N when ?edit is set).
|
||||||
// 3. A virtual day URL (/diary/<root>/YYYY/MM/DD/) redirects to
|
// 3. A virtual day URL (/diary/<root>/YYYY/MM/DD/) redirects to
|
||||||
@@ -57,7 +57,7 @@ func (d *diaryHandler) dateShortcutRedirect(root, fsPath, urlPath string) (strin
|
|||||||
|
|
||||||
parentFS := filepath.Dir(fsPath)
|
parentFS := filepath.Dir(fsPath)
|
||||||
parentURLPath := parentURL(urlPath)
|
parentURLPath := parentURL(urlPath)
|
||||||
_, diaryRootFS, diaryRootURL, ok := findDiaryContext(root, parentFS, parentURLPath)
|
_, _, diaryRootURL, ok := findDiaryContext(root, parentFS, parentURLPath)
|
||||||
if !ok {
|
if !ok {
|
||||||
return "", false
|
return "", false
|
||||||
}
|
}
|
||||||
@@ -71,16 +71,7 @@ func (d *diaryHandler) dateShortcutRedirect(root, fsPath, urlPath string) (strin
|
|||||||
switch base {
|
switch base {
|
||||||
case "today":
|
case "today":
|
||||||
dayHeading := fmt.Sprintf("%s-%s-%s", year, month, day)
|
dayHeading := fmt.Sprintf("%s-%s-%s", year, month, day)
|
||||||
raw, _ := os.ReadFile(filepath.Join(diaryRootFS, year, "index.md"))
|
|
||||||
sections := splitSections(raw)
|
|
||||||
if _, found := findSectionIndex(sections, dayHeading); found {
|
|
||||||
return yearURL + "#" + dayHeading, true
|
return yearURL + "#" + dayHeading, true
|
||||||
}
|
|
||||||
// Missing day: route through the insert flow so today's section
|
|
||||||
// is spliced in at the right chronological position.
|
|
||||||
insertIdx := computeInsertIndex(sections, dayHeading)
|
|
||||||
return fmt.Sprintf("%s?edit&insert_before=%d&heading=%s",
|
|
||||||
yearURL, insertIdx, url.QueryEscape(dayHeading)), true
|
|
||||||
case "this-month":
|
case "this-month":
|
||||||
return yearURL + "#" + fmt.Sprintf("%s-%s", year, month), true
|
return yearURL + "#" + fmt.Sprintf("%s-%s", year, month), true
|
||||||
case "this-year":
|
case "this-year":
|
||||||
@@ -159,7 +150,7 @@ func parseDiaryURLParts(fsPath string, depth int) (year, month, day string, ok b
|
|||||||
return "", "", "", false
|
return "", "", "", false
|
||||||
}
|
}
|
||||||
|
|
||||||
func (d *diaryHandler) handle(root, fsPath, urlPath string) *specialPage {
|
func (d *diaryHandler) handle(root, fsPath, urlPath string, _ *http.Request) *specialPage {
|
||||||
depth, diaryRootFS, diaryRootURL, ok := findDiaryContext(root, fsPath, urlPath)
|
depth, diaryRootFS, diaryRootURL, ok := findDiaryContext(root, fsPath, urlPath)
|
||||||
if !ok {
|
if !ok {
|
||||||
return nil
|
return nil
|
||||||
@@ -216,22 +207,6 @@ func findDiaryContext(root, fsPath, urlPath string) (depth int, diaryRootFS, dia
|
|||||||
return 0, "", "", false
|
return 0, "", "", false
|
||||||
}
|
}
|
||||||
|
|
||||||
// headingTextRe matches an ATX heading at the start of a section. The
|
|
||||||
// heading text is everything after the `#`s and the required space, on the
|
|
||||||
// first line.
|
|
||||||
var headingTextRe = regexp.MustCompile(`^(#{1,6})\s+([^\n]*)`)
|
|
||||||
|
|
||||||
// sectionHeading returns the heading level (1..6) and trimmed text of a
|
|
||||||
// section produced by splitSections. Returns level=0 for the pre-heading
|
|
||||||
// section (index 0).
|
|
||||||
func sectionHeading(section []byte) (level int, text string) {
|
|
||||||
m := headingTextRe.FindSubmatch(section)
|
|
||||||
if m == nil {
|
|
||||||
return 0, ""
|
|
||||||
}
|
|
||||||
return len(m[1]), strings.TrimSpace(string(m[2]))
|
|
||||||
}
|
|
||||||
|
|
||||||
// findSectionIndex returns the absolute section index whose heading text
|
// findSectionIndex returns the absolute section index whose heading text
|
||||||
// matches target (e.g. "2026-05" or "2026-05-28"). Returns the first match.
|
// matches target (e.g. "2026-05" or "2026-05-28"). Returns the first match.
|
||||||
func findSectionIndex(sections [][]byte, target string) (int, bool) {
|
func findSectionIndex(sections [][]byte, target string) (int, bool) {
|
||||||
@@ -343,8 +318,8 @@ type calYear struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// calMonthGrid carries everything the template needs to render one month's
|
// calMonthGrid carries everything the template needs to render one month's
|
||||||
// grid plus the dropdown / heading entry that targets it. The calendar
|
// grid plus the caption that links to it. The calendar widget renders all 12
|
||||||
// widget ships all 12 in the initial HTML; JS swaps which one is visible.
|
// stacked vertically; JS centers the current month in the rail on load.
|
||||||
type calMonthGrid struct {
|
type calMonthGrid struct {
|
||||||
Num int
|
Num int
|
||||||
Name string
|
Name string
|
||||||
@@ -355,9 +330,7 @@ type calMonthGrid struct {
|
|||||||
type calendarData struct {
|
type calendarData struct {
|
||||||
DisplayYear int
|
DisplayYear int
|
||||||
DisplayMonth int
|
DisplayMonth int
|
||||||
DisplayMonthName string // pre-resolved so the template doesn't need arithmetic
|
|
||||||
DiaryURL string
|
DiaryURL string
|
||||||
YearURL string
|
|
||||||
Months []calMonthGrid
|
Months []calMonthGrid
|
||||||
Years []calYear
|
Years []calYear
|
||||||
}
|
}
|
||||||
@@ -491,9 +464,7 @@ func computeCalendarWidget(diaryRootFS, diaryRootURL, fsPath string, depth int)
|
|||||||
data := calendarData{
|
data := calendarData{
|
||||||
DisplayYear: displayYear,
|
DisplayYear: displayYear,
|
||||||
DisplayMonth: displayMonth,
|
DisplayMonth: displayMonth,
|
||||||
DisplayMonthName: months[displayMonth-1].Name,
|
|
||||||
DiaryURL: diaryRootURL,
|
DiaryURL: diaryRootURL,
|
||||||
YearURL: yearURL,
|
|
||||||
Months: months,
|
Months: months,
|
||||||
Years: years,
|
Years: years,
|
||||||
}
|
}
|
||||||
@@ -544,7 +515,6 @@ func buildMonthGrid(year, month int, today time.Time, currentDay int, hasDayEntr
|
|||||||
return weeks
|
return weeks
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// diaryPhoto is a photo file whose name starts with a YYYY-MM-DD date prefix.
|
// diaryPhoto is a photo file whose name starts with a YYYY-MM-DD date prefix.
|
||||||
type diaryPhoto struct {
|
type diaryPhoto struct {
|
||||||
Date time.Time
|
Date time.Time
|
||||||
|
|||||||
@@ -0,0 +1,186 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"path"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/yuin/goldmark"
|
||||||
|
"github.com/yuin/goldmark/ast"
|
||||||
|
"github.com/yuin/goldmark/parser"
|
||||||
|
"github.com/yuin/goldmark/renderer"
|
||||||
|
"github.com/yuin/goldmark/text"
|
||||||
|
"github.com/yuin/goldmark/util"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Embeds are ordinary Markdown images: .
|
||||||
|
//
|
||||||
|
// Destinations are relative to the page they sit on. Pages always render at a
|
||||||
|
// URL ending in "/" (ServeHTTP redirects the slashless form), so the browser
|
||||||
|
// resolves the destination and nothing here builds a URL or touches the
|
||||||
|
// filesystem. Goldmark's own image parser produces the nodes; only rendering
|
||||||
|
// is overridden. The title slot carries the float alignment.
|
||||||
|
const (
|
||||||
|
alignLeft = "left"
|
||||||
|
alignRight = "right"
|
||||||
|
alignCenter = "center"
|
||||||
|
|
||||||
|
// embedThumbW is the width embeds request through the ?w= form of the
|
||||||
|
// file's own URL, which keeps the <img src> relative.
|
||||||
|
embedThumbW = 300
|
||||||
|
)
|
||||||
|
|
||||||
|
// embedAlign maps an image title to its float class. A title that is not an
|
||||||
|
// alignment keyword is ignored and the embed floats right.
|
||||||
|
func embedAlign(title []byte) string {
|
||||||
|
switch strings.TrimSpace(string(title)) {
|
||||||
|
case alignLeft:
|
||||||
|
return alignLeft
|
||||||
|
case alignCenter:
|
||||||
|
return alignCenter
|
||||||
|
}
|
||||||
|
return alignRight
|
||||||
|
}
|
||||||
|
|
||||||
|
// embedIsImage reports whether a destination names an image, i.e. whether it
|
||||||
|
// renders as a <figure> rather than degrading to a link. Extension-only — no
|
||||||
|
// stat, so a destination gone stale simply 404s when clicked.
|
||||||
|
func embedIsImage(dest []byte) bool {
|
||||||
|
return isImageFile(path.Base(string(dest)))
|
||||||
|
}
|
||||||
|
|
||||||
|
type embedRenderer struct{}
|
||||||
|
|
||||||
|
func (r *embedRenderer) RegisterFuncs(reg renderer.NodeRendererFuncRegisterer) {
|
||||||
|
reg.Register(ast.KindImage, r.render)
|
||||||
|
}
|
||||||
|
|
||||||
|
// render writes a <figure> holding a thumbnail that links to the full file,
|
||||||
|
// with the image's inline children as <figcaption>. Children are rendered by
|
||||||
|
// goldmark — WalkContinue, not WalkSkipChildren — so a caption may carry
|
||||||
|
// emphasis or a link. A non-image destination renders as a plain anchor
|
||||||
|
// labelled with the caption, or with the file name when there is none.
|
||||||
|
func (r *embedRenderer) render(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
|
||||||
|
n := node.(*ast.Image)
|
||||||
|
name := path.Base(string(n.Destination))
|
||||||
|
dest := util.EscapeHTML(util.URLEscape(n.Destination, true))
|
||||||
|
captioned := n.FirstChild() != nil
|
||||||
|
|
||||||
|
if !embedIsImage(n.Destination) {
|
||||||
|
if entering {
|
||||||
|
w.WriteString(`<a href="`)
|
||||||
|
w.Write(dest)
|
||||||
|
w.WriteString(`">`)
|
||||||
|
if !captioned {
|
||||||
|
w.Write(util.EscapeHTML([]byte(name)))
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
w.WriteString(`</a>`)
|
||||||
|
}
|
||||||
|
return ast.WalkContinue, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if entering {
|
||||||
|
w.WriteString(`<figure class="embed embed-`)
|
||||||
|
w.WriteString(embedAlign(n.Title))
|
||||||
|
w.WriteString(`"><a class="embed-link" href="`)
|
||||||
|
w.Write(dest)
|
||||||
|
w.WriteString(`"><img src="`)
|
||||||
|
w.Write(dest)
|
||||||
|
w.WriteString(`?w=`)
|
||||||
|
w.WriteString(strconv.Itoa(embedThumbW))
|
||||||
|
w.WriteString(`" alt="`)
|
||||||
|
w.Write(util.EscapeHTML([]byte(name)))
|
||||||
|
w.WriteString(`"></a>`)
|
||||||
|
if captioned {
|
||||||
|
w.WriteString(`<figcaption>`)
|
||||||
|
}
|
||||||
|
return ast.WalkContinue, nil
|
||||||
|
}
|
||||||
|
if captioned {
|
||||||
|
w.WriteString(`</figcaption>`)
|
||||||
|
}
|
||||||
|
w.WriteString(`</figure>`)
|
||||||
|
return ast.WalkContinue, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// embedBlockTransformer lifts an embed that sits alone in a paragraph up to
|
||||||
|
// block level. Goldmark wraps inline content in <p>, but the embed renders a
|
||||||
|
// <figure> (block), and a <figure> inside a <p> is auto-closed by the browser —
|
||||||
|
// which strands empty <p> elements (they still carry .content paragraph margins)
|
||||||
|
// and breaks the float layout once several embeds share a page. Dissolving the
|
||||||
|
// wrapping paragraph makes each embed render as a clean block sibling with no
|
||||||
|
// stray <p>.
|
||||||
|
type embedBlockTransformer struct{}
|
||||||
|
|
||||||
|
func (t embedBlockTransformer) Transform(doc *ast.Document, reader text.Reader, pc parser.Context) {
|
||||||
|
source := reader.Source()
|
||||||
|
var paras []*ast.Paragraph
|
||||||
|
ast.Walk(doc, func(n ast.Node, entering bool) (ast.WalkStatus, error) {
|
||||||
|
if entering {
|
||||||
|
if p, ok := n.(*ast.Paragraph); ok && t.paragraphFiguresOnly(p, source) {
|
||||||
|
paras = append(paras, p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ast.WalkContinue, nil
|
||||||
|
})
|
||||||
|
for _, p := range paras {
|
||||||
|
parent := p.Parent()
|
||||||
|
if parent == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// InsertBefore isolates the embed from p first, so hoist each embed to a
|
||||||
|
// block sibling ahead of the (soon removed) paragraph, then drop p.
|
||||||
|
for c := p.FirstChild(); c != nil; {
|
||||||
|
next := c.NextSibling()
|
||||||
|
if _, ok := c.(*ast.Image); ok {
|
||||||
|
parent.InsertBefore(parent, p, c)
|
||||||
|
}
|
||||||
|
c = next
|
||||||
|
}
|
||||||
|
parent.RemoveChild(parent, p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// paragraphFiguresOnly reports whether p holds at least one figure-rendering
|
||||||
|
// image and nothing else visible — only such images and whitespace/line-break
|
||||||
|
// text. Only these are safe to dissolve: a paragraph carrying prose, or an
|
||||||
|
// image that degrades to an inline link (non-image destination), stays wrapped
|
||||||
|
// so the fallback anchor keeps its paragraph.
|
||||||
|
func (t embedBlockTransformer) paragraphFiguresOnly(p *ast.Paragraph, source []byte) bool {
|
||||||
|
hasFigure := false
|
||||||
|
for c := p.FirstChild(); c != nil; c = c.NextSibling() {
|
||||||
|
switch n := c.(type) {
|
||||||
|
case *ast.Image:
|
||||||
|
if !embedIsImage(n.Destination) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
hasFigure = true
|
||||||
|
case *ast.Text:
|
||||||
|
if len(bytes.TrimSpace(n.Segment.Value(source))) != 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return hasFigure
|
||||||
|
}
|
||||||
|
|
||||||
|
type embedExt struct{}
|
||||||
|
|
||||||
|
// newEmbedExt returns a goldmark extension that renders Markdown images as
|
||||||
|
// wiki embeds. It adds no parser of its own: goldmark parses the images, and
|
||||||
|
// the renderer registered at 500 wins over the default HTML renderer
|
||||||
|
// (registered at 1000 — lowest priority number is applied last and wins).
|
||||||
|
func newEmbedExt() goldmark.Extender { return &embedExt{} }
|
||||||
|
|
||||||
|
func (e *embedExt) Extend(m goldmark.Markdown) {
|
||||||
|
m.Parser().AddOptions(parser.WithASTTransformers(
|
||||||
|
util.Prioritized(embedBlockTransformer{}, 100),
|
||||||
|
))
|
||||||
|
m.Renderer().AddOptions(renderer.WithNodeRenderers(
|
||||||
|
util.Prioritized(&embedRenderer{}, 500),
|
||||||
|
))
|
||||||
|
}
|
||||||
-57
@@ -1,57 +0,0 @@
|
|||||||
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),
|
|
||||||
))
|
|
||||||
}
|
|
||||||
+504
@@ -0,0 +1,504 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"html/template"
|
||||||
|
"log"
|
||||||
|
"math"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
pageTypeHandlers = append(pageTypeHandlers, &fitnessHandler{})
|
||||||
|
}
|
||||||
|
|
||||||
|
// waistlineExportFile is the exact filename the user copies into the folder.
|
||||||
|
// The single file is always the latest export — no glob, no multi-file merge.
|
||||||
|
const waistlineExportFile = "waistline_export.json"
|
||||||
|
|
||||||
|
type fitnessHandler struct{}
|
||||||
|
|
||||||
|
// redirect: the fitness dashboard has no virtual URLs; everything renders
|
||||||
|
// inside the normal GET /{path}/ flow.
|
||||||
|
func (f *fitnessHandler) redirect(root, fsPath, urlPath string, r *http.Request) (string, bool) {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
// handle renders the dashboard for folders whose .page-settings declares
|
||||||
|
// type = fitness. Markdown content and the folder listing stay visible so
|
||||||
|
// the user can verify an uploaded export arrived.
|
||||||
|
func (f *fitnessHandler) handle(root, fsPath, urlPath string, r *http.Request) *specialPage {
|
||||||
|
s := readPageSettings(fsPath)
|
||||||
|
if s == nil || s.Type != "fitness" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
weightSel := validFitnessRange(r.URL.Query().Get("weight"), "3m")
|
||||||
|
weeklySel := validFitnessRange(r.URL.Query().Get("weekly"), "1y")
|
||||||
|
return &specialPage{
|
||||||
|
Content: renderFitnessDashboard(fsPath, weightSel, weeklySel),
|
||||||
|
SuppressTOC: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// === Time ranges ===
|
||||||
|
|
||||||
|
type fitnessRange struct {
|
||||||
|
Value string
|
||||||
|
Label string
|
||||||
|
Months int // 0 = all data
|
||||||
|
}
|
||||||
|
|
||||||
|
var fitnessRanges = []fitnessRange{
|
||||||
|
{"1m", "1 month", 1},
|
||||||
|
{"3m", "3 months", 3},
|
||||||
|
{"1y", "1 year", 12},
|
||||||
|
{"all", "All", 0},
|
||||||
|
}
|
||||||
|
|
||||||
|
func validFitnessRange(v, fallback string) string {
|
||||||
|
for _, r := range fitnessRanges {
|
||||||
|
if r.Value == v {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
func rangeMonths(v string) int {
|
||||||
|
for _, r := range fitnessRanges {
|
||||||
|
if r.Value == v {
|
||||||
|
return r.Months
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// === Waistline export parsing ===
|
||||||
|
|
||||||
|
// wlNum is a number in a Waistline export: values appear as JSON numbers,
|
||||||
|
// numeric strings, null, or are absent. Unparsable values read as not-ok
|
||||||
|
// instead of failing the whole export parse.
|
||||||
|
type wlNum struct {
|
||||||
|
val float64
|
||||||
|
ok bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func (n *wlNum) UnmarshalJSON(b []byte) error {
|
||||||
|
s := strings.Trim(string(b), `"`)
|
||||||
|
if s == "" || s == "null" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
v, err := strconv.ParseFloat(s, 64)
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
n.val = v
|
||||||
|
n.ok = true
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calorie tracking was removed pending a rethink of the (undocumented)
|
||||||
|
// per-item formula; only the weight series is read from the export.
|
||||||
|
type wlExport struct {
|
||||||
|
Diary []wlDiaryEntry `json:"diary"`
|
||||||
|
Settings json.RawMessage `json:"settings"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type wlDiaryEntry struct {
|
||||||
|
DateTime string `json:"dateTime"`
|
||||||
|
Stats struct {
|
||||||
|
Weight wlNum `json:"weight"`
|
||||||
|
} `json:"stats"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// exportDate extracts the calendar day from a Waistline dateTime. The values
|
||||||
|
// are UTC-midnight timestamps; taking the first 10 characters avoids time
|
||||||
|
// zone conversions shifting the day.
|
||||||
|
func exportDate(s string) (time.Time, bool) {
|
||||||
|
if len(s) < 10 {
|
||||||
|
return time.Time{}, false
|
||||||
|
}
|
||||||
|
t, err := time.Parse("2006-01-02", s[:10])
|
||||||
|
if err != nil {
|
||||||
|
return time.Time{}, false
|
||||||
|
}
|
||||||
|
return t, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// goalValue extracts settings.goals.<key>.goal-list[0].goal[0] — the first
|
||||||
|
// weekday slot of the shared goal. Best-effort: any missing or unparsable
|
||||||
|
// level means "no goal line", never an error.
|
||||||
|
func goalValue(settings json.RawMessage, key string) (float64, bool) {
|
||||||
|
var s struct {
|
||||||
|
Goals map[string]json.RawMessage `json:"goals"`
|
||||||
|
}
|
||||||
|
if json.Unmarshal(settings, &s) != nil {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
var g struct {
|
||||||
|
GoalList []struct {
|
||||||
|
Goal []wlNum `json:"goal"`
|
||||||
|
} `json:"goal-list"`
|
||||||
|
}
|
||||||
|
if json.Unmarshal(s.Goals[key], &g) != nil {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
if len(g.GoalList) == 0 || len(g.GoalList[0].Goal) == 0 {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
n := g.GoalList[0].Goal[0]
|
||||||
|
return n.val, n.ok
|
||||||
|
}
|
||||||
|
|
||||||
|
func exportWeightUnit(settings json.RawMessage) string {
|
||||||
|
var s struct {
|
||||||
|
Units struct {
|
||||||
|
Weight string `json:"weight"`
|
||||||
|
} `json:"units"`
|
||||||
|
}
|
||||||
|
if json.Unmarshal(settings, &s) == nil && s.Units.Weight != "" {
|
||||||
|
return s.Units.Weight
|
||||||
|
}
|
||||||
|
return "kg"
|
||||||
|
}
|
||||||
|
|
||||||
|
// === Series extraction ===
|
||||||
|
|
||||||
|
type weightPoint struct {
|
||||||
|
date time.Time
|
||||||
|
value float64
|
||||||
|
days int // >0: weekly mean over this many measured days
|
||||||
|
}
|
||||||
|
|
||||||
|
// extractWeights computes the weight series from the export: one point per
|
||||||
|
// diary entry with stats.weight. Duplicate dates (shouldn't happen) — last
|
||||||
|
// one wins.
|
||||||
|
func extractWeights(ex *wlExport) []weightPoint {
|
||||||
|
byDate := map[time.Time]float64{}
|
||||||
|
for _, e := range ex.Diary {
|
||||||
|
day, ok := exportDate(e.DateTime)
|
||||||
|
if !ok || !e.Stats.Weight.ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
byDate[day] = e.Stats.Weight.val
|
||||||
|
}
|
||||||
|
weights := make([]weightPoint, 0, len(byDate))
|
||||||
|
for d, v := range byDate {
|
||||||
|
weights = append(weights, weightPoint{date: d, value: v})
|
||||||
|
}
|
||||||
|
sort.Slice(weights, func(i, j int) bool { return weights[i].date.Before(weights[j].date) })
|
||||||
|
return weights
|
||||||
|
}
|
||||||
|
|
||||||
|
func mondayOf(t time.Time) time.Time {
|
||||||
|
return t.AddDate(0, 0, -((int(t.Weekday()) + 6) % 7))
|
||||||
|
}
|
||||||
|
|
||||||
|
// weeklyMeanWeights buckets the daily weight series into ISO weeks (Monday
|
||||||
|
// start) and averages over the days that have a measurement. Weeks without
|
||||||
|
// any measurement produce no point.
|
||||||
|
func weeklyMeanWeights(points []weightPoint) []weightPoint {
|
||||||
|
type acc struct {
|
||||||
|
sum float64
|
||||||
|
n int
|
||||||
|
}
|
||||||
|
byWeek := map[time.Time]*acc{}
|
||||||
|
for _, p := range points {
|
||||||
|
w := mondayOf(p.date)
|
||||||
|
a := byWeek[w]
|
||||||
|
if a == nil {
|
||||||
|
a = &acc{}
|
||||||
|
byWeek[w] = a
|
||||||
|
}
|
||||||
|
a.sum += p.value
|
||||||
|
a.n++
|
||||||
|
}
|
||||||
|
out := make([]weightPoint, 0, len(byWeek))
|
||||||
|
for w, a := range byWeek {
|
||||||
|
out = append(out, weightPoint{date: w, value: a.sum / float64(a.n), days: a.n})
|
||||||
|
}
|
||||||
|
sort.Slice(out, func(i, j int) bool { return out[i].date.Before(out[j].date) })
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// === SVG geometry ===
|
||||||
|
|
||||||
|
// Chart canvas in viewBox units. The SVG scales to container width via
|
||||||
|
// viewBox + width:100%, so these only set proportions and text size.
|
||||||
|
const (
|
||||||
|
chartW = 560.0
|
||||||
|
chartH = 240.0
|
||||||
|
chartLeft = 46.0
|
||||||
|
chartRight = 8.0
|
||||||
|
chartTop = 10.0
|
||||||
|
chartBottom = 24.0
|
||||||
|
)
|
||||||
|
|
||||||
|
// svgNum formats a coordinate or display value: rounded to 2 decimals,
|
||||||
|
// trailing zeros trimmed.
|
||||||
|
func svgNum(v float64) string {
|
||||||
|
return strconv.FormatFloat(math.Round(v*100)/100, 'f', -1, 64)
|
||||||
|
}
|
||||||
|
|
||||||
|
type chartScale struct {
|
||||||
|
x0, x1 time.Time
|
||||||
|
y0, y1 float64
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s chartScale) x(t time.Time) float64 {
|
||||||
|
span := s.x1.Sub(s.x0).Seconds()
|
||||||
|
if span <= 0 {
|
||||||
|
return chartLeft
|
||||||
|
}
|
||||||
|
return chartLeft + (chartW-chartLeft-chartRight)*t.Sub(s.x0).Seconds()/span
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s chartScale) y(v float64) float64 {
|
||||||
|
span := s.y1 - s.y0
|
||||||
|
if span <= 0 {
|
||||||
|
return chartH - chartBottom
|
||||||
|
}
|
||||||
|
return chartH - chartBottom - (chartH-chartBottom-chartTop)*(v-s.y0)/span
|
||||||
|
}
|
||||||
|
|
||||||
|
// === View models ===
|
||||||
|
|
||||||
|
type fitnessOptVM struct {
|
||||||
|
Value, Label string
|
||||||
|
Selected bool
|
||||||
|
}
|
||||||
|
|
||||||
|
type fitnessTickVM struct {
|
||||||
|
Pos, Label, Anchor string
|
||||||
|
}
|
||||||
|
|
||||||
|
type fitnessDotVM struct {
|
||||||
|
X, Y, Title string
|
||||||
|
}
|
||||||
|
|
||||||
|
type fitnessGoalVM struct {
|
||||||
|
Y, LabelY, Label string
|
||||||
|
}
|
||||||
|
|
||||||
|
type fitnessChartVM struct {
|
||||||
|
Title string
|
||||||
|
Param string
|
||||||
|
Options []fitnessOptVM
|
||||||
|
Empty bool
|
||||||
|
|
||||||
|
ViewW, ViewH string
|
||||||
|
PlotX, PlotY, PlotR, PlotB string
|
||||||
|
YLabelX, XLabelY string
|
||||||
|
YTicks, XTicks []fitnessTickVM
|
||||||
|
Goal *fitnessGoalVM
|
||||||
|
Lines []string // polyline points attributes
|
||||||
|
Dots []fitnessDotVM
|
||||||
|
}
|
||||||
|
|
||||||
|
type fitnessDashVM struct {
|
||||||
|
Notice string
|
||||||
|
Charts []fitnessChartVM
|
||||||
|
}
|
||||||
|
|
||||||
|
func newChartVM(title, param, sel string) fitnessChartVM {
|
||||||
|
opts := make([]fitnessOptVM, len(fitnessRanges))
|
||||||
|
for i, r := range fitnessRanges {
|
||||||
|
opts[i] = fitnessOptVM{r.Value, r.Label, r.Value == sel}
|
||||||
|
}
|
||||||
|
return fitnessChartVM{
|
||||||
|
Title: title, Param: param, Options: opts,
|
||||||
|
ViewW: svgNum(chartW), ViewH: svgNum(chartH),
|
||||||
|
PlotX: svgNum(chartLeft), PlotY: svgNum(chartTop),
|
||||||
|
PlotR: svgNum(chartW - chartRight), PlotB: svgNum(chartH - chartBottom),
|
||||||
|
YLabelX: svgNum(chartLeft - 5), XLabelY: svgNum(chartH - chartBottom + 14),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// yTickVMs places gridlines at fixed multiples of step across the y domain
|
||||||
|
// (always 5 kg guides on weight charts, regardless of range).
|
||||||
|
func yTickVMs(sc chartScale, step float64) []fitnessTickVM {
|
||||||
|
var out []fitnessTickVM
|
||||||
|
for v := math.Ceil(sc.y0/step) * step; v <= sc.y1+step/1e6; v += step {
|
||||||
|
out = append(out, fitnessTickVM{Pos: svgNum(sc.y(v)), Label: svgNum(v)})
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func (vm *fitnessChartVM) setGoal(sc chartScale, goal float64, label string) {
|
||||||
|
gy := sc.y(goal)
|
||||||
|
ly := gy - 4
|
||||||
|
if ly < chartTop+10 {
|
||||||
|
ly = gy + 12
|
||||||
|
}
|
||||||
|
vm.Goal = &fitnessGoalVM{Y: svgNum(gy), LabelY: svgNum(ly), Label: label}
|
||||||
|
}
|
||||||
|
|
||||||
|
// === Chart builders ===
|
||||||
|
|
||||||
|
// buildWeightChart renders a weight line chart. It serves both the per-day
|
||||||
|
// series and the weekly-mean series (points carrying days > 0); the line is
|
||||||
|
// drawn continuous across days/weeks without a measurement.
|
||||||
|
func buildWeightChart(all []weightPoint, goal float64, hasGoal bool, sel, param, title, unit string, today time.Time) fitnessChartVM {
|
||||||
|
vm := newChartVM(title, param, sel)
|
||||||
|
|
||||||
|
points := all
|
||||||
|
var x0, x1 time.Time
|
||||||
|
if m := rangeMonths(sel); m > 0 {
|
||||||
|
x0 = today.AddDate(0, -m, 0)
|
||||||
|
points = filterWeights(all, x0)
|
||||||
|
if len(points) == 0 {
|
||||||
|
vm.Empty = true
|
||||||
|
return vm
|
||||||
|
}
|
||||||
|
x1 = today
|
||||||
|
if last := points[len(points)-1].date; last.After(x1) {
|
||||||
|
x1 = last
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if len(points) == 0 {
|
||||||
|
vm.Empty = true
|
||||||
|
return vm
|
||||||
|
}
|
||||||
|
x0 = points[0].date
|
||||||
|
x1 = points[len(points)-1].date
|
||||||
|
}
|
||||||
|
// Degenerate domain (single point on All): widen so the dot sits inside
|
||||||
|
// the plot instead of on its edge.
|
||||||
|
if !x0.Before(x1) {
|
||||||
|
x0 = x0.AddDate(0, 0, -1)
|
||||||
|
x1 = x1.AddDate(0, 0, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
lo, hi := points[0].value, points[0].value
|
||||||
|
for _, p := range points {
|
||||||
|
lo = min(lo, p.value)
|
||||||
|
hi = max(hi, p.value)
|
||||||
|
}
|
||||||
|
if hasGoal {
|
||||||
|
lo = min(lo, goal)
|
||||||
|
hi = max(hi, goal)
|
||||||
|
}
|
||||||
|
pad := (hi - lo) * 0.05
|
||||||
|
if pad == 0 {
|
||||||
|
pad = 1
|
||||||
|
}
|
||||||
|
sc := chartScale{x0, x1, lo - pad, hi + pad}
|
||||||
|
|
||||||
|
vm.YTicks = yTickVMs(sc, 5)
|
||||||
|
vm.XTicks = timeXTicks(sc, 4)
|
||||||
|
|
||||||
|
// One continuous polyline through every point in range — days without a
|
||||||
|
// measurement do not break the line. Point markers carry the hover
|
||||||
|
// <title>; on dense ranges they are dropped and the bare line stays
|
||||||
|
// legible. A single point in range renders as a dot.
|
||||||
|
dot := func(p weightPoint) fitnessDotVM {
|
||||||
|
label := p.date.Format("2006-01-02") + ": " + svgNum(p.value) + " " + unit
|
||||||
|
if p.days > 0 {
|
||||||
|
label = fmt.Sprintf("Week of %s: %s %s (%d days)",
|
||||||
|
p.date.Format("2006-01-02"), svgNum(p.value), unit, p.days)
|
||||||
|
}
|
||||||
|
return fitnessDotVM{X: svgNum(sc.x(p.date)), Y: svgNum(sc.y(p.value)), Title: label}
|
||||||
|
}
|
||||||
|
if len(points) >= 2 {
|
||||||
|
var b strings.Builder
|
||||||
|
for i, p := range points {
|
||||||
|
if i > 0 {
|
||||||
|
b.WriteByte(' ')
|
||||||
|
}
|
||||||
|
b.WriteString(svgNum(sc.x(p.date)))
|
||||||
|
b.WriteByte(',')
|
||||||
|
b.WriteString(svgNum(sc.y(p.value)))
|
||||||
|
}
|
||||||
|
vm.Lines = append(vm.Lines, b.String())
|
||||||
|
}
|
||||||
|
if len(points) <= 100 {
|
||||||
|
for _, p := range points {
|
||||||
|
vm.Dots = append(vm.Dots, dot(p))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if hasGoal {
|
||||||
|
vm.setGoal(sc, goal, "goal "+svgNum(goal))
|
||||||
|
}
|
||||||
|
return vm
|
||||||
|
}
|
||||||
|
|
||||||
|
func filterWeights(points []weightPoint, from time.Time) []weightPoint {
|
||||||
|
var out []weightPoint
|
||||||
|
for _, p := range points {
|
||||||
|
if !p.date.Before(from) {
|
||||||
|
out = append(out, p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// timeXTicks places n+1 evenly spaced date labels across a continuous time
|
||||||
|
// axis; the last label is end-anchored so it stays inside the viewBox.
|
||||||
|
func timeXTicks(sc chartScale, n int) []fitnessTickVM {
|
||||||
|
span := sc.x1.Sub(sc.x0)
|
||||||
|
var out []fitnessTickVM
|
||||||
|
prev := ""
|
||||||
|
for i := 0; i <= n; i++ {
|
||||||
|
t := sc.x0.Add(time.Duration(float64(span) * float64(i) / float64(n)))
|
||||||
|
label := t.Format("2006-01-02")
|
||||||
|
if label == prev {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
prev = label
|
||||||
|
anchor := "middle"
|
||||||
|
if i == n {
|
||||||
|
anchor = "end"
|
||||||
|
}
|
||||||
|
out = append(out, fitnessTickVM{Pos: svgNum(sc.x(t)), Label: label, Anchor: anchor})
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// === Rendering ===
|
||||||
|
|
||||||
|
var fitnessTmpl = template.Must(template.ParseFS(assets, "assets/fitness/main.html"))
|
||||||
|
|
||||||
|
func renderFitnessDashboard(fsPath, weightSel, weeklySel string) template.HTML {
|
||||||
|
data := buildFitnessDash(fsPath, weightSel, weeklySel, time.Now())
|
||||||
|
var buf bytes.Buffer
|
||||||
|
if err := fitnessTmpl.Execute(&buf, data); err != nil {
|
||||||
|
log.Printf("fitness template: %v", err)
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return template.HTML(buf.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildFitnessDash reads and parses the export per request — no caching, no
|
||||||
|
// indexes. A read or parse failure (including a truncated mid-upload file)
|
||||||
|
// becomes an inline notice; the page itself always renders.
|
||||||
|
func buildFitnessDash(fsPath, weightSel, weeklySel string, now time.Time) fitnessDashVM {
|
||||||
|
raw, err := os.ReadFile(filepath.Join(fsPath, waistlineExportFile))
|
||||||
|
if err != nil {
|
||||||
|
return fitnessDashVM{Notice: "No Waistline export found — upload " + waistlineExportFile + " to this folder."}
|
||||||
|
}
|
||||||
|
var ex wlExport
|
||||||
|
if err := json.Unmarshal(raw, &ex); err != nil {
|
||||||
|
return fitnessDashVM{Notice: "Could not read " + waistlineExportFile + ": " + err.Error()}
|
||||||
|
}
|
||||||
|
|
||||||
|
today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.UTC)
|
||||||
|
weights := extractWeights(&ex)
|
||||||
|
wGoal, hasWGoal := goalValue(ex.Settings, "weight")
|
||||||
|
unit := exportWeightUnit(ex.Settings)
|
||||||
|
|
||||||
|
return fitnessDashVM{Charts: []fitnessChartVM{
|
||||||
|
buildWeightChart(weights, wGoal, hasWGoal, weightSel,
|
||||||
|
"weight", "Weight ("+unit+")", unit, today),
|
||||||
|
buildWeightChart(weeklyMeanWeights(weights), wGoal, hasWGoal, weeklySel,
|
||||||
|
"weekly", "Weekly average weight ("+unit+")", unit, today),
|
||||||
|
}}
|
||||||
|
}
|
||||||
@@ -10,6 +10,7 @@ import (
|
|||||||
"io/fs"
|
"io/fs"
|
||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
"path"
|
"path"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
@@ -36,12 +37,18 @@ func hashAsset(name string) string {
|
|||||||
return hex.EncodeToString(sum[:])[:12]
|
return hex.EncodeToString(sum[:])[:12]
|
||||||
}
|
}
|
||||||
|
|
||||||
var (
|
// tmplFuncs is shared by every layout-based template: the edit template appends
|
||||||
pageTmpl = template.Must(template.ParseFS(assets, "assets/layout.html", "assets/page/main.html"))
|
// editorBundleVersion to its CodeMirror <script> src to cache-bust the bundle.
|
||||||
editTmpl = template.Must(template.New("edit").Funcs(template.FuncMap{
|
var tmplFuncs = template.FuncMap{
|
||||||
"editorBundleVersion": func() string { return editorBundleVersion },
|
"editorBundleVersion": func() string { return editorBundleVersion },
|
||||||
}).ParseFS(assets, "assets/layout.html", "assets/editor/main.html"))
|
"fileIcon": fileIcon,
|
||||||
searchTmpl = template.Must(template.ParseFS(assets, "assets/layout.html", "assets/search/main.html"))
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
pageTmpl = template.Must(template.New("page").Funcs(tmplFuncs).ParseFS(assets, "assets/layout.html", "assets/page/main.html"))
|
||||||
|
editTmpl = template.Must(template.New("edit").Funcs(tmplFuncs).ParseFS(assets, "assets/layout.html", "assets/editor/main.html"))
|
||||||
|
searchTmpl = template.Must(template.New("search").Funcs(tmplFuncs).ParseFS(assets, "assets/layout.html", "assets/search/main.html"))
|
||||||
|
canvasTmpl = template.Must(template.New("canvas").Funcs(tmplFuncs).ParseFS(assets, "assets/layout.html", "assets/canvas/main.html"))
|
||||||
)
|
)
|
||||||
|
|
||||||
// specialPage is the result returned by a pageTypeHandler.
|
// specialPage is the result returned by a pageTypeHandler.
|
||||||
@@ -58,7 +65,9 @@ type specialPage struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// pageTypeHandler is implemented by each special folder type (diary, gallery, …).
|
// pageTypeHandler is implemented by each special folder type (diary, gallery, …).
|
||||||
// handle returns nil when the handler does not apply to the given path.
|
// handle returns nil when the handler does not apply to the given path. The
|
||||||
|
// request is passed read-only (e.g. query params selecting a view variant);
|
||||||
|
// mutations belong in the POST flow, not here.
|
||||||
// redirect returns ok=true with an absolute URL when the request should be
|
// redirect returns ok=true with an absolute URL when the request should be
|
||||||
// short-circuited with a 302 redirect (e.g. persistent date links in a diary,
|
// short-circuited with a 302 redirect (e.g. persistent date links in a diary,
|
||||||
// or virtual diary URLs in edit mode that delegate to the year file's editor).
|
// or virtual diary URLs in edit mode that delegate to the year file's editor).
|
||||||
@@ -66,7 +75,7 @@ type specialPage struct {
|
|||||||
// When adding a new hook, prefer a sibling method here over folding logic
|
// When adding a new hook, prefer a sibling method here over folding logic
|
||||||
// into main.go or render.go.
|
// into main.go or render.go.
|
||||||
type pageTypeHandler interface {
|
type pageTypeHandler interface {
|
||||||
handle(root, fsPath, urlPath string) *specialPage
|
handle(root, fsPath, urlPath string, r *http.Request) *specialPage
|
||||||
redirect(root, fsPath, urlPath string, r *http.Request) (target string, ok bool)
|
redirect(root, fsPath, urlPath string, r *http.Request) (target string, ok bool)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -98,7 +107,7 @@ func main() {
|
|||||||
log.Fatal(err)
|
log.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
initMarkdown(root)
|
initMarkdown()
|
||||||
|
|
||||||
authKey, err := loadOrCreateAuthKey(root)
|
authKey, err := loadOrCreateAuthKey(root)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -117,6 +126,7 @@ func main() {
|
|||||||
http.HandleFunc("/_logout", h.handleLogout)
|
http.HandleFunc("/_logout", h.handleLogout)
|
||||||
http.HandleFunc("/_reindex", h.handleReindex)
|
http.HandleFunc("/_reindex", h.handleReindex)
|
||||||
http.HandleFunc("/_search", h.handleSearchSuggest)
|
http.HandleFunc("/_search", h.handleSearchSuggest)
|
||||||
|
http.HandleFunc("/_canvas/render", h.handleCanvasRender)
|
||||||
http.HandleFunc("/quickadd", h.handleQuickAdd)
|
http.HandleFunc("/quickadd", h.handleQuickAdd)
|
||||||
http.Handle("/", h)
|
http.Handle("/", h)
|
||||||
|
|
||||||
@@ -125,13 +135,19 @@ func main() {
|
|||||||
// so the first search after a cold start still returns correct results.
|
// so the first search after a cold start still returns correct results.
|
||||||
go func() {
|
go func() {
|
||||||
folderIndex.buildMu.Lock()
|
folderIndex.buildMu.Lock()
|
||||||
entries := buildFolderIndex(root)
|
folders, files := buildIndexes(root)
|
||||||
|
now := time.Now()
|
||||||
folderIndex.Lock()
|
folderIndex.Lock()
|
||||||
folderIndex.entries = entries
|
folderIndex.entries = folders
|
||||||
folderIndex.builtAt = time.Now()
|
folderIndex.builtAt = now
|
||||||
folderIndex.Unlock()
|
folderIndex.Unlock()
|
||||||
|
fileIndex.Lock()
|
||||||
|
fileIndex.entries = files
|
||||||
|
fileIndex.builtAt = now
|
||||||
|
fileIndex.Unlock()
|
||||||
folderIndex.buildMu.Unlock()
|
folderIndex.buildMu.Unlock()
|
||||||
close(folderIndex.ready)
|
close(folderIndex.ready)
|
||||||
|
close(fileIndex.ready)
|
||||||
}()
|
}()
|
||||||
|
|
||||||
if *reindexInterval > 0 {
|
if *reindexInterval > 0 {
|
||||||
@@ -199,6 +215,16 @@ func (h *handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A canvas is addressed as a view of its folder (?canvas=Name), so a direct
|
||||||
|
// hit on the file collapses onto that canonical form — the same shape as
|
||||||
|
// diary.go's virtual-URL redirects. ?raw keeps the bytes reachable.
|
||||||
|
if r.Method == http.MethodGet && strings.HasSuffix(urlPath, canvasExt) && !r.URL.Query().Has("raw") {
|
||||||
|
if target, ok := canvasViewURL(urlPath); ok {
|
||||||
|
http.Redirect(w, r, target, http.StatusFound)
|
||||||
|
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) {
|
||||||
@@ -207,7 +233,7 @@ func (h *handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
|||||||
// POSTs must not be redirected — the form action has no trailing
|
// POSTs must not be redirected — the form action has no trailing
|
||||||
// slash (path.Clean strips it) and the content would be lost.
|
// slash (path.Clean strips it) and the content would be lost.
|
||||||
if !strings.HasSuffix(r.URL.Path, "/") && r.Method != http.MethodPost {
|
if !strings.HasSuffix(r.URL.Path, "/") && r.Method != http.MethodPost {
|
||||||
http.Redirect(w, r, r.URL.Path+"/", http.StatusMovedPermanently)
|
http.Redirect(w, r, slashedURL(r.URL), http.StatusMovedPermanently)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
h.serveDir(w, r, urlPath, fsPath)
|
h.serveDir(w, r, urlPath, fsPath)
|
||||||
@@ -219,14 +245,39 @@ func (h *handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
if info.IsDir() {
|
if info.IsDir() {
|
||||||
if urlPath != "/" {
|
if urlPath != "/" {
|
||||||
|
// Pages must live at a slash-terminated URL: embed destinations are
|
||||||
|
// relative, so "/Trips/Japan" would resolve them against "/Trips/".
|
||||||
|
if !strings.HasSuffix(r.URL.Path, "/") && r.Method != http.MethodPost {
|
||||||
|
http.Redirect(w, r, slashedURL(r.URL), http.StatusMovedPermanently)
|
||||||
|
return
|
||||||
|
}
|
||||||
urlPath += "/"
|
urlPath += "/"
|
||||||
}
|
}
|
||||||
h.serveDir(w, r, urlPath, fsPath)
|
h.serveDir(w, r, urlPath, fsPath)
|
||||||
} else {
|
} else {
|
||||||
|
// ?w= on a file's own URL serves a thumbnail. Embeds use this form so
|
||||||
|
// their <img src> can stay relative to the page; /_thumb/<abs path>
|
||||||
|
// stays for listings, which have absolute paths on hand anyway. A file
|
||||||
|
// with no thumbnailer ignores the parameter and serves normally.
|
||||||
|
if r.URL.Query().Has("w") && hasThumbnail(fsPath) {
|
||||||
|
h.serveThumbnail(w, r, fsPath)
|
||||||
|
return
|
||||||
|
}
|
||||||
http.ServeFile(w, r, fsPath)
|
http.ServeFile(w, r, fsPath)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// slashedURL returns u's path with a trailing slash appended and the query
|
||||||
|
// string intact — the canonical page form that relative destinations resolve
|
||||||
|
// against.
|
||||||
|
func slashedURL(u *url.URL) string {
|
||||||
|
target := u.Path + "/"
|
||||||
|
if u.RawQuery != "" {
|
||||||
|
target += "?" + u.RawQuery
|
||||||
|
}
|
||||||
|
return target
|
||||||
|
}
|
||||||
|
|
||||||
func (h *handler) serveDir(w http.ResponseWriter, r *http.Request, urlPath, fsPath string) {
|
func (h *handler) serveDir(w http.ResponseWriter, r *http.Request, urlPath, fsPath string) {
|
||||||
_, editMode := r.URL.Query()["edit"]
|
_, editMode := r.URL.Query()["edit"]
|
||||||
|
|
||||||
@@ -235,6 +286,19 @@ func (h *handler) serveDir(w http.ResponseWriter, r *http.Request, urlPath, fsPa
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// An explicit ?canvas= selects this page's canvas view. Checked ahead of
|
||||||
|
// the pageTypeHandler redirect loop so an explicit view request always wins
|
||||||
|
// over a folder-type redirect (e.g. a canvas kept in a diary year folder).
|
||||||
|
//
|
||||||
|
// This is deliberately not a pageTypeHandler: that interface injects content
|
||||||
|
// into the standard page body, while a canvas replaces the whole body with a
|
||||||
|
// full-bleed surface and its own controls — a view mode like ?edit, which is
|
||||||
|
// likewise a template branch here rather than a handler.
|
||||||
|
if name := r.URL.Query().Get("canvas"); name != "" {
|
||||||
|
h.serveCanvas(w, r, urlPath, fsPath, name)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
for _, ph := range pageTypeHandlers {
|
for _, ph := range pageTypeHandlers {
|
||||||
if target, ok := ph.redirect(h.root, fsPath, urlPath, r); ok {
|
if target, ok := ph.redirect(h.root, fsPath, urlPath, r); ok {
|
||||||
http.Redirect(w, r, target, http.StatusFound)
|
http.Redirect(w, r, target, http.StatusFound)
|
||||||
@@ -248,6 +312,7 @@ func (h *handler) serveDir(w http.ResponseWriter, r *http.Request, urlPath, fsPa
|
|||||||
// Determine section index (-1 = whole page).
|
// Determine section index (-1 = whole page).
|
||||||
sectionIndex := -1
|
sectionIndex := -1
|
||||||
insertBefore := -1
|
insertBefore := -1
|
||||||
|
sectionSpan := false
|
||||||
if editMode {
|
if editMode {
|
||||||
if s := r.URL.Query().Get("section"); s != "" {
|
if s := r.URL.Query().Get("section"); s != "" {
|
||||||
if n, err := strconv.Atoi(s); err == nil && n >= 0 {
|
if n, err := strconv.Atoi(s); err == nil && n >= 0 {
|
||||||
@@ -259,12 +324,13 @@ func (h *handler) serveDir(w http.ResponseWriter, r *http.Request, urlPath, fsPa
|
|||||||
insertBefore = n
|
insertBefore = n
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
sectionSpan = r.URL.Query().Has("span")
|
||||||
}
|
}
|
||||||
|
|
||||||
var special *specialPage
|
var special *specialPage
|
||||||
if !editMode {
|
if !editMode {
|
||||||
for _, ph := range pageTypeHandlers {
|
for _, ph := range pageTypeHandlers {
|
||||||
if special = ph.handle(h.root, fsPath, urlPath); special != nil {
|
if special = ph.handle(h.root, fsPath, urlPath, r); special != nil {
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -275,6 +341,14 @@ func (h *handler) serveDir(w http.ResponseWriter, r *http.Request, urlPath, fsPa
|
|||||||
rendered = renderMarkdown(rawMD)
|
rendered = renderMarkdown(rawMD)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Canvases in this folder surface as a view switcher above the content, so
|
||||||
|
// a page and its diagrams read as one thing. Suppressed in the editor, which
|
||||||
|
// is about the markdown only.
|
||||||
|
var canvases []canvasRef
|
||||||
|
if !editMode {
|
||||||
|
canvases = listCanvases(fsPath, urlPath, "")
|
||||||
|
}
|
||||||
|
|
||||||
view, sortKey, order := readPageSettings(fsPath).viewSettings()
|
view, sortKey, order := readPageSettings(fsPath).viewSettings()
|
||||||
var entries []entry
|
var entries []entry
|
||||||
if !editMode && (special == nil || !special.SuppressListing) {
|
if !editMode && (special == nil || !special.SuppressListing) {
|
||||||
@@ -310,24 +384,24 @@ func (h *handler) serveDir(w http.ResponseWriter, r *http.Request, urlPath, fsPa
|
|||||||
} else if editMode && sectionIndex >= 0 {
|
} else if editMode && sectionIndex >= 0 {
|
||||||
sections := splitSections(rawMD)
|
sections := splitSections(rawMD)
|
||||||
if sectionIndex < len(sections) {
|
if sectionIndex < len(sections) {
|
||||||
rawContent = string(sections[sectionIndex])
|
end := sectionIndex + 1
|
||||||
|
if sectionSpan {
|
||||||
|
end = secionSpanEnd(sections, sectionIndex)
|
||||||
|
}
|
||||||
|
rawContent = string(joinSections(sections[sectionIndex:end]))
|
||||||
}
|
}
|
||||||
} else if editMode && rawContent == "" && urlPath != "/" {
|
} else if editMode && rawContent == "" && urlPath != "/" {
|
||||||
rawContent = "# " + pageTitle(urlPath) + "\n\n"
|
rawContent = "# " + pageTitle(urlPath) + "\n\n"
|
||||||
}
|
}
|
||||||
|
|
||||||
parent := ""
|
|
||||||
if urlPath != "/" {
|
|
||||||
parent = parentURL(urlPath)
|
|
||||||
}
|
|
||||||
data := pageData{
|
data := pageData{
|
||||||
Title: title,
|
Title: title,
|
||||||
ParentURL: parent,
|
|
||||||
CanEdit: true,
|
CanEdit: true,
|
||||||
EditMode: editMode,
|
EditMode: editMode,
|
||||||
IsRoot: urlPath == "/",
|
IsRoot: urlPath == "/",
|
||||||
SectionIndex: sectionIndex,
|
SectionIndex: sectionIndex,
|
||||||
InsertBefore: insertBefore,
|
InsertBefore: insertBefore,
|
||||||
|
Span: sectionSpan,
|
||||||
PostURL: urlPath,
|
PostURL: urlPath,
|
||||||
RawContent: rawContent,
|
RawContent: rawContent,
|
||||||
Content: rendered,
|
Content: rendered,
|
||||||
@@ -338,6 +412,7 @@ func (h *handler) serveDir(w http.ResponseWriter, r *http.Request, urlPath, fsPa
|
|||||||
SpecialContent: specialContent,
|
SpecialContent: specialContent,
|
||||||
SidebarWidget: sidebarWidget,
|
SidebarWidget: sidebarWidget,
|
||||||
SuppressTOC: suppressTOC,
|
SuppressTOC: suppressTOC,
|
||||||
|
Canvases: canvases,
|
||||||
}
|
}
|
||||||
|
|
||||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
@@ -353,12 +428,18 @@ func (h *handler) serveDir(w http.ResponseWriter, r *http.Request, urlPath, fsPa
|
|||||||
|
|
||||||
func (h *handler) handlePost(w http.ResponseWriter, r *http.Request, urlPath, fsPath string) {
|
func (h *handler) handlePost(w http.ResponseWriter, r *http.Request, urlPath, fsPath string) {
|
||||||
query := r.URL.Query()
|
query := r.URL.Query()
|
||||||
|
// Checked first so ?canvas=X&delete removes that canvas rather than falling
|
||||||
|
// through to the page-level delete below.
|
||||||
|
if name := query.Get("canvas"); name != "" {
|
||||||
|
h.handleCanvasPost(w, r, urlPath, fsPath, name)
|
||||||
|
return
|
||||||
|
}
|
||||||
if query.Has("delete") {
|
if query.Has("delete") {
|
||||||
h.handleDelete(w, r, urlPath, fsPath)
|
h.handleDelete(w, r, urlPath, fsPath)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if _, ok := query["move"]; ok {
|
if _, ok := query["move"]; ok {
|
||||||
h.handleMove(w, r, urlPath, fsPath, query.Get("move"), query.Has("links"))
|
h.handleMove(w, r, urlPath, fsPath, query.Get("move"), query.Has("merge"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if query.Has("toggle") {
|
if query.Has("toggle") {
|
||||||
@@ -369,14 +450,6 @@ func (h *handler) handlePost(w http.ResponseWriter, r *http.Request, urlPath, fs
|
|||||||
h.handleAppend(w, r, urlPath, fsPath)
|
h.handleAppend(w, r, urlPath, fsPath)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if query.Has("cleantasks") {
|
|
||||||
h.handleCleanTasks(w, r, urlPath, fsPath)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if query.Has("addtask") {
|
|
||||||
h.handleAddTask(w, r, urlPath, fsPath)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if query.Has("settings") {
|
if query.Has("settings") {
|
||||||
h.handleSettings(w, r, urlPath, fsPath)
|
h.handleSettings(w, r, urlPath, fsPath)
|
||||||
return
|
return
|
||||||
@@ -390,11 +463,7 @@ func (h *handler) handlePost(w http.ResponseWriter, r *http.Request, urlPath, fs
|
|||||||
indexPath := filepath.Join(fsPath, "index.md")
|
indexPath := filepath.Join(fsPath, "index.md")
|
||||||
redirectTarget := urlPath
|
redirectTarget := urlPath
|
||||||
|
|
||||||
// insert_before splices a new section into the file *at* index N rather
|
// handle section saving
|
||||||
// than replacing index N (used by the diary "create new day" flow).
|
|
||||||
// section replaces the section at index N (used by per-section edits).
|
|
||||||
// Exactly one of insert_before / section should be set; insert_before
|
|
||||||
// wins if both are present.
|
|
||||||
if s := r.FormValue("insert_before"); s != "" {
|
if s := r.FormValue("insert_before"); s != "" {
|
||||||
insertIndex, err := strconv.Atoi(s)
|
insertIndex, err := strconv.Atoi(s)
|
||||||
if err != nil || insertIndex < 0 {
|
if err != nil || insertIndex < 0 {
|
||||||
@@ -424,10 +493,25 @@ func (h *handler) handlePost(w http.ResponseWriter, r *http.Request, urlPath, fs
|
|||||||
}
|
}
|
||||||
rawMD, _ := os.ReadFile(indexPath)
|
rawMD, _ := os.ReadFile(indexPath)
|
||||||
sections := splitSections(rawMD)
|
sections := splitSections(rawMD)
|
||||||
if sectionIndex < len(sections) {
|
// Out of range means the file changed under the editor (or the index
|
||||||
sections[sectionIndex] = []byte(content)
|
// never matched it). Writing back the untouched file would swallow the
|
||||||
|
// edit silently, so refuse and keep the editor's content in the browser.
|
||||||
|
if sectionIndex >= len(sections) {
|
||||||
|
http.Error(w, "section no longer exists — the page changed since you opened the editor", http.StatusConflict)
|
||||||
|
return
|
||||||
}
|
}
|
||||||
content = string(joinSections(sections))
|
|
||||||
|
// handling of section editing that spans until the next heading of the same level
|
||||||
|
end := sectionIndex + 1
|
||||||
|
if r.FormValue("span") != "" {
|
||||||
|
end = secionSpanEnd(sections, sectionIndex)
|
||||||
|
}
|
||||||
|
merged := make([][]byte, 0, len(sections)-(end-sectionIndex)+1)
|
||||||
|
merged = append(merged, sections[:sectionIndex]...)
|
||||||
|
merged = append(merged, []byte(content))
|
||||||
|
merged = append(merged, sections[end:]...)
|
||||||
|
content = string(joinSections(merged))
|
||||||
|
|
||||||
// Section index ≥ 1 is a heading-anchored section. Redirect to its
|
// Section index ≥ 1 is a heading-anchored section. Redirect to its
|
||||||
// anchor so the user lands on the section they just saved, even if
|
// anchor so the user lands on the section they just saved, even if
|
||||||
// the heading text changed.
|
// the heading text changed.
|
||||||
@@ -439,12 +523,15 @@ func (h *handler) handlePost(w http.ResponseWriter, r *http.Request, urlPath, fs
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A save must never remove a page. An empty POST — a truncated mobile
|
||||||
|
// request, a lost `section` field, an editor that came up blank — is
|
||||||
|
// indistinguishable from "clear this page", and deleting index.md on that
|
||||||
|
// signal loses the whole file even though the user only edited one section.
|
||||||
|
// Removing a page is the explicit ?delete action's job (moves.go).
|
||||||
if strings.TrimSpace(content) == "" {
|
if strings.TrimSpace(content) == "" {
|
||||||
if err := os.Remove(indexPath); err != nil && !os.IsNotExist(err) {
|
http.Error(w, "refusing to save empty content — use DELETE to remove this page", http.StatusBadRequest)
|
||||||
http.Error(w, "delete failed: "+err.Error(), http.StatusInternalServerError)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
// Stat first so we know whether MkdirAll actually created the folder
|
// Stat first so we know whether MkdirAll actually created the folder
|
||||||
// — if it did, the search index needs a new entry.
|
// — if it did, the search index needs a new entry.
|
||||||
_, statErr := os.Stat(fsPath)
|
_, statErr := os.Stat(fsPath)
|
||||||
@@ -462,6 +549,14 @@ func (h *handler) handlePost(w http.ResponseWriter, r *http.Request, urlPath, fs
|
|||||||
folderIndexAdd(filepath.ToSlash(rel))
|
folderIndexAdd(filepath.ToSlash(rel))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// The editor saves via fetch so the save and its result share one history
|
||||||
|
// entry (see assets/history-nav.js). Hand it the target instead of a 303:
|
||||||
|
// the browser would follow the redirect into a second entry, and fetch
|
||||||
|
// drops the #section fragment from a followed redirect anyway.
|
||||||
|
if r.Header.Get("X-Save-Mode") == "replace" {
|
||||||
|
w.Header().Set("X-Target", redirectTarget)
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
return
|
||||||
}
|
}
|
||||||
http.Redirect(w, r, redirectTarget, http.StatusSeeOther)
|
http.Redirect(w, r, redirectTarget, http.StatusSeeOther)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,20 +1,22 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
// handleMove moves the folder at srcFsPath (wiki URL srcURL) to dstURL. When
|
// handleMove moves the folder at srcFsPath (wiki URL srcURL) to dstURL.
|
||||||
// updateLinks is true it also rewrites every [[...]] wiki link across the
|
//
|
||||||
// tree that targets the old path or any descendant; rewritten files are held
|
// If the destination folder already exists it is normally a hard conflict.
|
||||||
// in memory for rollback.
|
// The one exception is a merge: when the source carries a page (index.md) and
|
||||||
func (h *handler) handleMove(w http.ResponseWriter, r *http.Request, srcURL, srcFsPath, dstURL string, updateLinks bool) {
|
// the destination folder has none, the source's contents can fill the empty
|
||||||
|
// container without clobbering anything. That merge only proceeds when merge
|
||||||
|
// is true; otherwise the client is told a merge is available so it can ask the
|
||||||
|
// user to confirm.
|
||||||
|
func (h *handler) handleMove(w http.ResponseWriter, r *http.Request, srcURL, srcFsPath, dstURL string, merge 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)
|
||||||
@@ -40,63 +42,115 @@ func (h *handler) handleMove(w http.ResponseWriter, r *http.Request, srcURL, src
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
dstFsPath := filepath.Join(h.root, filepath.FromSlash(strings.TrimPrefix(newPath, "/")))
|
dstFsPath := filepath.Join(h.root, filepath.FromSlash(strings.TrimPrefix(newPath, "/")))
|
||||||
if _, err := os.Stat(dstFsPath); err == nil {
|
|
||||||
|
// Decide between a plain rename and a merge into an existing destination.
|
||||||
|
merging := false
|
||||||
|
if dstInfo, err := os.Stat(dstFsPath); err == nil {
|
||||||
|
if !canMergeMove(srcFsPath, dstFsPath, dstInfo) {
|
||||||
http.Error(w, "destination already exists", http.StatusConflict)
|
http.Error(w, "destination already exists", http.StatusConflict)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if !merge {
|
||||||
// Phase 1: optionally walk the tree and rewrite every index.md that
|
// Ask the client to confirm the merge. The header lets it tell
|
||||||
// references the moved path. Keep the pre-rewrite bytes in memory so we
|
// this apart from an ordinary conflict.
|
||||||
// can revert on failure. The walker only reads directory listings and
|
w.Header().Set("X-Merge-Available", "1")
|
||||||
// files literally named index.md; hidden directories are pruned. A cheap
|
http.Error(w, "destination already exists — merge folders?", http.StatusConflict)
|
||||||
// substring check skips parsing files that cannot contain a relevant
|
|
||||||
// link.
|
|
||||||
rewritten := map[string][]byte{}
|
|
||||||
if updateLinks {
|
|
||||||
needle := []byte("[[" + oldPath)
|
|
||||||
walkErr := walkIndexFiles(h.root, func(fsPath string) error {
|
|
||||||
orig, err := os.ReadFile(fsPath)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if !bytes.Contains(orig, needle) {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
updated, changed := rewriteWikiLinks(orig, oldPath, newPath)
|
|
||||||
if !changed {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
if err := writeFileAtomic(fsPath, updated, 0644); err != nil {
|
|
||||||
return fmt.Errorf("write %s: %w", fsPath, err)
|
|
||||||
}
|
|
||||||
rewritten[fsPath] = orig
|
|
||||||
return nil
|
|
||||||
})
|
|
||||||
if walkErr != nil {
|
|
||||||
rollbackRewrites(rewritten)
|
|
||||||
http.Error(w, "rewrite failed: "+walkErr.Error(), http.StatusInternalServerError)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
conflict, err := firstMergeConflict(srcFsPath, dstFsPath)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "merge check failed: "+err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if conflict != "" {
|
||||||
|
http.Error(w, "cannot merge: "+conflict+" exists in both folders", http.StatusConflict)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
merging = true
|
||||||
}
|
}
|
||||||
|
|
||||||
// Phase 2: create intermediate parent folders for the destination.
|
// Phase 1: create intermediate parent folders for the destination.
|
||||||
if parent := filepath.Dir(dstFsPath); parent != "" {
|
if parent := filepath.Dir(dstFsPath); parent != "" {
|
||||||
if err := os.MkdirAll(parent, 0755); err != nil {
|
if err := os.MkdirAll(parent, 0755); err != nil {
|
||||||
rollbackRewrites(rewritten)
|
|
||||||
http.Error(w, "mkdir failed: "+err.Error(), http.StatusInternalServerError)
|
http.Error(w, "mkdir failed: "+err.Error(), http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Phase 3: rename the source folder into place.
|
// Phase 2: move the source into place. A plain move renames the whole
|
||||||
|
// folder; a merge moves the source's entries into the existing
|
||||||
|
// destination and drops the emptied source.
|
||||||
|
if merging {
|
||||||
|
if err := mergeFolder(srcFsPath, dstFsPath); err != nil {
|
||||||
|
http.Error(w, "merge failed: "+err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
folderIndexMergeSubtree(strings.TrimPrefix(oldPath, "/"), strings.TrimPrefix(newPath, "/"))
|
||||||
|
} else {
|
||||||
if err := os.Rename(srcFsPath, dstFsPath); err != nil {
|
if err := os.Rename(srcFsPath, dstFsPath); err != nil {
|
||||||
rollbackRewrites(rewritten)
|
|
||||||
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, "/"))
|
folderIndexRenameSubtree(strings.TrimPrefix(oldPath, "/"), strings.TrimPrefix(newPath, "/"))
|
||||||
|
}
|
||||||
|
|
||||||
http.Redirect(w, r, wikiTargetHref(newPath), http.StatusSeeOther)
|
http.Redirect(w, r, fileURL(strings.TrimPrefix(newPath, "/"))+"/", http.StatusSeeOther)
|
||||||
|
}
|
||||||
|
|
||||||
|
// canMergeMove reports whether moving onto an existing destination should
|
||||||
|
// merge rather than conflict. Merging is allowed only when the destination is
|
||||||
|
// a folder with no page of its own (no index.md) and the source has one — so
|
||||||
|
// the source's page fills the empty destination without overwriting content.
|
||||||
|
func canMergeMove(srcFsPath, dstFsPath string, dstInfo os.FileInfo) bool {
|
||||||
|
if !dstInfo.IsDir() {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return hasIndexFile(srcFsPath) && !hasIndexFile(dstFsPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
// hasIndexFile reports whether dir contains a regular index.md.
|
||||||
|
func hasIndexFile(dir string) bool {
|
||||||
|
info, err := os.Stat(filepath.Join(dir, "index.md"))
|
||||||
|
return err == nil && info.Mode().IsRegular()
|
||||||
|
}
|
||||||
|
|
||||||
|
// firstMergeConflict returns the name of the first source entry that already
|
||||||
|
// exists in the destination, or "" when the merge can proceed without
|
||||||
|
// overwriting anything. index.md cannot collide here: canMergeMove already
|
||||||
|
// established the destination has none.
|
||||||
|
func firstMergeConflict(srcFsPath, dstFsPath string) (string, error) {
|
||||||
|
entries, err := os.ReadDir(srcFsPath)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
for _, e := range entries {
|
||||||
|
_, err := os.Lstat(filepath.Join(dstFsPath, e.Name()))
|
||||||
|
if err == nil {
|
||||||
|
return e.Name(), nil
|
||||||
|
}
|
||||||
|
if !os.IsNotExist(err) {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// mergeFolder moves every entry from srcFsPath into dstFsPath, then removes
|
||||||
|
// the now-empty source. Callers must run firstMergeConflict beforehand so no
|
||||||
|
// rename overwrites an existing destination entry.
|
||||||
|
func mergeFolder(srcFsPath, dstFsPath string) error {
|
||||||
|
entries, err := os.ReadDir(srcFsPath)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for _, e := range entries {
|
||||||
|
from := filepath.Join(srcFsPath, e.Name())
|
||||||
|
to := filepath.Join(dstFsPath, e.Name())
|
||||||
|
if err := os.Rename(from, to); err != nil {
|
||||||
|
return fmt.Errorf("move %s: %w", e.Name(), err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return os.Remove(srcFsPath)
|
||||||
}
|
}
|
||||||
|
|
||||||
// handleDelete removes the folder at fsPath (URL urlPath) and redirects to
|
// handleDelete removes the folder at fsPath (URL urlPath) and redirects to
|
||||||
@@ -152,77 +206,6 @@ func validateAndNormalizeNewPath(raw string) (string, error) {
|
|||||||
return "/" + trimmed, nil
|
return "/" + trimmed, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// rewriteWikiLinks returns (newContent, changed). Any [[target]] or
|
|
||||||
// [[target::display]] whose target equals oldPath or begins with oldPath+"/"
|
|
||||||
// has its target rewritten to the corresponding position under newPath.
|
|
||||||
func rewriteWikiLinks(content []byte, oldPath, newPath string) ([]byte, bool) {
|
|
||||||
changed := false
|
|
||||||
out := wikiLinkPattern.ReplaceAllFunc(content, func(match []byte) []byte {
|
|
||||||
parts := wikiLinkPattern.FindSubmatch(match)
|
|
||||||
if parts == nil {
|
|
||||||
return match
|
|
||||||
}
|
|
||||||
target := strings.TrimSpace(string(parts[1]))
|
|
||||||
normTarget := normalizeMovePath(target)
|
|
||||||
var newTarget string
|
|
||||||
switch {
|
|
||||||
case normTarget == oldPath:
|
|
||||||
newTarget = newPath
|
|
||||||
case strings.HasPrefix(normTarget, oldPath+"/"):
|
|
||||||
newTarget = newPath + strings.TrimPrefix(normTarget, oldPath)
|
|
||||||
default:
|
|
||||||
return match
|
|
||||||
}
|
|
||||||
changed = true
|
|
||||||
suffix := ""
|
|
||||||
if len(parts[2]) > 0 {
|
|
||||||
suffix = "::" + string(parts[2])
|
|
||||||
}
|
|
||||||
return []byte("[[" + newTarget + suffix + "]]")
|
|
||||||
})
|
|
||||||
return out, changed
|
|
||||||
}
|
|
||||||
|
|
||||||
// rollbackRewrites restores the given files to their pre-rewrite contents.
|
|
||||||
// Errors are logged; best-effort since we're already in a failure path.
|
|
||||||
func rollbackRewrites(rewritten map[string][]byte) {
|
|
||||||
for path, orig := range rewritten {
|
|
||||||
if err := writeFileAtomic(path, orig, 0644); err != nil {
|
|
||||||
log.Printf("rollback %s: %v", path, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// walkIndexFiles visits every `index.md` under root, skipping hidden
|
|
||||||
// directories (names beginning with `.`). Unlike filepath.WalkDir this does
|
|
||||||
// not stat each regular file — on spinning disks that saves the bulk of the
|
|
||||||
// traversal cost when folders contain many non-page files (photos, archives).
|
|
||||||
func walkIndexFiles(root string, visit func(fsPath string) error) error {
|
|
||||||
entries, err := os.ReadDir(root)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
for _, e := range entries {
|
|
||||||
name := e.Name()
|
|
||||||
if strings.HasPrefix(name, ".") {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
full := filepath.Join(root, name)
|
|
||||||
if e.IsDir() {
|
|
||||||
if err := walkIndexFiles(full, visit); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if name == "index.md" {
|
|
||||||
if err := visit(full); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// writeFileAtomic writes data to a temp file in the same directory as path
|
// writeFileAtomic writes data to a temp file in the same directory as path
|
||||||
// and renames it into place so readers never observe a partial file.
|
// and renames it into place so readers never observe a partial file.
|
||||||
func writeFileAtomic(path string, data []byte, perm os.FileMode) error {
|
func writeFileAtomic(path string, data []byte, perm os.FileMode) error {
|
||||||
|
|||||||
+2
-2
@@ -112,9 +112,9 @@ func formatAppendEntry(title, rawURL, comment string, ts time.Time) string {
|
|||||||
b.WriteString(escapeLinkLabel(title))
|
b.WriteString(escapeLinkLabel(title))
|
||||||
b.WriteString("](")
|
b.WriteString("](")
|
||||||
b.WriteString(rawURL)
|
b.WriteString(rawURL)
|
||||||
b.WriteString(")</br>")
|
b.WriteString(")\n")
|
||||||
b.WriteString(ts.Format("2006-01-02 15:04"))
|
b.WriteString(ts.Format("2006-01-02 15:04"))
|
||||||
b.WriteString("</br>")
|
b.WriteString("\n")
|
||||||
if comment != "" {
|
if comment != "" {
|
||||||
b.WriteString(" ")
|
b.WriteString(" ")
|
||||||
b.WriteString(comment)
|
b.WriteString(comment)
|
||||||
|
|||||||
@@ -19,14 +19,14 @@ import (
|
|||||||
|
|
||||||
var md goldmark.Markdown
|
var md goldmark.Markdown
|
||||||
|
|
||||||
// initMarkdown builds the package-level goldmark instance. Called once from
|
// initMarkdown builds the package-level goldmark instance. Neither extension
|
||||||
// main after the wiki root is known so the wiki-link extension can resolve
|
// resolves anything against the filesystem — wiki links are search queries and
|
||||||
// targets against the filesystem.
|
// embed destinations are relative to the page — so this needs no wiki root.
|
||||||
func initMarkdown(root string) {
|
func initMarkdown() {
|
||||||
md = goldmark.New(
|
md = goldmark.New(
|
||||||
goldmark.WithExtensions(extension.GFM, extension.Table, newWikiLinkExt(root), newExtLinksExt()),
|
goldmark.WithExtensions(extension.GFM, extension.Table, newWikiLinkExt(), newEmbedExt()),
|
||||||
goldmark.WithParserOptions(parser.WithAutoHeadingID()),
|
goldmark.WithParserOptions(parser.WithAutoHeadingID()),
|
||||||
goldmark.WithRendererOptions(html.WithUnsafe()),
|
goldmark.WithRendererOptions(html.WithUnsafe(), html.WithHardWraps()),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -36,6 +36,9 @@ type entry struct {
|
|||||||
// ThumbURL is set for thumbnailable files; the thumbnail view renders an
|
// ThumbURL is set for thumbnailable files; the thumbnail view renders an
|
||||||
// <img> when it is non-empty and falls back to Icon otherwise.
|
// <img> when it is non-empty and falls back to Icon otherwise.
|
||||||
ThumbURL string
|
ThumbURL string
|
||||||
|
// IsVideo marks a thumbnail tile as a video so the grid can overlay a play
|
||||||
|
// badge. Only meaningful when ThumbURL is set.
|
||||||
|
IsVideo bool
|
||||||
// modTime/size carry the raw sort keys; the template only reads the
|
// modTime/size carry the raw sort keys; the template only reads the
|
||||||
// formatted Meta string.
|
// formatted Meta string.
|
||||||
modTime time.Time
|
modTime time.Time
|
||||||
@@ -44,12 +47,12 @@ type entry struct {
|
|||||||
|
|
||||||
type pageData struct {
|
type pageData struct {
|
||||||
Title string
|
Title string
|
||||||
ParentURL string
|
|
||||||
CanEdit bool
|
CanEdit bool
|
||||||
EditMode bool
|
EditMode bool
|
||||||
IsRoot bool
|
IsRoot bool
|
||||||
SectionIndex int // -1 = whole page; >=0 = section being edited
|
SectionIndex int // -1 = whole page; >=0 = section being edited
|
||||||
InsertBefore int // -1 = no insert; >=0 = splice new section at this index
|
InsertBefore int // -1 = no insert; >=0 = splice new section at this index
|
||||||
|
Span bool // causes section editing to also cover every sub-section to the next section of the same level
|
||||||
PostURL string
|
PostURL string
|
||||||
RawContent string
|
RawContent string
|
||||||
Content template.HTML
|
Content template.HTML
|
||||||
@@ -61,6 +64,39 @@ type pageData struct {
|
|||||||
SidebarWidget template.HTML
|
SidebarWidget template.HTML
|
||||||
SuppressTOC bool
|
SuppressTOC bool
|
||||||
RenderMS int64
|
RenderMS int64
|
||||||
|
|
||||||
|
// Canvases lists every .canvas file in this folder as a view-switcher tab.
|
||||||
|
// ActiveCanvas names the one currently being viewed ("" on a normal page),
|
||||||
|
// and is what makes this page render as a canvas rather than as markdown.
|
||||||
|
Canvases []canvasRef
|
||||||
|
ActiveCanvas string
|
||||||
|
// CanvasJSON is the whole canvas document plus its server-rendered node
|
||||||
|
// HTML, inlined into the page so opening a canvas costs one request.
|
||||||
|
CanvasJSON template.JS
|
||||||
|
// CanvasError carries a parse/validation failure to the template, which
|
||||||
|
// shows it in place of the canvas instead of failing the request.
|
||||||
|
CanvasError string
|
||||||
|
}
|
||||||
|
|
||||||
|
// layoutData is the contract every view built on assets/layout.html satisfies.
|
||||||
|
// The layout is shared by pageData and searchPageData, and a method only one of
|
||||||
|
// them has fails at render time with a half-written response rather than at
|
||||||
|
// build time — so each is asserted against the interface here.
|
||||||
|
type layoutData interface {
|
||||||
|
ShowPageChrome() bool
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
_ layoutData = pageData{}
|
||||||
|
_ layoutData = searchPageData{}
|
||||||
|
)
|
||||||
|
|
||||||
|
// ShowPageChrome reports whether the standard page furniture — search box,
|
||||||
|
// folder-tree rail, right sidebar, and the collapsed ACTIONS dropdown — should
|
||||||
|
// render. The editor and the canvas view both take the full width and supply
|
||||||
|
// their own controls, so both opt out.
|
||||||
|
func (d pageData) ShowPageChrome() bool {
|
||||||
|
return !d.EditMode && d.ActiveCanvas == ""
|
||||||
}
|
}
|
||||||
|
|
||||||
// Allowed values for the listing view settings. Unknown values in the file or
|
// Allowed values for the listing view settings. Unknown values in the file or
|
||||||
@@ -127,6 +163,7 @@ var (
|
|||||||
iconAudio = readIcon("audio")
|
iconAudio = readIcon("audio")
|
||||||
iconArchive = readIcon("archive")
|
iconArchive = readIcon("archive")
|
||||||
iconGeneric = readIcon("generic")
|
iconGeneric = readIcon("generic")
|
||||||
|
iconCanvas = readIcon("canvas")
|
||||||
)
|
)
|
||||||
|
|
||||||
// renderMarkdown converts raw markdown to trusted HTML.
|
// renderMarkdown converts raw markdown to trusted HTML.
|
||||||
@@ -208,6 +245,9 @@ func listEntries(fsPath, urlPath, sortKey, order string) []entry {
|
|||||||
if name == "index.md" {
|
if name == "index.md" {
|
||||||
continue // rendered above, don't list it
|
continue // rendered above, don't list it
|
||||||
}
|
}
|
||||||
|
if isCanvasFile(name) {
|
||||||
|
continue // surfaced as a view-switcher tab above the content
|
||||||
|
}
|
||||||
f := entry{
|
f := entry{
|
||||||
Icon: fileIcon(name),
|
Icon: fileIcon(name),
|
||||||
Name: name,
|
Name: name,
|
||||||
@@ -218,6 +258,7 @@ func listEntries(fsPath, urlPath, sortKey, order string) []entry {
|
|||||||
}
|
}
|
||||||
if hasThumbnail(name) {
|
if hasThumbnail(name) {
|
||||||
f.ThumbURL = thumbURL(path.Join(urlPath, url.PathEscape(name)), 300)
|
f.ThumbURL = thumbURL(path.Join(urlPath, url.PathEscape(name)), 300)
|
||||||
|
f.IsVideo = isVideoFile(name)
|
||||||
}
|
}
|
||||||
files = append(files, f)
|
files = append(files, f)
|
||||||
}
|
}
|
||||||
@@ -229,6 +270,13 @@ func listEntries(fsPath, urlPath, sortKey, order string) []entry {
|
|||||||
sortEntries(folders, sortName, order)
|
sortEntries(folders, sortName, order)
|
||||||
sortEntries(files, sortKey, order)
|
sortEntries(files, sortKey, order)
|
||||||
|
|
||||||
|
// Nothing to list (only index.md, which is rendered above): return no
|
||||||
|
// entries so the Files section is suppressed entirely rather than showing
|
||||||
|
// an empty listing with just the `..` row.
|
||||||
|
if len(folders) == 0 && len(files) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// `..` row mirrors the header Up button so the listing itself is
|
// `..` row mirrors the header Up button so the listing itself is
|
||||||
// navigable without reaching for the header on mobile. Prepended after
|
// navigable without reaching for the header on mobile. Prepended after
|
||||||
// sort so it always sits at the top regardless of folder names.
|
// sort so it always sits at the top regardless of folder names.
|
||||||
@@ -289,6 +337,8 @@ func readIcon(name string) template.HTML {
|
|||||||
func fileIcon(name string) template.HTML {
|
func fileIcon(name string) template.HTML {
|
||||||
ext := strings.ToLower(path.Ext(name))
|
ext := strings.ToLower(path.Ext(name))
|
||||||
switch ext {
|
switch ext {
|
||||||
|
case ".canvas":
|
||||||
|
return iconCanvas
|
||||||
case ".md", ".pdf":
|
case ".md", ".pdf":
|
||||||
return iconDoc
|
return iconDoc
|
||||||
case ".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg":
|
case ".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg":
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"io/fs"
|
"io/fs"
|
||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"net/url"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -13,32 +14,70 @@ import (
|
|||||||
"unicode"
|
"unicode"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// searchSectionCap bounds how many rows each results section renders. The
|
||||||
|
// section header still reports the true total so the user knows more exist.
|
||||||
|
const searchSectionCap = 10
|
||||||
|
|
||||||
|
// exactNameScore is the score scoreName assigns to a whole-name exact match.
|
||||||
|
// handleSearch peels these into the promoted "Exact Match" section.
|
||||||
|
const exactNameScore = 1000
|
||||||
|
|
||||||
type searchResult struct {
|
type searchResult struct {
|
||||||
Name string
|
Name string
|
||||||
URL string
|
URL string
|
||||||
Path string
|
Path string
|
||||||
Score int
|
Score int
|
||||||
|
// Meta is the formatted "size · date" line for file results; empty for
|
||||||
|
// page results.
|
||||||
|
Meta string
|
||||||
}
|
}
|
||||||
|
|
||||||
type searchPageData struct {
|
type searchPageData struct {
|
||||||
Title string
|
Title string
|
||||||
ParentURL string
|
|
||||||
EditMode bool
|
EditMode bool
|
||||||
Query string
|
Query string
|
||||||
Results []searchResult
|
// Exact holds page(s) whose name equals the query exactly, promoted to
|
||||||
|
// their own section above the fuzzy matches.
|
||||||
|
Exact []searchResult
|
||||||
|
Pages []searchResult
|
||||||
|
Files []searchResult
|
||||||
|
// PageTotal/FileTotal are the true match totals; Pages/Files are capped at
|
||||||
|
// searchSectionCap for rendering.
|
||||||
|
PageTotal int
|
||||||
|
FileTotal int
|
||||||
IndexBuiltAt time.Time
|
IndexBuiltAt time.Time
|
||||||
RenderMS int64
|
RenderMS int64
|
||||||
}
|
}
|
||||||
|
|
||||||
// folderEntry is a single indexed directory: its forward-slash relative path
|
// ShowPageChrome satisfies the shared layout's contract (see layoutData). The
|
||||||
// plus pre-tokenized basename so the per-query scoring loop avoids redoing
|
// results page is always ordinary chrome — it is never the editor and never a
|
||||||
// the lowercasing and tokenization on every keystroke.
|
// canvas — but EditMode is honoured so the two views cannot drift apart.
|
||||||
type folderEntry struct {
|
func (d searchPageData) ShowPageChrome() bool { return !d.EditMode }
|
||||||
|
|
||||||
|
// indexEntry is the shared scoreable core of both folder and file index
|
||||||
|
// entries: a forward-slash relative path plus its pre-tokenized basename so
|
||||||
|
// the per-query scoring loop avoids redoing the lowercasing and tokenization
|
||||||
|
// on every request.
|
||||||
|
type indexEntry struct {
|
||||||
Path string
|
Path string
|
||||||
NameLower string
|
NameLower string
|
||||||
NameTokens []string
|
NameTokens []string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// folderEntry is a single indexed directory. It is exactly an indexEntry; the
|
||||||
|
// alias keeps the existing folder-index code readable while letting files and
|
||||||
|
// folders share the scoring loop.
|
||||||
|
type folderEntry = indexEntry
|
||||||
|
|
||||||
|
// fileEntry is a single indexed file: its scoreable core plus the size/modtime
|
||||||
|
// captured during the walk so results can show listing-parity metadata without
|
||||||
|
// a second stat.
|
||||||
|
type fileEntry struct {
|
||||||
|
indexEntry
|
||||||
|
Size int64
|
||||||
|
ModTime time.Time
|
||||||
|
}
|
||||||
|
|
||||||
// folderIndex holds the in-memory directory index used by search. Writers
|
// folderIndex holds the in-memory directory index used by search. Writers
|
||||||
// always replace the entries slice wholesale so a reader that snapshots the
|
// always replace the entries slice wholesale so a reader that snapshots the
|
||||||
// header under RLock can score without holding the lock.
|
// header under RLock can score without holding the lock.
|
||||||
@@ -50,15 +89,38 @@ var folderIndex struct {
|
|||||||
ready chan struct{}
|
ready chan struct{}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// fileIndex mirrors folderIndex for files. It is held separately so file
|
||||||
|
// volume can't perturb the page index, and shares folderIndex.buildMu since
|
||||||
|
// both are populated by the same single-pass walk. It is refreshed only by the
|
||||||
|
// full rebuild (startup / ticker / manual), never by the incremental folder
|
||||||
|
// hooks — file freshness on disk lags until the next rebuild.
|
||||||
|
var fileIndex struct {
|
||||||
|
sync.RWMutex
|
||||||
|
entries []fileEntry
|
||||||
|
builtAt time.Time
|
||||||
|
ready chan struct{}
|
||||||
|
}
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
folderIndex.ready = make(chan struct{})
|
folderIndex.ready = make(chan struct{})
|
||||||
|
fileIndex.ready = make(chan struct{})
|
||||||
}
|
}
|
||||||
|
|
||||||
// handleSearch renders the search results page for the query in
|
// handleSearch renders the search results page for the query in
|
||||||
// r.URL.Query().Get("q"). Only invoked when path is "/" and "q" is present.
|
// r.URL.Query().Get("q"). Only invoked when path is "/" and "q" is present.
|
||||||
func (h *handler) handleSearch(w http.ResponseWriter, r *http.Request) {
|
func (h *handler) handleSearch(w http.ResponseWriter, r *http.Request) {
|
||||||
query := strings.TrimSpace(r.URL.Query().Get("q"))
|
query := strings.TrimSpace(r.URL.Query().Get("q"))
|
||||||
results, builtAt := searchWiki(query)
|
pages, builtAt := searchWiki(query)
|
||||||
|
files := searchFiles(query)
|
||||||
|
|
||||||
|
// searchWiki sorts by score desc, so exact-name matches (score 1000) are
|
||||||
|
// at the front; peel them into their own section and drop them from the
|
||||||
|
// fuzzy page list so they aren't shown twice.
|
||||||
|
var exact []searchResult
|
||||||
|
for len(pages) > 0 && pages[0].Score == exactNameScore {
|
||||||
|
exact = append(exact, pages[0])
|
||||||
|
pages = pages[1:]
|
||||||
|
}
|
||||||
|
|
||||||
title := "Search"
|
title := "Search"
|
||||||
if query != "" {
|
if query != "" {
|
||||||
@@ -66,9 +128,12 @@ func (h *handler) handleSearch(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
data := searchPageData{
|
data := searchPageData{
|
||||||
Title: title,
|
Title: title,
|
||||||
ParentURL: "/",
|
|
||||||
Query: query,
|
Query: query,
|
||||||
Results: results,
|
Exact: exact,
|
||||||
|
Pages: capResults(pages),
|
||||||
|
Files: capResults(files),
|
||||||
|
PageTotal: len(pages),
|
||||||
|
FileTotal: len(files),
|
||||||
IndexBuiltAt: builtAt,
|
IndexBuiltAt: builtAt,
|
||||||
}
|
}
|
||||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
@@ -78,6 +143,55 @@ func (h *handler) handleSearch(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// capResults truncates a section to searchSectionCap rows for rendering.
|
||||||
|
func capResults(results []searchResult) []searchResult {
|
||||||
|
if len(results) > searchSectionCap {
|
||||||
|
return results[:searchSectionCap]
|
||||||
|
}
|
||||||
|
return results
|
||||||
|
}
|
||||||
|
|
||||||
|
// scoredEntry pairs a matched index entry with its score before it is turned
|
||||||
|
// into a UI searchResult.
|
||||||
|
type scoredEntry[T any] struct {
|
||||||
|
entry T
|
||||||
|
score int
|
||||||
|
}
|
||||||
|
|
||||||
|
// scoreEntries scores every entry against query, drops non-matches, and returns
|
||||||
|
// the survivors sorted by score (desc), then path depth (asc), then basename
|
||||||
|
// (asc). core extracts the shared scoreable fields so the folder and file
|
||||||
|
// indexes reuse one loop. fuzzy toggles the levenshtein fallback (pages yes,
|
||||||
|
// files no). Returns nil for an empty/tokenless query.
|
||||||
|
func scoreEntries[T any](entries []T, query string, fuzzy bool, core func(T) indexEntry) []scoredEntry[T] {
|
||||||
|
qLower := strings.ToLower(query)
|
||||||
|
qTokens := tokenize(qLower)
|
||||||
|
if len(qTokens) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var out []scoredEntry[T]
|
||||||
|
for _, e := range entries {
|
||||||
|
c := core(e)
|
||||||
|
score := scoreName(c.NameLower, c.NameTokens, qLower, qTokens, fuzzy)
|
||||||
|
if score == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out = append(out, scoredEntry[T]{entry: e, score: score})
|
||||||
|
}
|
||||||
|
sort.SliceStable(out, func(i, j int) bool {
|
||||||
|
if out[i].score != out[j].score {
|
||||||
|
return out[i].score > out[j].score
|
||||||
|
}
|
||||||
|
ci, cj := core(out[i].entry), core(out[j].entry)
|
||||||
|
di, dj := strings.Count(ci.Path, "/"), strings.Count(cj.Path, "/")
|
||||||
|
if di != dj {
|
||||||
|
return di < dj
|
||||||
|
}
|
||||||
|
return ci.NameLower < cj.NameLower
|
||||||
|
})
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
// searchWiki scores the cached folder index against query. Blocks on the
|
// searchWiki scores the cached folder index against query. Blocks on the
|
||||||
// initial build so the very first request after startup serves correct
|
// initial build so the very first request after startup serves correct
|
||||||
// results rather than an empty list. Returns the snapshot's builtAt so the
|
// results rather than an empty list. Returns the snapshot's builtAt so the
|
||||||
@@ -92,45 +206,65 @@ func searchWiki(query string) ([]searchResult, time.Time) {
|
|||||||
if query == "" {
|
if query == "" {
|
||||||
return nil, builtAt
|
return nil, builtAt
|
||||||
}
|
}
|
||||||
qLower := strings.ToLower(query)
|
scored := scoreEntries(entries, query, true, func(e folderEntry) indexEntry { return e })
|
||||||
qTokens := tokenize(qLower)
|
results := make([]searchResult, 0, len(scored))
|
||||||
if len(qTokens) == 0 {
|
for _, s := range scored {
|
||||||
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{
|
results = append(results, searchResult{
|
||||||
Name: filepath.Base(e.Path),
|
Name: filepath.Base(s.entry.Path),
|
||||||
URL: "/" + e.Path + "/",
|
URL: "/" + s.entry.Path + "/",
|
||||||
Path: e.Path,
|
Path: s.entry.Path,
|
||||||
Score: score,
|
Score: s.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
|
return results, builtAt
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// searchFiles scores the cached file index against query, matching on filename
|
||||||
|
// only. Blocks on the initial build so the first request after startup doesn't
|
||||||
|
// serve an empty Files section while the walk is still running.
|
||||||
|
func searchFiles(query string) []searchResult {
|
||||||
|
<-fileIndex.ready
|
||||||
|
fileIndex.RLock()
|
||||||
|
entries := fileIndex.entries
|
||||||
|
fileIndex.RUnlock()
|
||||||
|
|
||||||
|
if query == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
scored := scoreEntries(entries, query, false, func(e fileEntry) indexEntry { return e.indexEntry })
|
||||||
|
results := make([]searchResult, 0, len(scored))
|
||||||
|
for _, s := range scored {
|
||||||
|
p := s.entry.Path
|
||||||
|
results = append(results, searchResult{
|
||||||
|
Name: filepath.Base(p),
|
||||||
|
URL: fileURL(p),
|
||||||
|
Path: p,
|
||||||
|
Score: s.score,
|
||||||
|
Meta: formatSize(s.entry.Size) + " · " + s.entry.ModTime.Format("2006-01-02"),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return results
|
||||||
|
}
|
||||||
|
|
||||||
|
// fileURL builds the browser URL for a file's forward-slash relative path,
|
||||||
|
// percent-escaping each segment so spaces/umlauts/&c. survive round-tripping
|
||||||
|
// through the companion's wikiPathFromHref decode.
|
||||||
|
func fileURL(relPath string) string {
|
||||||
|
parts := strings.Split(relPath, "/")
|
||||||
|
for i, p := range parts {
|
||||||
|
parts[i] = url.PathEscape(p)
|
||||||
|
}
|
||||||
|
return "/" + strings.Join(parts, "/")
|
||||||
|
}
|
||||||
|
|
||||||
// scoreName ranks how well nameLower matches the query. Whole-name exact
|
// scoreName ranks how well nameLower matches the query. Whole-name exact
|
||||||
// match dominates; otherwise score is the sum of each token's best match
|
// 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.
|
// against the words in the name. nameTokens is precomputed by the index. fuzzy
|
||||||
func scoreName(nameLower string, nameTokens []string, qLower string, qTokens []string) int {
|
// enables the levenshtein near-match fallback; the file index passes false so
|
||||||
|
// large file volumes don't pay the edit-distance cost per query.
|
||||||
|
func scoreName(nameLower string, nameTokens []string, qLower string, qTokens []string, fuzzy bool) int {
|
||||||
if nameLower == qLower {
|
if nameLower == qLower {
|
||||||
return 1000
|
return exactNameScore
|
||||||
}
|
}
|
||||||
score := 0
|
score := 0
|
||||||
for _, qt := range qTokens {
|
for _, qt := range qTokens {
|
||||||
@@ -149,7 +283,7 @@ func scoreName(nameLower string, nameTokens []string, qLower string, qTokens []s
|
|||||||
if best < 20 {
|
if best < 20 {
|
||||||
best = 20
|
best = 20
|
||||||
}
|
}
|
||||||
case levenshtein(w, qt) <= 2:
|
case fuzzy && levenshtein(w, qt) <= 2:
|
||||||
if best < 5 {
|
if best < 5 {
|
||||||
best = 5
|
best = 5
|
||||||
}
|
}
|
||||||
@@ -216,12 +350,15 @@ func (h *handler) handleReindex(w http.ResponseWriter, r *http.Request) {
|
|||||||
w.WriteHeader(http.StatusNoContent)
|
w.WriteHeader(http.StatusNoContent)
|
||||||
}
|
}
|
||||||
|
|
||||||
// buildFolderIndex walks root and returns a fresh slice of folder entries.
|
// buildIndexes walks root once and returns fresh folder and file entries. The
|
||||||
// Hidden directories (`.git`, `.thumbs`, …) are pruned; the root itself is
|
// single pass avoids a second full traversal on the ARMv7 NAS. Hidden
|
||||||
// excluded since it cannot be a search match.
|
// directories (`.git`, `.thumbs`, …) are pruned and hidden files skipped; the
|
||||||
func buildFolderIndex(root string) []folderEntry {
|
// root itself and every `index.md` (page content, not a browsable file) are
|
||||||
|
// excluded.
|
||||||
|
func buildIndexes(root string) ([]folderEntry, []fileEntry) {
|
||||||
walkRoot := resolveWalkRoot(root)
|
walkRoot := resolveWalkRoot(root)
|
||||||
var entries []folderEntry
|
var folders []folderEntry
|
||||||
|
var files []fileEntry
|
||||||
_ = filepath.WalkDir(walkRoot, func(fsPath string, d fs.DirEntry, err error) error {
|
_ = filepath.WalkDir(walkRoot, func(fsPath string, d fs.DirEntry, err error) error {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil
|
return nil
|
||||||
@@ -229,46 +366,78 @@ func buildFolderIndex(root string) []folderEntry {
|
|||||||
if skip, walkErr := hiddenSkip(fsPath, walkRoot, d); skip {
|
if skip, walkErr := hiddenSkip(fsPath, walkRoot, d); skip {
|
||||||
return walkErr
|
return walkErr
|
||||||
}
|
}
|
||||||
if !d.IsDir() || fsPath == walkRoot {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
rel, relErr := filepath.Rel(walkRoot, fsPath)
|
rel, relErr := filepath.Rel(walkRoot, fsPath)
|
||||||
if relErr != nil {
|
if relErr != nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
entries = append(entries, newFolderEntry(filepath.ToSlash(rel)))
|
relSlash := filepath.ToSlash(rel)
|
||||||
|
if d.IsDir() {
|
||||||
|
if fsPath != walkRoot {
|
||||||
|
folders = append(folders, newFolderEntry(relSlash))
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if d.Name() == "index.md" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
info, infoErr := d.Info()
|
||||||
|
if infoErr != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
files = append(files, newFileEntry(relSlash, info.Size(), info.ModTime()))
|
||||||
return nil
|
return nil
|
||||||
})
|
})
|
||||||
return entries
|
return folders, files
|
||||||
}
|
}
|
||||||
|
|
||||||
// newFolderEntry builds a folderEntry from a forward-slash relative path,
|
// newFolderEntry builds a folderEntry from a forward-slash relative path,
|
||||||
// computing the lowercased basename and its tokens once so search scoring
|
// computing the lowercased basename and its tokens once so search scoring
|
||||||
// doesn't have to redo it per query.
|
// doesn't have to redo it per query.
|
||||||
func newFolderEntry(relPath string) folderEntry {
|
func newFolderEntry(relPath string) folderEntry {
|
||||||
|
return newIndexEntry(relPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
// newFileEntry builds a fileEntry, capturing the walk-time size/modtime so
|
||||||
|
// results show listing-parity metadata without a second stat.
|
||||||
|
func newFileEntry(relPath string, size int64, modTime time.Time) fileEntry {
|
||||||
|
return fileEntry{
|
||||||
|
indexEntry: newIndexEntry(relPath),
|
||||||
|
Size: size,
|
||||||
|
ModTime: modTime,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// newIndexEntry precomputes the lowercased basename and its tokens for the
|
||||||
|
// per-query scoring loop.
|
||||||
|
func newIndexEntry(relPath string) indexEntry {
|
||||||
name := relPath
|
name := relPath
|
||||||
if i := strings.LastIndex(relPath, "/"); i >= 0 {
|
if i := strings.LastIndex(relPath, "/"); i >= 0 {
|
||||||
name = relPath[i+1:]
|
name = relPath[i+1:]
|
||||||
}
|
}
|
||||||
nameLower := strings.ToLower(name)
|
nameLower := strings.ToLower(name)
|
||||||
return folderEntry{
|
return indexEntry{
|
||||||
Path: relPath,
|
Path: relPath,
|
||||||
NameLower: nameLower,
|
NameLower: nameLower,
|
||||||
NameTokens: tokenize(nameLower),
|
NameTokens: tokenize(nameLower),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// rebuildFolderIndex walks root and replaces the index entries atomically.
|
// rebuildFolderIndex walks root once and atomically replaces both the folder
|
||||||
// buildMu serializes overlapping rebuilds (manual + ticker + startup) so
|
// and file indexes. buildMu serializes overlapping rebuilds (manual + ticker +
|
||||||
// the WalkDir cost is paid once even under contention.
|
// startup) so the WalkDir cost is paid once even under contention.
|
||||||
func rebuildFolderIndex(root string) {
|
func rebuildFolderIndex(root string) {
|
||||||
folderIndex.buildMu.Lock()
|
folderIndex.buildMu.Lock()
|
||||||
defer folderIndex.buildMu.Unlock()
|
defer folderIndex.buildMu.Unlock()
|
||||||
entries := buildFolderIndex(root)
|
folders, files := buildIndexes(root)
|
||||||
|
now := time.Now()
|
||||||
folderIndex.Lock()
|
folderIndex.Lock()
|
||||||
folderIndex.entries = entries
|
folderIndex.entries = folders
|
||||||
folderIndex.builtAt = time.Now()
|
folderIndex.builtAt = now
|
||||||
folderIndex.Unlock()
|
folderIndex.Unlock()
|
||||||
|
fileIndex.Lock()
|
||||||
|
fileIndex.entries = files
|
||||||
|
fileIndex.builtAt = now
|
||||||
|
fileIndex.Unlock()
|
||||||
}
|
}
|
||||||
|
|
||||||
// folderIndexAdd appends relPath as a new entry. No-op for empty/root paths.
|
// folderIndexAdd appends relPath as a new entry. No-op for empty/root paths.
|
||||||
@@ -335,6 +504,38 @@ func folderIndexRenameSubtree(oldRel, newRel string) {
|
|||||||
folderIndex.entries = out
|
folderIndex.entries = out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// folderIndexMergeSubtree updates the index after a merge move: it drops the
|
||||||
|
// source root entry (that folder is gone) and rewrites every descendant's
|
||||||
|
// prefix to live under newRel. Unlike folderIndexRenameSubtree it does not add
|
||||||
|
// a newRel entry, since the destination folder already exists in the index.
|
||||||
|
func folderIndexMergeSubtree(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, 0, len(old))
|
||||||
|
for _, e := range old {
|
||||||
|
switch {
|
||||||
|
case e.Path == oldRel:
|
||||||
|
continue
|
||||||
|
case strings.HasPrefix(e.Path, oldPrefix):
|
||||||
|
out = append(out, folderEntry{
|
||||||
|
Path: newRel + "/" + strings.TrimPrefix(e.Path, oldPrefix),
|
||||||
|
NameLower: e.NameLower,
|
||||||
|
NameTokens: e.NameTokens,
|
||||||
|
})
|
||||||
|
default:
|
||||||
|
out = append(out, e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
folderIndex.entries = out
|
||||||
|
}
|
||||||
|
|
||||||
// resolveWalkRoot resolves symlinks so WalkDir descends into the real tree
|
// resolveWalkRoot resolves symlinks so WalkDir descends into the real tree
|
||||||
// even when the configured wiki root is itself a symlink (as on the NAS).
|
// even when the configured wiki root is itself a symlink (as on the NAS).
|
||||||
func resolveWalkRoot(root string) string {
|
func resolveWalkRoot(root string) string {
|
||||||
|
|||||||
+34
@@ -3,6 +3,7 @@ package main
|
|||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"regexp"
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"github.com/yuin/goldmark/ast"
|
"github.com/yuin/goldmark/ast"
|
||||||
"github.com/yuin/goldmark/text"
|
"github.com/yuin/goldmark/text"
|
||||||
@@ -10,6 +11,11 @@ import (
|
|||||||
|
|
||||||
var sectionHeadingRe = regexp.MustCompile(`(?m)^#{1,6} `)
|
var sectionHeadingRe = regexp.MustCompile(`(?m)^#{1,6} `)
|
||||||
|
|
||||||
|
// headingTextRe matches an ATX heading at the start of a section. The
|
||||||
|
// heading text is everything after the `#`s and the required space, on the
|
||||||
|
// first line.
|
||||||
|
var headingTextRe = regexp.MustCompile(`^(#{1,6})\s+([^\n]*)`)
|
||||||
|
|
||||||
// splitSections splits raw markdown into sections.
|
// splitSections splits raw markdown into sections.
|
||||||
// Section 0 is any content before the first heading.
|
// Section 0 is any content before the first heading.
|
||||||
// Each subsequent section begins at a heading line and runs to the next.
|
// Each subsequent section begins at a heading line and runs to the next.
|
||||||
@@ -64,3 +70,31 @@ func joinSections(sections [][]byte) []byte {
|
|||||||
}
|
}
|
||||||
return buf.Bytes()
|
return buf.Bytes()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// sectionHeading returns the heading level (1..6) and trimmed text of a
|
||||||
|
// section produced by splitSections. Returns level=0 for the pre-heading
|
||||||
|
// section (index 0).
|
||||||
|
func sectionHeading(section []byte) (level int, text string) {
|
||||||
|
m := headingTextRe.FindSubmatch(section)
|
||||||
|
if m == nil {
|
||||||
|
return 0, ""
|
||||||
|
}
|
||||||
|
return len(m[1]), strings.TrimSpace(string(m[2]))
|
||||||
|
}
|
||||||
|
|
||||||
|
// sectionSpanEnd returns the exclusive end index of the section span starting
|
||||||
|
// at start: the first following section whose heading level is <= start's.
|
||||||
|
// Sections nested deeper than start (its subsections) belong to the span, so
|
||||||
|
// editing a `##` covers every `###`+ under it up to the next `##` or `#`.
|
||||||
|
func secionSpanEnd(sections [][]byte, start int) int {
|
||||||
|
level, _ := sectionHeading(sections[start])
|
||||||
|
if level == 0 {
|
||||||
|
return start + 1
|
||||||
|
}
|
||||||
|
for i := start + 1; i < len(sections); i++ {
|
||||||
|
if l, _ := sectionHeading(sections[i]); l > 0 && l <= level {
|
||||||
|
return i
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return len(sections)
|
||||||
|
}
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import (
|
|||||||
"path/filepath"
|
"path/filepath"
|
||||||
"regexp"
|
"regexp"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// taskCheckboxRe matches the <input> tag goldmark's GFM extension emits for a
|
// taskCheckboxRe matches the <input> tag goldmark's GFM extension emits for a
|
||||||
@@ -111,201 +110,3 @@ func flipTaskLine(raw []byte, n int, checked bool) ([]byte, bool) {
|
|||||||
lines[target] = taskLineRe.ReplaceAll(lines[target], replacement)
|
lines[target] = taskLineRe.ReplaceAll(lines[target], replacement)
|
||||||
return bytes.Join(lines, []byte("\n")), true
|
return bytes.Join(lines, []byte("\n")), true
|
||||||
}
|
}
|
||||||
|
|
||||||
// handleCleanTasks rewrites index.md with every completed task line — and its
|
|
||||||
// continuation lines — removed. Triggered by POST /{path}?cleantasks=1.
|
|
||||||
func (h *handler) handleCleanTasks(w http.ResponseWriter, r *http.Request, urlPath, fsPath string) {
|
|
||||||
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 := stripCompletedTasks(raw)
|
|
||||||
if !bytes.Equal(updated, raw) {
|
|
||||||
if err := writeFileAtomic(indexPath, updated, 0644); err != nil {
|
|
||||||
http.Error(w, "write failed: "+err.Error(), http.StatusInternalServerError)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
http.Redirect(w, r, urlPath, http.StatusSeeOther)
|
|
||||||
}
|
|
||||||
|
|
||||||
// handleAddTask appends a single `- [ ] text` task to the last task list in
|
|
||||||
// the selected section, or creates a new list at the end of the section if
|
|
||||||
// none exists. Triggered by POST /{path}?addtask=<section-index> with form
|
|
||||||
// field `text`.
|
|
||||||
func (h *handler) handleAddTask(w http.ResponseWriter, r *http.Request, urlPath, fsPath string) {
|
|
||||||
sectionIndex, err := strconv.Atoi(r.URL.Query().Get("addtask"))
|
|
||||||
if err != nil || sectionIndex < 1 {
|
|
||||||
http.Error(w, "bad section index", http.StatusBadRequest)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if err := r.ParseForm(); err != nil {
|
|
||||||
http.Error(w, "bad request", http.StatusBadRequest)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
text := r.FormValue("text")
|
|
||||||
if strings.ContainsAny(text, "\r\n") {
|
|
||||||
http.Error(w, "text must be single-line", http.StatusBadRequest)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
text = strings.TrimSpace(text)
|
|
||||||
if text == "" {
|
|
||||||
http.Error(w, "empty text", http.StatusBadRequest)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
indexPath := filepath.Join(fsPath, "index.md")
|
|
||||||
raw, err := os.ReadFile(indexPath)
|
|
||||||
if err != nil {
|
|
||||||
http.Error(w, "read failed: "+err.Error(), http.StatusInternalServerError)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
sections := splitSections(raw)
|
|
||||||
if sectionIndex >= len(sections) {
|
|
||||||
http.Error(w, "section out of range", http.StatusBadRequest)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
sections[sectionIndex] = appendToLastTaskList(sections[sectionIndex], text)
|
|
||||||
updated := joinSections(sections)
|
|
||||||
if err := writeFileAtomic(indexPath, updated, 0644); err != nil {
|
|
||||||
http.Error(w, "write failed: "+err.Error(), http.StatusInternalServerError)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
target := urlPath
|
|
||||||
ids := headingIDs(updated)
|
|
||||||
if sectionIndex-1 < len(ids) {
|
|
||||||
target = urlPath + "#" + ids[sectionIndex-1]
|
|
||||||
}
|
|
||||||
http.Redirect(w, r, target, http.StatusSeeOther)
|
|
||||||
}
|
|
||||||
|
|
||||||
// splitLines returns raw split on '\n', dropping the trailing empty element
|
|
||||||
// produced when raw ends in '\n', and reports whether that newline was there.
|
|
||||||
// reassemble undoes the split with the matching trailing-newline state.
|
|
||||||
func splitLines(raw []byte) (lines [][]byte, trailingNewline bool) {
|
|
||||||
trailingNewline = len(raw) > 0 && raw[len(raw)-1] == '\n'
|
|
||||||
lines = bytes.Split(raw, []byte("\n"))
|
|
||||||
if trailingNewline && len(lines) > 0 && len(lines[len(lines)-1]) == 0 {
|
|
||||||
lines = lines[:len(lines)-1]
|
|
||||||
}
|
|
||||||
return lines, trailingNewline
|
|
||||||
}
|
|
||||||
|
|
||||||
func reassemble(lines [][]byte, trailingNewline bool) []byte {
|
|
||||||
out := bytes.Join(lines, []byte("\n"))
|
|
||||||
if trailingNewline && len(out) > 0 {
|
|
||||||
out = append(out, '\n')
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
// isFence reports whether line opens or closes a fenced code block.
|
|
||||||
func isFence(line []byte) bool {
|
|
||||||
t := bytes.TrimLeft(line, " \t")
|
|
||||||
return bytes.HasPrefix(t, []byte("```")) || bytes.HasPrefix(t, []byte("~~~"))
|
|
||||||
}
|
|
||||||
|
|
||||||
// indentWidth counts leading-whitespace columns, tabs and spaces equally.
|
|
||||||
// Adequate for the user's own wiki text, where mixed tab/space indents are rare.
|
|
||||||
func indentWidth(line []byte) int {
|
|
||||||
n := 0
|
|
||||||
for n < len(line) && (line[n] == ' ' || line[n] == '\t') {
|
|
||||||
n++
|
|
||||||
}
|
|
||||||
return n
|
|
||||||
}
|
|
||||||
|
|
||||||
// stripCompletedTasks removes every `[x]`/`[X]` task line and its continuation
|
|
||||||
// lines (blank, or indented strictly more than the bullet) from raw. Lines
|
|
||||||
// inside fenced code blocks are ignored, matching flipTaskLine's contract.
|
|
||||||
func stripCompletedTasks(raw []byte) []byte {
|
|
||||||
lines, trailing := splitLines(raw)
|
|
||||||
out := make([][]byte, 0, len(lines))
|
|
||||||
inFence := false
|
|
||||||
for i := 0; i < len(lines); i++ {
|
|
||||||
line := lines[i]
|
|
||||||
if isFence(line) {
|
|
||||||
inFence = !inFence
|
|
||||||
out = append(out, line)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if !inFence {
|
|
||||||
if m := taskLineRe.FindSubmatch(line); m != nil && (m[2][0] == 'x' || m[2][0] == 'X') {
|
|
||||||
bulletIndent := indentWidth(line)
|
|
||||||
j := i + 1
|
|
||||||
for j < len(lines) {
|
|
||||||
next := lines[j]
|
|
||||||
if len(bytes.TrimSpace(next)) > 0 && indentWidth(next) <= bulletIndent {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
j++
|
|
||||||
}
|
|
||||||
i = j - 1
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
}
|
|
||||||
out = append(out, line)
|
|
||||||
}
|
|
||||||
return reassemble(out, trailing)
|
|
||||||
}
|
|
||||||
|
|
||||||
// appendToLastTaskList inserts `- [ ] text` after the last task list item in
|
|
||||||
// sectionBytes. If no task list exists in the section, it appends a new list
|
|
||||||
// at the end, separated by a blank line. Bullet character and indent are
|
|
||||||
// inherited from the existing last item when present.
|
|
||||||
func appendToLastTaskList(sectionBytes []byte, text string) []byte {
|
|
||||||
lines, trailing := splitLines(sectionBytes)
|
|
||||||
|
|
||||||
// Forward scan: track fence state and remember the last non-fenced task line.
|
|
||||||
lastTask, lastPrefix, lastIndent := -1, "", 0
|
|
||||||
inFence := false
|
|
||||||
for i, line := range lines {
|
|
||||||
if isFence(line) {
|
|
||||||
inFence = !inFence
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if inFence {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if m := taskLineRe.FindSubmatch(line); m != nil {
|
|
||||||
lastTask = i
|
|
||||||
lastPrefix = string(m[1])
|
|
||||||
lastIndent = indentWidth(line)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if lastTask >= 0 {
|
|
||||||
// Walk forward over continuation lines (blank or more-indented), then
|
|
||||||
// back over trailing blanks so the new task slots in after the last
|
|
||||||
// real content line of the item.
|
|
||||||
end := lastTask + 1
|
|
||||||
for end < len(lines) {
|
|
||||||
next := lines[end]
|
|
||||||
if len(bytes.TrimSpace(next)) > 0 && indentWidth(next) <= lastIndent {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
end++
|
|
||||||
}
|
|
||||||
for end > lastTask+1 && len(bytes.TrimSpace(lines[end-1])) == 0 {
|
|
||||||
end--
|
|
||||||
}
|
|
||||||
newLine := []byte(lastPrefix + "[ ] " + text)
|
|
||||||
out := append(append(append([][]byte{}, lines[:end]...), newLine), lines[end:]...)
|
|
||||||
return reassemble(out, trailing)
|
|
||||||
}
|
|
||||||
|
|
||||||
// No task list — append one at section end, blank-line-separated from any
|
|
||||||
// preceding content. Trim trailing blanks first to control spacing exactly.
|
|
||||||
for len(lines) > 0 && len(bytes.TrimSpace(lines[len(lines)-1])) == 0 {
|
|
||||||
lines = lines[:len(lines)-1]
|
|
||||||
}
|
|
||||||
if len(lines) > 0 {
|
|
||||||
lines = append(lines, nil)
|
|
||||||
}
|
|
||||||
lines = append(lines, []byte("- [ ] "+text))
|
|
||||||
return reassemble(lines, trailing)
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -26,6 +26,14 @@ type Thumbnailer interface {
|
|||||||
Generate(src io.Reader, dst io.Writer, width int) error
|
Generate(src io.Reader, dst io.Writer, width int) error
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// PathThumbnailer is an optional capability for thumbnailers whose source must
|
||||||
|
// be a seekable file on disk rather than a stream (e.g. ffmpeg for video). When
|
||||||
|
// a Thumbnailer also implements this, handleThumb passes the file path directly
|
||||||
|
// and skips reading the file into memory and content-hashing it.
|
||||||
|
type PathThumbnailer interface {
|
||||||
|
GenerateFromPath(srcPath string, dst io.Writer, width int) error
|
||||||
|
}
|
||||||
|
|
||||||
var thumbnailers []Thumbnailer
|
var thumbnailers []Thumbnailer
|
||||||
|
|
||||||
// thumbCacheDir is set from the -cache flag at startup.
|
// thumbCacheDir is set from the -cache flag at startup.
|
||||||
@@ -88,9 +96,14 @@ func (h *handler) handleThumb(w http.ResponseWriter, r *http.Request) {
|
|||||||
http.Error(w, "bad path", http.StatusBadRequest)
|
http.Error(w, "bad path", http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
cleanPath := path.Clean(decoded)
|
h.serveThumbnail(w, r, filepath.Join(h.root, filepath.FromSlash(path.Clean(decoded))))
|
||||||
|
}
|
||||||
|
|
||||||
srcFS := filepath.Join(h.root, filepath.FromSlash(cleanPath))
|
// serveThumbnail generates (or serves from cache) a thumbnail of the file at
|
||||||
|
// srcFS, sized by the ?w= query. Both entry points land here: the /_thumb
|
||||||
|
// route used by listings and the diary, and the ?w= query on a file's own URL,
|
||||||
|
// which is the form embeds use so their <img src> stays relative to the page.
|
||||||
|
func (h *handler) serveThumbnail(w http.ResponseWriter, r *http.Request, srcFS string) {
|
||||||
rel, err := filepath.Rel(h.root, srcFS)
|
rel, err := filepath.Rel(h.root, srcFS)
|
||||||
if err != nil || strings.HasPrefix(rel, "..") {
|
if err != nil || strings.HasPrefix(rel, "..") {
|
||||||
http.Error(w, "Forbidden", http.StatusForbidden)
|
http.Error(w, "Forbidden", http.StatusForbidden)
|
||||||
@@ -116,12 +129,40 @@ func (h *handler) handleThumb(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
digest, data, err := sourceDigest(srcFS, srcInfo)
|
// Path thumbnailers (video/ffmpeg) take the source path directly and key
|
||||||
|
// the cache on path+mtime+size, so the file is never read into memory.
|
||||||
|
// Content thumbnailers (images) stay content-addressed so renames reuse
|
||||||
|
// the cache entry. Either way, generation runs through the closure below.
|
||||||
|
var (
|
||||||
|
digest string
|
||||||
|
write func(dst io.Writer) error
|
||||||
|
)
|
||||||
|
if pt, ok := t.(PathThumbnailer); ok {
|
||||||
|
digest = pathDigest(srcFS, srcInfo)
|
||||||
|
write = func(dst io.Writer) error { return pt.GenerateFromPath(srcFS, dst, width) }
|
||||||
|
} else {
|
||||||
|
d, data, err := sourceDigest(srcFS, srcInfo)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("thumb digest %s: %v", rel, err)
|
log.Printf("thumb digest %s: %v", rel, err)
|
||||||
http.Error(w, "thumbnail failed", http.StatusInternalServerError)
|
http.Error(w, "thumbnail failed", http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
digest = d
|
||||||
|
write = func(dst io.Writer) error {
|
||||||
|
var src io.Reader
|
||||||
|
if data != nil {
|
||||||
|
src = bytes.NewReader(data)
|
||||||
|
} else {
|
||||||
|
f, err := os.Open(srcFS)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
src = f
|
||||||
|
}
|
||||||
|
return t.Generate(src, dst, width)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
cacheFS := filepath.Join(thumbCacheDir, digest[:2], fmt.Sprintf("%s.%d.jpg", digest, width))
|
cacheFS := filepath.Join(thumbCacheDir, digest[:2], fmt.Sprintf("%s.%d.jpg", digest, width))
|
||||||
if _, err := os.Stat(cacheFS); err == nil {
|
if _, err := os.Stat(cacheFS); err == nil {
|
||||||
@@ -138,21 +179,7 @@ func (h *handler) handleThumb(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
var src io.Reader
|
if err := generateThumb(cacheFS, write); err != nil {
|
||||||
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)
|
log.Printf("thumb %s: %v", rel, err)
|
||||||
http.Error(w, "thumbnail failed", http.StatusInternalServerError)
|
http.Error(w, "thumbnail failed", http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
@@ -160,6 +187,14 @@ func (h *handler) handleThumb(w http.ResponseWriter, r *http.Request) {
|
|||||||
serveThumb(w, r, cacheFS)
|
serveThumb(w, r, cacheFS)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// pathDigest keys the cache for path-based thumbnailers on the source path plus
|
||||||
|
// its mtime and size, so the (potentially large) file is never read into memory
|
||||||
|
// just to hash it. Overwriting the file changes mtime/size and busts the entry.
|
||||||
|
func pathDigest(srcFS string, info os.FileInfo) string {
|
||||||
|
sum := sha256.Sum256(fmt.Appendf(nil, "%s\x00%d\x00%d", srcFS, info.ModTime().UnixNano(), info.Size()))
|
||||||
|
return hex.EncodeToString(sum[:])
|
||||||
|
}
|
||||||
|
|
||||||
// sourceDigest returns the SHA-256 hex digest of a source file's content.
|
// 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,
|
// 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
|
// so the caller knows to open the file itself. On a miss the file is read
|
||||||
@@ -191,7 +226,10 @@ func serveThumb(w http.ResponseWriter, r *http.Request, cacheFS string) {
|
|||||||
http.ServeFile(w, r, cacheFS)
|
http.ServeFile(w, r, cacheFS)
|
||||||
}
|
}
|
||||||
|
|
||||||
func generateThumb(t Thumbnailer, src io.Reader, cacheFS string, width int) error {
|
// generateThumb writes a thumbnail to cacheFS atomically: write builds the
|
||||||
|
// image into a temp file in the same dir, which is renamed into place only on
|
||||||
|
// success so readers never see a partial file.
|
||||||
|
func generateThumb(cacheFS string, write func(dst io.Writer) error) error {
|
||||||
if err := os.MkdirAll(filepath.Dir(cacheFS), 0755); err != nil {
|
if err := os.MkdirAll(filepath.Dir(cacheFS), 0755); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -200,7 +238,7 @@ func generateThumb(t Thumbnailer, src io.Reader, cacheFS string, width int) erro
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
tmpName := tmp.Name()
|
tmpName := tmp.Name()
|
||||||
if err := t.Generate(src, tmp, width); err != nil {
|
if err := write(tmp); err != nil {
|
||||||
tmp.Close()
|
tmp.Close()
|
||||||
os.Remove(tmpName)
|
os.Remove(tmpName)
|
||||||
return err
|
return err
|
||||||
|
|||||||
@@ -7,12 +7,22 @@ import (
|
|||||||
"image/jpeg"
|
"image/jpeg"
|
||||||
_ "image/png"
|
_ "image/png"
|
||||||
"io"
|
"io"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
thumbnailers = append(thumbnailers, &imageThumbnailer{})
|
thumbnailers = append(thumbnailers, &imageThumbnailer{})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// isImageFile reports whether name is a raster image we can thumbnail and thus
|
||||||
|
// embed on a page. Video files also carry thumbnails but are not embeddable, so
|
||||||
|
// this keys on the image thumbnailer's own extension set rather than
|
||||||
|
// hasThumbnail.
|
||||||
|
func isImageFile(name string) bool {
|
||||||
|
return (&imageThumbnailer{}).CanHandle(strings.ToLower(filepath.Ext(name)))
|
||||||
|
}
|
||||||
|
|
||||||
type imageThumbnailer struct{}
|
type imageThumbnailer struct{}
|
||||||
|
|
||||||
func (it *imageThumbnailer) CanHandle(ext string) bool {
|
func (it *imageThumbnailer) CanHandle(ext string) bool {
|
||||||
|
|||||||
+136
@@ -0,0 +1,136 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
thumbnailers = append(thumbnailers, &videoThumbnailer{})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ffmpegPath is resolved once at startup. Empty means ffmpeg is not installed,
|
||||||
|
// in which case the video thumbnailer disables itself and videos keep their
|
||||||
|
// icon. ffmpeg is a graceful soft-dependency, not a hard requirement: the
|
||||||
|
// binary still runs standalone without it.
|
||||||
|
var ffmpegPath, _ = exec.LookPath("ffmpeg")
|
||||||
|
|
||||||
|
type videoThumbnailer struct{}
|
||||||
|
|
||||||
|
func isVideoExt(ext string) bool {
|
||||||
|
switch ext {
|
||||||
|
case ".mp4", ".mkv", ".mov", ".avi", ".webm", ".m4v":
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// isVideoFile reports whether name is a video container we generate previews
|
||||||
|
// for. Independent of ffmpeg availability — used to tag tiles with a play badge.
|
||||||
|
func isVideoFile(name string) bool {
|
||||||
|
return isVideoExt(strings.ToLower(filepath.Ext(name)))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (vt *videoThumbnailer) CanHandle(ext string) bool {
|
||||||
|
return ffmpegPath != "" && isVideoExt(ext)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate satisfies Thumbnailer but is never used: handleThumb routes video
|
||||||
|
// through the PathThumbnailer branch (GenerateFromPath), since ffmpeg needs a
|
||||||
|
// seekable file path and we must not read large videos into memory.
|
||||||
|
func (vt *videoThumbnailer) Generate(src io.Reader, dst io.Writer, width int) error {
|
||||||
|
return fmt.Errorf("video thumbnails require a file path; use GenerateFromPath")
|
||||||
|
}
|
||||||
|
|
||||||
|
// GenerateFromPath extracts a single frame and writes it to dst as JPEG. It
|
||||||
|
// prefers a frame from the middle of the clip (see seekOffsets) to avoid the
|
||||||
|
// black/fade-in frames common at the very start, falling back to earlier
|
||||||
|
// offsets. The (small) JPEG is buffered first so a failed attempt never leaves
|
||||||
|
// partial bytes in dst.
|
||||||
|
func (vt *videoThumbnailer) GenerateFromPath(srcPath string, dst io.Writer, width int) error {
|
||||||
|
for _, seek := range seekOffsets(srcPath) {
|
||||||
|
var buf bytes.Buffer
|
||||||
|
if err := runFFmpegFrame(srcPath, &buf, width, seek); err == nil && buf.Len() > 0 {
|
||||||
|
_, err = dst.Write(buf.Bytes())
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return fmt.Errorf("ffmpeg produced no frame for %s", srcPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
// seekOffsets returns seek targets (in seconds) to try in order. When the clip
|
||||||
|
// duration is known the midpoint comes first — a representative frame that
|
||||||
|
// dodges black intros and end credits. "1" and "0" are fallbacks for when the
|
||||||
|
// duration is unknown or the midpoint seek yields nothing (very short clips, or
|
||||||
|
// a keyframe gap at the midpoint).
|
||||||
|
func seekOffsets(srcPath string) []string {
|
||||||
|
if d := videoDurationSec(srcPath); d > 2 {
|
||||||
|
return []string{strconv.FormatFloat(d/2, 'f', 2, 64), "1", "0"}
|
||||||
|
}
|
||||||
|
return []string{"1", "0"}
|
||||||
|
}
|
||||||
|
|
||||||
|
// videoDurationSec parses the clip duration from ffmpeg's own stderr, so it
|
||||||
|
// depends only on ffmpeg (ffprobe is not installed everywhere we deploy).
|
||||||
|
// `ffmpeg -i <file>` with no output exits non-zero but prints the container
|
||||||
|
// metadata, including a "Duration: HH:MM:SS.ss" line. Returns 0 when the
|
||||||
|
// duration can't be determined, so the caller falls back to a fixed offset.
|
||||||
|
func videoDurationSec(srcPath string) float64 {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
cmd := exec.CommandContext(ctx, ffmpegPath, "-i", srcPath)
|
||||||
|
var stderr bytes.Buffer
|
||||||
|
cmd.Stderr = &stderr
|
||||||
|
_ = cmd.Run() // expected to "fail": no output file specified. We want stderr.
|
||||||
|
return parseFFmpegDuration(stderr.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseFFmpegDuration extracts seconds from an ffmpeg log containing a line like
|
||||||
|
// " Duration: 00:01:23.45, start: 0.000000, bitrate: 1234 kb/s".
|
||||||
|
func parseFFmpegDuration(log string) float64 {
|
||||||
|
_, after, ok := strings.Cut(log, "Duration:")
|
||||||
|
if !ok {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
field := strings.TrimSpace(after)
|
||||||
|
if c := strings.IndexByte(field, ','); c >= 0 {
|
||||||
|
field = field[:c]
|
||||||
|
}
|
||||||
|
parts := strings.Split(strings.TrimSpace(field), ":")
|
||||||
|
if len(parts) != 3 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
h, err1 := strconv.ParseFloat(parts[0], 64)
|
||||||
|
m, err2 := strconv.ParseFloat(parts[1], 64)
|
||||||
|
s, err3 := strconv.ParseFloat(parts[2], 64)
|
||||||
|
if err1 != nil || err2 != nil || err3 != nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return h*3600 + m*60 + s
|
||||||
|
}
|
||||||
|
|
||||||
|
func runFFmpegFrame(srcPath string, dst io.Writer, width int, seek string) error {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
cmd := exec.CommandContext(ctx, ffmpegPath,
|
||||||
|
"-ss", seek,
|
||||||
|
"-i", srcPath,
|
||||||
|
"-frames:v", "1",
|
||||||
|
// Cap width at the requested size but never upscale (matches the image
|
||||||
|
// thumbnailer); -2 keeps the height even. The comma in min() is escaped
|
||||||
|
// so ffmpeg does not read it as a filter separator.
|
||||||
|
"-vf", "scale='min(iw\\,"+strconv.Itoa(width)+")':-2",
|
||||||
|
"-f", "mjpeg",
|
||||||
|
"-q:v", "5",
|
||||||
|
"pipe:1",
|
||||||
|
)
|
||||||
|
cmd.Stdout = dst
|
||||||
|
return cmd.Run()
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
|
"path/filepath"
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
@@ -11,6 +12,9 @@ import (
|
|||||||
type treeEntry struct {
|
type treeEntry struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Kind string `json:"kind"`
|
Kind string `json:"kind"`
|
||||||
|
// Children is populated only along the expandTo chain (see handleTree);
|
||||||
|
// omitted otherwise so the flat picker listing keeps its original shape.
|
||||||
|
Children []treeEntry `json:"children,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type treeResponse struct {
|
type treeResponse struct {
|
||||||
@@ -36,12 +40,18 @@ func (h *handler) handleTree(w http.ResponseWriter, r *http.Request, urlPath, fs
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
entries, err := listTreeEntries(fsPath)
|
foldersOnly := r.URL.Query().Has("foldersOnly")
|
||||||
|
|
||||||
|
entries, err := listTreeEntries(fsPath, foldersOnly)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(w, "read failed", http.StatusInternalServerError)
|
http.Error(w, "read failed", http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if expandTo := r.URL.Query().Get("expandTo"); expandTo != "" {
|
||||||
|
expandTreeChain(fsPath, entries, treePathSegments(expandTo), foldersOnly)
|
||||||
|
}
|
||||||
|
|
||||||
resp := treeResponse{Path: canonicalTreePath(urlPath), Entries: entries}
|
resp := treeResponse{Path: canonicalTreePath(urlPath), Entries: entries}
|
||||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||||
_ = json.NewEncoder(w).Encode(resp)
|
_ = json.NewEncoder(w).Encode(resp)
|
||||||
@@ -58,8 +68,8 @@ func canonicalTreePath(urlPath string) string {
|
|||||||
|
|
||||||
// listTreeEntries returns the immediate children of fsPath, filtering hidden
|
// listTreeEntries returns the immediate children of fsPath, filtering hidden
|
||||||
// entries and index.md. Folders are listed before files; both groups are
|
// entries and index.md. Folders are listed before files; both groups are
|
||||||
// sorted alphabetically.
|
// sorted alphabetically. foldersOnly will not list any individual files at all.
|
||||||
func listTreeEntries(fsPath string) ([]treeEntry, error) {
|
func listTreeEntries(fsPath string, foldersOnly bool) ([]treeEntry, error) {
|
||||||
raw, err := os.ReadDir(fsPath)
|
raw, err := os.ReadDir(fsPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -73,7 +83,7 @@ func listTreeEntries(fsPath string) ([]treeEntry, error) {
|
|||||||
if e.IsDir() {
|
if e.IsDir() {
|
||||||
folders = append(folders, treeEntry{Name: name, Kind: "folder"})
|
folders = append(folders, treeEntry{Name: name, Kind: "folder"})
|
||||||
} else {
|
} else {
|
||||||
if name == "index.md" {
|
if foldersOnly || name == "index.md" {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
files = append(files, treeEntry{Name: name, Kind: "file"})
|
files = append(files, treeEntry{Name: name, Kind: "file"})
|
||||||
@@ -83,3 +93,39 @@ func listTreeEntries(fsPath string) ([]treeEntry, error) {
|
|||||||
sort.Slice(files, func(i, j int) bool { return files[i].Name < files[j].Name })
|
sort.Slice(files, func(i, j int) bool { return files[i].Name < files[j].Name })
|
||||||
return append(folders, files...), nil
|
return append(folders, files...), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// treePathSegments splits a wiki path into its non-empty segments.
|
||||||
|
func treePathSegments(p string) []string {
|
||||||
|
var segs []string
|
||||||
|
for _, s := range strings.Split(p, "/") {
|
||||||
|
if s != "" {
|
||||||
|
segs = append(segs, s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return segs
|
||||||
|
}
|
||||||
|
|
||||||
|
// expandTreeChain walks segs, matching each against a folder in entries by
|
||||||
|
// name, loading that folder's children in place, and recursing. The walk stops
|
||||||
|
// at the deepest matching segment, so a stale or deleted path simply expands as
|
||||||
|
// far as it still exists. Segments only ever match real directory names from
|
||||||
|
// listTreeEntries (no "." or ".." entries), so this cannot traverse outside the
|
||||||
|
// listed tree.
|
||||||
|
func expandTreeChain(fsPath string, entries []treeEntry, segs []string, foldersOnly bool) {
|
||||||
|
if len(segs) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for i := range entries {
|
||||||
|
if entries[i].Kind != "folder" || entries[i].Name != segs[0] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
childFs := filepath.Join(fsPath, segs[0])
|
||||||
|
kids, err := listTreeEntries(childFs, foldersOnly)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
entries[i].Children = kids
|
||||||
|
expandTreeChain(childFs, entries[i].Children, segs[1:], foldersOnly)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+55
-99
@@ -3,8 +3,6 @@ package main
|
|||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"net/url"
|
"net/url"
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"regexp"
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
@@ -16,20 +14,17 @@ import (
|
|||||||
"github.com/yuin/goldmark/util"
|
"github.com/yuin/goldmark/util"
|
||||||
)
|
)
|
||||||
|
|
||||||
// wikiLinkRe matches [[target]] and [[target::display]] anchored at the start
|
// wikiLinkRe matches [[query]] and [[query::display]] anchored at the start of
|
||||||
// of the current inline reader. Target and display forbid newlines and
|
// the current inline reader. Query and display forbid newlines and brackets;
|
||||||
// brackets; the target is non-greedy so the first `::` separates target from
|
// the query is non-greedy so the first `::` separates query from display when
|
||||||
// display when both are present.
|
// both are present.
|
||||||
var wikiLinkRe = regexp.MustCompile(`^\[\[([^\[\]\n]+?)(?:::([^\[\]\n]+))?\]\]`)
|
var wikiLinkRe = regexp.MustCompile(`^\[\[([^\[\]\n]+?)(?:::([^\[\]\n]+))?\]\]`)
|
||||||
|
|
||||||
// wikiLinkPattern matches wiki-link tokens anywhere in a markdown source.
|
// wikiLinkNode is the AST node produced by wikiLinkParser. Query is already
|
||||||
// Used by the move-endpoint rewriter; not by the goldmark parser.
|
// normalized by wikiSearchQuery.
|
||||||
var wikiLinkPattern = regexp.MustCompile(`\[\[([^\[\]\n]+?)(?:::([^\[\]\n]+))?\]\]`)
|
|
||||||
|
|
||||||
// wikiLinkNode is the AST node produced by wikiLinkParser.
|
|
||||||
type wikiLinkNode struct {
|
type wikiLinkNode struct {
|
||||||
ast.BaseInline
|
ast.BaseInline
|
||||||
Target []byte
|
Query []byte
|
||||||
Display []byte
|
Display []byte
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -39,27 +34,32 @@ func (n *wikiLinkNode) Kind() ast.NodeKind { return kindWikiLink }
|
|||||||
|
|
||||||
func (n *wikiLinkNode) Dump(source []byte, level int) {
|
func (n *wikiLinkNode) Dump(source []byte, level int) {
|
||||||
ast.DumpHelper(n, source, level, map[string]string{
|
ast.DumpHelper(n, source, level, map[string]string{
|
||||||
"Target": string(n.Target),
|
"Query": string(n.Query),
|
||||||
"Display": string(n.Display),
|
"Display": string(n.Display),
|
||||||
}, nil)
|
}, nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
// isValidWikiTarget rejects targets that are not absolute or that contain
|
// wikiSearchQuery normalizes the raw text inside [[...]] into a search query.
|
||||||
// traversal / empty segments. Matches the validation used by the move endpoint.
|
// A leading `/` marks a link written under the old path-based syntax; those
|
||||||
func isValidWikiTarget(target []byte) bool {
|
// collapse to their last segment ("/Trips/Japan 2024" -> "Japan 2024") so
|
||||||
if len(target) == 0 || target[0] != '/' {
|
// existing content keeps resolving without a rewrite. Everything else is free
|
||||||
return false
|
// text and passes through with only surrounding space trimmed.
|
||||||
|
func wikiSearchQuery(raw string) string {
|
||||||
|
q := strings.TrimSpace(raw)
|
||||||
|
if !strings.HasPrefix(q, "/") {
|
||||||
|
return q
|
||||||
}
|
}
|
||||||
trimmed := strings.Trim(string(target), "/")
|
trimmed := strings.Trim(q, "/")
|
||||||
if trimmed == "" {
|
if i := strings.LastIndex(trimmed, "/"); i >= 0 {
|
||||||
return true // root link
|
return strings.TrimSpace(trimmed[i+1:])
|
||||||
}
|
}
|
||||||
for _, seg := range strings.Split(trimmed, "/") {
|
return trimmed
|
||||||
if seg == "" || seg == "." || seg == ".." {
|
}
|
||||||
return false
|
|
||||||
}
|
// wikiSearchHref builds the search-page URL for a query. Escaping leaves only
|
||||||
}
|
// URL-safe bytes, so the result needs no further HTML escaping in an href.
|
||||||
return true
|
func wikiSearchHref(query string) string {
|
||||||
|
return "/?q=" + url.QueryEscape(query)
|
||||||
}
|
}
|
||||||
|
|
||||||
type wikiLinkParser struct{}
|
type wikiLinkParser struct{}
|
||||||
@@ -75,8 +75,10 @@ func (p *wikiLinkParser) Parse(parent ast.Node, block text.Reader, pc parser.Con
|
|||||||
if m == nil {
|
if m == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
target := bytes.TrimSpace(line[m[2]:m[3]])
|
// A query that normalizes to nothing (e.g. a legacy "[[/]]" root link) is
|
||||||
if !isValidWikiTarget(target) {
|
// not a link; leave the source text alone.
|
||||||
|
query := wikiSearchQuery(string(line[m[2]:m[3]]))
|
||||||
|
if query == "" {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
var display []byte
|
var display []byte
|
||||||
@@ -85,58 +87,12 @@ func (p *wikiLinkParser) Parse(parent ast.Node, block text.Reader, pc parser.Con
|
|||||||
}
|
}
|
||||||
block.Advance(m[1])
|
block.Advance(m[1])
|
||||||
return &wikiLinkNode{
|
return &wikiLinkNode{
|
||||||
Target: append([]byte(nil), target...),
|
Query: []byte(query),
|
||||||
Display: append([]byte(nil), display...),
|
Display: append([]byte(nil), display...),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// normalizeWikiTarget strips a trailing slash (but leaves "/" intact) and
|
type wikiLinkRenderer struct{}
|
||||||
// returns the cleaned absolute path.
|
|
||||||
func normalizeWikiTarget(target string) string {
|
|
||||||
if target == "/" {
|
|
||||||
return "/"
|
|
||||||
}
|
|
||||||
return "/" + strings.Trim(target, "/")
|
|
||||||
}
|
|
||||||
|
|
||||||
// wikiTargetHref converts a wiki target to a URL href with each segment
|
|
||||||
// percent-encoded and a trailing slash appended.
|
|
||||||
func wikiTargetHref(target string) string {
|
|
||||||
target = normalizeWikiTarget(target)
|
|
||||||
if target == "/" {
|
|
||||||
return "/"
|
|
||||||
}
|
|
||||||
var b strings.Builder
|
|
||||||
for _, seg := range strings.Split(strings.TrimPrefix(target, "/"), "/") {
|
|
||||||
b.WriteByte('/')
|
|
||||||
b.WriteString(url.PathEscape(seg))
|
|
||||||
}
|
|
||||||
b.WriteByte('/')
|
|
||||||
return b.String()
|
|
||||||
}
|
|
||||||
|
|
||||||
// wikiTargetExists reports whether the on-disk folder backing the target
|
|
||||||
// exists under root.
|
|
||||||
func wikiTargetExists(root, target string) bool {
|
|
||||||
target = normalizeWikiTarget(target)
|
|
||||||
fsPath := filepath.Join(root, filepath.FromSlash(strings.TrimPrefix(target, "/")))
|
|
||||||
info, err := os.Stat(fsPath)
|
|
||||||
return err == nil && info.IsDir()
|
|
||||||
}
|
|
||||||
|
|
||||||
// wikiDefaultDisplay returns the last segment of a target, or "/" for the root.
|
|
||||||
func wikiDefaultDisplay(target string) string {
|
|
||||||
target = normalizeWikiTarget(target)
|
|
||||||
if target == "/" {
|
|
||||||
return "/"
|
|
||||||
}
|
|
||||||
segs := strings.Split(strings.TrimPrefix(target, "/"), "/")
|
|
||||||
return segs[len(segs)-1]
|
|
||||||
}
|
|
||||||
|
|
||||||
type wikiLinkRenderer struct {
|
|
||||||
root string
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *wikiLinkRenderer) RegisterFuncs(reg renderer.NodeRendererFuncRegisterer) {
|
func (r *wikiLinkRenderer) RegisterFuncs(reg renderer.NodeRendererFuncRegisterer) {
|
||||||
reg.Register(kindWikiLink, r.render)
|
reg.Register(kindWikiLink, r.render)
|
||||||
@@ -147,32 +103,32 @@ func (r *wikiLinkRenderer) render(w util.BufWriter, source []byte, node ast.Node
|
|||||||
return ast.WalkContinue, nil
|
return ast.WalkContinue, nil
|
||||||
}
|
}
|
||||||
n := node.(*wikiLinkNode)
|
n := node.(*wikiLinkNode)
|
||||||
target := string(n.Target)
|
writeWikiSearchLink(w, string(n.Query), string(n.Display))
|
||||||
href := wikiTargetHref(target)
|
|
||||||
display := string(n.Display)
|
|
||||||
if display == "" {
|
|
||||||
display = wikiDefaultDisplay(target)
|
|
||||||
}
|
|
||||||
broken := !wikiTargetExists(r.root, target)
|
|
||||||
|
|
||||||
w.WriteString(`<a href="`)
|
|
||||||
w.WriteString(href)
|
|
||||||
w.WriteString(`"`)
|
|
||||||
if broken {
|
|
||||||
w.WriteString(` class="broken"`)
|
|
||||||
}
|
|
||||||
w.WriteString(`>`)
|
|
||||||
w.Write(util.EscapeHTML([]byte(display)))
|
|
||||||
w.WriteString(`</a>`)
|
|
||||||
return ast.WalkContinue, nil
|
return ast.WalkContinue, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
type wikiLinkExt struct{ root string }
|
// writeWikiSearchLink renders the anchor for a [[query]] link: an href into the
|
||||||
|
// search page, labelled with the display override or the query itself. Nothing
|
||||||
|
// is resolved at render time — whether the query matches anything is the search
|
||||||
|
// page's business, so there is no broken state here.
|
||||||
|
func writeWikiSearchLink(w util.BufWriter, query, display string) {
|
||||||
|
if display == "" {
|
||||||
|
display = query
|
||||||
|
}
|
||||||
|
w.WriteString(`<a href="`)
|
||||||
|
w.WriteString(wikiSearchHref(query))
|
||||||
|
w.WriteString(`">`)
|
||||||
|
w.Write(util.EscapeHTML([]byte(display)))
|
||||||
|
w.WriteString(`</a>`)
|
||||||
|
}
|
||||||
|
|
||||||
|
type wikiLinkExt struct{}
|
||||||
|
|
||||||
// newWikiLinkExt returns a goldmark extension that turns [[...]] tokens into
|
// newWikiLinkExt returns a goldmark extension that turns [[...]] tokens into
|
||||||
// links resolved against root.
|
// links to the search page. It needs no wiki root — a query is never resolved
|
||||||
func newWikiLinkExt(root string) goldmark.Extender {
|
// against the filesystem.
|
||||||
return &wikiLinkExt{root: root}
|
func newWikiLinkExt() goldmark.Extender {
|
||||||
|
return &wikiLinkExt{}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (e *wikiLinkExt) Extend(m goldmark.Markdown) {
|
func (e *wikiLinkExt) Extend(m goldmark.Markdown) {
|
||||||
@@ -182,6 +138,6 @@ func (e *wikiLinkExt) Extend(m goldmark.Markdown) {
|
|||||||
util.Prioritized(&wikiLinkParser{}, 199),
|
util.Prioritized(&wikiLinkParser{}, 199),
|
||||||
))
|
))
|
||||||
m.Renderer().AddOptions(renderer.WithNodeRenderers(
|
m.Renderer().AddOptions(renderer.WithNodeRenderers(
|
||||||
util.Prioritized(&wikiLinkRenderer{root: e.root}, 500),
|
util.Prioritized(&wikiLinkRenderer{}, 500),
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user