Compare commits
12 Commits
master
..
86f2b7c34f
| Author | SHA1 | Date | |
|---|---|---|---|
| 86f2b7c34f | |||
| c688761e89 | |||
| 174e2dd1cd | |||
| a9ca40c2bd | |||
| eae5d1cc25 | |||
| 1d8dfdb1da | |||
| 6c268aa829 | |||
| 73a8b4f78f | |||
| 1f7cfd637a | |||
| 02a1482789 | |||
| 60b514eae7 | |||
| dedeeb77a8 |
@@ -6,8 +6,3 @@ cache/
|
|||||||
# Binaries
|
# Binaries
|
||||||
datascape
|
datascape
|
||||||
*.exe
|
*.exe
|
||||||
bin/
|
|
||||||
companion/datascape-companion-*
|
|
||||||
|
|
||||||
# Editor build tooling deps (the built bundle is committed; node_modules is not)
|
|
||||||
editor-build/node_modules/
|
|
||||||
|
|||||||
@@ -16,35 +16,12 @@ go build .
|
|||||||
make deploy
|
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
|
## HTTP API Surface
|
||||||
|
|
||||||
| Method | Path | Behaviour |
|
| Method | Path | Behaviour |
|
||||||
|--------|------|-----------|
|
|--------|------|-----------|
|
||||||
| GET | `/{path}/` | If folder exists: render `index.md` + list contents. If not: show empty create prompt. |
|
| 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 |
|
| GET | `/{path}/?edit` | Mobile-friendly editor with `index.md` content in a textarea |
|
||||||
| POST | `/{path}` | Write `index.md` to disk; creates the folder if it does not exist yet |
|
| 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
|
Non-existent paths without a trailing slash redirect to the slash form (GET only — POSTs
|
||||||
@@ -72,33 +49,13 @@ Prefer separate, human-readable `.html` files over inlined HTML strings in Go. E
|
|||||||
|
|
||||||
## Frontend Rules
|
## Frontend Rules
|
||||||
|
|
||||||
- Vanilla JS only — no frameworks, no build pipeline (the single exception is the vendored CodeMirror editor bundle; see Build & Deploy)
|
- Vanilla JS only — no frameworks, no build pipeline
|
||||||
- Each feature gets its own JS file; global behaviour goes in `global-shortcuts.js`
|
- 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
|
- 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
|
- `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
|
- 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:
|
- Prefer generic, descriptive CSS classes (`btn`, `btn-small`, `muted`, `danger`) over element-specific names (`save-button`, `cancel-button`, `form-name-input`). Use a modifier + base class pattern (`btn btn-small`) rather than one-off classes that duplicate shared styles.
|
||||||
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.
|
- Where possible, re-use existing CSS classes
|
||||||
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
|
## Development Priorities
|
||||||
|
|
||||||
@@ -111,8 +68,7 @@ When building features, apply this order:
|
|||||||
## Date Formatting
|
## Date Formatting
|
||||||
|
|
||||||
- General UI dates (file listings, metadata): ISO `YYYY-MM-DD`
|
- 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.
|
- Diary long-form dates: German locale, e.g. `Mittwoch, 1. April 2026` — use `formatGermanDate` in `diary.go`; Go's `time.Format` is English-only so locale names are kept in maps keyed by `time.Weekday` / `time.Month`
|
||||||
- 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
|
## What to Avoid
|
||||||
|
|
||||||
|
|||||||
@@ -1,42 +1,7 @@
|
|||||||
NAS := luxick@192.168.3.3
|
NAS := luxick@192.168.3.3
|
||||||
|
|
||||||
COMPANION_WIN := companion/datascape-companion-windows-amd64.exe
|
.PHONY: deploy
|
||||||
COMPANION_LIN := companion/datascape-companion-linux-amd64
|
deploy:
|
||||||
COMPANION_SRCS := $(wildcard cmd/companion/*.go) $(wildcard cmd/companion/*.html) go.mod go.sum
|
|
||||||
|
|
||||||
EDITOR_BUNDLE := assets/editor/vendor/codemirror.bundle.js
|
|
||||||
EDITOR_SRCS := $(wildcard editor-build/*.js) editor-build/package.json editor-build/package-lock.json
|
|
||||||
|
|
||||||
.PHONY: deploy companion companion-windows companion-linux companion-release editor
|
|
||||||
|
|
||||||
# Cross-compiled companion artifacts the wiki binary embeds. Both must exist
|
|
||||||
# before `go build .` so embed.FS picks them up.
|
|
||||||
companion-release: $(COMPANION_WIN) $(COMPANION_LIN)
|
|
||||||
|
|
||||||
$(COMPANION_WIN): $(COMPANION_SRCS)
|
|
||||||
GOOS=windows GOARCH=amd64 go build -ldflags="-H windowsgui" -o $@ ./cmd/companion
|
|
||||||
|
|
||||||
$(COMPANION_LIN): $(COMPANION_SRCS)
|
|
||||||
GOOS=linux GOARCH=amd64 go build -o $@ ./cmd/companion
|
|
||||||
|
|
||||||
companion-windows: $(COMPANION_WIN)
|
|
||||||
companion-linux: $(COMPANION_LIN)
|
|
||||||
|
|
||||||
# Local companion build for the host OS (handy for development).
|
|
||||||
companion:
|
|
||||||
mkdir -p bin
|
|
||||||
go build -o bin/ ./cmd/companion
|
|
||||||
|
|
||||||
# Regenerate the vendored CodeMirror bundle. One-time/dev-only step: run after
|
|
||||||
# upgrading the @codemirror/* versions in editor-build/package.json. The built
|
|
||||||
# artifact ($(EDITOR_BUNDLE)) is committed; `go build` only consumes it and
|
|
||||||
# never runs Node.
|
|
||||||
editor: $(EDITOR_BUNDLE)
|
|
||||||
|
|
||||||
$(EDITOR_BUNDLE): $(EDITOR_SRCS)
|
|
||||||
cd editor-build && npm ci && npm run build
|
|
||||||
|
|
||||||
deploy: companion-release
|
|
||||||
GOOS=linux GOARCH=arm GOARM=7 go build -o datascape-arm .
|
GOOS=linux GOARCH=arm GOARM=7 go build -o datascape-arm .
|
||||||
ssh $(NAS) 'kill $$(cat /share/homes/luxick/.local/bin/datascape.pid) 2>/dev/null; rm -f /share/homes/luxick/.local/bin/datascape.pid'
|
ssh $(NAS) 'kill $$(cat /share/homes/luxick/.local/bin/datascape.pid) 2>/dev/null; rm -f /share/homes/luxick/.local/bin/datascape.pid'
|
||||||
scp datascape-arm $(NAS):/share/homes/luxick/.local/bin/datascape
|
scp datascape-arm $(NAS):/share/homes/luxick/.local/bin/datascape
|
||||||
|
|||||||
@@ -2,21 +2,12 @@
|
|||||||
|
|
||||||
Minimal self-hosted personal wiki. Folders are pages.
|
Minimal self-hosted personal wiki. Folders are pages.
|
||||||
|
|
||||||
## Features
|
## Run
|
||||||
|
|
||||||
- **Pages** every folder is a page. Place an `index.md` inside a folder and it renders as HTML. Drop any other files (PDFs, images, etc.) alongside it and they appear in the listing below the content. Navigating to a path that does not exist shows a **[CREATE]** prompt.
|
```bash
|
||||||
|
go run . -dir ./wiki -addr :8080
|
||||||
- **View settings** per folder, display the file listing as a list or thumbnail grid and pick the sort key/order, via the **view** button in the `Files` header. See the [View Settings](#view-settings) section.
|
go run . -dir ./wiki -addr :8080 -user me -pass secret
|
||||||
|
```
|
||||||
- **Search** search across all page names (folder names) in the wiki, accessible from the navigation bar.
|
|
||||||
|
|
||||||
- **Wikilinks** link between pages with `[[Page Name]]` syntax. When a page is renamed or moved, all wikilinks pointing to it are rewritten automatically to reflect the new path.
|
|
||||||
|
|
||||||
- **Movie import** import movie entries via the OMDb API. Fetches title, year, runtime, genre, director, cast, plot, and poster, and pre-fills a new page with that metadata.
|
|
||||||
|
|
||||||
- **Special folder types** folders can opt into custom rendering (e.g. a photo diary with calendar navigation). See the [Special Folder Types](#special-folder-types) section for details.
|
|
||||||
|
|
||||||
- **Quick-add bookmarklet** save the current browser tab to a predetermined wiki page (e.g. `/Topics/Bookmarks/`) with one click. See the [Quick-Add Bookmarklet](#quick-add-bookmarklet) section.
|
|
||||||
|
|
||||||
## Build
|
## Build
|
||||||
|
|
||||||
@@ -30,36 +21,20 @@ GOOS=linux GOARCH=arm go build -o datascape .
|
|||||||
|
|
||||||
## Usage
|
## Usage
|
||||||
|
|
||||||
```bash
|
| Action | How |
|
||||||
go run . -dir ./wiki -addr :8080
|
|--------|-----|
|
||||||
go run . -dir ./wiki -addr :8080 -user me -pass secret
|
| Browse | Navigate folders at `/` |
|
||||||
```
|
| Read | Any folder with `index.md` renders it as HTML |
|
||||||
|
| Edit | Append `?edit` to any folder URL, or click **[EDIT]** (Alt+Shift+E) |
|
||||||
|
| Save | POST from the edit form writes `index.md` to disk; folder is created if needed |
|
||||||
|
| New page | Click **[NEW]** (Alt+Shift+N), enter a name — opens the new page in edit mode |
|
||||||
|
| Files | Drop PDFs, images, etc. next to `index.md` — they appear in the listing |
|
||||||
|
|
||||||
| Flag | Default | Description |
|
Navigating to a URL that does not exist shows an empty page with a **[CREATE]** prompt.
|
||||||
|------|---------|-------------|
|
|
||||||
| `-addr` | `:8080` | Listen address |
|
|
||||||
| `-dir` | `./wiki` | Wiki root directory |
|
|
||||||
| `-cache` | `./cache` | Thumbnail cache directory |
|
|
||||||
| `-user` | _(none)_ | Basic auth username — omit to disable auth |
|
|
||||||
| `-pass` | _(none)_ | Basic auth password |
|
|
||||||
| `-reindex-interval` | `30m` | Periodic search index rebuild interval (`0` disables) |
|
|
||||||
|
|
||||||
## View Settings
|
|
||||||
|
|
||||||
The **view** button in a folder's `Files` header sets how its listing renders,
|
|
||||||
persisting three keys to `.page-settings`:
|
|
||||||
|
|
||||||
| Key | Values (default first) |
|
|
||||||
|------|------------------------|
|
|
||||||
| `view` | `list`, `thumbnail` |
|
|
||||||
| `sort` | `name`, `modified`, `size` (folders always sort by name, grouped first) |
|
|
||||||
| `order` | `asc`, `desc` |
|
|
||||||
|
|
||||||
## Special Folder Types
|
## Special Folder Types
|
||||||
|
|
||||||
A folder can opt into special rendering by adding a `.page-settings` file. The
|
A folder can opt into special rendering by adding a `.page-settings` file:
|
||||||
same file also holds the [View Settings](#view-settings) keys; only the `type`
|
|
||||||
key selects a special renderer:
|
|
||||||
|
|
||||||
```
|
```
|
||||||
type = diary
|
type = diary
|
||||||
@@ -67,57 +42,22 @@ type = diary
|
|||||||
|
|
||||||
### Diary
|
### Diary
|
||||||
|
|
||||||
Designed for a chronological photo diary. The whole year lives in a single
|
Designed for a chronological photo diary. Expected structure:
|
||||||
file as ISO-headed sections; photos are loose JPEGs named with a date prefix.
|
|
||||||
|
|
||||||
```
|
```
|
||||||
FolderName/
|
FolderName/
|
||||||
.page-settings ← type = diary
|
.page-settings ← type = diary
|
||||||
YYYY/
|
YYYY/
|
||||||
index.md ← `# YYYY` + `## YYYY-MM` + `### YYYY-MM-DD` sections
|
YYYY-MM-DD Desc.jpg ← photos named with date prefix
|
||||||
YYYY-MM-DD Desc.jpg ← photos named with the date they belong to
|
MM/
|
||||||
|
DD/
|
||||||
|
index.md ← diary entry for that day
|
||||||
```
|
```
|
||||||
|
|
||||||
The year page (`YYYY/`) renders every section in the file with photos
|
| View | What renders |
|
||||||
attached to each `### YYYY-MM-DD` heading. Months and days the file doesn't
|
|
||||||
yet contain are rendered as **virtual** headings with an `[edit]` button that
|
|
||||||
splices a new section into the year file at the right chronological position;
|
|
||||||
virtual day headings still carry photos for that date. Past years render
|
|
||||||
every month/day slot; the current year stops at today; future years skip
|
|
||||||
virtual entries entirely. The file may contain non-date headings (e.g.
|
|
||||||
`## Events` → `### Festival` between `# YYYY` and `## YYYY-01`); these keep
|
|
||||||
their document position.
|
|
||||||
|
|
||||||
A sidebar calendar widget shows one month grid at a time; the month-name
|
|
||||||
button opens a dropdown of all twelve months, and a separate year dropdown
|
|
||||||
jumps between years. Day cells link to the matching anchor on the year page
|
|
||||||
regardless of whether the date has a real section yet.
|
|
||||||
|
|
||||||
#### Persistent date links
|
|
||||||
|
|
||||||
Each diary root exposes three stable paths intended for browser bookmarks.
|
|
||||||
They resolve against the year page rather than separate per-day URLs:
|
|
||||||
|
|
||||||
| Path | Redirects to |
|
|
||||||
|------|-------------|
|
|------|-------------|
|
||||||
| `<diary>/today/` | `<diary>/YYYY/#YYYY-MM-DD` (or the year file's insert-section editor when today's section doesn't exist yet) |
|
| Year (`YYYY/`) | Section per month with link and photo count |
|
||||||
| `<diary>/this-month/` | `<diary>/YYYY/#YYYY-MM` |
|
| Month (`MM/`) | Section per day with entry content and photo grid |
|
||||||
| `<diary>/this-year/` | `<diary>/YYYY/` |
|
| Day (`DD/`) | Entry content and photo grid |
|
||||||
|
|
||||||
Legacy `YYYY/MM/` and `YYYY/MM/DD/` URLs (no longer the canonical form) redirect to the matching anchor on the year page.
|
Days with photos but no `index.md` still appear in the month view and can be created by clicking their heading link.
|
||||||
|
|
||||||
## Quick-Add Bookmarklet
|
|
||||||
|
|
||||||
Replace `wiki.host` with your wiki host and `/Topics/Bookmarks/` with the destination page (one bookmarklet per target):
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
javascript:(function(){var s=window.getSelection().toString().trim();var t=s||document.title;var u=location.href;var to='/Topics/Bookmarks/';var q='?to='+encodeURIComponent(to)+'&url='+encodeURIComponent(u)+'&title='+encodeURIComponent(t);window.open('https://wiki.host/quickadd'+q,'quickadd','width=480,height=320');})();
|
|
||||||
```
|
|
||||||
|
|
||||||
Each save appends an entry of the following form to the destination page's `index.md`:
|
|
||||||
|
|
||||||
```markdown
|
|
||||||
- [Example Page](https://example.com)
|
|
||||||
2026-05-11 14:30
|
|
||||||
optional comment
|
|
||||||
```
|
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
(function () {
|
||||||
|
document.querySelectorAll('.content h1, .content h2, .content h3, .content h4, .content h5, .content h6').forEach(function (h) {
|
||||||
|
if (!h.id) return;
|
||||||
|
var a = document.createElement('a');
|
||||||
|
a.href = '#' + h.id;
|
||||||
|
a.className = 'heading-anchor';
|
||||||
|
a.setAttribute('aria-label', 'Link to this section');
|
||||||
|
a.textContent = '#';
|
||||||
|
h.insertBefore(a, h.firstChild);
|
||||||
|
});
|
||||||
|
}());
|
||||||
@@ -1,179 +0,0 @@
|
|||||||
// Detects the local datascape-companion via a /status probe and wires up
|
|
||||||
// the footer status icon, file-row click interception, and the "reveal in
|
|
||||||
// file manager" page action. All companion calls are best-effort: if the
|
|
||||||
// fetch fails the page falls back to default browser behavior.
|
|
||||||
|
|
||||||
(function () {
|
|
||||||
var COMPANION_PORT = 17680;
|
|
||||||
var COMPANION_BASE = 'http://127.0.0.1:' + COMPANION_PORT;
|
|
||||||
var STATUS_TIMEOUT_MS = 1500;
|
|
||||||
|
|
||||||
var state = { available: false, info: null };
|
|
||||||
|
|
||||||
function wikiPathFromHref(href) {
|
|
||||||
// href is the URL the wiki rendered for the listing item (e.g.
|
|
||||||
// "/photos/2024/img.jpg"). Strip leading slash and decode so the
|
|
||||||
// companion sees a relative wiki path matching its on-disk layout.
|
|
||||||
try {
|
|
||||||
var u = new URL(href, window.location.href);
|
|
||||||
if (u.origin !== window.location.origin) return null;
|
|
||||||
var p = u.pathname.replace(/^\/+/, '');
|
|
||||||
return decodeURIComponent(p);
|
|
||||||
} catch (_) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function companionGET(path, params) {
|
|
||||||
var qs = '';
|
|
||||||
if (params) {
|
|
||||||
var parts = [];
|
|
||||||
for (var k in params) {
|
|
||||||
parts.push(encodeURIComponent(k) + '=' + encodeURIComponent(params[k]));
|
|
||||||
}
|
|
||||||
qs = '?' + parts.join('&');
|
|
||||||
}
|
|
||||||
var ctrl = new AbortController();
|
|
||||||
var timer = setTimeout(function () { ctrl.abort(); }, STATUS_TIMEOUT_MS);
|
|
||||||
return fetch(COMPANION_BASE + path + qs, {
|
|
||||||
method: 'GET',
|
|
||||||
mode: 'cors',
|
|
||||||
credentials: 'omit',
|
|
||||||
signal: ctrl.signal
|
|
||||||
}).finally(function () { clearTimeout(timer); });
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderFlyout(menu) {
|
|
||||||
menu.innerHTML = '';
|
|
||||||
if (state.available) {
|
|
||||||
var info = state.info || {};
|
|
||||||
var name = info.name || 'datascape-companion';
|
|
||||||
var ver = info.version ? ' v' + info.version : '';
|
|
||||||
var head = document.createElement('div');
|
|
||||||
head.className = 'panel-header';
|
|
||||||
head.textContent = 'Companion';
|
|
||||||
menu.appendChild(head);
|
|
||||||
|
|
||||||
var label = document.createElement('div');
|
|
||||||
label.className = 'companion-line';
|
|
||||||
label.textContent = name + ver;
|
|
||||||
menu.appendChild(label);
|
|
||||||
|
|
||||||
var link = document.createElement('a');
|
|
||||||
link.className = 'btn btn-block';
|
|
||||||
link.href = COMPANION_BASE + '/config';
|
|
||||||
link.target = '_blank';
|
|
||||||
link.rel = 'noopener';
|
|
||||||
link.textContent = 'Settings';
|
|
||||||
menu.appendChild(link);
|
|
||||||
} else {
|
|
||||||
var head2 = document.createElement('div');
|
|
||||||
head2.className = 'panel-header';
|
|
||||||
head2.textContent = 'Companion not detected';
|
|
||||||
menu.appendChild(head2);
|
|
||||||
|
|
||||||
var msg = document.createElement('div');
|
|
||||||
msg.className = 'companion-line muted';
|
|
||||||
msg.textContent = 'Install the companion to open files locally.';
|
|
||||||
menu.appendChild(msg);
|
|
||||||
|
|
||||||
var win = document.createElement('a');
|
|
||||||
win.className = 'btn btn-block';
|
|
||||||
win.href = '/companion/download/windows';
|
|
||||||
win.textContent = 'Download — Windows';
|
|
||||||
menu.appendChild(win);
|
|
||||||
|
|
||||||
var lin = document.createElement('a');
|
|
||||||
lin.className = 'btn btn-block';
|
|
||||||
lin.href = '/companion/download/linux';
|
|
||||||
lin.textContent = 'Download — Linux';
|
|
||||||
menu.appendChild(lin);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateFooterIcon() {
|
|
||||||
var wrap = document.querySelector('[data-companion-status]');
|
|
||||||
if (!wrap) return;
|
|
||||||
wrap.hidden = false;
|
|
||||||
var btn = wrap.querySelector('.companion-icon');
|
|
||||||
if (state.available) {
|
|
||||||
btn.textContent = '●';
|
|
||||||
btn.classList.add('companion-on');
|
|
||||||
btn.classList.remove('companion-off');
|
|
||||||
btn.title = 'Companion detected';
|
|
||||||
} else {
|
|
||||||
btn.textContent = '○';
|
|
||||||
btn.classList.add('companion-off');
|
|
||||||
btn.classList.remove('companion-on');
|
|
||||||
btn.title = 'Companion not detected';
|
|
||||||
}
|
|
||||||
var menu = wrap.querySelector('.companion-flyout');
|
|
||||||
renderFlyout(menu);
|
|
||||||
if (typeof wireDropdown === 'function') wireDropdown(btn);
|
|
||||||
}
|
|
||||||
|
|
||||||
function wireFileLinks() {
|
|
||||||
if (!state.available) return;
|
|
||||||
document.addEventListener('click', function (e) {
|
|
||||||
if (!e.target.closest) return;
|
|
||||||
// Match both listing styles: table rows expose the file link inside
|
|
||||||
// a .list-item row; thumbnail tiles are bare a.thumb-tile anchors.
|
|
||||||
var anchor = e.target.closest('.list-item a, a.thumb-tile');
|
|
||||||
if (!anchor) return;
|
|
||||||
var item = anchor.closest('.list-item');
|
|
||||||
// Only intercept the primary file link, and only for files (not folders).
|
|
||||||
// Folders end with "/" — let the browser navigate normally.
|
|
||||||
var path = (item && item.dataset.path) || anchor.getAttribute('href');
|
|
||||||
if (!path || path.endsWith('/')) return;
|
|
||||||
// Allow modified clicks (open in new tab, etc.) to pass through.
|
|
||||||
if (e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return;
|
|
||||||
var rel = wikiPathFromHref(path);
|
|
||||||
if (rel === null) return;
|
|
||||||
e.preventDefault();
|
|
||||||
companionGET('/open-file', { path: rel }).catch(function () {
|
|
||||||
// Fallback: navigate to the file (download / inline view).
|
|
||||||
window.location.href = anchor.href;
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function wireRevealButton() {
|
|
||||||
if (!state.available) return;
|
|
||||||
var btns = document.querySelectorAll('[data-companion-reveal]');
|
|
||||||
btns.forEach(function (btn) {
|
|
||||||
btn.hidden = false;
|
|
||||||
btn.addEventListener('click', function () {
|
|
||||||
var rel = wikiPathFromHref(window.location.pathname);
|
|
||||||
if (rel === null) rel = '';
|
|
||||||
companionGET('/open-folder', { path: rel }).catch(function () { });
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function probeStatus() {
|
|
||||||
return companionGET('/status').then(function (r) {
|
|
||||||
if (!r.ok) throw new Error('status ' + r.status);
|
|
||||||
return r.json();
|
|
||||||
}).then(function (info) {
|
|
||||||
state.available = true;
|
|
||||||
state.info = info;
|
|
||||||
}).catch(function () {
|
|
||||||
state.available = false;
|
|
||||||
state.info = null;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function init() {
|
|
||||||
probeStatus().then(function () {
|
|
||||||
updateFooterIcon();
|
|
||||||
wireFileLinks();
|
|
||||||
wireRevealButton();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (document.readyState === 'loading') {
|
|
||||||
document.addEventListener('DOMContentLoaded', init);
|
|
||||||
} else {
|
|
||||||
init();
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
<div class="diary-cal panel panel-sidebar"
|
|
||||||
data-display-year="{{.DisplayYear}}"
|
|
||||||
data-display-month="{{.DisplayMonth}}">
|
|
||||||
<div class="panel-header"><a href="{{.DiaryURL}}">Chronological</a></div>
|
|
||||||
<div class="diary-cal-nav">
|
|
||||||
<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>
|
|
||||||
<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">
|
|
||||||
{{range .Years}}<a class="btn btn-block{{if .IsCurrent}} cal-current{{end}}" href="{{.URL}}">{{.Num}}</a>{{end}}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{{range .Months}}
|
|
||||||
<table class="diary-cal-grid" data-cal-month="{{.Num}}"{{if ne .Num $.DisplayMonth}} hidden{{end}}>
|
|
||||||
<thead>
|
|
||||||
<tr><th>Mo</th><th>Di</th><th>Mi</th><th>Do</th><th>Fr</th><th>Sa</th><th>So</th></tr>
|
|
||||||
</thead>
|
|
||||||
<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>
|
|
||||||
{{end}}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
{{end}}
|
|
||||||
</div>
|
|
||||||
<script src="/_/diary/calendar.js"></script>
|
|
||||||
@@ -1,49 +0,0 @@
|
|||||||
(function () {
|
|
||||||
var cal = document.querySelector(".diary-cal");
|
|
||||||
if (!cal) return;
|
|
||||||
cal.querySelectorAll(".dropdown > button").forEach(wireDropdown);
|
|
||||||
|
|
||||||
var displayYear = parseInt(cal.dataset.displayYear, 10);
|
|
||||||
var current = parseInt(cal.dataset.displayMonth, 10);
|
|
||||||
var monthLabel = cal.querySelector("[data-cal-month-link]");
|
|
||||||
var months = {};
|
|
||||||
cal.querySelectorAll("[data-cal-month]").forEach(function (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) {
|
|
||||||
if (m === current) return;
|
|
||||||
if (!months[m]) return;
|
|
||||||
months[current].hidden = true;
|
|
||||||
months[m].hidden = false;
|
|
||||||
current = m;
|
|
||||||
if (monthLabel) {
|
|
||||||
var label = jumpLinks[m];
|
|
||||||
if (label) monthLabel.textContent = " " + label.textContent + " ";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Dropdown month picks scroll via the href anchor; we also swap the grid
|
|
||||||
// so the calendar reflects what the user just navigated to.
|
|
||||||
Object.keys(jumpLinks).forEach(function (key) {
|
|
||||||
var a = jumpLinks[key];
|
|
||||||
a.addEventListener("click", function () { show(parseInt(key, 10)); });
|
|
||||||
});
|
|
||||||
|
|
||||||
// Any in-page anchor click (#YYYY-MM or #YYYY-MM-DD) updates the calendar
|
|
||||||
// so it tracks the user's focus through the year page.
|
|
||||||
function syncFromHash() {
|
|
||||||
var h = window.location.hash;
|
|
||||||
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();
|
|
||||||
})();
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
{{range .Sections}}
|
|
||||||
{{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}}<h3 id="{{.ID}}">{{.Heading}}{{if .EditURL}} <a href="{{.EditURL}}" class="btn btn-small">edit</a>{{end}}</h3>{{end}}
|
|
||||||
{{if .Body}}{{.Body}}{{end}}
|
|
||||||
{{if .Photos}}
|
|
||||||
<div class="photo-grid">
|
|
||||||
{{range .Photos}}
|
|
||||||
<a href="{{.URL}}" target="_blank"><img src="{{.ThumbURL}}" alt="" loading="lazy"></a>
|
|
||||||
{{end}}
|
|
||||||
</div>
|
|
||||||
{{end}}
|
|
||||||
{{end}}
|
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
<div class="diary-cal">
|
||||||
|
<div class="panel-header"><a href="{{.DiaryURL}}">Chronological</a></div>
|
||||||
|
<div class="diary-cal-nav">
|
||||||
|
<a href="{{.MonthURL}}" class="diary-cal-heading">{{.MonthName}}</a>
|
||||||
|
<div class="dropdown diary-cal-drop">
|
||||||
|
<button type="button" class="btn btn-small" data-action="cal-month-drop" aria-expanded="false" title="Monat wählen">▾</button>
|
||||||
|
<div class="dropdown-menu">
|
||||||
|
{{range .AllMonths}}<a class="dropdown-item{{if .IsCurrent}} cal-current{{end}}" href="{{.URL}}">{{.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 btn-small" data-action="cal-year-drop" aria-expanded="false" title="Jahr wählen">▾</button>
|
||||||
|
<div class="dropdown-menu align-right">
|
||||||
|
{{range .Years}}<a class="dropdown-item{{if .IsCurrent}} cal-current{{end}}" href="{{.URL}}">{{.Num}}</a>{{end}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<table class="diary-cal-grid">
|
||||||
|
<thead>
|
||||||
|
<tr><th>Mo</th><th>Di</th><th>Mi</th><th>Do</th><th>Fr</th><th>Sa</th><th>So</th></tr>
|
||||||
|
</thead>
|
||||||
|
<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>
|
||||||
|
{{end}}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<script src="/_/diary/diary-calendar.js"></script>
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
(function () {
|
||||||
|
var cal = document.querySelector(".diary-cal");
|
||||||
|
if (!cal) return;
|
||||||
|
|
||||||
|
var toggle = document.createElement("button");
|
||||||
|
toggle.type = "button";
|
||||||
|
toggle.className = "panel-toggle";
|
||||||
|
toggle.textContent = "Kalender";
|
||||||
|
toggle.setAttribute("aria-expanded", "false");
|
||||||
|
toggle.addEventListener("click", function () {
|
||||||
|
var open = cal.classList.toggle("is-open");
|
||||||
|
toggle.setAttribute("aria-expanded", open ? "true" : "false");
|
||||||
|
});
|
||||||
|
|
||||||
|
var main = document.querySelector("main");
|
||||||
|
if (main) {
|
||||||
|
main.parentNode.insertBefore(toggle, main);
|
||||||
|
main.parentNode.insertBefore(cal, main);
|
||||||
|
}
|
||||||
|
|
||||||
|
cal.querySelectorAll(".diary-cal-drop > button").forEach(wireDropdown);
|
||||||
|
|
||||||
|
var pageHeader = document.querySelector("header");
|
||||||
|
function updateTop() {
|
||||||
|
if (!pageHeader || getComputedStyle(cal).position !== "fixed") return;
|
||||||
|
var rect = pageHeader.getBoundingClientRect();
|
||||||
|
cal.style.top = Math.max(8, rect.bottom + 8) + "px";
|
||||||
|
}
|
||||||
|
window.addEventListener("scroll", updateTop, { passive: true });
|
||||||
|
window.addEventListener("resize", updateTop);
|
||||||
|
updateTop();
|
||||||
|
})();
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
{{if .Photos}}
|
||||||
|
<div class="photo-grid">
|
||||||
|
{{range .Photos}}
|
||||||
|
<a href="{{.URL}}" target="_blank"><img src="{{.ThumbURL}}" alt="" loading="lazy"></a>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
{{range .Days}}
|
||||||
|
<h2 id="{{.ID}}">
|
||||||
|
{{if .URL}}<a href="{{.URL}}">{{.Heading}}</a>{{else}}{{.Heading}}{{end}}
|
||||||
|
{{if .EditURL}}<a href="{{.EditURL}}" class="btn btn-small">edit</a>{{end}}
|
||||||
|
</h2>
|
||||||
|
{{if .Content}}{{.Content}}{{end}}
|
||||||
|
{{if .Photos}}
|
||||||
|
<div class="photo-grid">
|
||||||
|
{{range .Photos}}
|
||||||
|
<a href="{{.URL}}" target="_blank"><img src="{{.ThumbURL}}" alt="" loading="lazy"></a>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
{{end}}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
<h2 id="months">Monate</h2>
|
||||||
|
{{range .Months}}
|
||||||
|
<h3 id="{{.ID}}">
|
||||||
|
<a href="{{.URL}}">{{.Name}}</a>
|
||||||
|
</h3>
|
||||||
|
{{if .Photos}}
|
||||||
|
<div class="photo-grid">
|
||||||
|
{{range .Photos}}
|
||||||
|
<a href="{{.URL}}" target="_blank"><img src="{{.ThumbURL}}" alt="" loading="lazy"></a>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
{{end}}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
{{define "headerActions"}}
|
||||||
|
<a class="btn" href="{{.PostURL}}">CANCEL</a>
|
||||||
|
<button class="btn" type="submit" form="edit-form" data-action="save" data-key="S" title="Save (S)">SAVE</button>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
{{define "content"}}
|
||||||
|
<form id="edit-form" class="edit-form" method="POST" action="{{.PostURL}}">
|
||||||
|
{{if ge .SectionIndex 0}}<input type="hidden" name="section" value="{{.SectionIndex}}">{{end}}
|
||||||
|
<div class="editor-toolbar">
|
||||||
|
<button type="button" class="btn btn-tool" data-action="bold" data-key="B" title="Bold (B)">**</button>
|
||||||
|
<button type="button" class="btn btn-tool" data-action="italic" data-key="I" title="Italic (I)">*</button>
|
||||||
|
<span class="toolbar-sep"></span>
|
||||||
|
<button type="button" class="btn btn-tool" data-action="h1" data-key="1" title="Heading 1 (1)">#</button>
|
||||||
|
<button type="button" class="btn btn-tool" data-action="h2" data-key="2" title="Heading 2 (2)">##</button>
|
||||||
|
<button type="button" class="btn btn-tool" data-action="h3" data-key="3" title="Heading 3 (3)">###</button>
|
||||||
|
<span class="toolbar-sep"></span>
|
||||||
|
<button type="button" class="btn btn-tool" data-action="code" data-key="C" title="Inline code (C)">`</button>
|
||||||
|
<button type="button" class="btn btn-tool" data-action="codeblock" data-key="K" title="Code block (K)">```</button>
|
||||||
|
<span class="toolbar-sep"></span>
|
||||||
|
<button type="button" class="btn btn-tool" data-action="link" data-key="L" title="Link (L)">[]</button>
|
||||||
|
<button type="button" class="btn btn-tool" data-action="wikilink" data-key="P" title="Insert wiki link (P)">[[]]</button>
|
||||||
|
<button type="button" class="btn btn-tool" data-action="quote" data-key="Q" title="Blockquote (Q)">></button>
|
||||||
|
<button type="button" class="btn btn-tool" data-action="ul" data-key="U" title="Unordered list (U)">-</button>
|
||||||
|
<button type="button" class="btn btn-tool" data-action="ol" data-key="O" title="Ordered list (O)">1.</button>
|
||||||
|
<button type="button" class="btn btn-tool" data-action="hr" data-key="R" title="Horizontal rule (R)">---</button>
|
||||||
|
<span class="toolbar-sep"></span>
|
||||||
|
<button type="button" class="btn btn-tool dropdown" data-action="tbldrop" title="Table (T)">T▾</button>
|
||||||
|
<button type="button" class="btn btn-tool dropdown" data-action="datedrop" title="Insert date (D/W)">D▾</button>
|
||||||
|
<span class="toolbar-sep"></span>
|
||||||
|
<button type="button" class="btn btn-tool" data-action="movie" data-key="V" title="Import movie (V)">MV</button>
|
||||||
|
</div>
|
||||||
|
<textarea name="content" id="editor" autofocus>{{.RawContent}}</textarea>
|
||||||
|
</form>
|
||||||
|
<script src="/_/editor/lists.js"></script>
|
||||||
|
<script src="/_/editor/tables.js"></script>
|
||||||
|
<script src="/_/editor/dates.js"></script>
|
||||||
|
<script src="/_/editor/movie.js"></script>
|
||||||
|
<script src="/_/editor.js"></script>
|
||||||
|
{{end}}
|
||||||
@@ -0,0 +1,226 @@
|
|||||||
|
(function () {
|
||||||
|
var textarea = document.getElementById('editor');
|
||||||
|
if (!textarea) return;
|
||||||
|
|
||||||
|
var form = textarea.closest('form');
|
||||||
|
|
||||||
|
// --- DOM helpers ---
|
||||||
|
|
||||||
|
function wrap(before, after, placeholder) {
|
||||||
|
var start = textarea.selectionStart;
|
||||||
|
var end = textarea.selectionEnd;
|
||||||
|
var selected = textarea.value.slice(start, end) || placeholder;
|
||||||
|
var replacement = before + selected + after;
|
||||||
|
textarea.value = textarea.value.slice(0, start) + replacement + textarea.value.slice(end);
|
||||||
|
if (selected === placeholder) {
|
||||||
|
textarea.selectionStart = start + before.length;
|
||||||
|
textarea.selectionEnd = start + before.length + placeholder.length;
|
||||||
|
} else {
|
||||||
|
textarea.selectionStart = start + replacement.length;
|
||||||
|
textarea.selectionEnd = start + replacement.length;
|
||||||
|
}
|
||||||
|
textarea.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
function linePrefix(prefix) {
|
||||||
|
var start = textarea.selectionStart;
|
||||||
|
var lineStart = textarea.value.lastIndexOf('\n', start - 1) + 1;
|
||||||
|
textarea.value = textarea.value.slice(0, lineStart) + prefix + textarea.value.slice(lineStart);
|
||||||
|
textarea.selectionStart = textarea.selectionEnd = start + prefix.length;
|
||||||
|
textarea.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
function insertAtCursor(s) {
|
||||||
|
var start = textarea.selectionStart;
|
||||||
|
var end = textarea.selectionEnd;
|
||||||
|
textarea.value = textarea.value.slice(0, start) + s + textarea.value.slice(end);
|
||||||
|
textarea.selectionStart = textarea.selectionEnd = start + s.length;
|
||||||
|
textarea.dispatchEvent(new Event('input'));
|
||||||
|
textarea.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyResult(result) {
|
||||||
|
textarea.value = result.text;
|
||||||
|
textarea.selectionStart = textarea.selectionEnd = result.cursor;
|
||||||
|
textarea.dispatchEvent(new Event('input'));
|
||||||
|
textarea.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyTableOp(fn, arg) {
|
||||||
|
var result = arg !== undefined
|
||||||
|
? fn(textarea.value, textarea.selectionStart, arg)
|
||||||
|
: fn(textarea.value, textarea.selectionStart);
|
||||||
|
if (result) applyResult(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Actions ---
|
||||||
|
|
||||||
|
var T = EditorTables;
|
||||||
|
var L = EditorLists;
|
||||||
|
var D = EditorDates;
|
||||||
|
var M = EditorMovie;
|
||||||
|
|
||||||
|
var actions = {
|
||||||
|
save: function () { form.submit(); },
|
||||||
|
bold: function () { wrap('**', '**', 'bold text'); },
|
||||||
|
italic: function () { wrap('*', '*', 'italic text'); },
|
||||||
|
h1: function () { linePrefix('# '); },
|
||||||
|
h2: function () { linePrefix('## '); },
|
||||||
|
h3: function () { linePrefix('### '); },
|
||||||
|
code: function () { wrap('`', '`', 'code'); },
|
||||||
|
codeblock: function () { wrap('```\n', '\n```', 'code'); },
|
||||||
|
quote: function () { linePrefix('> '); },
|
||||||
|
link: function () { wrap('[', '](url)', 'link text'); },
|
||||||
|
wikilink: function () {
|
||||||
|
var sel = textarea.value.slice(textarea.selectionStart, textarea.selectionEnd);
|
||||||
|
openTreePicker({
|
||||||
|
title: 'Insert link',
|
||||||
|
mode: 'any',
|
||||||
|
initialPath: '/',
|
||||||
|
confirmLabel: 'INSERT',
|
||||||
|
onSelect: function (path, kind) {
|
||||||
|
if (kind === 'folder') {
|
||||||
|
insertAtCursor(sel ? '[[' + path + '|' + sel + ']]' : '[[' + path + ']]');
|
||||||
|
} else {
|
||||||
|
var name = path.split('/').pop();
|
||||||
|
insertAtCursor('[' + (sel || name) + '](' + path + ')');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
ul: function () { linePrefix('- '); },
|
||||||
|
ol: function () { linePrefix('1. '); },
|
||||||
|
hr: function () { wrap('\n\n---\n\n', '', ''); },
|
||||||
|
fmttable: function () { applyTableOp(T.formatTableText); },
|
||||||
|
tblalignleft: function () { applyTableOp(T.setColumnAlignment, 'left'); },
|
||||||
|
tblaligncenter: function () { applyTableOp(T.setColumnAlignment, 'center'); },
|
||||||
|
tblalignright: function () { applyTableOp(T.setColumnAlignment, 'right'); },
|
||||||
|
tblinsertcol: function () { applyTableOp(T.insertColumn); },
|
||||||
|
tbldeletecol: function () { applyTableOp(T.deleteColumn); },
|
||||||
|
tblinsertrow: function () { applyTableOp(T.insertRow); },
|
||||||
|
tbldeleterow: function () { applyTableOp(T.deleteRow); },
|
||||||
|
dateiso: function () { insertAtCursor(D.isoDate()); },
|
||||||
|
datelong: function () { insertAtCursor(D.longDate()); },
|
||||||
|
movie: function () { M.run(textarea); },
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- Keyboard shortcut registration ---
|
||||||
|
|
||||||
|
var keyMap = {};
|
||||||
|
document.querySelectorAll('[data-action]').forEach(function (btn) {
|
||||||
|
btn.addEventListener('click', function () {
|
||||||
|
var action = actions[btn.dataset.action];
|
||||||
|
if (action) action();
|
||||||
|
});
|
||||||
|
if (btn.dataset.key) {
|
||||||
|
keyMap[btn.dataset.key] = actions[btn.dataset.action];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
keyMap['T'] = actions.fmttable;
|
||||||
|
keyMap['D'] = actions.dateiso;
|
||||||
|
keyMap['W'] = actions.datelong;
|
||||||
|
|
||||||
|
document.addEventListener('keydown', function (e) {
|
||||||
|
if (!e.altKey || !e.shiftKey) return;
|
||||||
|
// Shift+digit produces a layout-dependent character in e.key (e.g. "!"
|
||||||
|
// on US, "!" on DE), so fall back to e.code for digit rows.
|
||||||
|
var key = /^Digit[0-9]$/.test(e.code) ? e.code.slice(5) : e.key;
|
||||||
|
var action = keyMap[key];
|
||||||
|
if (action) {
|
||||||
|
e.preventDefault();
|
||||||
|
action();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Textarea key handling ---
|
||||||
|
|
||||||
|
textarea.addEventListener('keydown', function (e) {
|
||||||
|
if (e.key === 'Delete' && e.shiftKey) {
|
||||||
|
var result = T.deleteRow(textarea.value, textarea.selectionStart)
|
||||||
|
|| L.deleteOrderedLine(textarea.value, textarea.selectionStart);
|
||||||
|
if (!result) return;
|
||||||
|
e.preventDefault();
|
||||||
|
applyResult(result);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (e.key === 'Enter' && e.shiftKey) {
|
||||||
|
var result = T.insertRowBelow(textarea.value, textarea.selectionStart);
|
||||||
|
if (!result) return;
|
||||||
|
e.preventDefault();
|
||||||
|
applyResult(result);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (e.key !== 'Enter') return;
|
||||||
|
var result = L.handleEnterKey(textarea.value, textarea.selectionStart);
|
||||||
|
if (!result) return;
|
||||||
|
e.preventDefault();
|
||||||
|
applyResult(result);
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Dropdown helper ---
|
||||||
|
|
||||||
|
var openMenus = [];
|
||||||
|
|
||||||
|
function makeDropdown(triggerBtn, items) {
|
||||||
|
var menu = document.createElement('div');
|
||||||
|
menu.className = 'dropdown-menu';
|
||||||
|
items.forEach(function (item) {
|
||||||
|
var btn = document.createElement('button');
|
||||||
|
btn.type = 'button';
|
||||||
|
btn.className = 'btn btn-tool dropdown-item';
|
||||||
|
btn.textContent = item.label;
|
||||||
|
btn.addEventListener('mousedown', function (e) {
|
||||||
|
e.preventDefault();
|
||||||
|
actions[item.action]();
|
||||||
|
menu.classList.remove('is-open');
|
||||||
|
});
|
||||||
|
menu.appendChild(btn);
|
||||||
|
});
|
||||||
|
triggerBtn.appendChild(menu);
|
||||||
|
openMenus.push(menu);
|
||||||
|
|
||||||
|
triggerBtn.addEventListener('click', function (e) {
|
||||||
|
if (e.target !== triggerBtn) return;
|
||||||
|
var wasOpen = menu.classList.contains('is-open');
|
||||||
|
openMenus.forEach(function (m) { m.classList.remove('is-open'); });
|
||||||
|
if (!wasOpen) menu.classList.add('is-open');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('click', function (e) {
|
||||||
|
var insideAny = openMenus.some(function (m) {
|
||||||
|
return m.parentElement && m.parentElement.contains(e.target);
|
||||||
|
});
|
||||||
|
if (!insideAny) openMenus.forEach(function (m) { m.classList.remove('is-open'); });
|
||||||
|
});
|
||||||
|
document.addEventListener('keydown', function (e) {
|
||||||
|
if (e.key === 'Escape') openMenus.forEach(function (m) { m.classList.remove('is-open'); });
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Table dropdown ---
|
||||||
|
|
||||||
|
var tblDropBtn = document.querySelector('[data-action="tbldrop"]');
|
||||||
|
if (tblDropBtn) {
|
||||||
|
makeDropdown(tblDropBtn, [
|
||||||
|
{ label: 'Format table', action: 'fmttable' },
|
||||||
|
{ label: 'Align left', action: 'tblalignleft' },
|
||||||
|
{ label: 'Align center', action: 'tblaligncenter' },
|
||||||
|
{ label: 'Align right', action: 'tblalignright' },
|
||||||
|
{ label: 'Insert column', action: 'tblinsertcol' },
|
||||||
|
{ label: 'Delete column', action: 'tbldeletecol' },
|
||||||
|
{ label: 'Insert row', action: 'tblinsertrow' },
|
||||||
|
{ label: 'Delete row', action: 'tbldeleterow' },
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Date dropdown ---
|
||||||
|
|
||||||
|
var dateDropBtn = document.querySelector('[data-action="datedrop"]');
|
||||||
|
if (dateDropBtn) {
|
||||||
|
makeDropdown(dateDropBtn, [
|
||||||
|
{ label: 'YYYY-MM-DD', action: 'dateiso' },
|
||||||
|
{ label: 'DE Long', action: 'datelong' },
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
})();
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
window.EditorLists = (function () {
|
||||||
|
|
||||||
|
function detectListPrefix(lineText) {
|
||||||
|
var m;
|
||||||
|
m = lineText.match(/^(\s*)(- \[[ x]\] )/);
|
||||||
|
if (m) return { indent: m[1], prefix: m[2], type: 'task' };
|
||||||
|
m = lineText.match(/^(\s*)([-*+] )/);
|
||||||
|
if (m) return { indent: m[1], prefix: m[2], type: 'unordered' };
|
||||||
|
m = lineText.match(/^(\s*)(\d+)\. /);
|
||||||
|
if (m) return { indent: m[1], prefix: m[2] + '. ', type: 'ordered', num: parseInt(m[2], 10) };
|
||||||
|
m = lineText.match(/^(\s*)(> )/);
|
||||||
|
if (m) return { indent: m[1], prefix: m[2], type: 'blockquote' };
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function continuationPrefix(info) {
|
||||||
|
if (info.type === 'task') return info.indent + '- [ ] ';
|
||||||
|
if (info.type === 'ordered') return info.indent + (info.num + 1) + '. ';
|
||||||
|
return info.indent + info.prefix;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renumberOrderedList(text, fromLineIndex, indent, startNum) {
|
||||||
|
var lines = text.split('\n');
|
||||||
|
var re = new RegExp('^' + indent.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '(\\d+)\\. ');
|
||||||
|
var num = startNum !== undefined ? startNum : null;
|
||||||
|
for (var i = fromLineIndex; i < lines.length; i++) {
|
||||||
|
var m = lines[i].match(re);
|
||||||
|
if (!m) break;
|
||||||
|
if (num === null) num = parseInt(m[1], 10);
|
||||||
|
var newNumStr = String(num);
|
||||||
|
if (m[1] !== newNumStr) {
|
||||||
|
lines[i] = indent + newNumStr + '. ' + lines[i].slice(m[0].length);
|
||||||
|
}
|
||||||
|
num++;
|
||||||
|
}
|
||||||
|
return lines.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleEnterKey(text, cursorPos) {
|
||||||
|
var before = text.slice(0, cursorPos);
|
||||||
|
var after = text.slice(cursorPos);
|
||||||
|
var lineStart = before.lastIndexOf('\n') + 1;
|
||||||
|
var lineEnd = text.indexOf('\n', cursorPos);
|
||||||
|
if (lineEnd === -1) lineEnd = text.length;
|
||||||
|
var fullLine = text.slice(lineStart, lineEnd);
|
||||||
|
var info = detectListPrefix(fullLine);
|
||||||
|
if (!info) return null;
|
||||||
|
|
||||||
|
var contentAfterPrefix = fullLine.slice(info.indent.length + info.prefix.length);
|
||||||
|
if (contentAfterPrefix.trim() === '') {
|
||||||
|
var newText = text.slice(0, lineStart) + '\n' + after;
|
||||||
|
var newCursor = lineStart + 1;
|
||||||
|
if (info.type === 'ordered') {
|
||||||
|
var lineIndex = text.slice(0, lineStart).split('\n').length;
|
||||||
|
newText = renumberOrderedList(newText, lineIndex, info.indent);
|
||||||
|
}
|
||||||
|
return { text: newText, cursor: newCursor };
|
||||||
|
}
|
||||||
|
|
||||||
|
var cont = continuationPrefix(info);
|
||||||
|
var newText = before + '\n' + cont + after;
|
||||||
|
var newCursor = cursorPos + 1 + cont.length;
|
||||||
|
if (info.type === 'ordered') {
|
||||||
|
var insertedLineIndex = before.split('\n').length;
|
||||||
|
newText = renumberOrderedList(newText, insertedLineIndex, info.indent);
|
||||||
|
}
|
||||||
|
return { text: newText, cursor: newCursor };
|
||||||
|
}
|
||||||
|
|
||||||
|
function deleteOrderedLine(text, cursorPos) {
|
||||||
|
var lineStart = text.lastIndexOf('\n', cursorPos - 1) + 1;
|
||||||
|
var lineEnd = text.indexOf('\n', cursorPos);
|
||||||
|
if (lineEnd === -1) lineEnd = text.length;
|
||||||
|
var fullLine = text.slice(lineStart, lineEnd);
|
||||||
|
var info = detectListPrefix(fullLine);
|
||||||
|
if (!info || info.type !== 'ordered') return null;
|
||||||
|
|
||||||
|
var newText = text.slice(0, lineStart) + text.slice(lineEnd === text.length ? lineEnd : lineEnd + 1);
|
||||||
|
var newCursor = lineStart;
|
||||||
|
var fromLineIndex = text.slice(0, lineStart).split('\n').length - 1;
|
||||||
|
newText = renumberOrderedList(newText, fromLineIndex, info.indent, info.num);
|
||||||
|
return { text: newText, cursor: Math.min(newCursor, newText.length) };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { handleEnterKey: handleEnterKey, deleteOrderedLine: deleteOrderedLine };
|
||||||
|
})();
|
||||||
@@ -1,85 +0,0 @@
|
|||||||
{{define "headerActions"}}
|
|
||||||
<a class="btn" href="{{.PostURL}}">CANCEL</a>
|
|
||||||
<button class="btn" type="submit" form="edit-form" data-action="save" data-key="S" title="Save (S)">SAVE</button>
|
|
||||||
{{end}}
|
|
||||||
|
|
||||||
{{define "content"}}
|
|
||||||
<script>
|
|
||||||
document.body.classList.add('edit-mode');
|
|
||||||
</script>
|
|
||||||
<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 .InsertBefore 0}}<input type="hidden" name="insert_before" value="{{.InsertBefore}}">{{end}}
|
|
||||||
<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="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>
|
|
||||||
<button type="button" class="btn btn-tool" data-action="bold" data-key="B" title="Bold (B)">**</button>
|
|
||||||
<button type="button" class="btn btn-tool" data-action="italic" data-key="I" title="Italic (I)">*</button>
|
|
||||||
<span class="dropdown">
|
|
||||||
<button type="button" class="btn btn-tool dropdown-toggle" title="Heading (1/2/3)">H▾</button>
|
|
||||||
<div class="dropdown-menu">
|
|
||||||
<button type="button" class="btn btn-tool btn-block" data-action="h1" data-key="1" title="Heading 1 (1)">Heading 1</button>
|
|
||||||
<button type="button" class="btn btn-tool btn-block" data-action="h2" data-key="2" title="Heading 2 (2)">Heading 2</button>
|
|
||||||
<button type="button" class="btn btn-tool btn-block" data-action="h3" data-key="3" title="Heading 3 (3)">Heading 3</button>
|
|
||||||
</div>
|
|
||||||
</span>
|
|
||||||
<span class="toolbar-sep"></span>
|
|
||||||
<button type="button" class="btn btn-tool" data-action="code" data-key="C" title="Inline code (C)">`</button>
|
|
||||||
<button type="button" class="btn btn-tool" data-action="codeblock" data-key="K" title="Code block (K)">```</button>
|
|
||||||
<span class="dropdown">
|
|
||||||
<button type="button" class="btn btn-tool dropdown-toggle" title="Link (L/P)">L▾</button>
|
|
||||||
<div class="dropdown-menu">
|
|
||||||
<button type="button" class="btn btn-tool 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>
|
|
||||||
</div>
|
|
||||||
</span>
|
|
||||||
<span class="dropdown">
|
|
||||||
<button type="button" class="btn btn-tool dropdown-toggle" title="List (U/O/X)">≡▾</button>
|
|
||||||
<div class="dropdown-menu">
|
|
||||||
<button type="button" class="btn btn-tool btn-block" data-action="ul" data-key="U" title="Unordered list (U)">Unordered list</button>
|
|
||||||
<button type="button" class="btn btn-tool btn-block" data-action="ol" data-key="O" title="Ordered list (O)">Ordered list</button>
|
|
||||||
<button type="button" class="btn btn-tool btn-block" data-action="task" data-key="X" title="Task list (X)">Task list</button>
|
|
||||||
</div>
|
|
||||||
</span>
|
|
||||||
<button type="button" class="btn btn-tool" data-action="quote" data-key="Q" title="Blockquote (Q)">></button>
|
|
||||||
<button type="button" class="btn btn-tool" data-action="hr" data-key="R" title="Horizontal rule (R)">---</button>
|
|
||||||
<span class="toolbar-sep"></span>
|
|
||||||
<span class="dropdown">
|
|
||||||
<button type="button" class="btn btn-tool dropdown-toggle" title="Table (T)">T▾</button>
|
|
||||||
<div class="dropdown-menu">
|
|
||||||
<button type="button" class="btn btn-tool btn-block" data-action="fmttable" data-key="T" title="Format table (T)">Format table</button>
|
|
||||||
<button type="button" class="btn btn-tool btn-block" data-action="tblalignleft" title="Align left">Align left</button>
|
|
||||||
<button type="button" class="btn btn-tool btn-block" data-action="tblaligncenter" title="Align center">Align center</button>
|
|
||||||
<button type="button" class="btn btn-tool btn-block" data-action="tblalignright" title="Align right">Align right</button>
|
|
||||||
<button type="button" class="btn btn-tool btn-block" data-action="tblinsertcol" title="Insert column">Insert column</button>
|
|
||||||
<button type="button" class="btn btn-tool btn-block" data-action="tbldeletecol" title="Delete column">Delete column</button>
|
|
||||||
<button type="button" class="btn btn-tool btn-block" data-action="tblinsertrow" title="Insert row">Insert row</button>
|
|
||||||
<button type="button" class="btn btn-tool btn-block" data-action="tbldeleterow" title="Delete row">Delete row</button>
|
|
||||||
</div>
|
|
||||||
</span>
|
|
||||||
<span class="dropdown">
|
|
||||||
<button type="button" class="btn btn-tool dropdown-toggle" title="Insert date (D/W)">D▾</button>
|
|
||||||
<div class="dropdown-menu">
|
|
||||||
<button type="button" class="btn btn-tool btn-block" data-action="dateiso" data-key="D" title="YYYY-MM-DD (D)">YYYY-MM-DD</button>
|
|
||||||
<button type="button" class="btn btn-tool btn-block" data-action="datelong" data-key="W" title="DE Long (W)">DE Long</button>
|
|
||||||
</div>
|
|
||||||
</span>
|
|
||||||
<span class="dropdown">
|
|
||||||
<button type="button" class="btn btn-tool dropdown-toggle" title="Special (V)">★▾</button>
|
|
||||||
<div class="dropdown-menu">
|
|
||||||
<button type="button" class="btn btn-tool btn-block" data-action="movie" data-key="V" title="Import movie (V)">Import movie</button>
|
|
||||||
</div>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div id="editor" class="editor-cm"></div>
|
|
||||||
<textarea name="content" id="editor-content" hidden>{{.RawContent}}</textarea>
|
|
||||||
</form>
|
|
||||||
<script src="/_/editor/vendor/codemirror.bundle.js?v={{editorBundleVersion}}"></script>
|
|
||||||
<script src="/_/editor/tables.js"></script>
|
|
||||||
<script src="/_/editor/dates.js"></script>
|
|
||||||
<script src="/_/editor/movie.js"></script>
|
|
||||||
<script src="/_/editor/wikicomplete.js"></script>
|
|
||||||
<script src="/_/editor/main.js"></script>
|
|
||||||
{{end}}
|
|
||||||
@@ -1,323 +0,0 @@
|
|||||||
(function () {
|
|
||||||
var mount = document.getElementById('editor');
|
|
||||||
var hidden = document.getElementById('editor-content');
|
|
||||||
if (!mount || !hidden || !window.CM) return;
|
|
||||||
|
|
||||||
var form = hidden.closest('form');
|
|
||||||
|
|
||||||
var T = EditorTables;
|
|
||||||
var D = EditorDates;
|
|
||||||
var M = EditorMovie;
|
|
||||||
|
|
||||||
// --- CodeMirror setup ---
|
|
||||||
|
|
||||||
// Shift+Enter (new table row below) / Shift+Delete (delete table row) run at
|
|
||||||
// highest precedence so they win over CM's default newline/forward-delete.
|
|
||||||
// Returning false (no table at cursor) lets CM fall back to its default.
|
|
||||||
function tableKey(fn) {
|
|
||||||
return function (view) {
|
|
||||||
var result = fn(view.state.doc.toString(), view.state.selection.main.head);
|
|
||||||
if (!result) return false;
|
|
||||||
dispatchFullReplace(result);
|
|
||||||
return true;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
var tableKeymap = [
|
|
||||||
{ key: 'Shift-Enter', run: tableKey(T.insertRowBelow) },
|
|
||||||
{ key: 'Shift-Delete', run: tableKey(T.deleteRow) },
|
|
||||||
];
|
|
||||||
|
|
||||||
var state = CM.EditorState.create({
|
|
||||||
doc: hidden.value,
|
|
||||||
extensions: [
|
|
||||||
CM.history(),
|
|
||||||
CM.drawSelection(),
|
|
||||||
CM.indentOnInput(),
|
|
||||||
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.syntaxHighlighting(CM.highlightStyle),
|
|
||||||
CM.closeBrackets(),
|
|
||||||
CM.autocompletion({ override: [WikiComplete.source] }),
|
|
||||||
CM.theme,
|
|
||||||
CM.Prec.highest(CM.keymap.of(tableKeymap)),
|
|
||||||
CM.keymap.of([].concat(
|
|
||||||
CM.closeBracketsKeymap,
|
|
||||||
CM.completionKeymap,
|
|
||||||
CM.markdownKeymap,
|
|
||||||
CM.defaultKeymap,
|
|
||||||
CM.historyKeymap,
|
|
||||||
[CM.indentWithTab]
|
|
||||||
)),
|
|
||||||
],
|
|
||||||
});
|
|
||||||
|
|
||||||
var view = new CM.EditorView({ state: state, parent: mount });
|
|
||||||
view.focus();
|
|
||||||
|
|
||||||
// --- CM document helpers ---
|
|
||||||
|
|
||||||
function dispatchFullReplace(result) {
|
|
||||||
view.dispatch({
|
|
||||||
changes: { from: 0, to: view.state.doc.length, insert: result.text },
|
|
||||||
selection: { anchor: result.cursor },
|
|
||||||
scrollIntoView: true,
|
|
||||||
});
|
|
||||||
view.focus();
|
|
||||||
}
|
|
||||||
|
|
||||||
function insertAtCursor(s) {
|
|
||||||
var sel = view.state.selection.main;
|
|
||||||
view.dispatch({
|
|
||||||
changes: { from: sel.from, to: sel.to, insert: s },
|
|
||||||
selection: { anchor: sel.from + s.length },
|
|
||||||
scrollIntoView: true,
|
|
||||||
});
|
|
||||||
view.focus();
|
|
||||||
}
|
|
||||||
|
|
||||||
function wrap(before, after, placeholder) {
|
|
||||||
var sel = view.state.selection.main;
|
|
||||||
var hadSelection = sel.to > sel.from;
|
|
||||||
var selected = hadSelection ? view.state.sliceDoc(sel.from, sel.to) : placeholder;
|
|
||||||
var insert = before + selected + after;
|
|
||||||
var anchor, head;
|
|
||||||
if (hadSelection) {
|
|
||||||
anchor = head = sel.from + insert.length;
|
|
||||||
} else {
|
|
||||||
anchor = sel.from + before.length;
|
|
||||||
head = anchor + placeholder.length;
|
|
||||||
}
|
|
||||||
view.dispatch({
|
|
||||||
changes: { from: sel.from, to: sel.to, insert: insert },
|
|
||||||
selection: { anchor: anchor, head: head },
|
|
||||||
scrollIntoView: true,
|
|
||||||
});
|
|
||||||
view.focus();
|
|
||||||
}
|
|
||||||
|
|
||||||
function linePrefix(prefix) {
|
|
||||||
var sel = view.state.selection.main;
|
|
||||||
var line = view.state.doc.lineAt(sel.from);
|
|
||||||
view.dispatch({
|
|
||||||
changes: { from: line.from, to: line.from, insert: prefix },
|
|
||||||
selection: { anchor: sel.from + prefix.length },
|
|
||||||
scrollIntoView: true,
|
|
||||||
});
|
|
||||||
view.focus();
|
|
||||||
}
|
|
||||||
|
|
||||||
function applyTableOp(fn, arg) {
|
|
||||||
var text = view.state.doc.toString();
|
|
||||||
var pos = view.state.selection.main.head;
|
|
||||||
var result = arg !== undefined ? fn(text, pos, arg) : fn(text, pos);
|
|
||||||
if (result) dispatchFullReplace(result);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Adapter passed to movie.js so it reads/writes the CM document instead of a
|
|
||||||
// textarea (replace() dispatches a transaction; cursor lands after the block).
|
|
||||||
var movieCtx = {
|
|
||||||
getValue: function () { return view.state.doc.toString(); },
|
|
||||||
replace: function (start, end, text) {
|
|
||||||
view.dispatch({
|
|
||||||
changes: { from: start, to: end, insert: text },
|
|
||||||
selection: { anchor: start + text.length },
|
|
||||||
scrollIntoView: true,
|
|
||||||
});
|
|
||||||
view.focus();
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
// --- Content sync ---
|
|
||||||
|
|
||||||
// Serialize the CM document into the hidden textarea on submit only — Save
|
|
||||||
// must not depend on CM focus or async state (decision 8). requestSubmit
|
|
||||||
// fires this listener even for the ALT+SHIFT+S path.
|
|
||||||
function syncContent() {
|
|
||||||
hidden.value = view.state.doc.toString();
|
|
||||||
}
|
|
||||||
form.addEventListener('submit', syncContent);
|
|
||||||
|
|
||||||
// --- Actions ---
|
|
||||||
|
|
||||||
var actions = {
|
|
||||||
save: function () { form.requestSubmit(); },
|
|
||||||
undo: function () { CM.undo(view); view.focus(); },
|
|
||||||
redo: function () { CM.redo(view); view.focus(); },
|
|
||||||
deleteline: function () { CM.deleteLine(view); view.focus(); },
|
|
||||||
bold: function () { wrap('**', '**', 'bold text'); },
|
|
||||||
italic: function () { wrap('*', '*', 'italic text'); },
|
|
||||||
h1: function () { linePrefix('# '); },
|
|
||||||
h2: function () { linePrefix('## '); },
|
|
||||||
h3: function () { linePrefix('### '); },
|
|
||||||
code: function () { wrap('`', '`', 'code'); },
|
|
||||||
codeblock: function () { wrap('```\n', '\n```', 'code'); },
|
|
||||||
quote: function () { linePrefix('> '); },
|
|
||||||
link: function () { wrap('[', '](url)', 'link text'); },
|
|
||||||
wikilink: insertWikilink,
|
|
||||||
ul: function () { linePrefix('- '); },
|
|
||||||
ol: function () { linePrefix('1. '); },
|
|
||||||
task: function () { linePrefix('- [ ] '); },
|
|
||||||
hr: function () { wrap('\n\n---\n\n', '', ''); },
|
|
||||||
fmttable: function () { applyTableOp(T.formatTableText); },
|
|
||||||
tblalignleft: function () { applyTableOp(T.setColumnAlignment, 'left'); },
|
|
||||||
tblaligncenter: function () { applyTableOp(T.setColumnAlignment, 'center'); },
|
|
||||||
tblalignright: function () { applyTableOp(T.setColumnAlignment, 'right'); },
|
|
||||||
tblinsertcol: function () { applyTableOp(T.insertColumn); },
|
|
||||||
tbldeletecol: function () { applyTableOp(T.deleteColumn); },
|
|
||||||
tblinsertrow: function () { applyTableOp(T.insertRow); },
|
|
||||||
tbldeleterow: function () { applyTableOp(T.deleteRow); },
|
|
||||||
dateiso: function () { insertAtCursor(D.isoDate()); },
|
|
||||||
datelong: function () { insertAtCursor(D.longDate()); },
|
|
||||||
movie: function () { M.run(movieCtx); },
|
|
||||||
};
|
|
||||||
|
|
||||||
// isValidWikiTarget mirrors the Go validator in wikilinks.go — absolute
|
|
||||||
// path, no empty/dot segments. Used to gate the modal's INSERT button.
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Wiki link button (ALT+SHIFT+P): open a modal with a target field backed by
|
|
||||||
// full /_search typeahead plus an optional display-text field, then insert
|
|
||||||
// [[target]] or [[target::display]] at the cursor. (Inline `[[` typing uses
|
|
||||||
// the folder-scoped completion in wikicomplete.js instead.)
|
|
||||||
function insertWikilink() {
|
|
||||||
var sel = view.state.selection.main;
|
|
||||||
var selectedText = view.state.sliceDoc(sel.from, sel.to);
|
|
||||||
|
|
||||||
var container = document.createElement('div');
|
|
||||||
|
|
||||||
var targetWrap = document.createElement('div');
|
|
||||||
var targetInput = document.createElement('input');
|
|
||||||
targetInput.type = 'text';
|
|
||||||
targetInput.className = 'input';
|
|
||||||
targetInput.placeholder = 'Page path or search…';
|
|
||||||
targetWrap.appendChild(targetInput);
|
|
||||||
|
|
||||||
var displayInput = document.createElement('input');
|
|
||||||
displayInput.type = 'text';
|
|
||||||
displayInput.className = 'input';
|
|
||||||
displayInput.placeholder = 'Display text (optional)';
|
|
||||||
if (selectedText) displayInput.value = selectedText;
|
|
||||||
|
|
||||||
container.appendChild(targetWrap);
|
|
||||||
container.appendChild(displayInput);
|
|
||||||
|
|
||||||
var handle = openModal({
|
|
||||||
title: 'Insert link',
|
|
||||||
body: container,
|
|
||||||
confirm: {
|
|
||||||
label: 'INSERT',
|
|
||||||
initiallyDisabled: true,
|
|
||||||
onConfirm: function () {
|
|
||||||
var target = targetInput.value.trim();
|
|
||||||
if (!isValidWikiTarget(target)) return;
|
|
||||||
var display = displayInput.value.trim();
|
|
||||||
handle.close();
|
|
||||||
insertAtCursor(display ? '[[' + target + '::' + display + ']]' : '[[' + target + ']]');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
function updateConfirm() {
|
|
||||||
handle.setConfirmDisabled(!isValidWikiTarget(targetInput.value.trim()));
|
|
||||||
}
|
|
||||||
targetInput.addEventListener('input', updateConfirm);
|
|
||||||
|
|
||||||
window.attachSuggestions(targetInput, {
|
|
||||||
showFooter: false,
|
|
||||||
container: targetWrap,
|
|
||||||
onPick: function (r) {
|
|
||||||
targetInput.value = '/' + r.path;
|
|
||||||
updateConfirm();
|
|
||||||
displayInput.focus();
|
|
||||||
displayInput.select();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Keyboard shortcut registration ---
|
|
||||||
|
|
||||||
var keyMap = {};
|
|
||||||
document.querySelectorAll('[data-action]').forEach(function (btn) {
|
|
||||||
btn.addEventListener('click', function () {
|
|
||||||
var action = actions[btn.dataset.action];
|
|
||||||
if (action) action();
|
|
||||||
});
|
|
||||||
if (btn.dataset.key) {
|
|
||||||
keyMap[btn.dataset.key] = actions[btn.dataset.action];
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// 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) {
|
|
||||||
if (!e.altKey || !e.shiftKey) return;
|
|
||||||
// Shift+digit produces a layout-dependent character in e.key (e.g. "!"
|
|
||||||
// on US, "!" on DE), so fall back to e.code for digit rows.
|
|
||||||
var key = /^Digit[0-9]$/.test(e.code) ? e.code.slice(5) : e.key;
|
|
||||||
var action = keyMap[key];
|
|
||||||
if (action) {
|
|
||||||
e.preventDefault();
|
|
||||||
action();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// --- Dropdowns ---
|
|
||||||
|
|
||||||
// The toolbar scrolls horizontally (so it clips its absolutely-positioned
|
|
||||||
// menus) and on mobile is fixed to the bottom of the viewport. Pin an open
|
|
||||||
// menu to the viewport so it escapes the clip, opening upward when there
|
|
||||||
// isn't room below it (the bottom-toolbar case).
|
|
||||||
function pinMenu(toggle, menu) {
|
|
||||||
if (!menu.classList.contains('is-open')) return;
|
|
||||||
var r = toggle.getBoundingClientRect();
|
|
||||||
var vh = window.innerHeight;
|
|
||||||
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.maxHeight = (spaceBelow - 8) + 'px';
|
|
||||||
}
|
|
||||||
var left = Math.min(r.left, document.documentElement.clientWidth - menu.offsetWidth - 4);
|
|
||||||
menu.style.left = Math.max(4, left) + 'px';
|
|
||||||
}
|
|
||||||
|
|
||||||
document.querySelectorAll('.dropdown-toggle').forEach(function (toggle) {
|
|
||||||
wireDropdown(toggle);
|
|
||||||
var menu = toggle.parentElement.querySelector('.dropdown-menu');
|
|
||||||
if (!menu || !toolbar || !toolbar.contains(toggle)) return;
|
|
||||||
// Runs after wireDropdown's own click handler has toggled is-open.
|
|
||||||
toggle.addEventListener('click', function () { pinMenu(toggle, menu); });
|
|
||||||
});
|
|
||||||
})();
|
|
||||||
+18
-74
@@ -1,7 +1,8 @@
|
|||||||
window.EditorMovie = (function () {
|
window.EditorMovie = (function () {
|
||||||
'use strict';
|
'use strict';
|
||||||
|
|
||||||
var STORAGE_KEY = 'omdb-api-key';
|
// OMDb API key. Shipped to the browser; acceptable for a single-user LAN tool.
|
||||||
|
var OMDB_API_KEY = 'c906744f';
|
||||||
|
|
||||||
var BEGIN = '<!-- BEGIN MOVIE -->';
|
var BEGIN = '<!-- BEGIN MOVIE -->';
|
||||||
var END = '<!-- END MOVIE -->';
|
var END = '<!-- END MOVIE -->';
|
||||||
@@ -48,26 +49,26 @@ window.EditorMovie = (function () {
|
|||||||
return out.join('\n');
|
return out.join('\n');
|
||||||
}
|
}
|
||||||
|
|
||||||
// ctx is the CM adapter from main.js: { getValue(), replace(start,end,text) }.
|
function insertOrReplace(ta, markup) {
|
||||||
function insertOrReplace(ctx, markup) {
|
var t = ta.value || '';
|
||||||
var t = ctx.getValue() || '';
|
|
||||||
var b = t.indexOf(BEGIN);
|
var b = t.indexOf(BEGIN);
|
||||||
var e = t.indexOf(END);
|
var e = t.indexOf(END);
|
||||||
if (b !== -1 && e !== -1 && e > b) {
|
if (b !== -1 && e !== -1 && e > b) {
|
||||||
ctx.replace(b, e + END.length, markup);
|
ta.value = t.slice(0, b) + markup + t.slice(e + END.length);
|
||||||
} else {
|
} else {
|
||||||
var h = t.match(/^#{1,6}\s+.+?\s*$/m);
|
var h = t.match(/^#{1,6}\s+.+?\s*$/m);
|
||||||
if (h) {
|
if (h) {
|
||||||
var idx = t.indexOf(h[0]) + h[0].length;
|
var idx = t.indexOf(h[0]) + h[0].length;
|
||||||
ctx.replace(idx, idx, '\n\n' + markup);
|
ta.value = t.slice(0, idx) + '\n\n' + markup + t.slice(idx);
|
||||||
} else {
|
} else {
|
||||||
ctx.replace(0, 0, t ? markup + '\n\n' : markup);
|
ta.value = markup + (t ? '\n\n' + t : '');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
ta.dispatchEvent(new Event('input'));
|
||||||
}
|
}
|
||||||
|
|
||||||
function fetchMovie(key, title, year) {
|
function fetchMovie(title, year) {
|
||||||
var url = 'https://www.omdbapi.com/?apikey=' + encodeURIComponent(key) +
|
var url = 'https://www.omdbapi.com/?apikey=' + encodeURIComponent(OMDB_API_KEY) +
|
||||||
'&type=movie&t=' + encodeURIComponent(title);
|
'&type=movie&t=' + encodeURIComponent(title);
|
||||||
if (year) url += '&y=' + encodeURIComponent(year);
|
if (year) url += '&y=' + encodeURIComponent(year);
|
||||||
return fetch(url).then(function (r) { return r.json(); });
|
return fetch(url).then(function (r) { return r.json(); });
|
||||||
@@ -77,54 +78,17 @@ window.EditorMovie = (function () {
|
|||||||
openModal({ title: title, body: msg, confirm: { label: 'OK' } });
|
openModal({ title: title, body: msg, confirm: { label: 'OK' } });
|
||||||
}
|
}
|
||||||
|
|
||||||
function promptForKey(rejected, onSaved) {
|
function run(textarea) {
|
||||||
var body = document.createDocumentFragment();
|
if (!OMDB_API_KEY) {
|
||||||
|
showMessage('Movie import', 'OMDb API key is not set. Edit assets/editor/movie.js.');
|
||||||
if (rejected) {
|
return;
|
||||||
var notice = document.createElement('p');
|
|
||||||
notice.textContent = 'The previously stored key was rejected by OMDb.';
|
|
||||||
body.appendChild(notice);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var info = document.createElement('p');
|
|
||||||
info.appendChild(document.createTextNode('Enter your OMDb API key. Get one at '));
|
|
||||||
var link = document.createElement('a');
|
|
||||||
link.href = 'https://www.omdbapi.com/apikey.aspx';
|
|
||||||
link.target = '_blank';
|
|
||||||
link.rel = 'noopener';
|
|
||||||
link.textContent = 'omdbapi.com/apikey.aspx';
|
|
||||||
info.appendChild(link);
|
|
||||||
info.appendChild(document.createTextNode('.'));
|
|
||||||
body.appendChild(info);
|
|
||||||
|
|
||||||
var input = document.createElement('input');
|
var input = document.createElement('input');
|
||||||
input.type = 'text';
|
input.type = 'text';
|
||||||
input.className = 'input';
|
input.className = 'modal-input';
|
||||||
input.placeholder = 'OMDb API key';
|
|
||||||
body.appendChild(input);
|
|
||||||
|
|
||||||
openModal({
|
|
||||||
title: 'OMDb API key required',
|
|
||||||
body: body,
|
|
||||||
confirm: {
|
|
||||||
label: 'SAVE',
|
|
||||||
onConfirm: function () {
|
|
||||||
var key = input.value.trim();
|
|
||||||
if (!key) return;
|
|
||||||
localStorage.setItem(STORAGE_KEY, key);
|
|
||||||
closeModal();
|
|
||||||
onSaved(key);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function importWithKey(ctx, key, initialTitle) {
|
|
||||||
var input = document.createElement('input');
|
|
||||||
input.type = 'text';
|
|
||||||
input.className = 'input';
|
|
||||||
input.placeholder = 'Title, optionally with (YYYY)';
|
input.placeholder = 'Title, optionally with (YYYY)';
|
||||||
input.value = initialTitle;
|
input.value = firstHeading(textarea.value || '');
|
||||||
|
|
||||||
openModal({
|
openModal({
|
||||||
title: 'Import movie',
|
title: 'Import movie',
|
||||||
@@ -136,22 +100,14 @@ window.EditorMovie = (function () {
|
|||||||
if (!raw) return;
|
if (!raw) return;
|
||||||
var parsed = parseTitleYear(raw);
|
var parsed = parseTitleYear(raw);
|
||||||
closeModal();
|
closeModal();
|
||||||
fetchMovie(key, parsed.title, parsed.year)
|
fetchMovie(parsed.title, parsed.year)
|
||||||
.then(function (data) {
|
.then(function (data) {
|
||||||
if (data && data.Response === 'False' &&
|
|
||||||
data.Error === 'Invalid API key!') {
|
|
||||||
localStorage.removeItem(STORAGE_KEY);
|
|
||||||
promptForKey(true, function (newKey) {
|
|
||||||
importWithKey(ctx, newKey, raw);
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!data || data.Response === 'False') {
|
if (!data || data.Response === 'False') {
|
||||||
showMessage('Not found',
|
showMessage('Not found',
|
||||||
(data && data.Error) || 'Movie not found.');
|
(data && data.Error) || 'Movie not found.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
insertOrReplace(ctx, buildBlock(data));
|
insertOrReplace(textarea, buildBlock(data));
|
||||||
})
|
})
|
||||||
.catch(function () {
|
.catch(function () {
|
||||||
showMessage('Import failed', 'OMDb lookup failed.');
|
showMessage('Import failed', 'OMDb lookup failed.');
|
||||||
@@ -161,17 +117,5 @@ window.EditorMovie = (function () {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function run(ctx) {
|
|
||||||
var initialTitle = firstHeading(ctx.getValue() || '');
|
|
||||||
var key = localStorage.getItem(STORAGE_KEY);
|
|
||||||
if (!key) {
|
|
||||||
promptForKey(false, function (newKey) {
|
|
||||||
importWithKey(ctx, newKey, initialTitle);
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
importWithKey(ctx, key, initialTitle);
|
|
||||||
}
|
|
||||||
|
|
||||||
return { run: run };
|
return { run: run };
|
||||||
})();
|
})();
|
||||||
|
|||||||
-27
File diff suppressed because one or more lines are too long
@@ -1,115 +0,0 @@
|
|||||||
// wikicomplete.js — the `[[` wikilink autocomplete source for CodeMirror.
|
|
||||||
//
|
|
||||||
// A level-by-level folder/file browser scoped to the path typed so far. It
|
|
||||||
// fires only once the `[[` token's content begins with `/` (targets are
|
|
||||||
// absolute; free-text search lives in the toolbar modal instead). The content
|
|
||||||
// is split into a parent path (up to and including the last `/`) and a partial
|
|
||||||
// segment (the text after it); the parent's children are fetched from the
|
|
||||||
// existing `?tree=1` endpoint and filtered to names containing the partial
|
|
||||||
// (case-insensitive substring).
|
|
||||||
//
|
|
||||||
// Picking a folder inserts `<name>/` and re-opens the popup to drill one level
|
|
||||||
// deeper; picking a file inserts `<name>` and stops. Only the current segment
|
|
||||||
// is replaced, so the trailing `]]` stays put and the cursor parks before it,
|
|
||||||
// leaving room to type a `::display` alias. Exposes window.WikiComplete.source
|
|
||||||
// for main.js to register via CM's autocompletion().
|
|
||||||
window.WikiComplete = (function () {
|
|
||||||
// treeURL builds the `?tree=1` request URL for an absolute parent path,
|
|
||||||
// percent-encoding each segment. A leading/trailing slash is tolerated;
|
|
||||||
// root resolves to `/?tree=1`.
|
|
||||||
function treeURL(parent) {
|
|
||||||
var trimmed = parent.replace(/^\/+/, '').replace(/\/+$/, '');
|
|
||||||
if (trimmed === '') return '/?tree=1';
|
|
||||||
var enc = trimmed.split('/').map(encodeURIComponent).join('/');
|
|
||||||
return '/' + enc + '/?tree=1';
|
|
||||||
}
|
|
||||||
|
|
||||||
function fetchTree(parent) {
|
|
||||||
return fetch(treeURL(parent), {
|
|
||||||
credentials: 'same-origin',
|
|
||||||
headers: { 'Accept': 'application/json' },
|
|
||||||
}).then(function (r) {
|
|
||||||
// A 404 means the parent folder doesn't exist (typo, or a path under
|
|
||||||
// a file) — treat it as "no completions", not an error.
|
|
||||||
if (r.status === 404) return null;
|
|
||||||
if (!r.ok) throw new Error('HTTP ' + r.status);
|
|
||||||
return r.json();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// makeApply builds the apply() for a chosen entry. It replaces only the
|
|
||||||
// current segment ([from, to]); the trailing `]]` is untouched, so the
|
|
||||||
// cursor ends up parked before it. Folders append `/` and re-open the popup
|
|
||||||
// to drill into the next level; files terminate.
|
|
||||||
function makeApply(name, kind) {
|
|
||||||
return function (view, completion, from, to) {
|
|
||||||
var isFolder = kind === 'folder';
|
|
||||||
var insert = isFolder ? name + '/' : name;
|
|
||||||
view.dispatch({
|
|
||||||
changes: { from: from, to: to, insert: insert },
|
|
||||||
selection: { anchor: from + insert.length },
|
|
||||||
scrollIntoView: true,
|
|
||||||
});
|
|
||||||
if (isFolder) {
|
|
||||||
// Re-open after the transaction so the completion plugin sees
|
|
||||||
// the updated document (the next level's parent path).
|
|
||||||
setTimeout(function () { CM.startCompletion(view); }, 0);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// CM completion source. Activates when `[[` is followed by content that
|
|
||||||
// begins with `/`. The content is split at its last `/` into a parent path
|
|
||||||
// and a partial segment; the parent's children are fetched, filtered to
|
|
||||||
// names containing the partial (case-insensitive substring), and offered
|
|
||||||
// with name-only labels.
|
|
||||||
function source(context) {
|
|
||||||
var match = context.matchBefore(/\[\[[^\]\n]*/);
|
|
||||||
if (!match) return null;
|
|
||||||
var content = context.state.sliceDoc(match.from + 2, context.pos);
|
|
||||||
if (content[0] !== '/') return null;
|
|
||||||
|
|
||||||
var lastSlash = content.lastIndexOf('/');
|
|
||||||
var parent = content.slice(0, lastSlash + 1);
|
|
||||||
var partial = content.slice(lastSlash + 1);
|
|
||||||
|
|
||||||
// Replace only the current segment: from the start of the partial up to
|
|
||||||
// the next `/` or `]` (or end of line). This narrows re-edits inside an
|
|
||||||
// existing `[[…]]` so drilling doesn't duplicate trailing text.
|
|
||||||
var from = match.from + 2 + lastSlash + 1;
|
|
||||||
var line = context.state.doc.lineAt(context.pos);
|
|
||||||
var to = context.pos;
|
|
||||||
while (to < line.to) {
|
|
||||||
var ch = context.state.sliceDoc(to, to + 1);
|
|
||||||
if (ch === '/' || ch === ']') break;
|
|
||||||
to++;
|
|
||||||
}
|
|
||||||
|
|
||||||
return new Promise(function (resolve) {
|
|
||||||
if (context.aborted) { resolve(null); return; }
|
|
||||||
fetchTree(parent).then(function (resp) {
|
|
||||||
if (context.aborted || !resp) { resolve(null); return; }
|
|
||||||
var needle = partial.toLowerCase();
|
|
||||||
var options = (resp.entries || []).reduce(function (acc, e) {
|
|
||||||
if (needle && e.name.toLowerCase().indexOf(needle) === -1) {
|
|
||||||
return acc;
|
|
||||||
}
|
|
||||||
acc.push({
|
|
||||||
label: e.name,
|
|
||||||
type: e.kind === 'folder' ? 'folder' : 'file',
|
|
||||||
apply: makeApply(e.name, e.kind),
|
|
||||||
});
|
|
||||||
return acc;
|
|
||||||
}, []);
|
|
||||||
// No validFor: the source re-runs on each keystroke, so every
|
|
||||||
// edit (more chars, backspace, or a `/` that drills into the
|
|
||||||
// next folder) re-fetches and re-filters from scratch.
|
|
||||||
resolve({ from: from, to: to, options: options });
|
|
||||||
}).catch(function () {
|
|
||||||
resolve(null);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return { source: source };
|
|
||||||
})();
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
// 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();
|
|
||||||
});
|
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
{{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>
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
<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" stroke-linecap="square">
|
|
||||||
<path d="M8 13V3M3 8l5-5 5 5"/>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 233 B |
+11
-22
@@ -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, interactive-widget=resizes-content" />
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
<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,38 +10,27 @@
|
|||||||
<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="/_/search-suggest.js" defer></script>
|
|
||||||
<script src="/_/tree-picker.js"></script>
|
<script src="/_/tree-picker.js"></script>
|
||||||
<script src="/_/companion.js" defer></script>
|
|
||||||
{{block "headScripts" .}}{{end}}
|
{{block "headScripts" .}}{{end}}
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<header>
|
<header>
|
||||||
<nav class="breadcrumb row">
|
<nav class="breadcrumb">
|
||||||
<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="/"><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>
|
||||||
{{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}}
|
{{range .Crumbs}}
|
||||||
|
<span class="sep">/</span><a href="{{.URL}}">{{.Name}}</a>
|
||||||
|
{{end}}
|
||||||
</nav>
|
</nav>
|
||||||
{{if not .EditMode}}
|
{{if not .EditMode}}
|
||||||
<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="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>
|
{{block "headerActions" .}}{{end}}
|
||||||
</header>
|
</header>
|
||||||
<div class="page-wrap">
|
<main>
|
||||||
<main>
|
{{block "content" .}}{{end}}
|
||||||
{{block "content" .}}{{end}}
|
</main>
|
||||||
</main>
|
|
||||||
<aside class="sidebar">{{block "sidebar" .}}{{end}}</aside>
|
|
||||||
</div>
|
|
||||||
<footer>
|
|
||||||
<span class="muted">Request: {{.RenderMS}} ms</span>
|
|
||||||
{{block "footerExtras" .}}{{end}}
|
|
||||||
<span class="dropdown companion-status" data-companion-status hidden>
|
|
||||||
<button type="button" class="btn btn-small companion-icon" data-action="companion-toggle" title="Companion status" aria-label="Companion status">○</button>
|
|
||||||
<div class="dropdown-menu align-right open-up companion-flyout"></div>
|
|
||||||
</span>
|
|
||||||
</footer>
|
|
||||||
{{block "extras" .}}{{end}}
|
{{block "extras" .}}{{end}}
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
+4
-4
@@ -15,20 +15,20 @@
|
|||||||
backdrop.className = 'modal-backdrop';
|
backdrop.className = 'modal-backdrop';
|
||||||
|
|
||||||
modal = document.createElement('div');
|
modal = document.createElement('div');
|
||||||
modal.className = 'modal panel panel-floating';
|
modal.className = 'modal';
|
||||||
modal.setAttribute('role', 'dialog');
|
modal.setAttribute('role', 'dialog');
|
||||||
modal.setAttribute('aria-modal', 'true');
|
modal.setAttribute('aria-modal', 'true');
|
||||||
|
|
||||||
var header = document.createElement('div');
|
var header = document.createElement('div');
|
||||||
header.className = 'panel-header';
|
header.className = 'modal-header';
|
||||||
titleEl = document.createElement('span');
|
titleEl = document.createElement('span');
|
||||||
header.appendChild(titleEl);
|
header.appendChild(titleEl);
|
||||||
|
|
||||||
bodyEl = document.createElement('div');
|
bodyEl = document.createElement('div');
|
||||||
bodyEl.className = 'panel-body';
|
bodyEl.className = 'modal-body';
|
||||||
|
|
||||||
footerEl = document.createElement('div');
|
footerEl = document.createElement('div');
|
||||||
footerEl.className = 'panel-footer';
|
footerEl.className = 'modal-footer';
|
||||||
|
|
||||||
cancelBtn = document.createElement('button');
|
cancelBtn = document.createElement('button');
|
||||||
cancelBtn.type = 'button';
|
cancelBtn.type = 'button';
|
||||||
|
|||||||
@@ -0,0 +1,99 @@
|
|||||||
|
function encodePickedPath(p) {
|
||||||
|
if (p === '/' || p === '') return '/';
|
||||||
|
return '/' + p.replace(/^\/+/, '').split('/').map(encodeURIComponent).join('/');
|
||||||
|
}
|
||||||
|
|
||||||
|
function promptPageName(title, initial, confirmLabel, onName) {
|
||||||
|
var input = document.createElement('input');
|
||||||
|
input.type = 'text';
|
||||||
|
input.className = 'modal-input';
|
||||||
|
input.placeholder = 'Page name';
|
||||||
|
if (initial) input.value = initial;
|
||||||
|
openModal({
|
||||||
|
title: title,
|
||||||
|
body: input,
|
||||||
|
confirm: {
|
||||||
|
label: confirmLabel,
|
||||||
|
onConfirm: function () {
|
||||||
|
var name = input.value.trim();
|
||||||
|
if (!name) return;
|
||||||
|
onName(name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function newPage() {
|
||||||
|
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) {
|
||||||
|
promptPageName('New page — name?', '', 'CREATE', function (name) {
|
||||||
|
var base = parentPath === '/' ? '/' : encodePickedPath(parentPath) + '/';
|
||||||
|
window.location.href = base + encodeURIComponent(name) + '/?edit';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function movePage() {
|
||||||
|
var current = decodeURIComponent(window.location.pathname).replace(/\/+$/, '');
|
||||||
|
if (!current) return;
|
||||||
|
var segs = current.split('/').filter(Boolean);
|
||||||
|
var currentName = segs[segs.length - 1] || '';
|
||||||
|
var parent = '/' + segs.slice(0, -1).join('/');
|
||||||
|
if (parent === '/') parent = '/';
|
||||||
|
|
||||||
|
openTreePicker({
|
||||||
|
title: 'Move — new parent?',
|
||||||
|
mode: 'folder',
|
||||||
|
initialPath: parent,
|
||||||
|
preselect: parent,
|
||||||
|
hideFiles: true,
|
||||||
|
confirmLabel: 'NEXT',
|
||||||
|
allowRoot: false,
|
||||||
|
onSelect: function (newParent) {
|
||||||
|
promptPageName('Move — new name?', currentName, 'MOVE', function (name) {
|
||||||
|
var dest = (newParent === '/' ? '' : newParent) + '/' + name;
|
||||||
|
var form = document.createElement('form');
|
||||||
|
form.method = 'POST';
|
||||||
|
form.action = window.location.pathname + '?move=' +
|
||||||
|
encodeURIComponent(dest);
|
||||||
|
document.body.appendChild(form);
|
||||||
|
form.submit();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function deletePage() {
|
||||||
|
var decodedPath = decodeURIComponent(window.location.pathname);
|
||||||
|
|
||||||
|
openModal({
|
||||||
|
title: 'Delete page',
|
||||||
|
body: 'Delete ' + decodedPath + ' and everything inside it?',
|
||||||
|
confirm: {
|
||||||
|
label: 'DELETE',
|
||||||
|
danger: true,
|
||||||
|
enterConfirms: false,
|
||||||
|
onConfirm: function () {
|
||||||
|
var form = document.createElement('form');
|
||||||
|
form.method = 'POST';
|
||||||
|
form.action = window.location.pathname + '?delete=1';
|
||||||
|
document.body.appendChild(form);
|
||||||
|
form.submit();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
cancel: { autofocus: true },
|
||||||
|
swapButtons: true
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('DOMContentLoaded', function () {
|
||||||
|
wireDropdown(document.querySelector('[data-action="actions-drop"]'));
|
||||||
|
});
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
{{define "headScripts"}}<script src="/_/page-actions.js"></script>{{end}}
|
||||||
|
|
||||||
|
{{define "headerActions"}}
|
||||||
|
{{if .CanEdit}}
|
||||||
|
<div class="dropdown">
|
||||||
|
<button class="btn" data-action="actions-drop" title="Actions">ACTIONS ▾</button>
|
||||||
|
<div class="dropdown-menu align-right">
|
||||||
|
<button class="btn dropdown-item" onclick="newPage()" title="New page (N)">NEW</button>
|
||||||
|
<a class="btn dropdown-item" href="?edit" title="Edit page (E)">EDIT</a>
|
||||||
|
{{if not .IsRoot}}
|
||||||
|
<button class="btn dropdown-item" onclick="movePage()" title="Move page (M)">MOVE</button>
|
||||||
|
<button class="btn dropdown-item danger" onclick="deletePage()" title="Delete page">DELETE</button>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
{{define "content"}}
|
||||||
|
{{if .Content}}
|
||||||
|
<div class="content">{{.Content}}</div>
|
||||||
|
{{end}}
|
||||||
|
{{if .SpecialContent}}
|
||||||
|
<div class="content">{{.SpecialContent}}</div>
|
||||||
|
{{end}}
|
||||||
|
{{if or .Content .SpecialContent}}
|
||||||
|
<script src="/_/content.js"></script>
|
||||||
|
<script src="/_/anchors.js"></script>
|
||||||
|
<script src="/_/toc.js"></script>
|
||||||
|
{{end}}
|
||||||
|
{{if .Content}}
|
||||||
|
<script src="/_/sections.js"></script>
|
||||||
|
{{end}}
|
||||||
|
{{if .Entries}}
|
||||||
|
<div class="listing">
|
||||||
|
<div class="listing-header">Contents</div>
|
||||||
|
{{range .Entries}}
|
||||||
|
<div class="listing-item">
|
||||||
|
<span class="icon">{{.Icon}}</span>
|
||||||
|
<a href="{{.URL}}">{{.Name}}</a>
|
||||||
|
<span class="meta">{{.Meta}}</span>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
{{else if not .Content}}
|
||||||
|
{{if not .SpecialContent}}
|
||||||
|
<p class="empty">Empty folder — <a href="?edit">[CREATE]</a></p>
|
||||||
|
{{end}}
|
||||||
|
{{end}}
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
{{define "extras"}}{{if .SidebarWidget}}{{.SidebarWidget}}{{end}}{{end}}
|
||||||
@@ -1,154 +0,0 @@
|
|||||||
function encodePickedPath(p) {
|
|
||||||
if (p === '/' || p === '') return '/';
|
|
||||||
return '/' + p.replace(/^\/+/, '').split('/').map(encodeURIComponent).join('/');
|
|
||||||
}
|
|
||||||
|
|
||||||
// postReplace POSTs to action with the optional form body, then loads target
|
|
||||||
// into the current history entry — so the action and its result occupy one
|
|
||||||
// entry instead of two, and back-navigation skips past the stale pre-mutation
|
|
||||||
// snapshot in bfcache. body may be null for empty POSTs.
|
|
||||||
//
|
|
||||||
// We can't just call window.location.replace(target): when target differs from
|
|
||||||
// the current URL only by fragment, the browser updates the URL bar without
|
|
||||||
// re-fetching, so a server-side mutation wouldn't be reflected. Instead,
|
|
||||||
// rewrite the current entry's URL via history.replaceState, then reload — the
|
|
||||||
// reload always re-fetches and preserves the (new) URL including its fragment.
|
|
||||||
function postReplace(action, body, target) {
|
|
||||||
var init = { method: 'POST', redirect: 'manual' };
|
|
||||||
if (body) {
|
|
||||||
init.headers = { 'Content-Type': 'application/x-www-form-urlencoded' };
|
|
||||||
init.body = body;
|
|
||||||
}
|
|
||||||
fetch(action, init).then(function (res) {
|
|
||||||
if (res.type === 'opaqueredirect' || res.ok) {
|
|
||||||
window.history.replaceState(null, '', target);
|
|
||||||
window.location.reload();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
return res.text().then(function (msg) {
|
|
||||||
alert(msg || ('Request failed (' + res.status + ')'));
|
|
||||||
});
|
|
||||||
}).catch(function () {
|
|
||||||
alert('Network error');
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function promptPageName(title, initial, confirmLabel, onName) {
|
|
||||||
var input = document.createElement('input');
|
|
||||||
input.type = 'text';
|
|
||||||
input.className = 'input';
|
|
||||||
input.placeholder = 'Page name';
|
|
||||||
if (initial) input.value = initial;
|
|
||||||
openModal({
|
|
||||||
title: title,
|
|
||||||
body: input,
|
|
||||||
confirm: {
|
|
||||||
label: confirmLabel,
|
|
||||||
onConfirm: function () {
|
|
||||||
var name = input.value.trim();
|
|
||||||
if (!name) return;
|
|
||||||
onName(name);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function newPage() {
|
|
||||||
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) {
|
|
||||||
promptPageName('New page — name?', '', 'CREATE', function (name) {
|
|
||||||
var base = parentPath === '/' ? '/' : encodePickedPath(parentPath) + '/';
|
|
||||||
window.location.href = base + encodeURIComponent(name) + '/?edit';
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function movePage() {
|
|
||||||
var current = decodeURIComponent(window.location.pathname).replace(/\/+$/, '');
|
|
||||||
if (!current) return;
|
|
||||||
var segs = current.split('/').filter(Boolean);
|
|
||||||
var currentName = segs[segs.length - 1] || '';
|
|
||||||
var parent = '/' + segs.slice(0, -1).join('/');
|
|
||||||
if (parent === '/') parent = '/';
|
|
||||||
|
|
||||||
openTreePicker({
|
|
||||||
title: 'Move — new parent?',
|
|
||||||
mode: 'folder',
|
|
||||||
initialPath: parent,
|
|
||||||
preselect: parent,
|
|
||||||
hideFiles: true,
|
|
||||||
confirmLabel: 'NEXT',
|
|
||||||
onSelect: function (newParent) {
|
|
||||||
var input = document.createElement('input');
|
|
||||||
input.type = 'text';
|
|
||||||
input.className = 'input';
|
|
||||||
input.placeholder = 'Page name';
|
|
||||||
input.value = currentName;
|
|
||||||
|
|
||||||
var linksCheckbox = document.createElement('input');
|
|
||||||
linksCheckbox.type = 'checkbox';
|
|
||||||
linksCheckbox.id = 'move-update-links';
|
|
||||||
|
|
||||||
var linksLabel = document.createElement('label');
|
|
||||||
linksLabel.htmlFor = linksCheckbox.id;
|
|
||||||
linksLabel.className = 'row';
|
|
||||||
linksLabel.appendChild(linksCheckbox);
|
|
||||||
linksLabel.appendChild(document.createTextNode('Update links'));
|
|
||||||
|
|
||||||
var body = document.createDocumentFragment();
|
|
||||||
body.appendChild(input);
|
|
||||||
body.appendChild(linksLabel);
|
|
||||||
|
|
||||||
openModal({
|
|
||||||
title: 'Move — new name?',
|
|
||||||
body: body,
|
|
||||||
confirm: {
|
|
||||||
label: 'MOVE',
|
|
||||||
onConfirm: function () {
|
|
||||||
var name = input.value.trim();
|
|
||||||
if (!name) return;
|
|
||||||
var dest = (newParent === '/' ? '' : newParent) + '/' + name;
|
|
||||||
var action = window.location.pathname + '?move=' +
|
|
||||||
encodeURIComponent(dest);
|
|
||||||
if (linksCheckbox.checked) action += '&links=1';
|
|
||||||
var target = encodePickedPath(dest) + '/';
|
|
||||||
closeModal();
|
|
||||||
postReplace(action, null, target);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function deletePage() {
|
|
||||||
var decodedPath = decodeURIComponent(window.location.pathname);
|
|
||||||
|
|
||||||
openModal({
|
|
||||||
title: 'Delete page',
|
|
||||||
body: 'Delete ' + decodedPath + ' and everything inside it?',
|
|
||||||
confirm: {
|
|
||||||
label: 'DELETE',
|
|
||||||
danger: true,
|
|
||||||
enterConfirms: false,
|
|
||||||
onConfirm: function () {
|
|
||||||
var p = window.location.pathname.replace(/\/+$/, '');
|
|
||||||
var idx = p.lastIndexOf('/');
|
|
||||||
var parent = idx > 0 ? p.substring(0, idx + 1) : '/';
|
|
||||||
closeModal();
|
|
||||||
postReplace(window.location.pathname + '?delete=1', null, parent);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
cancel: { autofocus: true },
|
|
||||||
swapButtons: true
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -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);
|
|
||||||
});
|
|
||||||
}());
|
|
||||||
@@ -1,64 +0,0 @@
|
|||||||
{{define "headScripts"}}<script src="/_/page/actions.js"></script>{{end}}
|
|
||||||
|
|
||||||
{{define "content"}}
|
|
||||||
{{if .Content}}
|
|
||||||
<div class="content">{{.Content}}</div>
|
|
||||||
{{end}}
|
|
||||||
{{if .SpecialContent}}
|
|
||||||
<div class="content">{{.SpecialContent}}</div>
|
|
||||||
{{end}}
|
|
||||||
{{if .Entries}}
|
|
||||||
<h2 id="files">Files <button class="btn btn-small" data-companion-reveal hidden title="Open folder in file manager">open</button>{{if .CanEdit}} <button class="btn btn-small" id="view-settings-btn" onclick="openViewSettings()" title="View & sorting" data-view="{{.View}}" data-sort="{{.Sort}}" data-order="{{.Order}}">view</button>{{end}}</h2>
|
|
||||||
{{if eq .View "thumbnail"}}
|
|
||||||
<div class="thumb-grid">
|
|
||||||
{{range .Entries}}
|
|
||||||
<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}}
|
|
||||||
<span class="thumb-label truncate">{{.Name}}</span>
|
|
||||||
</a>
|
|
||||||
{{end}}
|
|
||||||
</div>
|
|
||||||
{{else}}
|
|
||||||
<table class="data-table panel">
|
|
||||||
<tbody>
|
|
||||||
{{range .Entries}}
|
|
||||||
<tr class="list-item" data-path="{{.URL}}">
|
|
||||||
<td class="icon">{{.Icon}}</td>
|
|
||||||
<td class="name"><a href="{{.URL}}">{{.Name}}</a></td>
|
|
||||||
<td class="meta">{{.Meta}}</td>
|
|
||||||
</tr>
|
|
||||||
{{end}}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
{{end}}
|
|
||||||
{{if .CanEdit}}<script src="/_/page/view-settings.js"></script>{{end}}
|
|
||||||
{{else if not .Content}}
|
|
||||||
{{if not .SpecialContent}}
|
|
||||||
<p class="empty">Empty folder — <a href="?edit">[CREATE]</a></p>
|
|
||||||
{{end}}
|
|
||||||
{{end}}
|
|
||||||
{{if or .Content .SpecialContent}}
|
|
||||||
<script src="/_/page/content.js"></script>
|
|
||||||
<script src="/_/page/anchors.js"></script>
|
|
||||||
{{if not .SuppressTOC}}<script src="/_/page/toc.js"></script>{{end}}
|
|
||||||
<script src="/_/page/tasks.js"></script>
|
|
||||||
{{end}}
|
|
||||||
{{if .Content}}
|
|
||||||
<script src="/_/page/sections.js"></script>
|
|
||||||
{{end}}
|
|
||||||
<script src="/_/page/sidebar-fab.js"></script>
|
|
||||||
{{end}}
|
|
||||||
|
|
||||||
{{define "sidebar"}}{{if .CanEdit}}<nav class="actions panel panel-sidebar">
|
|
||||||
<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}}
|
|
||||||
@@ -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);
|
|
||||||
});
|
|
||||||
@@ -1,45 +0,0 @@
|
|||||||
(function () {
|
|
||||||
document.querySelectorAll('input.task-checkbox[data-task-index]').forEach(function (cb) {
|
|
||||||
cb.addEventListener('change', function () {
|
|
||||||
var idx = cb.dataset.taskIndex;
|
|
||||||
var checked = cb.checked;
|
|
||||||
cb.disabled = true;
|
|
||||||
fetch(window.location.pathname + '?toggle=' + idx, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
||||||
body: 'checked=' + checked
|
|
||||||
}).then(function (res) {
|
|
||||||
if (!res.ok) {
|
|
||||||
cb.checked = !checked;
|
|
||||||
alert('Failed to save task state (' + res.status + ')');
|
|
||||||
}
|
|
||||||
}).catch(function () {
|
|
||||||
cb.checked = !checked;
|
|
||||||
alert('Failed to save task state');
|
|
||||||
}).finally(function () {
|
|
||||||
cb.disabled = false;
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
document.addEventListener("DOMContentLoaded", function () {
|
|
||||||
var content = document.querySelector("main");
|
|
||||||
if (!content) return;
|
|
||||||
|
|
||||||
var headings = content.querySelectorAll("h2, h3, h4");
|
|
||||||
if (headings.length < 2) return;
|
|
||||||
|
|
||||||
var nav = document.createElement("nav");
|
|
||||||
nav.className = "toc panel panel-sidebar";
|
|
||||||
|
|
||||||
var header = document.createElement("div");
|
|
||||||
header.className = "panel-header";
|
|
||||||
header.textContent = "Contents";
|
|
||||||
nav.appendChild(header);
|
|
||||||
|
|
||||||
var list = document.createElement("ul");
|
|
||||||
headings.forEach(function (h) {
|
|
||||||
if (!h.id) return;
|
|
||||||
var li = document.createElement("li");
|
|
||||||
li.className = "toc-" + h.tagName.toLowerCase();
|
|
||||||
var a = document.createElement("a");
|
|
||||||
a.href = "#" + h.id;
|
|
||||||
var clone = h.cloneNode(true);
|
|
||||||
clone.querySelectorAll(".btn, .muted, .heading-anchor, .dropdown").forEach(function (el) { el.remove(); });
|
|
||||||
a.textContent = clone.textContent.trim();
|
|
||||||
li.appendChild(a);
|
|
||||||
list.appendChild(li);
|
|
||||||
});
|
|
||||||
nav.appendChild(list);
|
|
||||||
|
|
||||||
var rail = document.querySelector("aside.sidebar");
|
|
||||||
rail.appendChild(nav);
|
|
||||||
});
|
|
||||||
@@ -1,85 +0,0 @@
|
|||||||
// View-settings modal: lets the user pick the folder listing's view style,
|
|
||||||
// sort key, and order, then persists them by POSTing to the folder with
|
|
||||||
// ?settings. Reuses openModal/closeModal and postReplace from page/actions.js.
|
|
||||||
function openViewSettings() {
|
|
||||||
var btn = document.getElementById('view-settings-btn');
|
|
||||||
var state = {
|
|
||||||
view: (btn && btn.dataset.view) || 'list',
|
|
||||||
sort: (btn && btn.dataset.sort) || 'name',
|
|
||||||
order: (btn && btn.dataset.order) || 'asc'
|
|
||||||
};
|
|
||||||
|
|
||||||
// segmented builds a row of mutually-exclusive .btn toggles bound to a
|
|
||||||
// single state key, marking the current choice with .is-active.
|
|
||||||
function segmented(key, options) {
|
|
||||||
var wrap = document.createElement('div');
|
|
||||||
wrap.className = 'row gap-1';
|
|
||||||
options.forEach(function (opt) {
|
|
||||||
var b = document.createElement('button');
|
|
||||||
b.type = 'button';
|
|
||||||
b.className = 'btn';
|
|
||||||
b.textContent = opt.label;
|
|
||||||
if (state[key] === opt.value) b.classList.add('is-active');
|
|
||||||
b.addEventListener('click', function () {
|
|
||||||
state[key] = opt.value;
|
|
||||||
wrap.querySelectorAll('button').forEach(function (x) {
|
|
||||||
x.classList.remove('is-active');
|
|
||||||
});
|
|
||||||
b.classList.add('is-active');
|
|
||||||
});
|
|
||||||
wrap.appendChild(b);
|
|
||||||
});
|
|
||||||
return wrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
function field(labelText, control) {
|
|
||||||
var row = document.createElement('div');
|
|
||||||
row.className = 'col gap-1';
|
|
||||||
var label = document.createElement('span');
|
|
||||||
label.className = 'caption';
|
|
||||||
label.textContent = labelText;
|
|
||||||
row.appendChild(label);
|
|
||||||
row.appendChild(control);
|
|
||||||
return row;
|
|
||||||
}
|
|
||||||
|
|
||||||
var sortSelect = document.createElement('select');
|
|
||||||
sortSelect.className = 'input';
|
|
||||||
[['name', 'Name'], ['modified', 'Modified'], ['size', 'Size']].forEach(function (o) {
|
|
||||||
var opt = document.createElement('option');
|
|
||||||
opt.value = o[0];
|
|
||||||
opt.textContent = o[1];
|
|
||||||
if (state.sort === o[0]) opt.selected = true;
|
|
||||||
sortSelect.appendChild(opt);
|
|
||||||
});
|
|
||||||
sortSelect.addEventListener('change', function () { state.sort = sortSelect.value; });
|
|
||||||
|
|
||||||
var body = document.createElement('div');
|
|
||||||
body.className = 'col';
|
|
||||||
body.appendChild(field('View style', segmented('view', [
|
|
||||||
{ value: 'list', label: 'List' },
|
|
||||||
{ value: 'thumbnail', label: 'Thumbnail' }
|
|
||||||
])));
|
|
||||||
body.appendChild(field('Sort by', sortSelect));
|
|
||||||
body.appendChild(field('Order', segmented('order', [
|
|
||||||
{ value: 'asc', label: 'Asc' },
|
|
||||||
{ value: 'desc', label: 'Desc' }
|
|
||||||
])));
|
|
||||||
|
|
||||||
openModal({
|
|
||||||
title: 'View settings',
|
|
||||||
body: body,
|
|
||||||
confirm: {
|
|
||||||
label: 'SAVE',
|
|
||||||
onConfirm: function () {
|
|
||||||
var action = window.location.pathname + '?settings';
|
|
||||||
var formBody = 'view=' + encodeURIComponent(state.view) +
|
|
||||||
'&sort=' + encodeURIComponent(state.sort) +
|
|
||||||
'&order=' + encodeURIComponent(state.order);
|
|
||||||
var target = window.location.pathname;
|
|
||||||
closeModal();
|
|
||||||
postReplace(action, formBody, target);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -1,93 +0,0 @@
|
|||||||
<!doctype html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8" />
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
||||||
<title>Save link</title>
|
|
||||||
<link rel="icon" href="/_/favicon.ico" />
|
|
||||||
<link rel="stylesheet" href="/_/style.css" />
|
|
||||||
<style>
|
|
||||||
body { padding: 0.8rem; }
|
|
||||||
.qa-form { display: flex; flex-direction: column; gap: 0.5rem; }
|
|
||||||
.qa-row { display: flex; flex-direction: column; gap: 0.1rem; }
|
|
||||||
.qa-label { font-size: 0.7rem; }
|
|
||||||
.qa-value { word-break: break-all; font-size: 0.85rem; }
|
|
||||||
.qa-comment {
|
|
||||||
width: 100%;
|
|
||||||
padding: 0.35rem;
|
|
||||||
background: var(--bg-panel);
|
|
||||||
color: var(--text);
|
|
||||||
border: 1px solid var(--bg-panel-hover);
|
|
||||||
font: inherit;
|
|
||||||
}
|
|
||||||
.qa-actions { display: flex; gap: 1rem; }
|
|
||||||
.qa-status { min-height: 1em; font-size: 0.85rem; }
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<form id="qa-form" class="qa-form"
|
|
||||||
data-to="{{.To}}" data-url="{{.URL}}" data-title="{{.Title}}">
|
|
||||||
<div class="qa-row">
|
|
||||||
<span class="qa-label muted">Save to</span>
|
|
||||||
<span class="qa-value">{{.To}}</span>
|
|
||||||
</div>
|
|
||||||
<div class="qa-row">
|
|
||||||
<span class="qa-label muted">Title</span>
|
|
||||||
<span class="qa-value">{{.Title}}</span>
|
|
||||||
</div>
|
|
||||||
<div class="qa-row">
|
|
||||||
<span class="qa-label muted">URL</span>
|
|
||||||
<span class="qa-value">{{.URL}}</span>
|
|
||||||
</div>
|
|
||||||
<div class="qa-row">
|
|
||||||
<label class="qa-label muted" for="qa-comment">Comment</label>
|
|
||||||
<input id="qa-comment" name="comment" type="text" class="qa-comment" autofocus />
|
|
||||||
</div>
|
|
||||||
<div class="qa-actions">
|
|
||||||
<button type="submit" class="btn">SAVE</button>
|
|
||||||
<button type="button" class="btn" id="qa-cancel">CANCEL</button>
|
|
||||||
</div>
|
|
||||||
<div id="qa-status" class="qa-status muted"></div>
|
|
||||||
</form>
|
|
||||||
<script>
|
|
||||||
(function () {
|
|
||||||
const form = document.getElementById("qa-form");
|
|
||||||
const status = document.getElementById("qa-status");
|
|
||||||
const comment = document.getElementById("qa-comment");
|
|
||||||
document
|
|
||||||
.getElementById("qa-cancel")
|
|
||||||
.addEventListener("click", () => window.close());
|
|
||||||
form.addEventListener("submit", async (ev) => {
|
|
||||||
ev.preventDefault();
|
|
||||||
status.classList.remove("danger");
|
|
||||||
status.classList.add("muted");
|
|
||||||
status.textContent = "Saving…";
|
|
||||||
const body = new URLSearchParams();
|
|
||||||
body.set("url", form.dataset.url);
|
|
||||||
body.set("title", form.dataset.title);
|
|
||||||
body.set("comment", comment.value);
|
|
||||||
try {
|
|
||||||
const res = await fetch(form.dataset.to + "?append", {
|
|
||||||
method: "POST",
|
|
||||||
body,
|
|
||||||
credentials: "same-origin",
|
|
||||||
});
|
|
||||||
if (!res.ok) {
|
|
||||||
const text = (await res.text()).trim();
|
|
||||||
status.classList.remove("muted");
|
|
||||||
status.classList.add("danger");
|
|
||||||
status.textContent = text || "HTTP " + res.status;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
status.textContent = "Saved ✓";
|
|
||||||
setTimeout(() => window.close(), 1000);
|
|
||||||
} catch (e) {
|
|
||||||
status.classList.remove("muted");
|
|
||||||
status.classList.add("danger");
|
|
||||||
status.textContent = (e && e.message) || "Network error";
|
|
||||||
}
|
|
||||||
});
|
|
||||||
})();
|
|
||||||
</script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@@ -1,323 +0,0 @@
|
|||||||
// search-suggest.js — instant typeahead dropdown.
|
|
||||||
//
|
|
||||||
// Exposes window.attachSuggestions(inputEl, opts) used by both the header
|
|
||||||
// search box and the editor's "Insert link" modal. Owns: debounced fetching,
|
|
||||||
// request ordering, DOM creation, keyboard handling, open/close lifecycle.
|
|
||||||
//
|
|
||||||
// opts:
|
|
||||||
// onPick(result) — called when the user selects a row
|
|
||||||
// onShowAll(query) — optional; called when the footer row activates
|
|
||||||
// showFooter (bool) — show the "Show all N matches" footer row
|
|
||||||
// container (Element) — optional parent (defaults to inputEl.parentNode)
|
|
||||||
(function () {
|
|
||||||
var DEBOUNCE_MS = 100;
|
|
||||||
var MIN_QUERY_LEN = 2;
|
|
||||||
|
|
||||||
function escapeHTML(s) {
|
|
||||||
return String(s)
|
|
||||||
.replace(/&/g, '&')
|
|
||||||
.replace(/</g, '<')
|
|
||||||
.replace(/>/g, '>')
|
|
||||||
.replace(/"/g, '"');
|
|
||||||
}
|
|
||||||
|
|
||||||
function tokenize(s) {
|
|
||||||
return s.toLowerCase().split(/[^\p{L}\p{N}]+/u).filter(Boolean);
|
|
||||||
}
|
|
||||||
|
|
||||||
// highlight bolds the substring spans in `name` that match any of the
|
|
||||||
// query tokens (case-insensitive). Overlapping/adjacent spans merge.
|
|
||||||
// Returns a safe HTML string.
|
|
||||||
function highlight(name, tokens) {
|
|
||||||
if (!tokens.length) return escapeHTML(name);
|
|
||||||
var lower = name.toLowerCase();
|
|
||||||
var spans = [];
|
|
||||||
tokens.forEach(function (t) {
|
|
||||||
if (!t) return;
|
|
||||||
var idx = lower.indexOf(t);
|
|
||||||
if (idx >= 0) spans.push([idx, idx + t.length]);
|
|
||||||
});
|
|
||||||
if (!spans.length) return escapeHTML(name);
|
|
||||||
spans.sort(function (a, b) { return a[0] - b[0]; });
|
|
||||||
var merged = [spans[0].slice()];
|
|
||||||
for (var i = 1; i < spans.length; i++) {
|
|
||||||
var last = merged[merged.length - 1];
|
|
||||||
if (spans[i][0] <= last[1]) {
|
|
||||||
last[1] = Math.max(last[1], spans[i][1]);
|
|
||||||
} else {
|
|
||||||
merged.push(spans[i].slice());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
var out = '';
|
|
||||||
var cursor = 0;
|
|
||||||
merged.forEach(function (sp) {
|
|
||||||
out += escapeHTML(name.slice(cursor, sp[0]));
|
|
||||||
out += '<strong>' + escapeHTML(name.slice(sp[0], sp[1])) + '</strong>';
|
|
||||||
cursor = sp[1];
|
|
||||||
});
|
|
||||||
out += escapeHTML(name.slice(cursor));
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
|
|
||||||
function attachSuggestions(inputEl, opts) {
|
|
||||||
if (!inputEl) return;
|
|
||||||
opts = opts || {};
|
|
||||||
var host = opts.container || inputEl.parentNode;
|
|
||||||
if (!host) return;
|
|
||||||
host.classList.add('suggest-host');
|
|
||||||
|
|
||||||
var dropdown = document.createElement('div');
|
|
||||||
dropdown.className = 'suggest-dropdown';
|
|
||||||
host.appendChild(dropdown);
|
|
||||||
|
|
||||||
function makeRow(cls, tabbable) {
|
|
||||||
var tr = document.createElement('tr');
|
|
||||||
tr.className = cls;
|
|
||||||
if (tabbable) tr.setAttribute('tabindex', '0');
|
|
||||||
var td = document.createElement('td');
|
|
||||||
tr.appendChild(td);
|
|
||||||
return { tr: tr, td: td };
|
|
||||||
}
|
|
||||||
|
|
||||||
var state = {
|
|
||||||
results: [],
|
|
||||||
total: 0,
|
|
||||||
query: '',
|
|
||||||
activeIdx: -1,
|
|
||||||
open: false,
|
|
||||||
reqSeq: 0,
|
|
||||||
debounceTimer: null,
|
|
||||||
blurTimer: null,
|
|
||||||
};
|
|
||||||
|
|
||||||
function rowCount() {
|
|
||||||
var n = state.results.length;
|
|
||||||
if (state.results.length === 0 && state.query.length >= MIN_QUERY_LEN) {
|
|
||||||
return 0; // "no matches" row is non-interactive
|
|
||||||
}
|
|
||||||
if (opts.showFooter && state.total > state.results.length) n += 1;
|
|
||||||
return n;
|
|
||||||
}
|
|
||||||
|
|
||||||
function isFooterIdx(idx) {
|
|
||||||
return opts.showFooter
|
|
||||||
&& state.total > state.results.length
|
|
||||||
&& idx === state.results.length;
|
|
||||||
}
|
|
||||||
|
|
||||||
function render() {
|
|
||||||
dropdown.textContent = '';
|
|
||||||
if (!state.open) {
|
|
||||||
dropdown.classList.remove('is-open');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
var table = document.createElement('table');
|
|
||||||
table.className = 'data-table';
|
|
||||||
var tbody = document.createElement('tbody');
|
|
||||||
table.appendChild(tbody);
|
|
||||||
|
|
||||||
var tokens = tokenize(state.query);
|
|
||||||
if (state.results.length === 0) {
|
|
||||||
var empty = makeRow('is-empty', false);
|
|
||||||
empty.td.textContent = 'No matches';
|
|
||||||
tbody.appendChild(empty.tr);
|
|
||||||
} else {
|
|
||||||
state.results.forEach(function (r, i) {
|
|
||||||
var row = makeRow('suggest-row', true);
|
|
||||||
row.tr.setAttribute('data-idx', String(i));
|
|
||||||
var nameEl = document.createElement('span');
|
|
||||||
nameEl.className = 'suggest-name';
|
|
||||||
nameEl.innerHTML = highlight(r.name, tokens);
|
|
||||||
var pathEl = document.createElement('span');
|
|
||||||
pathEl.className = 'suggest-path';
|
|
||||||
pathEl.textContent = '/' + r.path;
|
|
||||||
row.td.appendChild(nameEl);
|
|
||||||
row.td.appendChild(pathEl);
|
|
||||||
if (i === state.activeIdx) row.tr.classList.add('is-active');
|
|
||||||
row.tr.addEventListener('mousedown', function (e) {
|
|
||||||
// mousedown (not click) so the input doesn't blur-close
|
|
||||||
// the dropdown before the pick handler fires.
|
|
||||||
e.preventDefault();
|
|
||||||
pick(i);
|
|
||||||
});
|
|
||||||
tbody.appendChild(row.tr);
|
|
||||||
});
|
|
||||||
if (opts.showFooter && state.total > state.results.length) {
|
|
||||||
var footer = makeRow('suggest-row suggest-footer', true);
|
|
||||||
footer.td.textContent = 'Show all ' + state.total + ' matches';
|
|
||||||
var footerIdx = state.results.length;
|
|
||||||
if (state.activeIdx === footerIdx) footer.tr.classList.add('is-active');
|
|
||||||
footer.tr.addEventListener('mousedown', function (e) {
|
|
||||||
e.preventDefault();
|
|
||||||
pickFooter();
|
|
||||||
});
|
|
||||||
tbody.appendChild(footer.tr);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
dropdown.appendChild(table);
|
|
||||||
dropdown.classList.add('is-open');
|
|
||||||
}
|
|
||||||
|
|
||||||
function pick(idx) {
|
|
||||||
var r = state.results[idx];
|
|
||||||
if (!r) return;
|
|
||||||
close();
|
|
||||||
if (opts.onPick) opts.onPick(r);
|
|
||||||
}
|
|
||||||
|
|
||||||
function pickFooter() {
|
|
||||||
close();
|
|
||||||
if (opts.onShowAll) {
|
|
||||||
opts.onShowAll(state.query);
|
|
||||||
} else if (inputEl.form) {
|
|
||||||
inputEl.form.submit();
|
|
||||||
} else {
|
|
||||||
window.location.href = '/?q=' + encodeURIComponent(state.query);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function open() {
|
|
||||||
state.open = true;
|
|
||||||
render();
|
|
||||||
}
|
|
||||||
|
|
||||||
function close() {
|
|
||||||
state.open = false;
|
|
||||||
state.activeIdx = -1;
|
|
||||||
render();
|
|
||||||
}
|
|
||||||
|
|
||||||
function fetchResults(query) {
|
|
||||||
var seq = ++state.reqSeq;
|
|
||||||
fetch('/_search?q=' + encodeURIComponent(query), {
|
|
||||||
credentials: 'same-origin',
|
|
||||||
headers: { 'Accept': 'application/json' },
|
|
||||||
}).then(function (r) {
|
|
||||||
if (!r.ok) throw new Error('HTTP ' + r.status);
|
|
||||||
return r.json();
|
|
||||||
}).then(function (resp) {
|
|
||||||
if (seq !== state.reqSeq) return; // stale
|
|
||||||
state.results = resp.results || [];
|
|
||||||
state.total = resp.total || 0;
|
|
||||||
state.query = resp.query || query;
|
|
||||||
state.activeIdx = -1;
|
|
||||||
open();
|
|
||||||
}).catch(function () {
|
|
||||||
if (seq !== state.reqSeq) return;
|
|
||||||
state.results = [];
|
|
||||||
state.total = 0;
|
|
||||||
close();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function onInput() {
|
|
||||||
var q = inputEl.value.trim();
|
|
||||||
state.query = q;
|
|
||||||
if (state.debounceTimer) clearTimeout(state.debounceTimer);
|
|
||||||
if (q.length < MIN_QUERY_LEN) {
|
|
||||||
state.reqSeq++; // invalidate any in-flight response
|
|
||||||
state.results = [];
|
|
||||||
state.total = 0;
|
|
||||||
close();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
state.debounceTimer = setTimeout(function () {
|
|
||||||
fetchResults(q);
|
|
||||||
}, DEBOUNCE_MS);
|
|
||||||
}
|
|
||||||
|
|
||||||
function moveActive(delta) {
|
|
||||||
var n = rowCount();
|
|
||||||
if (n === 0) return;
|
|
||||||
var next = state.activeIdx + delta;
|
|
||||||
if (next < 0) next = n - 1;
|
|
||||||
if (next >= n) next = 0;
|
|
||||||
state.activeIdx = next;
|
|
||||||
render();
|
|
||||||
// Keep the active row in view.
|
|
||||||
var active = dropdown.querySelector('tr.is-active');
|
|
||||||
if (active && active.scrollIntoView) {
|
|
||||||
try { active.scrollIntoView({ block: 'nearest' }); } catch (e) {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function activateCurrent() {
|
|
||||||
if (state.activeIdx < 0) return false;
|
|
||||||
if (isFooterIdx(state.activeIdx)) {
|
|
||||||
pickFooter();
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
pick(state.activeIdx);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
inputEl.addEventListener('input', onInput);
|
|
||||||
inputEl.addEventListener('focus', function () {
|
|
||||||
if (state.blurTimer) {
|
|
||||||
clearTimeout(state.blurTimer);
|
|
||||||
state.blurTimer = null;
|
|
||||||
}
|
|
||||||
if (inputEl.value.trim().length >= MIN_QUERY_LEN
|
|
||||||
&& (state.results.length || state.query)) {
|
|
||||||
open();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
inputEl.addEventListener('blur', function () {
|
|
||||||
// Delay so click/mousedown on a row still resolves.
|
|
||||||
state.blurTimer = setTimeout(close, 150);
|
|
||||||
});
|
|
||||||
inputEl.addEventListener('keydown', function (e) {
|
|
||||||
if (e.key === 'ArrowDown') {
|
|
||||||
if (!state.open) return;
|
|
||||||
e.preventDefault();
|
|
||||||
moveActive(1);
|
|
||||||
} else if (e.key === 'ArrowUp') {
|
|
||||||
if (!state.open) return;
|
|
||||||
e.preventDefault();
|
|
||||||
moveActive(-1);
|
|
||||||
} else if (e.key === 'Escape') {
|
|
||||||
if (!state.open) return;
|
|
||||||
e.preventDefault();
|
|
||||||
close();
|
|
||||||
} else if (e.key === 'Enter') {
|
|
||||||
if (state.open && state.activeIdx >= 0) {
|
|
||||||
e.preventDefault();
|
|
||||||
activateCurrent();
|
|
||||||
}
|
|
||||||
// else: native form submit behaviour (full results page)
|
|
||||||
} else if (e.key === 'Tab') {
|
|
||||||
if (!state.open || rowCount() === 0) return;
|
|
||||||
e.preventDefault();
|
|
||||||
moveActive(e.shiftKey ? -1 : 1);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Click outside the host closes the dropdown.
|
|
||||||
document.addEventListener('mousedown', function (e) {
|
|
||||||
if (!state.open) return;
|
|
||||||
if (host.contains(e.target)) return;
|
|
||||||
close();
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
close: close,
|
|
||||||
destroy: function () {
|
|
||||||
if (dropdown.parentNode) dropdown.parentNode.removeChild(dropdown);
|
|
||||||
host.classList.remove('suggest-host');
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
window.attachSuggestions = attachSuggestions;
|
|
||||||
|
|
||||||
// Auto-bind to the header search input. Header search submits the form
|
|
||||||
// for the "show all" action; we route to a navigate-on-pick handler.
|
|
||||||
document.addEventListener('DOMContentLoaded', function () {
|
|
||||||
var input = document.querySelector('header .search-input');
|
|
||||||
if (!input) return;
|
|
||||||
attachSuggestions(input, {
|
|
||||||
showFooter: true,
|
|
||||||
onPick: function (r) { window.location.href = r.url; },
|
|
||||||
});
|
|
||||||
});
|
|
||||||
})();
|
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
{{define "searchQuery"}}{{.Query}}{{end}}
|
||||||
|
|
||||||
|
{{define "content"}}
|
||||||
|
{{if .Query}}
|
||||||
|
{{if .Results}}
|
||||||
|
<h2 class="muted search-summary">{{len .Results}} match{{if ne (len .Results) 1}}es{{end}} for “{{.Query}}”</h2>
|
||||||
|
<div class="search-results">
|
||||||
|
{{range .Results}}
|
||||||
|
<article class="search-card">
|
||||||
|
<a class="search-card-name" href="{{.URL}}">{{.Name}}</a>
|
||||||
|
<div class="search-card-path muted">/{{.Path}}</div>
|
||||||
|
{{if .Snippet}}<div class="search-card-snippet">{{.Snippet}}</div>{{end}}
|
||||||
|
</article>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
{{else}}
|
||||||
|
<p class="empty">No matches for “{{.Query}}”.</p>
|
||||||
|
{{end}}
|
||||||
|
{{else}}
|
||||||
|
<p class="empty">Enter a query above.</p>
|
||||||
|
{{end}}
|
||||||
|
{{end}}
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
function rebuildIndex() {
|
|
||||||
openModal({ title: 'Rebuilding search index…', body: 'Walking the wiki tree.' });
|
|
||||||
fetch('/_reindex', { method: 'POST' })
|
|
||||||
.then(function (resp) {
|
|
||||||
if (!resp.ok) throw new Error('rebuild failed: ' + resp.status);
|
|
||||||
window.location.href = window.location.href;
|
|
||||||
})
|
|
||||||
.catch(function (err) {
|
|
||||||
closeModal();
|
|
||||||
openModal({ title: 'Rebuild failed', body: String(err), confirm: { label: 'OK' } });
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
document.addEventListener('DOMContentLoaded', function () {
|
|
||||||
wireDropdown(document.querySelector('[data-action="actions-drop"]'));
|
|
||||||
|
|
||||||
// Focus the search input on results pages so Tab steps directly into the
|
|
||||||
// first match — the input sits immediately before the results in DOM
|
|
||||||
// order, so the natural tab sequence is input → first result → next, …
|
|
||||||
var input = document.querySelector('.search-input');
|
|
||||||
if (input && input.value) {
|
|
||||||
input.focus();
|
|
||||||
var end = input.value.length;
|
|
||||||
try { input.setSelectionRange(end, end); } catch (e) {}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
@@ -1,35 +0,0 @@
|
|||||||
{{define "headScripts"}}<script src="/_/search/actions.js"></script>{{end}}
|
|
||||||
|
|
||||||
{{define "searchQuery"}}{{.Query}}{{end}}
|
|
||||||
|
|
||||||
{{define "content"}}
|
|
||||||
{{if .Query}}
|
|
||||||
{{if .Results}}
|
|
||||||
<p class="muted">{{len .Results}} match{{if ne (len .Results) 1}}es{{end}} for “{{.Query}}”</p>
|
|
||||||
<hr/>
|
|
||||||
{{range .Results}}
|
|
||||||
<article class="search-card">
|
|
||||||
<a href="{{.URL}}">{{.Name}}</a>
|
|
||||||
<div class="muted">/{{.Path}}</div>
|
|
||||||
</article>
|
|
||||||
{{end}}
|
|
||||||
{{else}}
|
|
||||||
<p class="empty">No matches for “{{.Query}}”.</p>
|
|
||||||
{{end}}
|
|
||||||
{{else}}
|
|
||||||
<p class="empty">Enter a query above.</p>
|
|
||||||
{{end}}
|
|
||||||
{{end}}
|
|
||||||
|
|
||||||
{{define "footerExtras"}}
|
|
||||||
{{if not .IndexBuiltAt.IsZero}}<span class="muted">· Index: {{.IndexBuiltAt.Format "2006-01-02 15:04"}}</span>{{end}}
|
|
||||||
{{end}}
|
|
||||||
|
|
||||||
{{define "extras"}}
|
|
||||||
<div class="fab dropdown">
|
|
||||||
<button class="btn btn-fab" data-action="actions-drop" title="Actions" aria-label="Actions">≡</button>
|
|
||||||
<div class="dropdown-menu align-right open-up">
|
|
||||||
<button class="btn btn-block" onclick="rebuildIndex()" title="Rebuild search index">REBUILD INDEX</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{{end}}
|
|
||||||
@@ -6,13 +6,11 @@
|
|||||||
|
|
||||||
// Section 0 is pre-heading content, editable via full-page edit.
|
// 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.
|
// 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;
|
|
||||||
var a = document.createElement('a');
|
var a = document.createElement('a');
|
||||||
a.href = '?edit§ion=' + (i + 1);
|
a.href = '?edit§ion=' + (i + 1);
|
||||||
a.className = 'btn btn-small';
|
a.className = 'btn btn-small';
|
||||||
a.textContent = 'edit';
|
a.textContent = 'edit';
|
||||||
h.appendChild(document.createTextNode(' '))
|
h.appendChild(document.createTextNode(' '))
|
||||||
h.appendChild(a);
|
h.appendChild(a);
|
||||||
});
|
});
|
||||||
+586
-518
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,59 @@
|
|||||||
|
(function () {
|
||||||
|
var content = document.querySelector("main");
|
||||||
|
if (!content) return;
|
||||||
|
|
||||||
|
var headings = content.querySelectorAll("h1, h2, h3");
|
||||||
|
if (headings.length < 2) return;
|
||||||
|
|
||||||
|
var nav = document.createElement("nav");
|
||||||
|
nav.className = "toc";
|
||||||
|
|
||||||
|
var header = document.createElement("div");
|
||||||
|
header.className = "panel-header";
|
||||||
|
header.textContent = "Contents";
|
||||||
|
nav.appendChild(header);
|
||||||
|
|
||||||
|
var list = document.createElement("ul");
|
||||||
|
headings.forEach(function (h) {
|
||||||
|
if (!h.id) return;
|
||||||
|
var li = document.createElement("li");
|
||||||
|
li.className = "toc-" + h.tagName.toLowerCase();
|
||||||
|
var a = document.createElement("a");
|
||||||
|
a.href = "#" + h.id;
|
||||||
|
var clone = h.cloneNode(true);
|
||||||
|
clone.querySelectorAll(".btn, .muted, .heading-anchor").forEach(function (el) { el.remove(); });
|
||||||
|
a.textContent = clone.textContent.trim();
|
||||||
|
li.appendChild(a);
|
||||||
|
list.appendChild(li);
|
||||||
|
});
|
||||||
|
nav.appendChild(list);
|
||||||
|
|
||||||
|
var toggle = document.createElement("button");
|
||||||
|
toggle.type = "button";
|
||||||
|
toggle.className = "panel-toggle";
|
||||||
|
toggle.textContent = "Contents";
|
||||||
|
toggle.setAttribute("aria-expanded", "false");
|
||||||
|
toggle.addEventListener("click", function () {
|
||||||
|
var open = nav.classList.toggle("is-open");
|
||||||
|
toggle.setAttribute("aria-expanded", open ? "true" : "false");
|
||||||
|
});
|
||||||
|
|
||||||
|
var main = document.querySelector("main");
|
||||||
|
if (main) {
|
||||||
|
main.parentNode.insertBefore(toggle, main);
|
||||||
|
main.parentNode.insertBefore(nav, main);
|
||||||
|
} else {
|
||||||
|
document.body.appendChild(toggle);
|
||||||
|
document.body.appendChild(nav);
|
||||||
|
}
|
||||||
|
|
||||||
|
var pageHeader = document.querySelector("header");
|
||||||
|
function updateTop() {
|
||||||
|
if (!pageHeader || getComputedStyle(nav).position !== "fixed") return;
|
||||||
|
var rect = pageHeader.getBoundingClientRect();
|
||||||
|
nav.style.top = Math.max(8, rect.bottom + 8) + "px";
|
||||||
|
}
|
||||||
|
window.addEventListener("scroll", updateTop, { passive: true });
|
||||||
|
window.addEventListener("resize", updateTop);
|
||||||
|
updateTop();
|
||||||
|
})();
|
||||||
@@ -28,7 +28,7 @@
|
|||||||
var container = document.createElement('div');
|
var container = document.createElement('div');
|
||||||
|
|
||||||
var treeEl = document.createElement('div');
|
var treeEl = document.createElement('div');
|
||||||
treeEl.className = 'tree-picker panel';
|
treeEl.className = 'tree-picker';
|
||||||
|
|
||||||
var selectedPathEl = document.createElement('div');
|
var selectedPathEl = document.createElement('div');
|
||||||
selectedPathEl.className = 'tree-selected-path muted';
|
selectedPathEl.className = 'tree-selected-path muted';
|
||||||
@@ -138,13 +138,6 @@
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
if (kind === 'folder') {
|
|
||||||
row.addEventListener('dblclick', function (e) {
|
|
||||||
e.preventDefault();
|
|
||||||
if (isOpen) collapse(); else expand();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
rowEl: row,
|
rowEl: row,
|
||||||
name: name,
|
name: name,
|
||||||
|
|||||||
@@ -1,96 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"os/exec"
|
|
||||||
"runtime"
|
|
||||||
"strings"
|
|
||||||
)
|
|
||||||
|
|
||||||
// commandDefaults holds the per-OS open-command templates used when the user
|
|
||||||
// hasn't overridden them in the config.
|
|
||||||
type commandDefaults struct {
|
|
||||||
OpenFile string
|
|
||||||
OpenFolder string
|
|
||||||
}
|
|
||||||
|
|
||||||
func defaultCommands() commandDefaults {
|
|
||||||
if runtime.GOOS == "windows" {
|
|
||||||
return commandDefaults{
|
|
||||||
OpenFile: `cmd /c start "" "{path}"`,
|
|
||||||
OpenFolder: `explorer.exe "{path}"`,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return commandDefaults{
|
|
||||||
OpenFile: `xdg-open "{path}"`,
|
|
||||||
OpenFolder: `xdg-open "{path}"`,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// resolveOpenCommand returns the user-configured command if non-blank, else
|
|
||||||
// the platform default.
|
|
||||||
func resolveOpenCommand(configured, fallback string) string {
|
|
||||||
if strings.TrimSpace(configured) != "" {
|
|
||||||
return configured
|
|
||||||
}
|
|
||||||
return fallback
|
|
||||||
}
|
|
||||||
|
|
||||||
// runOpenCommand tokenizes template, substitutes {path} with the resolved
|
|
||||||
// path (appending it if the placeholder is missing), and starts the command.
|
|
||||||
func runOpenCommand(template, path string) error {
|
|
||||||
tokens, err := tokenizeCommand(template)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("parse command: %w", err)
|
|
||||||
}
|
|
||||||
if len(tokens) == 0 {
|
|
||||||
return errors.New("command is empty")
|
|
||||||
}
|
|
||||||
sawPath := false
|
|
||||||
for i, t := range tokens {
|
|
||||||
if strings.Contains(t, "{path}") {
|
|
||||||
tokens[i] = strings.ReplaceAll(t, "{path}", path)
|
|
||||||
sawPath = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if !sawPath {
|
|
||||||
tokens = append(tokens, path)
|
|
||||||
}
|
|
||||||
return exec.Command(tokens[0], tokens[1:]...).Start()
|
|
||||||
}
|
|
||||||
|
|
||||||
// tokenizeCommand splits a command-line string into argv tokens, honouring
|
|
||||||
// double-quoted segments. An empty pair "" yields an empty argument — needed
|
|
||||||
// for Windows `cmd /c start "" file`, where the empty quotes are the title.
|
|
||||||
func tokenizeCommand(s string) ([]string, error) {
|
|
||||||
var tokens []string
|
|
||||||
var cur strings.Builder
|
|
||||||
inQuote := false
|
|
||||||
inToken := false
|
|
||||||
for i := 0; i < len(s); i++ {
|
|
||||||
c := s[i]
|
|
||||||
if c == '"' {
|
|
||||||
inQuote = !inQuote
|
|
||||||
inToken = true
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if !inQuote && (c == ' ' || c == '\t') {
|
|
||||||
if inToken {
|
|
||||||
tokens = append(tokens, cur.String())
|
|
||||||
cur.Reset()
|
|
||||||
inToken = false
|
|
||||||
}
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
cur.WriteByte(c)
|
|
||||||
inToken = true
|
|
||||||
}
|
|
||||||
if inQuote {
|
|
||||||
return nil, errors.New("unclosed quote")
|
|
||||||
}
|
|
||||||
if inToken {
|
|
||||||
tokens = append(tokens, cur.String())
|
|
||||||
}
|
|
||||||
return tokens, nil
|
|
||||||
}
|
|
||||||
@@ -1,94 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/json"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"runtime"
|
|
||||||
)
|
|
||||||
|
|
||||||
const defaultPort = 17680
|
|
||||||
|
|
||||||
type config struct {
|
|
||||||
WikiRoot string `json:"wikiRoot"`
|
|
||||||
AllowedOrigins []string `json:"allowedOrigins"`
|
|
||||||
Port int `json:"port,omitempty"`
|
|
||||||
OpenFileCommand string `json:"openFileCommand,omitempty"`
|
|
||||||
OpenFolderCommand string `json:"openFolderCommand,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// configPath returns the platform-conventional config path.
|
|
||||||
//
|
|
||||||
// Windows: %APPDATA%\datascape\companion.json
|
|
||||||
// Linux: $XDG_CONFIG_HOME/datascape/companion.json
|
|
||||||
// (fallback ~/.config/datascape/companion.json)
|
|
||||||
func configPath() (string, error) {
|
|
||||||
if runtime.GOOS == "windows" {
|
|
||||||
appData := os.Getenv("APPDATA")
|
|
||||||
if appData == "" {
|
|
||||||
return "", errors.New("APPDATA not set")
|
|
||||||
}
|
|
||||||
return filepath.Join(appData, "datascape", "companion.json"), nil
|
|
||||||
}
|
|
||||||
if x := os.Getenv("XDG_CONFIG_HOME"); x != "" {
|
|
||||||
return filepath.Join(x, "datascape", "companion.json"), nil
|
|
||||||
}
|
|
||||||
home, err := os.UserHomeDir()
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
return filepath.Join(home, ".config", "datascape", "companion.json"), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// loadOrInitConfig reads the on-disk config, creating a default file if none
|
|
||||||
// exists. Returns the resolved config (with port defaulted) and the path.
|
|
||||||
func loadOrInitConfig() (*config, string, error) {
|
|
||||||
p, err := configPath()
|
|
||||||
if err != nil {
|
|
||||||
return nil, "", err
|
|
||||||
}
|
|
||||||
data, err := os.ReadFile(p)
|
|
||||||
if errors.Is(err, os.ErrNotExist) {
|
|
||||||
cfg := &config{AllowedOrigins: []string{}, Port: defaultPort}
|
|
||||||
if err := writeConfigFile(p, cfg); err != nil {
|
|
||||||
return nil, p, err
|
|
||||||
}
|
|
||||||
return cfg, p, nil
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
return nil, p, err
|
|
||||||
}
|
|
||||||
var cfg config
|
|
||||||
if err := json.Unmarshal(data, &cfg); err != nil {
|
|
||||||
return nil, p, fmt.Errorf("parse config: %w", err)
|
|
||||||
}
|
|
||||||
if cfg.Port == 0 {
|
|
||||||
cfg.Port = defaultPort
|
|
||||||
}
|
|
||||||
if cfg.AllowedOrigins == nil {
|
|
||||||
cfg.AllowedOrigins = []string{}
|
|
||||||
}
|
|
||||||
return &cfg, p, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// writeConfigFile atomically writes cfg to p (write-temp + rename).
|
|
||||||
func writeConfigFile(p string, cfg *config) error {
|
|
||||||
if err := os.MkdirAll(filepath.Dir(p), 0755); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
out := *cfg
|
|
||||||
if out.AllowedOrigins == nil {
|
|
||||||
out.AllowedOrigins = []string{}
|
|
||||||
}
|
|
||||||
data, err := json.MarshalIndent(out, "", " ")
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
tmp := p + ".tmp"
|
|
||||||
if err := os.WriteFile(tmp, data, 0644); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return os.Rename(tmp, p)
|
|
||||||
}
|
|
||||||
@@ -1,153 +0,0 @@
|
|||||||
<!doctype html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
||||||
<title>datascape companion — settings</title>
|
|
||||||
<style>
|
|
||||||
:root {
|
|
||||||
--bg: #2e2e2e;
|
|
||||||
--bg-panel: #434343;
|
|
||||||
--text: #e6e6e6;
|
|
||||||
--text-muted: #cfcfcf;
|
|
||||||
--primary: #87458a;
|
|
||||||
--primary-hover: #d64d95;
|
|
||||||
--secondary: #c48401;
|
|
||||||
--link: #01b6c4;
|
|
||||||
}
|
|
||||||
body {
|
|
||||||
background: var(--bg);
|
|
||||||
color: var(--text);
|
|
||||||
font: 1rem ui-monospace, monospace;
|
|
||||||
margin: 0;
|
|
||||||
padding: 2rem 1rem;
|
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
main { width: 100%; max-width: 40rem; }
|
|
||||||
h1 { font-size: 1.2rem; margin: 0 0 0.25rem; }
|
|
||||||
.muted { color: var(--text-muted); font-size: 0.85rem; }
|
|
||||||
.header { border-bottom: 1px dashed var(--secondary); padding-bottom: 0.75rem; margin-bottom: 1rem; }
|
|
||||||
label { display: block; margin: 1rem 0 0.25rem; font-size: 0.9rem; }
|
|
||||||
input[type=text], textarea {
|
|
||||||
width: 100%;
|
|
||||||
background: var(--bg-panel);
|
|
||||||
color: var(--text);
|
|
||||||
border: 1px solid var(--secondary);
|
|
||||||
font: inherit;
|
|
||||||
padding: 0.5rem;
|
|
||||||
}
|
|
||||||
textarea { min-height: 6rem; resize: vertical; }
|
|
||||||
.help { color: var(--text-muted); font-size: 0.8rem; margin-top: 0.25rem; }
|
|
||||||
button {
|
|
||||||
background: var(--primary);
|
|
||||||
color: var(--text);
|
|
||||||
border: none;
|
|
||||||
padding: 0.6rem 1.2rem;
|
|
||||||
font: inherit;
|
|
||||||
cursor: pointer;
|
|
||||||
margin-top: 1.25rem;
|
|
||||||
}
|
|
||||||
button:hover { background: var(--primary-hover); }
|
|
||||||
.btn-small {
|
|
||||||
padding: 0.4rem 0.75rem;
|
|
||||||
font-size: 0.8rem;
|
|
||||||
margin-top: 0;
|
|
||||||
}
|
|
||||||
.input-row {
|
|
||||||
display: flex;
|
|
||||||
gap: 0.5rem;
|
|
||||||
align-items: stretch;
|
|
||||||
}
|
|
||||||
.input-row input { flex: 1; }
|
|
||||||
.notice {
|
|
||||||
background: var(--bg-panel);
|
|
||||||
border-left: 3px solid var(--secondary);
|
|
||||||
padding: 0.5rem 0.75rem;
|
|
||||||
margin-bottom: 1rem;
|
|
||||||
}
|
|
||||||
.meta { margin-top: 2rem; font-size: 0.8rem; color: var(--text-muted); border-top: 1px dashed var(--secondary); padding-top: 0.75rem; }
|
|
||||||
.meta div { margin: 0.2rem 0; }
|
|
||||||
code { color: var(--link); word-break: break-all; }
|
|
||||||
.log {
|
|
||||||
margin-top: 2rem;
|
|
||||||
border-top: 1px dashed var(--secondary);
|
|
||||||
padding-top: 0.75rem;
|
|
||||||
}
|
|
||||||
.log h2 { font-size: 0.95rem; margin: 0 0 0.5rem; }
|
|
||||||
.log pre {
|
|
||||||
background: var(--bg-panel);
|
|
||||||
border: 1px solid var(--secondary);
|
|
||||||
margin: 0;
|
|
||||||
padding: 0.5rem 0.75rem;
|
|
||||||
max-height: 20rem;
|
|
||||||
overflow: auto;
|
|
||||||
font-size: 0.8rem;
|
|
||||||
white-space: pre-wrap;
|
|
||||||
word-break: break-all;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<main>
|
|
||||||
<div class="header">
|
|
||||||
<h1>datascape-companion</h1>
|
|
||||||
<div class="muted">version {{.Version}} · port {{.Port}}</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{{if .Notice}}<div class="notice">{{.Notice}}</div>{{end}}
|
|
||||||
|
|
||||||
<form method="POST" action="/config">
|
|
||||||
<label for="wikiRoot">Wiki content mount path</label>
|
|
||||||
<input id="wikiRoot" name="wikiRoot" type="text" value="{{.WikiRoot}}" placeholder="Z:\wiki or /mnt/wiki">
|
|
||||||
<div class="help">Local filesystem path where the wiki's content tree is mounted.</div>
|
|
||||||
|
|
||||||
<label for="allowedOrigins">Allowed wiki origins</label>
|
|
||||||
<textarea id="allowedOrigins" name="allowedOrigins" placeholder="https://wiki.example.lan http://192.168.1.10:8080">{{.AllowedOrigins}}</textarea>
|
|
||||||
<div class="help">One origin per line (scheme + host + optional port, no trailing slash). Only browser tabs from these origins can ask the companion to open files.</div>
|
|
||||||
|
|
||||||
<label for="openFileCommand">Open-file command</label>
|
|
||||||
<div class="input-row">
|
|
||||||
<input id="openFileCommand" name="openFileCommand" type="text" value="{{.OpenFileCommand}}" placeholder="{{.DefaultOpenFileCommand}}">
|
|
||||||
<button type="button" class="btn-small" data-reset="openFileCommand" data-default="{{.DefaultOpenFileCommand}}">RESET</button>
|
|
||||||
</div>
|
|
||||||
<div class="help">Run when the wiki asks to open a file. Use <code>{{`{path}`}}</code> for the resolved file path. Leave blank to use the default. Default: <code>{{.DefaultOpenFileCommand}}</code></div>
|
|
||||||
|
|
||||||
<label for="openFolderCommand">Open-folder command</label>
|
|
||||||
<div class="input-row">
|
|
||||||
<input id="openFolderCommand" name="openFolderCommand" type="text" value="{{.OpenFolderCommand}}" placeholder="{{.DefaultOpenFolderCommand}}">
|
|
||||||
<button type="button" class="btn-small" data-reset="openFolderCommand" data-default="{{.DefaultOpenFolderCommand}}">RESET</button>
|
|
||||||
</div>
|
|
||||||
<div class="help">Run when the wiki asks to reveal a folder. Default: <code>{{.DefaultOpenFolderCommand}}</code></div>
|
|
||||||
|
|
||||||
<button type="submit">SAVE</button>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
document.querySelectorAll('button[data-reset]').forEach(function (btn) {
|
|
||||||
btn.addEventListener('click', function () {
|
|
||||||
var input = document.getElementById(btn.dataset.reset);
|
|
||||||
if (input) input.value = btn.dataset.default;
|
|
||||||
});
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div class="meta">
|
|
||||||
<div>Config file: <code>{{.ConfigPath}}</code></div>
|
|
||||||
<div>Log file: <code>{{.LogPath}}</code></div>
|
|
||||||
<div>Port is set in the config file only; restart after editing.</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="log">
|
|
||||||
<h2>Log (last 50 lines)</h2>
|
|
||||||
{{if .LogError}}
|
|
||||||
<div class="muted">Could not read log: {{.LogError}}</div>
|
|
||||||
{{else if .LogTail}}
|
|
||||||
<pre>{{.LogTail}}</pre>
|
|
||||||
{{else}}
|
|
||||||
<div class="muted">Log is empty.</div>
|
|
||||||
{{end}}
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"io/fs"
|
|
||||||
"os"
|
|
||||||
)
|
|
||||||
|
|
||||||
func statPath(p string) (fs.FileInfo, error) {
|
|
||||||
return os.Stat(p)
|
|
||||||
}
|
|
||||||
@@ -1,65 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"io"
|
|
||||||
"log"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
|
||||||
)
|
|
||||||
|
|
||||||
// setupFileLogging tees the standard logger to a file alongside the config.
|
|
||||||
// Returns the log file path. Stderr is kept as a secondary sink so dev runs
|
|
||||||
// (linux, console) still print, while windowsgui builds rely on the file.
|
|
||||||
func setupFileLogging(cfgDir string) (string, error) {
|
|
||||||
if err := os.MkdirAll(cfgDir, 0755); err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
p := filepath.Join(cfgDir, "companion.log")
|
|
||||||
f, err := os.OpenFile(p, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
log.SetOutput(io.MultiWriter(f, os.Stderr))
|
|
||||||
return p, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// tailLog returns the last n lines of the log file. Reads only the trailing
|
|
||||||
// chunk of the file so the cost is bounded regardless of log size.
|
|
||||||
func tailLog(p string, n int) ([]string, error) {
|
|
||||||
const tailBytes = 32 * 1024
|
|
||||||
f, err := os.Open(p)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer f.Close()
|
|
||||||
info, err := f.Stat()
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
size := info.Size()
|
|
||||||
var off int64
|
|
||||||
if size > tailBytes {
|
|
||||||
off = size - tailBytes
|
|
||||||
}
|
|
||||||
buf := make([]byte, size-off)
|
|
||||||
if _, err := f.ReadAt(buf, off); err != nil && err != io.EOF {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
s := string(buf)
|
|
||||||
// Drop the (likely partial) first line when we didn't start at 0.
|
|
||||||
if off > 0 {
|
|
||||||
if i := strings.IndexByte(s, '\n'); i >= 0 {
|
|
||||||
s = s[i+1:]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
s = strings.TrimRight(s, "\n")
|
|
||||||
if s == "" {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
lines := strings.Split(s, "\n")
|
|
||||||
if len(lines) > n {
|
|
||||||
lines = lines[len(lines)-n:]
|
|
||||||
}
|
|
||||||
return lines, nil
|
|
||||||
}
|
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"flag"
|
|
||||||
"log"
|
|
||||||
"path/filepath"
|
|
||||||
)
|
|
||||||
|
|
||||||
const version = "1"
|
|
||||||
|
|
||||||
func main() {
|
|
||||||
flag.Parse()
|
|
||||||
|
|
||||||
cfgPath, err := configPath()
|
|
||||||
if err != nil {
|
|
||||||
log.Fatalf("config path: %v", err)
|
|
||||||
}
|
|
||||||
logPath, err := setupFileLogging(filepath.Dir(cfgPath))
|
|
||||||
if err != nil {
|
|
||||||
log.Fatalf("log setup: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
log.Printf("datascape-companion %s", version)
|
|
||||||
log.Printf("log file: %s", logPath)
|
|
||||||
log.Printf("config file: %s", cfgPath)
|
|
||||||
|
|
||||||
cfg, _, err := loadOrInitConfig()
|
|
||||||
if err != nil {
|
|
||||||
log.Fatalf("config: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
srv := newServer(cfg, cfgPath, logPath)
|
|
||||||
log.Printf("listening on http://127.0.0.1:%d", cfg.Port)
|
|
||||||
log.Printf("settings page: http://127.0.0.1:%d/config", cfg.Port)
|
|
||||||
if err := srv.run(); err != nil {
|
|
||||||
log.Fatal(err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,297 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"embed"
|
|
||||||
"encoding/json"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"html/template"
|
|
||||||
"net/http"
|
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
|
||||||
"sync"
|
|
||||||
)
|
|
||||||
|
|
||||||
//go:embed config.html
|
|
||||||
var templates embed.FS
|
|
||||||
|
|
||||||
var configTmpl = template.Must(template.ParseFS(templates, "config.html"))
|
|
||||||
|
|
||||||
type server struct {
|
|
||||||
mu sync.Mutex
|
|
||||||
cfg *config
|
|
||||||
cfgPath string
|
|
||||||
logPath string
|
|
||||||
port int
|
|
||||||
}
|
|
||||||
|
|
||||||
func newServer(cfg *config, cfgPath, logPath string) *server {
|
|
||||||
return &server{cfg: cfg, cfgPath: cfgPath, logPath: logPath, port: cfg.Port}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *server) snapshot() config {
|
|
||||||
s.mu.Lock()
|
|
||||||
defer s.mu.Unlock()
|
|
||||||
c := *s.cfg
|
|
||||||
c.AllowedOrigins = append([]string(nil), s.cfg.AllowedOrigins...)
|
|
||||||
return c
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *server) run() error {
|
|
||||||
mux := http.NewServeMux()
|
|
||||||
mux.HandleFunc("/status", s.handleStatus)
|
|
||||||
mux.HandleFunc("/open-file", s.handleOpenFile)
|
|
||||||
mux.HandleFunc("/open-folder", s.handleOpenFolder)
|
|
||||||
mux.HandleFunc("/config", s.handleConfig)
|
|
||||||
|
|
||||||
addr := fmt.Sprintf("127.0.0.1:%d", s.port)
|
|
||||||
return http.ListenAndServe(addr, mux)
|
|
||||||
}
|
|
||||||
|
|
||||||
// methodAllowed enforces a single allowed method, sending 405 otherwise.
|
|
||||||
func methodAllowed(w http.ResponseWriter, r *http.Request, method string) bool {
|
|
||||||
if r.Method == method {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
w.Header().Set("Allow", method)
|
|
||||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
// requireAllowedOrigin checks the Origin header against the allowlist.
|
|
||||||
// Sets the matching CORS header on success. Returns false (and writes a
|
|
||||||
// 403) when no match is found.
|
|
||||||
func (s *server) requireAllowedOrigin(w http.ResponseWriter, r *http.Request) bool {
|
|
||||||
origin := r.Header.Get("Origin")
|
|
||||||
if origin == "" {
|
|
||||||
http.Error(w, "origin required", http.StatusForbidden)
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
cfg := s.snapshot()
|
|
||||||
for _, allowed := range cfg.AllowedOrigins {
|
|
||||||
if origin == allowed {
|
|
||||||
w.Header().Set("Access-Control-Allow-Origin", origin)
|
|
||||||
w.Header().Set("Vary", "Origin")
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
http.Error(w, "origin not allowed", http.StatusForbidden)
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *server) handleStatus(w http.ResponseWriter, r *http.Request) {
|
|
||||||
if !methodAllowed(w, r, http.MethodGet) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if !s.requireAllowedOrigin(w, r) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
|
||||||
_ = json.NewEncoder(w).Encode(map[string]string{
|
|
||||||
"name": "datascape-companion",
|
|
||||||
"version": version,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *server) handleOpenFile(w http.ResponseWriter, r *http.Request) {
|
|
||||||
s.handleOpen(w, r, false)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *server) handleOpenFolder(w http.ResponseWriter, r *http.Request) {
|
|
||||||
s.handleOpen(w, r, true)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *server) handleOpen(w http.ResponseWriter, r *http.Request, isFolder bool) {
|
|
||||||
if !methodAllowed(w, r, http.MethodGet) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if !s.requireAllowedOrigin(w, r) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
wikiPath := r.URL.Query().Get("path")
|
|
||||||
cfg := s.snapshot()
|
|
||||||
if cfg.WikiRoot == "" {
|
|
||||||
writeJSONError(w, http.StatusBadRequest, "wikiRoot is not configured")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
resolved, err := resolveWikiPath(cfg.WikiRoot, wikiPath)
|
|
||||||
if err != nil {
|
|
||||||
writeJSONError(w, http.StatusBadRequest, err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
info, err := statPath(resolved)
|
|
||||||
if err != nil {
|
|
||||||
writeJSONError(w, http.StatusNotFound, "path not found")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if isFolder && !info.IsDir() {
|
|
||||||
// Reveal: if the user asked to open a file's folder, walk up.
|
|
||||||
resolved = filepath.Dir(resolved)
|
|
||||||
}
|
|
||||||
if !isFolder && info.IsDir() {
|
|
||||||
writeJSONError(w, http.StatusBadRequest, "path is a directory")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
defs := defaultCommands()
|
|
||||||
var template string
|
|
||||||
if isFolder {
|
|
||||||
template = resolveOpenCommand(cfg.OpenFolderCommand, defs.OpenFolder)
|
|
||||||
} else {
|
|
||||||
template = resolveOpenCommand(cfg.OpenFileCommand, defs.OpenFile)
|
|
||||||
}
|
|
||||||
if err := runOpenCommand(template, resolved); err != nil {
|
|
||||||
writeJSONError(w, http.StatusInternalServerError, err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
w.WriteHeader(http.StatusNoContent)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *server) handleConfig(w http.ResponseWriter, r *http.Request) {
|
|
||||||
switch r.Method {
|
|
||||||
case http.MethodGet:
|
|
||||||
s.renderConfig(w, "")
|
|
||||||
case http.MethodPost:
|
|
||||||
s.saveConfig(w, r)
|
|
||||||
default:
|
|
||||||
w.Header().Set("Allow", "GET, POST")
|
|
||||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
type configPageData struct {
|
|
||||||
WikiRoot string
|
|
||||||
AllowedOrigins string
|
|
||||||
Port int
|
|
||||||
ConfigPath string
|
|
||||||
LogPath string
|
|
||||||
LogTail string
|
|
||||||
LogError string
|
|
||||||
Version string
|
|
||||||
Notice string
|
|
||||||
OpenFileCommand string
|
|
||||||
OpenFolderCommand string
|
|
||||||
DefaultOpenFileCommand string
|
|
||||||
DefaultOpenFolderCommand string
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *server) renderConfig(w http.ResponseWriter, notice string) {
|
|
||||||
cfg := s.snapshot()
|
|
||||||
defs := defaultCommands()
|
|
||||||
data := configPageData{
|
|
||||||
WikiRoot: cfg.WikiRoot,
|
|
||||||
AllowedOrigins: strings.Join(cfg.AllowedOrigins, "\n"),
|
|
||||||
Port: cfg.Port,
|
|
||||||
ConfigPath: s.cfgPath,
|
|
||||||
LogPath: s.logPath,
|
|
||||||
Version: version,
|
|
||||||
Notice: notice,
|
|
||||||
OpenFileCommand: cfg.OpenFileCommand,
|
|
||||||
OpenFolderCommand: cfg.OpenFolderCommand,
|
|
||||||
DefaultOpenFileCommand: defs.OpenFile,
|
|
||||||
DefaultOpenFolderCommand: defs.OpenFolder,
|
|
||||||
}
|
|
||||||
if s.logPath != "" {
|
|
||||||
lines, err := tailLog(s.logPath, 50)
|
|
||||||
if err != nil {
|
|
||||||
data.LogError = err.Error()
|
|
||||||
} else {
|
|
||||||
data.LogTail = strings.Join(lines, "\n")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
||||||
if err := configTmpl.Execute(w, data); err != nil {
|
|
||||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *server) saveConfig(w http.ResponseWriter, r *http.Request) {
|
|
||||||
expected := fmt.Sprintf("http://127.0.0.1:%d", s.port)
|
|
||||||
if r.Header.Get("Origin") != expected {
|
|
||||||
http.Error(w, "origin mismatch", http.StatusForbidden)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if err := r.ParseForm(); err != nil {
|
|
||||||
http.Error(w, "bad form", http.StatusBadRequest)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
wikiRoot := strings.TrimSpace(r.FormValue("wikiRoot"))
|
|
||||||
originsRaw := r.FormValue("allowedOrigins")
|
|
||||||
var origins []string
|
|
||||||
for _, line := range strings.Split(originsRaw, "\n") {
|
|
||||||
line = strings.TrimSpace(line)
|
|
||||||
line = strings.TrimRight(line, "/")
|
|
||||||
if line == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
origins = append(origins, line)
|
|
||||||
}
|
|
||||||
if origins == nil {
|
|
||||||
origins = []string{}
|
|
||||||
}
|
|
||||||
|
|
||||||
openFileCmd := strings.TrimSpace(r.FormValue("openFileCommand"))
|
|
||||||
openFolderCmd := strings.TrimSpace(r.FormValue("openFolderCommand"))
|
|
||||||
defs := defaultCommands()
|
|
||||||
// Persist as blank when the user submits the default verbatim, so the
|
|
||||||
// config file stays clean and future default changes propagate.
|
|
||||||
if openFileCmd == defs.OpenFile {
|
|
||||||
openFileCmd = ""
|
|
||||||
}
|
|
||||||
if openFolderCmd == defs.OpenFolder {
|
|
||||||
openFolderCmd = ""
|
|
||||||
}
|
|
||||||
|
|
||||||
s.mu.Lock()
|
|
||||||
newCfg := *s.cfg
|
|
||||||
newCfg.WikiRoot = wikiRoot
|
|
||||||
newCfg.AllowedOrigins = origins
|
|
||||||
newCfg.OpenFileCommand = openFileCmd
|
|
||||||
newCfg.OpenFolderCommand = openFolderCmd
|
|
||||||
if err := writeConfigFile(s.cfgPath, &newCfg); err != nil {
|
|
||||||
s.mu.Unlock()
|
|
||||||
http.Error(w, "save failed: "+err.Error(), http.StatusInternalServerError)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
s.cfg = &newCfg
|
|
||||||
s.mu.Unlock()
|
|
||||||
|
|
||||||
s.renderConfig(w, "Saved.")
|
|
||||||
}
|
|
||||||
|
|
||||||
func writeJSONError(w http.ResponseWriter, code int, msg string) {
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
|
||||||
w.WriteHeader(code)
|
|
||||||
_ = json.NewEncoder(w).Encode(map[string]string{"error": msg})
|
|
||||||
}
|
|
||||||
|
|
||||||
// resolveWikiPath joins wikiRoot with a wiki-relative path after rejecting
|
|
||||||
// absolute paths, traversal segments, and null bytes. The cleaned result
|
|
||||||
// must remain inside wikiRoot.
|
|
||||||
func resolveWikiPath(wikiRoot, wikiPath string) (string, error) {
|
|
||||||
if strings.ContainsRune(wikiPath, 0) {
|
|
||||||
return "", errors.New("invalid path")
|
|
||||||
}
|
|
||||||
// Reject absolute paths from either family before any cleaning so we
|
|
||||||
// don't depend on filepath.IsAbs's per-OS behavior.
|
|
||||||
if strings.HasPrefix(wikiPath, "/") || strings.HasPrefix(wikiPath, `\`) ||
|
|
||||||
(len(wikiPath) >= 2 && wikiPath[1] == ':') {
|
|
||||||
return "", errors.New("absolute path not allowed")
|
|
||||||
}
|
|
||||||
clean := filepath.Clean(filepath.FromSlash(wikiPath))
|
|
||||||
if clean == ".." || strings.HasPrefix(clean, ".."+string(filepath.Separator)) {
|
|
||||||
return "", errors.New("path escapes wikiRoot")
|
|
||||||
}
|
|
||||||
rootAbs, err := filepath.Abs(wikiRoot)
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
full := filepath.Join(rootAbs, clean)
|
|
||||||
rel, err := filepath.Rel(rootAbs, full)
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
if rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) {
|
|
||||||
return "", errors.New("path escapes wikiRoot")
|
|
||||||
}
|
|
||||||
return full, nil
|
|
||||||
}
|
|
||||||
@@ -1,53 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"embed"
|
|
||||||
"io"
|
|
||||||
"net/http"
|
|
||||||
"strconv"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Cross-compiled companion binaries served as downloads from the wiki footer
|
|
||||||
// flyout when no local companion is detected. The Makefile produces these
|
|
||||||
// before invoking `go build .`; missing files will fail the build at compile
|
|
||||||
// time via the embed directive below.
|
|
||||||
//
|
|
||||||
//go:embed companion/datascape-companion-windows-amd64.exe
|
|
||||||
//go:embed companion/datascape-companion-linux-amd64
|
|
||||||
var companionBinaries embed.FS
|
|
||||||
|
|
||||||
func init() {
|
|
||||||
http.HandleFunc("/companion/download/windows", serveCompanionBinary(
|
|
||||||
"companion/datascape-companion-windows-amd64.exe",
|
|
||||||
"datascape-companion.exe",
|
|
||||||
))
|
|
||||||
http.HandleFunc("/companion/download/linux", serveCompanionBinary(
|
|
||||||
"companion/datascape-companion-linux-amd64",
|
|
||||||
"datascape-companion",
|
|
||||||
))
|
|
||||||
}
|
|
||||||
|
|
||||||
func serveCompanionBinary(embedPath, downloadName string) http.HandlerFunc {
|
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
if r.Method != http.MethodGet {
|
|
||||||
w.Header().Set("Allow", http.MethodGet)
|
|
||||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
f, err := companionBinaries.Open(embedPath)
|
|
||||||
if err != nil {
|
|
||||||
http.Error(w, "companion binary not available", http.StatusNotFound)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
defer f.Close()
|
|
||||||
info, err := f.Stat()
|
|
||||||
if err != nil {
|
|
||||||
http.Error(w, "companion binary not available", http.StatusInternalServerError)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
w.Header().Set("Content-Type", "application/octet-stream")
|
|
||||||
w.Header().Set("Content-Disposition", `attachment; filename="`+downloadName+`"`)
|
|
||||||
w.Header().Set("Content-Length", strconv.FormatInt(info.Size(), 10))
|
|
||||||
_, _ = io.Copy(w, f)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -5,12 +5,10 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"html/template"
|
"html/template"
|
||||||
"log"
|
"log"
|
||||||
"net/http"
|
|
||||||
"net/url"
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
"path"
|
"path"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"regexp"
|
|
||||||
"sort"
|
"sort"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -23,172 +21,25 @@ func init() {
|
|||||||
|
|
||||||
type diaryHandler struct{}
|
type diaryHandler struct{}
|
||||||
|
|
||||||
// redirect handles diary-specific redirect cases. The year page is the only
|
func (d *diaryHandler) handle(root, fsPath, urlPath string) *specialPage {
|
||||||
// real diary page; month and day URLs are aliases that collapse to a year
|
|
||||||
// page anchor (or to the year-file editor when ?edit is set).
|
|
||||||
//
|
|
||||||
// 1. `today` / `this-month` / `this-year` shortcuts resolve directly to a
|
|
||||||
// year+anchor target (or insert flow when today's section is missing).
|
|
||||||
// 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).
|
|
||||||
// 3. A virtual day URL (/diary/<root>/YYYY/MM/DD/) redirects to
|
|
||||||
// /diary/<root>/YYYY/#YYYY-MM-DD (or to the section / insert_before
|
|
||||||
// editor flow when ?edit is set).
|
|
||||||
//
|
|
||||||
// Returns ok=false when the request is not a diary-handled redirect.
|
|
||||||
func (d *diaryHandler) redirect(root, fsPath, urlPath string, r *http.Request) (string, bool) {
|
|
||||||
if target, ok := d.dateShortcutRedirect(root, fsPath, urlPath); ok {
|
|
||||||
return target, true
|
|
||||||
}
|
|
||||||
if r.Method != http.MethodGet {
|
|
||||||
return "", false
|
|
||||||
}
|
|
||||||
_, edit := r.URL.Query()["edit"]
|
|
||||||
return d.virtualURLRedirect(root, fsPath, urlPath, edit)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (d *diaryHandler) dateShortcutRedirect(root, fsPath, urlPath string) (string, bool) {
|
|
||||||
base := path.Base(strings.TrimSuffix(urlPath, "/"))
|
|
||||||
switch base {
|
|
||||||
case "today", "this-month", "this-year":
|
|
||||||
default:
|
|
||||||
return "", false
|
|
||||||
}
|
|
||||||
|
|
||||||
parentFS := filepath.Dir(fsPath)
|
|
||||||
parentURLPath := parentURL(urlPath)
|
|
||||||
_, diaryRootFS, diaryRootURL, ok := findDiaryContext(root, parentFS, parentURLPath)
|
|
||||||
if !ok {
|
|
||||||
return "", false
|
|
||||||
}
|
|
||||||
|
|
||||||
now := time.Now()
|
|
||||||
year := fmt.Sprintf("%d", now.Year())
|
|
||||||
month := fmt.Sprintf("%02d", int(now.Month()))
|
|
||||||
day := fmt.Sprintf("%02d", now.Day())
|
|
||||||
yearURL := path.Join(diaryRootURL, year) + "/"
|
|
||||||
|
|
||||||
switch base {
|
|
||||||
case "today":
|
|
||||||
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
|
|
||||||
}
|
|
||||||
// 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":
|
|
||||||
return yearURL + "#" + fmt.Sprintf("%s-%s", year, month), true
|
|
||||||
case "this-year":
|
|
||||||
return yearURL, true
|
|
||||||
}
|
|
||||||
return "", false
|
|
||||||
}
|
|
||||||
|
|
||||||
// virtualURLRedirect collapses month/day URLs onto the year page. For
|
|
||||||
// non-edit GETs the target is `/YYYY/#YYYY-MM[-DD]`. For ?edit GETs the
|
|
||||||
// target is the year-file editor URL (section edit when the section exists,
|
|
||||||
// otherwise insert_before+heading for new days, whole-year edit for new
|
|
||||||
// months). Returns ok=false when fsPath is a real folder (preferring the
|
|
||||||
// real folder over the virtual redirect lets users recover from an
|
|
||||||
// unfinished migration).
|
|
||||||
func (d *diaryHandler) virtualURLRedirect(root, fsPath, urlPath string, edit bool) (string, bool) {
|
|
||||||
depth, diaryRootFS, diaryRootURL, ok := findDiaryContext(root, fsPath, urlPath)
|
|
||||||
if !ok || (depth != 2 && depth != 3) {
|
|
||||||
return "", false
|
|
||||||
}
|
|
||||||
if info, err := os.Stat(fsPath); err == nil && info.IsDir() {
|
|
||||||
return "", false
|
|
||||||
}
|
|
||||||
|
|
||||||
year, month, day, ok := parseDiaryURLParts(fsPath, depth)
|
|
||||||
if !ok {
|
|
||||||
return "", false
|
|
||||||
}
|
|
||||||
yearFS := filepath.Join(diaryRootFS, year)
|
|
||||||
yearURL := path.Join(diaryRootURL, year) + "/"
|
|
||||||
|
|
||||||
if !edit {
|
|
||||||
anchor := fmt.Sprintf("%s-%s", year, month)
|
|
||||||
if depth == 3 {
|
|
||||||
anchor = fmt.Sprintf("%s-%s-%s", year, month, day)
|
|
||||||
}
|
|
||||||
return yearURL + "#" + anchor, true
|
|
||||||
}
|
|
||||||
|
|
||||||
raw, _ := os.ReadFile(filepath.Join(yearFS, "index.md"))
|
|
||||||
sections := splitSections(raw)
|
|
||||||
|
|
||||||
if depth == 2 {
|
|
||||||
target := fmt.Sprintf("%s-%s", year, month)
|
|
||||||
if idx, found := findSectionIndex(sections, target); found {
|
|
||||||
return fmt.Sprintf("%s?edit§ion=%d", yearURL, idx), true
|
|
||||||
}
|
|
||||||
return yearURL + "?edit", true
|
|
||||||
}
|
|
||||||
target := fmt.Sprintf("%s-%s-%s", year, month, day)
|
|
||||||
if idx, found := findSectionIndex(sections, target); found {
|
|
||||||
return fmt.Sprintf("%s?edit§ion=%d", yearURL, idx), true
|
|
||||||
}
|
|
||||||
insertIdx := computeInsertIndex(sections, target)
|
|
||||||
return fmt.Sprintf("%s?edit&insert_before=%d&heading=%s",
|
|
||||||
yearURL, insertIdx, url.QueryEscape(target)), true
|
|
||||||
}
|
|
||||||
|
|
||||||
// parseDiaryURLParts extracts year/month/day from fsPath based on depth.
|
|
||||||
// depth=1 returns year only; depth=2 returns year+month; depth=3 returns all.
|
|
||||||
func parseDiaryURLParts(fsPath string, depth int) (year, month, day string, ok bool) {
|
|
||||||
parts := []string{}
|
|
||||||
cur := fsPath
|
|
||||||
for i := 0; i < depth; i++ {
|
|
||||||
parts = append([]string{filepath.Base(cur)}, parts...)
|
|
||||||
cur = filepath.Dir(cur)
|
|
||||||
}
|
|
||||||
switch depth {
|
|
||||||
case 1:
|
|
||||||
return parts[0], "", "", true
|
|
||||||
case 2:
|
|
||||||
return parts[0], parts[1], "", true
|
|
||||||
case 3:
|
|
||||||
return parts[0], parts[1], parts[2], true
|
|
||||||
}
|
|
||||||
return "", "", "", false
|
|
||||||
}
|
|
||||||
|
|
||||||
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
|
||||||
}
|
}
|
||||||
widget := computeCalendarWidget(diaryRootFS, diaryRootURL, fsPath, depth)
|
widget := computeCalendarWidget(diaryRootFS, diaryRootURL, fsPath, depth)
|
||||||
if depth == 0 {
|
if depth == 0 {
|
||||||
return &specialPage{Widget: widget, SuppressTOC: true}
|
return &specialPage{Widget: widget}
|
||||||
}
|
}
|
||||||
year, _, _, ok := parseDiaryURLParts(fsPath, depth)
|
var content template.HTML
|
||||||
if !ok {
|
switch depth {
|
||||||
return &specialPage{Widget: widget, SuppressTOC: true}
|
case 1:
|
||||||
|
content = renderDiaryYear(fsPath, urlPath)
|
||||||
|
case 2:
|
||||||
|
content = renderDiaryMonth(fsPath, urlPath)
|
||||||
|
case 3:
|
||||||
|
content = renderDiaryDay(fsPath, urlPath)
|
||||||
}
|
}
|
||||||
if depth == 1 {
|
return &specialPage{Content: content, SuppressListing: true, Widget: widget}
|
||||||
yearFS := filepath.Join(diaryRootFS, year)
|
|
||||||
yearURL := path.Join(diaryRootURL, year) + "/"
|
|
||||||
content := renderDiaryYear(yearFS, yearURL)
|
|
||||||
return &specialPage{
|
|
||||||
Content: content,
|
|
||||||
SuppressContent: true,
|
|
||||||
SuppressListing: true,
|
|
||||||
SuppressTOC: true,
|
|
||||||
Widget: widget,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// depth 2/3 only reach here when a real folder exists at the path
|
|
||||||
// (unfinished migration). The virtual URL would have been redirected
|
|
||||||
// in `redirect()` otherwise. Render the folder normally; just add the
|
|
||||||
// calendar widget.
|
|
||||||
return &specialPage{Widget: widget, SuppressTOC: true}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// findDiaryContext walks up from fsPath toward root looking for a
|
// findDiaryContext walks up from fsPath toward root looking for a
|
||||||
@@ -216,118 +67,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
|
|
||||||
// matches target (e.g. "2026-05" or "2026-05-28"). Returns the first match.
|
|
||||||
func findSectionIndex(sections [][]byte, target string) (int, bool) {
|
|
||||||
for i := 1; i < len(sections); i++ {
|
|
||||||
_, text := sectionHeading(sections[i])
|
|
||||||
if text == target {
|
|
||||||
return i, true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return 0, false
|
|
||||||
}
|
|
||||||
|
|
||||||
// computeInsertIndex returns the section index at which a new date heading
|
|
||||||
// (target = `YYYY-MM` or `YYYY-MM-DD`) should be spliced in to keep date
|
|
||||||
// sections chronologically ordered. Only date-format headings participate in
|
|
||||||
// the comparison; non-date headings (e.g. `## Events` in a year intro) are
|
|
||||||
// skipped so the new section is placed relative to the surrounding date
|
|
||||||
// sections, not the intro. Falls back to len(sections) when target is
|
|
||||||
// greater than every date heading. ISO formatting means string comparison
|
|
||||||
// is equivalent to chronological order.
|
|
||||||
func computeInsertIndex(sections [][]byte, target string) int {
|
|
||||||
for i := 1; i < len(sections); i++ {
|
|
||||||
_, text := sectionHeading(sections[i])
|
|
||||||
if !isDateHeading(text) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if text > target {
|
|
||||||
return i
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return len(sections)
|
|
||||||
}
|
|
||||||
|
|
||||||
// isDateHeading reports whether text is exactly a `YYYY`, `YYYY-MM`, or
|
|
||||||
// `YYYY-MM-DD` token. Used by the insert-index search to ignore non-date
|
|
||||||
// section headings.
|
|
||||||
func isDateHeading(text string) bool {
|
|
||||||
switch len(text) {
|
|
||||||
case 4, 7, 10:
|
|
||||||
default:
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
if _, err := time.Parse("2006", text[:4]); err != nil {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
if len(text) >= 7 {
|
|
||||||
if text[4] != '-' {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
if _, err := time.Parse("2006-01", text[:7]); err != nil {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if len(text) == 10 {
|
|
||||||
if text[7] != '-' {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
if _, err := time.Parse("2006-01-02", text); err != nil {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
// daysWithEntriesByMonth returns a `month → set[day]` map of `### YYYY-MM-DD`
|
|
||||||
// sections in the year's index.md. Used by the calendar widget to populate
|
|
||||||
// all 12 month grids in a single file read.
|
|
||||||
func daysWithEntriesByMonth(yearFS string, year int) map[int]map[int]bool {
|
|
||||||
out := map[int]map[int]bool{}
|
|
||||||
raw, err := os.ReadFile(filepath.Join(yearFS, "index.md"))
|
|
||||||
if err != nil {
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
yearPrefix := fmt.Sprintf("%d-", year)
|
|
||||||
sections := splitSections(raw)
|
|
||||||
for i := 1; i < len(sections); i++ {
|
|
||||||
level, text := sectionHeading(sections[i])
|
|
||||||
if level != 3 || !strings.HasPrefix(text, yearPrefix) || len(text) < 10 {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
m, err := strconv.Atoi(text[5:7])
|
|
||||||
if err != nil || m < 1 || m > 12 {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
d, err := strconv.Atoi(text[8:10])
|
|
||||||
if err != nil || d < 1 || d > 31 {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if out[m] == nil {
|
|
||||||
out[m] = map[int]bool{}
|
|
||||||
}
|
|
||||||
out[m][d] = true
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
type calDay struct {
|
type calDay struct {
|
||||||
Num int
|
Num int
|
||||||
URL string
|
URL string
|
||||||
@@ -342,31 +81,32 @@ type calYear struct {
|
|||||||
IsCurrent bool
|
IsCurrent bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// calMonthGrid carries everything the template needs to render one month's
|
type calMonth struct {
|
||||||
// grid plus the dropdown / heading entry that targets it. The calendar
|
|
||||||
// widget ships all 12 in the initial HTML; JS swaps which one is visible.
|
|
||||||
type calMonthGrid struct {
|
|
||||||
Num int
|
Num int
|
||||||
Name string
|
Name string
|
||||||
AnchorURL string // "#YYYY-MM" on a year page, full URL otherwise
|
URL string
|
||||||
Weeks [][]calDay
|
IsCurrent bool
|
||||||
}
|
}
|
||||||
|
|
||||||
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
|
MonthName string
|
||||||
DiaryURL string
|
DiaryURL string
|
||||||
YearURL string
|
YearURL string
|
||||||
Months []calMonthGrid
|
MonthURL string
|
||||||
Years []calYear
|
PrevMonURL string
|
||||||
|
NextMonURL string
|
||||||
|
Weeks [][]calDay
|
||||||
|
Years []calYear
|
||||||
|
AllMonths []calMonth
|
||||||
}
|
}
|
||||||
|
|
||||||
var diaryCalTmpl = template.Must(template.ParseFS(assets, "assets/diary/calendar.html"))
|
var diaryCalTmpl = template.Must(template.ParseFS(assets, "assets/diary/diary-calendar.html"))
|
||||||
|
|
||||||
func computeCalendarWidget(diaryRootFS, diaryRootURL, fsPath string, depth int) template.HTML {
|
func computeCalendarWidget(diaryRootFS, diaryRootURL, fsPath string, depth int) template.HTML {
|
||||||
today := time.Now()
|
today := time.Now()
|
||||||
var displayYear, displayMonth, currentDay, currentMonth int
|
var displayYear, displayMonth, currentDay int
|
||||||
|
|
||||||
switch depth {
|
switch depth {
|
||||||
case 0:
|
case 0:
|
||||||
@@ -411,55 +151,73 @@ func computeCalendarWidget(diaryRootFS, diaryRootURL, fsPath string, depth int)
|
|||||||
displayYear = y
|
displayYear = y
|
||||||
displayMonth = m
|
displayMonth = m
|
||||||
currentDay = d
|
currentDay = d
|
||||||
currentMonth = m
|
|
||||||
default:
|
default:
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
yearFS := filepath.Join(diaryRootFS, fmt.Sprintf("%d", displayYear))
|
// Which days in the display month have diary subfolders?
|
||||||
hasDayEntryByMonth := daysWithEntriesByMonth(yearFS, displayYear)
|
monthFSPath := filepath.Join(diaryRootFS,
|
||||||
|
fmt.Sprintf("%d", displayYear),
|
||||||
|
fmt.Sprintf("%02d", displayMonth))
|
||||||
|
dayEntries, _ := os.ReadDir(monthFSPath)
|
||||||
|
hasDayEntry := map[int]bool{}
|
||||||
|
for _, e := range dayEntries {
|
||||||
|
if !e.IsDir() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
d, err := strconv.Atoi(e.Name())
|
||||||
|
if err != nil || d < 1 || d > 31 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
hasDayEntry[d] = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build calendar grid with Monday as first column.
|
||||||
|
firstDay := time.Date(displayYear, time.Month(displayMonth), 1, 0, 0, 0, 0, time.UTC)
|
||||||
|
startOffset := int(firstDay.Weekday()+6) % 7
|
||||||
|
daysInMonth := time.Date(displayYear, time.Month(displayMonth)+1, 0, 0, 0, 0, 0, time.UTC).Day()
|
||||||
|
|
||||||
|
monthURLBase := path.Join(diaryRootURL,
|
||||||
|
fmt.Sprintf("%d", displayYear),
|
||||||
|
fmt.Sprintf("%02d", displayMonth)) + "/"
|
||||||
|
|
||||||
|
var weeks [][]calDay
|
||||||
|
week := make([]calDay, 7)
|
||||||
|
col := startOffset
|
||||||
|
for d := 1; d <= daysInMonth; d++ {
|
||||||
|
dayURL := path.Join(monthURLBase, fmt.Sprintf("%02d", d)) + "/"
|
||||||
|
cell := calDay{Num: d, HasEntry: hasDayEntry[d]}
|
||||||
|
if cell.HasEntry {
|
||||||
|
cell.URL = dayURL
|
||||||
|
} else {
|
||||||
|
cell.URL = dayURL + "?edit"
|
||||||
|
}
|
||||||
|
cell.IsCurrent = d == currentDay
|
||||||
|
cell.IsToday = d == today.Day() &&
|
||||||
|
time.Month(displayMonth) == today.Month() &&
|
||||||
|
displayYear == today.Year()
|
||||||
|
week[col] = cell
|
||||||
|
col++
|
||||||
|
if col == 7 {
|
||||||
|
weeks = append(weeks, week)
|
||||||
|
week = make([]calDay, 7)
|
||||||
|
col = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if col > 0 {
|
||||||
|
weeks = append(weeks, week)
|
||||||
|
}
|
||||||
|
|
||||||
|
prev := time.Date(displayYear, time.Month(displayMonth)-1, 1, 0, 0, 0, 0, time.UTC)
|
||||||
|
next := time.Date(displayYear, time.Month(displayMonth)+1, 1, 0, 0, 0, 0, time.UTC)
|
||||||
|
prevMonURL := path.Join(diaryRootURL,
|
||||||
|
fmt.Sprintf("%d", prev.Year()),
|
||||||
|
fmt.Sprintf("%02d", int(prev.Month()))) + "/"
|
||||||
|
nextMonURL := path.Join(diaryRootURL,
|
||||||
|
fmt.Sprintf("%d", next.Year()),
|
||||||
|
fmt.Sprintf("%02d", int(next.Month()))) + "/"
|
||||||
yearURL := path.Join(diaryRootURL, fmt.Sprintf("%d", displayYear)) + "/"
|
yearURL := path.Join(diaryRootURL, fmt.Sprintf("%d", displayYear)) + "/"
|
||||||
|
|
||||||
// On a year page, in-year month/day links collapse to anchors so the
|
|
||||||
// browser scrolls within the current page instead of navigating away.
|
|
||||||
// On the diary root (depth=0), all links remain full URLs.
|
|
||||||
pageYear := 0
|
|
||||||
if depth >= 1 {
|
|
||||||
pageYear = displayYear
|
|
||||||
}
|
|
||||||
|
|
||||||
monthAnchor := func(year, month int) string {
|
|
||||||
if pageYear == year {
|
|
||||||
return fmt.Sprintf("#%d-%02d", year, month)
|
|
||||||
}
|
|
||||||
return path.Join(diaryRootURL,
|
|
||||||
fmt.Sprintf("%d", year),
|
|
||||||
fmt.Sprintf("%02d", month)) + "/"
|
|
||||||
}
|
|
||||||
dayAnchor := func(year, month, day int) string {
|
|
||||||
if pageYear == year {
|
|
||||||
return fmt.Sprintf("#%d-%02d-%02d", year, month, day)
|
|
||||||
}
|
|
||||||
return path.Join(diaryRootURL,
|
|
||||||
fmt.Sprintf("%d", year),
|
|
||||||
fmt.Sprintf("%02d", month),
|
|
||||||
fmt.Sprintf("%02d", day)) + "/"
|
|
||||||
}
|
|
||||||
|
|
||||||
months := make([]calMonthGrid, 12)
|
|
||||||
for m := 1; m <= 12; m++ {
|
|
||||||
var cd int
|
|
||||||
if m == currentMonth {
|
|
||||||
cd = currentDay
|
|
||||||
}
|
|
||||||
months[m-1] = calMonthGrid{
|
|
||||||
Num: m,
|
|
||||||
Name: germanMonths[time.Month(m)],
|
|
||||||
AnchorURL: monthAnchor(displayYear, m),
|
|
||||||
Weeks: buildMonthGrid(displayYear, m, today, cd, hasDayEntryByMonth[m], dayAnchor),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Collect all year subdirectories in diary root (descending).
|
// Collect all year subdirectories in diary root (descending).
|
||||||
yearEntries, _ := os.ReadDir(diaryRootFS)
|
yearEntries, _ := os.ReadDir(diaryRootFS)
|
||||||
var years []calYear
|
var years []calYear
|
||||||
@@ -488,14 +246,31 @@ func computeCalendarWidget(diaryRootFS, diaryRootURL, fsPath string, depth int)
|
|||||||
}
|
}
|
||||||
sort.Slice(years, func(i, j int) bool { return years[i].Num > years[j].Num })
|
sort.Slice(years, func(i, j int) bool { return years[i].Num > years[j].Num })
|
||||||
|
|
||||||
|
// All 12 months, ascending, linked to current display year.
|
||||||
|
allMonths := make([]calMonth, 12)
|
||||||
|
for m := 1; m <= 12; m++ {
|
||||||
|
allMonths[m-1] = calMonth{
|
||||||
|
Num: m,
|
||||||
|
Name: germanMonths[time.Month(m)],
|
||||||
|
URL: path.Join(diaryRootURL,
|
||||||
|
fmt.Sprintf("%d", displayYear),
|
||||||
|
fmt.Sprintf("%02d", m)) + "/",
|
||||||
|
IsCurrent: m == displayMonth,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
data := calendarData{
|
data := calendarData{
|
||||||
DisplayYear: displayYear,
|
DisplayYear: displayYear,
|
||||||
DisplayMonth: displayMonth,
|
DisplayMonth: displayMonth,
|
||||||
DisplayMonthName: months[displayMonth-1].Name,
|
MonthName: germanMonths[time.Month(displayMonth)],
|
||||||
DiaryURL: diaryRootURL,
|
DiaryURL: diaryRootURL,
|
||||||
YearURL: yearURL,
|
YearURL: yearURL,
|
||||||
Months: months,
|
MonthURL: monthURLBase,
|
||||||
Years: years,
|
PrevMonURL: prevMonURL,
|
||||||
|
NextMonURL: nextMonURL,
|
||||||
|
Weeks: weeks,
|
||||||
|
Years: years,
|
||||||
|
AllMonths: allMonths,
|
||||||
}
|
}
|
||||||
|
|
||||||
var buf bytes.Buffer
|
var buf bytes.Buffer
|
||||||
@@ -506,44 +281,6 @@ func computeCalendarWidget(diaryRootFS, diaryRootURL, fsPath string, depth int)
|
|||||||
return template.HTML(buf.String())
|
return template.HTML(buf.String())
|
||||||
}
|
}
|
||||||
|
|
||||||
// buildMonthGrid renders one month's day cells as a Monday-first week grid.
|
|
||||||
// hasDayEntry maps day-of-month → has a diary entry. dayAnchor produces the
|
|
||||||
// in-page anchor (or full URL when crossing pages); empty days link to the
|
|
||||||
// same anchor — every day exists on the year page as either a real or
|
|
||||||
// virtual section, so navigation is enough. Page creation happens via the
|
|
||||||
// [edit] button on the heading itself.
|
|
||||||
func buildMonthGrid(year, month int, today time.Time, currentDay int, hasDayEntry map[int]bool, dayAnchor func(int, int, int) string) [][]calDay {
|
|
||||||
firstDay := time.Date(year, time.Month(month), 1, 0, 0, 0, 0, time.UTC)
|
|
||||||
startOffset := int(firstDay.Weekday()+6) % 7
|
|
||||||
daysInMonth := time.Date(year, time.Month(month)+1, 0, 0, 0, 0, 0, time.UTC).Day()
|
|
||||||
|
|
||||||
var weeks [][]calDay
|
|
||||||
week := make([]calDay, 7)
|
|
||||||
col := startOffset
|
|
||||||
for d := 1; d <= daysInMonth; d++ {
|
|
||||||
cell := calDay{
|
|
||||||
Num: d,
|
|
||||||
HasEntry: hasDayEntry[d],
|
|
||||||
URL: dayAnchor(year, month, d),
|
|
||||||
}
|
|
||||||
cell.IsCurrent = currentDay > 0 && d == currentDay
|
|
||||||
cell.IsToday = d == today.Day() &&
|
|
||||||
time.Month(month) == today.Month() &&
|
|
||||||
year == today.Year()
|
|
||||||
week[col] = cell
|
|
||||||
col++
|
|
||||||
if col == 7 {
|
|
||||||
weeks = append(weeks, week)
|
|
||||||
week = make([]calDay, 7)
|
|
||||||
col = 0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if col > 0 {
|
|
||||||
weeks = append(weeks, week)
|
|
||||||
}
|
|
||||||
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
|
||||||
@@ -552,20 +289,41 @@ type diaryPhoto struct {
|
|||||||
ThumbURL string
|
ThumbURL string
|
||||||
}
|
}
|
||||||
|
|
||||||
// diarySection is one rendered section of the diary content (year, month, or
|
type diaryMonthSummary struct {
|
||||||
// day). Edit URLs point back into the year file's section editor so per-day
|
ID string
|
||||||
// editing works from any slice page.
|
Name string
|
||||||
type diarySection struct {
|
URL string
|
||||||
Level int // 1, 2, or 3
|
Photos []diaryPhoto
|
||||||
ID string // anchor id (e.g. "2026-05-28")
|
}
|
||||||
Heading string // displayed heading text
|
|
||||||
EditURL string // year-file section edit URL ("" = no edit button)
|
type diaryDaySection struct {
|
||||||
Body template.HTML // rendered markdown body (excludes the heading line)
|
ID string
|
||||||
|
Heading string
|
||||||
|
URL string
|
||||||
|
EditURL string
|
||||||
|
Content template.HTML
|
||||||
Photos []diaryPhoto
|
Photos []diaryPhoto
|
||||||
}
|
}
|
||||||
|
|
||||||
type diaryContentData struct {
|
type diaryYearData struct {
|
||||||
Sections []diarySection
|
Months []diaryMonthSummary
|
||||||
|
Year int
|
||||||
|
}
|
||||||
|
type diaryMonthData struct{ Days []diaryDaySection }
|
||||||
|
type diaryDayData struct{ Photos []diaryPhoto }
|
||||||
|
|
||||||
|
var diaryYearTmpl = template.Must(template.ParseFS(assets, "assets/diary/diary-year.html"))
|
||||||
|
var diaryMonthTmpl = template.Must(template.ParseFS(assets, "assets/diary/diary-month.html"))
|
||||||
|
var diaryDayTmpl = template.Must(template.ParseFS(assets, "assets/diary/diary-day.html"))
|
||||||
|
|
||||||
|
var germanWeekdays = map[time.Weekday]string{
|
||||||
|
time.Sunday: "Sonntag",
|
||||||
|
time.Monday: "Montag",
|
||||||
|
time.Tuesday: "Dienstag",
|
||||||
|
time.Wednesday: "Mittwoch",
|
||||||
|
time.Thursday: "Donnerstag",
|
||||||
|
time.Friday: "Freitag",
|
||||||
|
time.Saturday: "Samstag",
|
||||||
}
|
}
|
||||||
|
|
||||||
var germanMonths = map[time.Month]string{
|
var germanMonths = map[time.Month]string{
|
||||||
@@ -583,6 +341,15 @@ var germanMonths = map[time.Month]string{
|
|||||||
time.December: "Dezember",
|
time.December: "Dezember",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func formatGermanDate(t time.Time) string {
|
||||||
|
return fmt.Sprintf("%s, %d. %s %d",
|
||||||
|
germanWeekdays[t.Weekday()],
|
||||||
|
t.Day(),
|
||||||
|
germanMonths[t.Month()],
|
||||||
|
t.Year(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
var photoExts = map[string]bool{
|
var photoExts = map[string]bool{
|
||||||
".jpg": true, ".jpeg": true, ".png": true, ".gif": true, ".webp": true,
|
".jpg": true, ".jpeg": true, ".png": true, ".gif": true, ".webp": true,
|
||||||
}
|
}
|
||||||
@@ -625,196 +392,196 @@ func yearPhotos(yearFsPath, yearURLPath string) []diaryPhoto {
|
|||||||
return photos
|
return photos
|
||||||
}
|
}
|
||||||
|
|
||||||
var diaryContentTmpl = template.Must(template.ParseFS(assets, "assets/diary/content.html"))
|
// renderDiaryYear renders month sections with photo counts for a year folder.
|
||||||
|
func renderDiaryYear(fsPath, urlPath string) template.HTML {
|
||||||
// sectionBody strips the first heading line and returns the rendered body.
|
year, err := strconv.Atoi(filepath.Base(fsPath))
|
||||||
// Used so the diary template can emit the heading explicitly (with edit URL)
|
|
||||||
// while still rendering the section body via goldmark.
|
|
||||||
func sectionBody(section []byte) template.HTML {
|
|
||||||
body := stripFirstHeading(section)
|
|
||||||
if len(bytes.TrimSpace(body)) == 0 {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
return renderMarkdown(body)
|
|
||||||
}
|
|
||||||
|
|
||||||
// renderDiaryYear renders the year page: every section from the year file
|
|
||||||
// (with photos attached to `### YYYY-MM-DD` headings) plus virtual entries
|
|
||||||
// for every month/day slot the file doesn't yet contain.
|
|
||||||
func renderDiaryYear(yearFS, yearURL string) template.HTML {
|
|
||||||
year, err := strconv.Atoi(filepath.Base(yearFS))
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
raw, _ := os.ReadFile(filepath.Join(yearFS, "index.md"))
|
|
||||||
sections := splitSections(raw)
|
|
||||||
photos := yearPhotos(yearFS, yearURL)
|
|
||||||
|
|
||||||
out := buildFileSections(sections, photos, yearURL)
|
photos := yearPhotos(fsPath, urlPath)
|
||||||
out = appendVirtualEntries(out, sections, photos, year, yearURL)
|
|
||||||
return renderDiaryContent(out)
|
|
||||||
}
|
|
||||||
|
|
||||||
// buildFileSections converts the year file's sections (skipping the
|
// Collect month numbers from both subdirectories and photo filenames so
|
||||||
// pre-heading section 0) into rendered diarySection entries. Photos are
|
// years that contain only photos (no diary entries) still list months.
|
||||||
// attached to level-3 headings whose text parses as `YYYY-MM-DD`.
|
monthSet := map[int]bool{}
|
||||||
func buildFileSections(sections [][]byte, photos []diaryPhoto, yearURL string) []diarySection {
|
monthDirs := map[int]string{}
|
||||||
var out []diarySection
|
entries, _ := os.ReadDir(fsPath)
|
||||||
for i := 1; i < len(sections); i++ {
|
for _, e := range entries {
|
||||||
level, text := sectionHeading(sections[i])
|
if !e.IsDir() {
|
||||||
if level == 0 {
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
sec := diarySection{
|
n, err := strconv.Atoi(e.Name())
|
||||||
Level: level,
|
if err != nil || n < 1 || n > 12 {
|
||||||
ID: text,
|
|
||||||
Heading: text,
|
|
||||||
EditURL: fmt.Sprintf("%s?edit§ion=%d", yearURL, i),
|
|
||||||
Body: sectionBody(sections[i]),
|
|
||||||
}
|
|
||||||
if level == 3 {
|
|
||||||
if y, m, d, ok := parseISODate(text); ok {
|
|
||||||
sec.Photos = filterPhotos(photos, y, m, d)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
out = append(out, sec)
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
// appendVirtualEntries inserts virtual month and day sections for every
|
|
||||||
// `## YYYY-MM` / `### YYYY-MM-DD` slot in `year` that lacks a real section.
|
|
||||||
// Virtual day sections carry photos when present. Each virtual entry's
|
|
||||||
// EditURL routes through the insert-before flow so clicking [edit] splices
|
|
||||||
// the section into the year file at the right chronological position.
|
|
||||||
//
|
|
||||||
// Scope: past years get all 12 months / 365(6) days; the current year stops
|
|
||||||
// at today; future years are returned unchanged.
|
|
||||||
//
|
|
||||||
// Interleave: real date sections (`## YYYY-MM`, `### YYYY-MM-DD`) keep their
|
|
||||||
// document position; virtual entries are spliced in lexicographic ID order
|
|
||||||
// before the next real date section. Non-date headings (e.g. `## Events` →
|
|
||||||
// `### Festival` in a year intro) are left where the user wrote them.
|
|
||||||
func appendVirtualEntries(existing []diarySection, sections [][]byte, photos []diaryPhoto, year int, yearURL string) []diarySection {
|
|
||||||
today := time.Now()
|
|
||||||
if year > today.Year() {
|
|
||||||
return existing
|
|
||||||
}
|
|
||||||
|
|
||||||
coveredMonth := map[string]bool{}
|
|
||||||
coveredDay := map[string]bool{}
|
|
||||||
for _, s := range existing {
|
|
||||||
switch {
|
|
||||||
case s.Level == 2 && len(s.ID) == 7 && isDateHeading(s.ID):
|
|
||||||
coveredMonth[s.ID] = true
|
|
||||||
case s.Level == 3 && len(s.ID) == 10 && isDateHeading(s.ID):
|
|
||||||
coveredDay[s.ID] = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
photoByDay := map[string][]diaryPhoto{}
|
|
||||||
for _, p := range photos {
|
|
||||||
if p.Date.Year() != year {
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
photoByDay[p.Date.Format("2006-01-02")] = append(photoByDay[p.Date.Format("2006-01-02")], p)
|
monthSet[n] = true
|
||||||
|
monthDirs[n] = e.Name()
|
||||||
}
|
}
|
||||||
|
|
||||||
lastDay := time.Date(year, time.December, 31, 0, 0, 0, 0, time.UTC)
|
|
||||||
if year == today.Year() {
|
|
||||||
lastDay = time.Date(year, today.Month(), today.Day(), 0, 0, 0, 0, time.UTC)
|
|
||||||
}
|
|
||||||
|
|
||||||
var virtual []diarySection
|
|
||||||
for d := time.Date(year, time.January, 1, 0, 0, 0, 0, time.UTC); !d.After(lastDay); d = d.AddDate(0, 0, 1) {
|
|
||||||
if d.Day() == 1 {
|
|
||||||
monthID := d.Format("2006-01")
|
|
||||||
if !coveredMonth[monthID] {
|
|
||||||
idx := computeInsertIndex(sections, monthID)
|
|
||||||
virtual = append(virtual, diarySection{
|
|
||||||
Level: 2,
|
|
||||||
ID: monthID,
|
|
||||||
Heading: monthID,
|
|
||||||
EditURL: fmt.Sprintf("%s?edit&insert_before=%d&heading=%s&level=%s",
|
|
||||||
yearURL, idx, url.QueryEscape(monthID), url.QueryEscape("##")),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
dayID := d.Format("2006-01-02")
|
|
||||||
if !coveredDay[dayID] {
|
|
||||||
idx := computeInsertIndex(sections, dayID)
|
|
||||||
virtual = append(virtual, diarySection{
|
|
||||||
Level: 3,
|
|
||||||
ID: dayID,
|
|
||||||
Heading: dayID,
|
|
||||||
EditURL: fmt.Sprintf("%s?edit&insert_before=%d&heading=%s",
|
|
||||||
yearURL, idx, url.QueryEscape(dayID)),
|
|
||||||
Photos: photoByDay[dayID],
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if len(virtual) == 0 {
|
|
||||||
return existing
|
|
||||||
}
|
|
||||||
|
|
||||||
out := make([]diarySection, 0, len(existing)+len(virtual))
|
|
||||||
vi := 0
|
|
||||||
for _, s := range existing {
|
|
||||||
if isRealDateSection(s) {
|
|
||||||
for vi < len(virtual) && virtual[vi].ID < s.ID {
|
|
||||||
out = append(out, virtual[vi])
|
|
||||||
vi++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
out = append(out, s)
|
|
||||||
}
|
|
||||||
for vi < len(virtual) {
|
|
||||||
out = append(out, virtual[vi])
|
|
||||||
vi++
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
// isRealDateSection reports whether a rendered diarySection is one of the
|
|
||||||
// date-headed slots (`## YYYY-MM` or `### YYYY-MM-DD`) the virtual-entry
|
|
||||||
// interleave sorts against.
|
|
||||||
func isRealDateSection(s diarySection) bool {
|
|
||||||
switch s.Level {
|
|
||||||
case 2:
|
|
||||||
return len(s.ID) == 7 && isDateHeading(s.ID)
|
|
||||||
case 3:
|
|
||||||
return len(s.ID) == 10 && isDateHeading(s.ID)
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
// parseISODate parses "YYYY-MM-DD" leading characters of s. Returns ok=false
|
|
||||||
// if the prefix does not match.
|
|
||||||
func parseISODate(s string) (year, month, day int, ok bool) {
|
|
||||||
if len(s) < 10 {
|
|
||||||
return 0, 0, 0, false
|
|
||||||
}
|
|
||||||
t, err := time.Parse("2006-01-02", s[:10])
|
|
||||||
if err != nil {
|
|
||||||
return 0, 0, 0, false
|
|
||||||
}
|
|
||||||
return t.Year(), int(t.Month()), t.Day(), true
|
|
||||||
}
|
|
||||||
|
|
||||||
func filterPhotos(photos []diaryPhoto, year, month, day int) []diaryPhoto {
|
|
||||||
var out []diaryPhoto
|
|
||||||
for _, p := range photos {
|
for _, p := range photos {
|
||||||
if p.Date.Year() == year && int(p.Date.Month()) == month && p.Date.Day() == day {
|
if p.Date.Year() == year {
|
||||||
out = append(out, p)
|
monthSet[int(p.Date.Month())] = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
func renderDiaryContent(sections []diarySection) template.HTML {
|
monthNums := make([]int, 0, len(monthSet))
|
||||||
|
for m := range monthSet {
|
||||||
|
monthNums = append(monthNums, m)
|
||||||
|
}
|
||||||
|
sort.Ints(monthNums)
|
||||||
|
|
||||||
|
var months []diaryMonthSummary
|
||||||
|
for _, monthNum := range monthNums {
|
||||||
|
var monthPhotos []diaryPhoto
|
||||||
|
for _, p := range photos {
|
||||||
|
if p.Date.Year() == year && int(p.Date.Month()) == monthNum {
|
||||||
|
monthPhotos = append(monthPhotos, p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
monthDate := time.Date(year, time.Month(monthNum), 1, 0, 0, 0, 0, time.UTC)
|
||||||
|
dirName, ok := monthDirs[monthNum]
|
||||||
|
if !ok {
|
||||||
|
dirName = fmt.Sprintf("%02d", monthNum)
|
||||||
|
}
|
||||||
|
months = append(months, diaryMonthSummary{
|
||||||
|
ID: monthDate.Format("2006-01"),
|
||||||
|
Name: fmt.Sprintf("%s %d", germanMonths[monthDate.Month()], year),
|
||||||
|
URL: path.Join(urlPath, dirName) + "/",
|
||||||
|
Photos: monthPhotos,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
var buf bytes.Buffer
|
var buf bytes.Buffer
|
||||||
if err := diaryContentTmpl.Execute(&buf, diaryContentData{Sections: sections}); err != nil {
|
if err := diaryYearTmpl.Execute(&buf, diaryYearData{Months: months, Year: year}); err != nil {
|
||||||
log.Printf("diary content template: %v", err)
|
log.Printf("diary year template: %v", err)
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return template.HTML(buf.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
// renderDiaryMonth renders a section per day, each with its markdown content
|
||||||
|
// and photos sourced from the parent year folder.
|
||||||
|
func renderDiaryMonth(fsPath, urlPath string) template.HTML {
|
||||||
|
yearFsPath := filepath.Dir(fsPath)
|
||||||
|
yearURLPath := parentURL(urlPath)
|
||||||
|
|
||||||
|
year, err := strconv.Atoi(filepath.Base(yearFsPath))
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
monthNum, err := strconv.Atoi(filepath.Base(fsPath))
|
||||||
|
if err != nil || monthNum < 1 || monthNum > 12 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
allPhotos := yearPhotos(yearFsPath, yearURLPath)
|
||||||
|
var monthPhotos []diaryPhoto
|
||||||
|
for _, p := range allPhotos {
|
||||||
|
if p.Date.Year() == year && int(p.Date.Month()) == monthNum {
|
||||||
|
monthPhotos = append(monthPhotos, p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Collect day numbers from subdirectories and from photo filenames.
|
||||||
|
daySet := map[int]bool{}
|
||||||
|
dayDirs := map[int]string{} // day number → actual directory name
|
||||||
|
entries, _ := os.ReadDir(fsPath)
|
||||||
|
for _, e := range entries {
|
||||||
|
if !e.IsDir() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
d, err := strconv.Atoi(e.Name())
|
||||||
|
if err != nil || d < 1 || d > 31 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
daySet[d] = true
|
||||||
|
dayDirs[d] = e.Name()
|
||||||
|
}
|
||||||
|
for _, p := range monthPhotos {
|
||||||
|
daySet[p.Date.Day()] = true
|
||||||
|
}
|
||||||
|
|
||||||
|
days := make([]int, 0, len(daySet))
|
||||||
|
for d := range daySet {
|
||||||
|
days = append(days, d)
|
||||||
|
}
|
||||||
|
sort.Ints(days)
|
||||||
|
|
||||||
|
var sections []diaryDaySection
|
||||||
|
for _, dayNum := range days {
|
||||||
|
date := time.Date(year, time.Month(monthNum), dayNum, 0, 0, 0, 0, time.UTC)
|
||||||
|
|
||||||
|
heading := date.Format("2006-01-02")
|
||||||
|
dayURL := path.Join(urlPath, fmt.Sprintf("%02d", dayNum)) + "/"
|
||||||
|
var content template.HTML
|
||||||
|
if dirName, ok := dayDirs[dayNum]; ok {
|
||||||
|
dayURL = path.Join(urlPath, dirName) + "/"
|
||||||
|
dayFsPath := filepath.Join(fsPath, dirName)
|
||||||
|
if raw, err := os.ReadFile(filepath.Join(dayFsPath, "index.md")); err == nil && len(raw) > 0 {
|
||||||
|
raw = stripFirstHeading(raw)
|
||||||
|
content = renderMarkdown(raw)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var photos []diaryPhoto
|
||||||
|
for _, p := range monthPhotos {
|
||||||
|
if p.Date.Day() == dayNum {
|
||||||
|
photos = append(photos, p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sections = append(sections, diaryDaySection{
|
||||||
|
ID: date.Format("2006-01-02"),
|
||||||
|
Heading: heading,
|
||||||
|
URL: dayURL,
|
||||||
|
EditURL: dayURL + "?edit",
|
||||||
|
Content: content,
|
||||||
|
Photos: photos,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
var buf bytes.Buffer
|
||||||
|
if err := diaryMonthTmpl.Execute(&buf, diaryMonthData{Days: sections}); err != nil {
|
||||||
|
log.Printf("diary month template: %v", err)
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return template.HTML(buf.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
// renderDiaryDay renders the photo grid for a single day, sourcing photos
|
||||||
|
// from the grandparent year folder.
|
||||||
|
func renderDiaryDay(fsPath, urlPath string) template.HTML {
|
||||||
|
monthFsPath := filepath.Dir(fsPath)
|
||||||
|
yearFsPath := filepath.Dir(monthFsPath)
|
||||||
|
yearURLPath := parentURL(parentURL(urlPath))
|
||||||
|
|
||||||
|
year, err := strconv.Atoi(filepath.Base(yearFsPath))
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
monthNum, err := strconv.Atoi(filepath.Base(monthFsPath))
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
dayNum, err := strconv.Atoi(filepath.Base(fsPath))
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
allPhotos := yearPhotos(yearFsPath, yearURLPath)
|
||||||
|
var photos []diaryPhoto
|
||||||
|
for _, p := range allPhotos {
|
||||||
|
if p.Date.Year() == year && int(p.Date.Month()) == monthNum && p.Date.Day() == dayNum {
|
||||||
|
photos = append(photos, p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(photos) == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
var buf bytes.Buffer
|
||||||
|
if err := diaryDayTmpl.Execute(&buf, diaryDayData{Photos: photos}); err != nil {
|
||||||
|
log.Printf("diary day template: %v", err)
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
return template.HTML(buf.String())
|
return template.HTML(buf.String())
|
||||||
|
|||||||
@@ -1,22 +0,0 @@
|
|||||||
// Builds the vendored CodeMirror 6 bundle consumed by the page editor.
|
|
||||||
//
|
|
||||||
// Output is an IIFE that assigns the CM primitives the editor scripts need
|
|
||||||
// onto window.CM (see entry.js). The editor JS is loaded as plain global
|
|
||||||
// scripts, not ES modules, so there is no runtime module loader.
|
|
||||||
//
|
|
||||||
// Run via `make editor` (or `npm run build` here) after changing CM versions.
|
|
||||||
// The committed artifact at assets/editor/vendor/codemirror.bundle.js is the
|
|
||||||
// only thing `go build` ever sees.
|
|
||||||
import * as esbuild from "esbuild";
|
|
||||||
|
|
||||||
await esbuild.build({
|
|
||||||
entryPoints: ["entry.js"],
|
|
||||||
bundle: true,
|
|
||||||
format: "iife",
|
|
||||||
minify: true,
|
|
||||||
target: ["es2018"],
|
|
||||||
legalComments: "none",
|
|
||||||
outfile: "../assets/editor/vendor/codemirror.bundle.js",
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log("built assets/editor/vendor/codemirror.bundle.js");
|
|
||||||
@@ -1,107 +0,0 @@
|
|||||||
// CodeMirror 6 bundle entry point.
|
|
||||||
//
|
|
||||||
// Imports only the CM packages the editor needs (keep the bundle small for the
|
|
||||||
// mobile/VPN path) and exposes them on window.CM for the global-style editor
|
|
||||||
// scripts. The editor theme and markdown highlight palette draw from the app's
|
|
||||||
// :root CSS variables so there are no hardcoded colors/spacing here.
|
|
||||||
import { EditorState, EditorSelection, Compartment, Prec } from "@codemirror/state";
|
|
||||||
import { EditorView, keymap, drawSelection } from "@codemirror/view";
|
|
||||||
import { history, historyKeymap, defaultKeymap, indentWithTab, undo, redo, deleteLine } from "@codemirror/commands";
|
|
||||||
import { markdown, markdownLanguage, markdownKeymap } from "@codemirror/lang-markdown";
|
|
||||||
import { syntaxHighlighting, HighlightStyle, indentOnInput } from "@codemirror/language";
|
|
||||||
import {
|
|
||||||
autocompletion,
|
|
||||||
closeBrackets,
|
|
||||||
closeBracketsKeymap,
|
|
||||||
completionKeymap,
|
|
||||||
startCompletion,
|
|
||||||
} from "@codemirror/autocomplete";
|
|
||||||
import { tags } from "@lezer/highlight";
|
|
||||||
|
|
||||||
// Editor chrome. Colors/spacing/fonts come from :root variables; var() works
|
|
||||||
// here because HighlightStyle/theme emit real CSS rules.
|
|
||||||
const theme = EditorView.theme(
|
|
||||||
{
|
|
||||||
"&": {
|
|
||||||
backgroundColor: "var(--bg)",
|
|
||||||
color: "var(--text)",
|
|
||||||
fontSize: "0.9rem",
|
|
||||||
border: "var(--border)",
|
|
||||||
borderTop: "none",
|
|
||||||
},
|
|
||||||
"&.cm-focused": { outline: "none" },
|
|
||||||
".cm-scroller": {
|
|
||||||
fontFamily: '"Iosevka Slab", monospace',
|
|
||||||
lineHeight: "1.6",
|
|
||||||
minHeight: "60vh",
|
|
||||||
},
|
|
||||||
".cm-content": {
|
|
||||||
padding: "var(--space-4)",
|
|
||||||
caretColor: "var(--text)",
|
|
||||||
},
|
|
||||||
".cm-cursor, .cm-dropCursor": { borderLeftColor: "var(--text)" },
|
|
||||||
"&.cm-focused .cm-selectionBackground, .cm-selectionBackground, .cm-content ::selection":
|
|
||||||
{ backgroundColor: "var(--bg-panel-hover)" },
|
|
||||||
".cm-activeLine": { backgroundColor: "transparent" },
|
|
||||||
".cm-tooltip": {
|
|
||||||
backgroundColor: "var(--bg-panel)",
|
|
||||||
border: "var(--border-dashed)",
|
|
||||||
color: "var(--text)",
|
|
||||||
},
|
|
||||||
".cm-tooltip.cm-tooltip-autocomplete > ul": {
|
|
||||||
fontFamily: '"Iosevka Slab", monospace',
|
|
||||||
fontSize: "var(--font-sm)",
|
|
||||||
},
|
|
||||||
".cm-tooltip-autocomplete ul li[aria-selected]": {
|
|
||||||
backgroundColor: "var(--bg-panel-hover)",
|
|
||||||
color: "var(--text)",
|
|
||||||
},
|
|
||||||
".cm-completionDetail": {
|
|
||||||
color: "var(--text-muted)",
|
|
||||||
fontStyle: "normal",
|
|
||||||
marginLeft: "var(--space-3)",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{ dark: true }
|
|
||||||
);
|
|
||||||
|
|
||||||
const highlightStyle = HighlightStyle.define([
|
|
||||||
{ tag: [tags.heading1, tags.heading2, tags.heading3, tags.heading4, tags.heading5, tags.heading6], color: "var(--secondary)", fontWeight: "bold" },
|
|
||||||
{ tag: tags.strong, fontWeight: "bold", color: "var(--text)" },
|
|
||||||
{ tag: tags.emphasis, fontStyle: "italic" },
|
|
||||||
{ tag: tags.strikethrough, textDecoration: "line-through" },
|
|
||||||
{ tag: [tags.link, tags.url], color: "var(--link)" },
|
|
||||||
{ tag: tags.monospace, color: "var(--primary-hover)" },
|
|
||||||
{ tag: tags.quote, color: "var(--text-muted)", fontStyle: "italic" },
|
|
||||||
{ tag: [tags.list, tags.contentSeparator], color: "var(--secondary)" },
|
|
||||||
{ tag: [tags.processingInstruction, tags.meta], color: "var(--text-muted)" },
|
|
||||||
]);
|
|
||||||
|
|
||||||
window.CM = {
|
|
||||||
EditorState,
|
|
||||||
EditorSelection,
|
|
||||||
Compartment,
|
|
||||||
Prec,
|
|
||||||
EditorView,
|
|
||||||
keymap,
|
|
||||||
drawSelection,
|
|
||||||
history,
|
|
||||||
historyKeymap,
|
|
||||||
defaultKeymap,
|
|
||||||
indentWithTab,
|
|
||||||
undo,
|
|
||||||
redo,
|
|
||||||
deleteLine,
|
|
||||||
markdown,
|
|
||||||
markdownLanguage,
|
|
||||||
markdownKeymap,
|
|
||||||
syntaxHighlighting,
|
|
||||||
indentOnInput,
|
|
||||||
autocompletion,
|
|
||||||
closeBrackets,
|
|
||||||
closeBracketsKeymap,
|
|
||||||
completionKeymap,
|
|
||||||
startCompletion,
|
|
||||||
theme,
|
|
||||||
highlightStyle,
|
|
||||||
};
|
|
||||||
Generated
-730
@@ -1,730 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "datascape-editor-build",
|
|
||||||
"version": "1.0.0",
|
|
||||||
"lockfileVersion": 3,
|
|
||||||
"requires": true,
|
|
||||||
"packages": {
|
|
||||||
"": {
|
|
||||||
"name": "datascape-editor-build",
|
|
||||||
"version": "1.0.0",
|
|
||||||
"devDependencies": {
|
|
||||||
"@codemirror/autocomplete": "^6.18.0",
|
|
||||||
"@codemirror/commands": "^6.7.0",
|
|
||||||
"@codemirror/lang-markdown": "^6.3.0",
|
|
||||||
"@codemirror/language": "^6.10.0",
|
|
||||||
"@codemirror/state": "^6.4.0",
|
|
||||||
"@codemirror/view": "^6.34.0",
|
|
||||||
"@lezer/highlight": "^1.2.0",
|
|
||||||
"esbuild": "^0.24.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@codemirror/autocomplete": {
|
|
||||||
"version": "6.20.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/@codemirror/autocomplete/-/autocomplete-6.20.3.tgz",
|
|
||||||
"integrity": "sha512-tlosUqb+3BbxCxZdu4tKeRghPFC+QM7q4X5YhKV2eCmPG+1r2F3f4AaSz5sCrFqUtX4Jh20VFTKecl16MgiV9g==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@codemirror/language": "^6.0.0",
|
|
||||||
"@codemirror/state": "^6.0.0",
|
|
||||||
"@codemirror/view": "^6.17.0",
|
|
||||||
"@lezer/common": "^1.0.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@codemirror/commands": {
|
|
||||||
"version": "6.10.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/@codemirror/commands/-/commands-6.10.3.tgz",
|
|
||||||
"integrity": "sha512-JFRiqhKu+bvSkDLI+rUhJwSxQxYb759W5GBezE8Uc8mHLqC9aV/9aTC7yJSqCtB3F00pylrLCwnyS91Ap5ej4Q==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@codemirror/language": "^6.0.0",
|
|
||||||
"@codemirror/state": "^6.6.0",
|
|
||||||
"@codemirror/view": "^6.27.0",
|
|
||||||
"@lezer/common": "^1.1.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@codemirror/lang-css": {
|
|
||||||
"version": "6.3.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/@codemirror/lang-css/-/lang-css-6.3.1.tgz",
|
|
||||||
"integrity": "sha512-kr5fwBGiGtmz6l0LSJIbno9QrifNMUusivHbnA1H6Dmqy4HZFte3UAICix1VuKo0lMPKQr2rqB+0BkKi/S3Ejg==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@codemirror/autocomplete": "^6.0.0",
|
|
||||||
"@codemirror/language": "^6.0.0",
|
|
||||||
"@codemirror/state": "^6.0.0",
|
|
||||||
"@lezer/common": "^1.0.2",
|
|
||||||
"@lezer/css": "^1.1.7"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@codemirror/lang-html": {
|
|
||||||
"version": "6.4.11",
|
|
||||||
"resolved": "https://registry.npmjs.org/@codemirror/lang-html/-/lang-html-6.4.11.tgz",
|
|
||||||
"integrity": "sha512-9NsXp7Nwp891pQchI7gPdTwBuSuT3K65NGTHWHNJ55HjYcHLllr0rbIZNdOzas9ztc1EUVBlHou85FFZS4BNnw==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@codemirror/autocomplete": "^6.0.0",
|
|
||||||
"@codemirror/lang-css": "^6.0.0",
|
|
||||||
"@codemirror/lang-javascript": "^6.0.0",
|
|
||||||
"@codemirror/language": "^6.4.0",
|
|
||||||
"@codemirror/state": "^6.0.0",
|
|
||||||
"@codemirror/view": "^6.17.0",
|
|
||||||
"@lezer/common": "^1.0.0",
|
|
||||||
"@lezer/css": "^1.1.0",
|
|
||||||
"@lezer/html": "^1.3.12"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@codemirror/lang-javascript": {
|
|
||||||
"version": "6.2.5",
|
|
||||||
"resolved": "https://registry.npmjs.org/@codemirror/lang-javascript/-/lang-javascript-6.2.5.tgz",
|
|
||||||
"integrity": "sha512-zD4e5mS+50htS7F+TYjBPsiIFGanfVqg4HyUz6WNFikgOPf2BgKlx+TQedI1w6n/IqRBVBbBWmGFdLB/7uxO4A==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@codemirror/autocomplete": "^6.0.0",
|
|
||||||
"@codemirror/language": "^6.6.0",
|
|
||||||
"@codemirror/lint": "^6.0.0",
|
|
||||||
"@codemirror/state": "^6.0.0",
|
|
||||||
"@codemirror/view": "^6.17.0",
|
|
||||||
"@lezer/common": "^1.0.0",
|
|
||||||
"@lezer/javascript": "^1.0.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@codemirror/lang-markdown": {
|
|
||||||
"version": "6.5.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/@codemirror/lang-markdown/-/lang-markdown-6.5.0.tgz",
|
|
||||||
"integrity": "sha512-0K40bZ35jpHya6FriukbgaleaqzBLZfOh7HuzqbMxBXkbYMJDxfF39c23xOgxFezR+3G+tR2/Mup+Xk865OMvw==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@codemirror/autocomplete": "^6.7.1",
|
|
||||||
"@codemirror/lang-html": "^6.0.0",
|
|
||||||
"@codemirror/language": "^6.3.0",
|
|
||||||
"@codemirror/state": "^6.0.0",
|
|
||||||
"@codemirror/view": "^6.0.0",
|
|
||||||
"@lezer/common": "^1.2.1",
|
|
||||||
"@lezer/markdown": "^1.0.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@codemirror/language": {
|
|
||||||
"version": "6.12.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/@codemirror/language/-/language-6.12.3.tgz",
|
|
||||||
"integrity": "sha512-QwCZW6Tt1siP37Jet9Tb02Zs81TQt6qQrZR2H+eGMcFsL1zMrk2/b9CLC7/9ieP1fjIUMgviLWMmgiHoJrj+ZA==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@codemirror/state": "^6.0.0",
|
|
||||||
"@codemirror/view": "^6.23.0",
|
|
||||||
"@lezer/common": "^1.5.0",
|
|
||||||
"@lezer/highlight": "^1.0.0",
|
|
||||||
"@lezer/lr": "^1.0.0",
|
|
||||||
"style-mod": "^4.0.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@codemirror/lint": {
|
|
||||||
"version": "6.9.6",
|
|
||||||
"resolved": "https://registry.npmjs.org/@codemirror/lint/-/lint-6.9.6.tgz",
|
|
||||||
"integrity": "sha512-6Kp7r6XfCi/D/5sdXieMfg9pJU1bUEx96WITuLU6ESaKizCz0QHFMjY/TaFSbigDdEAIgi93itLBIUETP4oK+A==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@codemirror/state": "^6.0.0",
|
|
||||||
"@codemirror/view": "^6.42.0",
|
|
||||||
"crelt": "^1.0.5"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@codemirror/state": {
|
|
||||||
"version": "6.6.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.6.0.tgz",
|
|
||||||
"integrity": "sha512-4nbvra5R5EtiCzr9BTHiTLc+MLXK2QGiAVYMyi8PkQd3SR+6ixar/Q/01Fa21TBIDOZXgeWV4WppsQolSreAPQ==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@marijn/find-cluster-break": "^1.0.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@codemirror/view": {
|
|
||||||
"version": "6.43.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.43.0.tgz",
|
|
||||||
"integrity": "sha512-V7ZCLQO3Jus9hzh2jVCCPW3mO4IBMr43O37PqSUYautJSnnJF41YlgLw21x0fLJTYvJ+Vkm6Gp+qKGH9pltgXA==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@codemirror/state": "^6.6.0",
|
|
||||||
"crelt": "^1.0.6",
|
|
||||||
"style-mod": "^4.1.0",
|
|
||||||
"w3c-keyname": "^2.2.4"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@esbuild/aix-ppc64": {
|
|
||||||
"version": "0.24.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.24.2.tgz",
|
|
||||||
"integrity": "sha512-thpVCb/rhxE/BnMLQ7GReQLLN8q9qbHmI55F4489/ByVg2aQaQ6kbcLb6FHkocZzQhxc4gx0sCk0tJkKBFzDhA==",
|
|
||||||
"cpu": [
|
|
||||||
"ppc64"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"aix"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@esbuild/android-arm": {
|
|
||||||
"version": "0.24.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.24.2.tgz",
|
|
||||||
"integrity": "sha512-tmwl4hJkCfNHwFB3nBa8z1Uy3ypZpxqxfTQOcHX+xRByyYgunVbZ9MzUUfb0RxaHIMnbHagwAxuTL+tnNM+1/Q==",
|
|
||||||
"cpu": [
|
|
||||||
"arm"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"android"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@esbuild/android-arm64": {
|
|
||||||
"version": "0.24.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.24.2.tgz",
|
|
||||||
"integrity": "sha512-cNLgeqCqV8WxfcTIOeL4OAtSmL8JjcN6m09XIgro1Wi7cF4t/THaWEa7eL5CMoMBdjoHOTh/vwTO/o2TRXIyzg==",
|
|
||||||
"cpu": [
|
|
||||||
"arm64"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"android"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@esbuild/android-x64": {
|
|
||||||
"version": "0.24.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.24.2.tgz",
|
|
||||||
"integrity": "sha512-B6Q0YQDqMx9D7rvIcsXfmJfvUYLoP722bgfBlO5cGvNVb5V/+Y7nhBE3mHV9OpxBf4eAS2S68KZztiPaWq4XYw==",
|
|
||||||
"cpu": [
|
|
||||||
"x64"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"android"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@esbuild/darwin-arm64": {
|
|
||||||
"version": "0.24.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.24.2.tgz",
|
|
||||||
"integrity": "sha512-kj3AnYWc+CekmZnS5IPu9D+HWtUI49hbnyqk0FLEJDbzCIQt7hg7ucF1SQAilhtYpIujfaHr6O0UHlzzSPdOeA==",
|
|
||||||
"cpu": [
|
|
||||||
"arm64"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"darwin"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@esbuild/darwin-x64": {
|
|
||||||
"version": "0.24.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.24.2.tgz",
|
|
||||||
"integrity": "sha512-WeSrmwwHaPkNR5H3yYfowhZcbriGqooyu3zI/3GGpF8AyUdsrrP0X6KumITGA9WOyiJavnGZUwPGvxvwfWPHIA==",
|
|
||||||
"cpu": [
|
|
||||||
"x64"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"darwin"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@esbuild/freebsd-arm64": {
|
|
||||||
"version": "0.24.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.24.2.tgz",
|
|
||||||
"integrity": "sha512-UN8HXjtJ0k/Mj6a9+5u6+2eZ2ERD7Edt1Q9IZiB5UZAIdPnVKDoG7mdTVGhHJIeEml60JteamR3qhsr1r8gXvg==",
|
|
||||||
"cpu": [
|
|
||||||
"arm64"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"freebsd"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@esbuild/freebsd-x64": {
|
|
||||||
"version": "0.24.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.24.2.tgz",
|
|
||||||
"integrity": "sha512-TvW7wE/89PYW+IevEJXZ5sF6gJRDY/14hyIGFXdIucxCsbRmLUcjseQu1SyTko+2idmCw94TgyaEZi9HUSOe3Q==",
|
|
||||||
"cpu": [
|
|
||||||
"x64"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"freebsd"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@esbuild/linux-arm": {
|
|
||||||
"version": "0.24.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.24.2.tgz",
|
|
||||||
"integrity": "sha512-n0WRM/gWIdU29J57hJyUdIsk0WarGd6To0s+Y+LwvlC55wt+GT/OgkwoXCXvIue1i1sSNWblHEig00GBWiJgfA==",
|
|
||||||
"cpu": [
|
|
||||||
"arm"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"linux"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@esbuild/linux-arm64": {
|
|
||||||
"version": "0.24.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.24.2.tgz",
|
|
||||||
"integrity": "sha512-7HnAD6074BW43YvvUmE/35Id9/NB7BeX5EoNkK9obndmZBUk8xmJJeU7DwmUeN7tkysslb2eSl6CTrYz6oEMQg==",
|
|
||||||
"cpu": [
|
|
||||||
"arm64"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"linux"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@esbuild/linux-ia32": {
|
|
||||||
"version": "0.24.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.24.2.tgz",
|
|
||||||
"integrity": "sha512-sfv0tGPQhcZOgTKO3oBE9xpHuUqguHvSo4jl+wjnKwFpapx+vUDcawbwPNuBIAYdRAvIDBfZVvXprIj3HA+Ugw==",
|
|
||||||
"cpu": [
|
|
||||||
"ia32"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"linux"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@esbuild/linux-loong64": {
|
|
||||||
"version": "0.24.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.24.2.tgz",
|
|
||||||
"integrity": "sha512-CN9AZr8kEndGooS35ntToZLTQLHEjtVB5n7dl8ZcTZMonJ7CCfStrYhrzF97eAecqVbVJ7APOEe18RPI4KLhwQ==",
|
|
||||||
"cpu": [
|
|
||||||
"loong64"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"linux"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@esbuild/linux-mips64el": {
|
|
||||||
"version": "0.24.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.24.2.tgz",
|
|
||||||
"integrity": "sha512-iMkk7qr/wl3exJATwkISxI7kTcmHKE+BlymIAbHO8xanq/TjHaaVThFF6ipWzPHryoFsesNQJPE/3wFJw4+huw==",
|
|
||||||
"cpu": [
|
|
||||||
"mips64el"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"linux"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@esbuild/linux-ppc64": {
|
|
||||||
"version": "0.24.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.24.2.tgz",
|
|
||||||
"integrity": "sha512-shsVrgCZ57Vr2L8mm39kO5PPIb+843FStGt7sGGoqiiWYconSxwTiuswC1VJZLCjNiMLAMh34jg4VSEQb+iEbw==",
|
|
||||||
"cpu": [
|
|
||||||
"ppc64"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"linux"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@esbuild/linux-riscv64": {
|
|
||||||
"version": "0.24.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.24.2.tgz",
|
|
||||||
"integrity": "sha512-4eSFWnU9Hhd68fW16GD0TINewo1L6dRrB+oLNNbYyMUAeOD2yCK5KXGK1GH4qD/kT+bTEXjsyTCiJGHPZ3eM9Q==",
|
|
||||||
"cpu": [
|
|
||||||
"riscv64"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"linux"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@esbuild/linux-s390x": {
|
|
||||||
"version": "0.24.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.24.2.tgz",
|
|
||||||
"integrity": "sha512-S0Bh0A53b0YHL2XEXC20bHLuGMOhFDO6GN4b3YjRLK//Ep3ql3erpNcPlEFed93hsQAjAQDNsvcK+hV90FubSw==",
|
|
||||||
"cpu": [
|
|
||||||
"s390x"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"linux"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@esbuild/linux-x64": {
|
|
||||||
"version": "0.24.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.24.2.tgz",
|
|
||||||
"integrity": "sha512-8Qi4nQcCTbLnK9WoMjdC9NiTG6/E38RNICU6sUNqK0QFxCYgoARqVqxdFmWkdonVsvGqWhmm7MO0jyTqLqwj0Q==",
|
|
||||||
"cpu": [
|
|
||||||
"x64"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"linux"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@esbuild/netbsd-arm64": {
|
|
||||||
"version": "0.24.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.24.2.tgz",
|
|
||||||
"integrity": "sha512-wuLK/VztRRpMt9zyHSazyCVdCXlpHkKm34WUyinD2lzK07FAHTq0KQvZZlXikNWkDGoT6x3TD51jKQ7gMVpopw==",
|
|
||||||
"cpu": [
|
|
||||||
"arm64"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"netbsd"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@esbuild/netbsd-x64": {
|
|
||||||
"version": "0.24.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.24.2.tgz",
|
|
||||||
"integrity": "sha512-VefFaQUc4FMmJuAxmIHgUmfNiLXY438XrL4GDNV1Y1H/RW3qow68xTwjZKfj/+Plp9NANmzbH5R40Meudu8mmw==",
|
|
||||||
"cpu": [
|
|
||||||
"x64"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"netbsd"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@esbuild/openbsd-arm64": {
|
|
||||||
"version": "0.24.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.24.2.tgz",
|
|
||||||
"integrity": "sha512-YQbi46SBct6iKnszhSvdluqDmxCJA+Pu280Av9WICNwQmMxV7nLRHZfjQzwbPs3jeWnuAhE9Jy0NrnJ12Oz+0A==",
|
|
||||||
"cpu": [
|
|
||||||
"arm64"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"openbsd"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@esbuild/openbsd-x64": {
|
|
||||||
"version": "0.24.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.24.2.tgz",
|
|
||||||
"integrity": "sha512-+iDS6zpNM6EnJyWv0bMGLWSWeXGN/HTaF/LXHXHwejGsVi+ooqDfMCCTerNFxEkM3wYVcExkeGXNqshc9iMaOA==",
|
|
||||||
"cpu": [
|
|
||||||
"x64"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"openbsd"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@esbuild/sunos-x64": {
|
|
||||||
"version": "0.24.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.24.2.tgz",
|
|
||||||
"integrity": "sha512-hTdsW27jcktEvpwNHJU4ZwWFGkz2zRJUz8pvddmXPtXDzVKTTINmlmga3ZzwcuMpUvLw7JkLy9QLKyGpD2Yxig==",
|
|
||||||
"cpu": [
|
|
||||||
"x64"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"sunos"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@esbuild/win32-arm64": {
|
|
||||||
"version": "0.24.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.24.2.tgz",
|
|
||||||
"integrity": "sha512-LihEQ2BBKVFLOC9ZItT9iFprsE9tqjDjnbulhHoFxYQtQfai7qfluVODIYxt1PgdoyQkz23+01rzwNwYfutxUQ==",
|
|
||||||
"cpu": [
|
|
||||||
"arm64"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"win32"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@esbuild/win32-ia32": {
|
|
||||||
"version": "0.24.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.24.2.tgz",
|
|
||||||
"integrity": "sha512-q+iGUwfs8tncmFC9pcnD5IvRHAzmbwQ3GPS5/ceCyHdjXubwQWI12MKWSNSMYLJMq23/IUCvJMS76PDqXe1fxA==",
|
|
||||||
"cpu": [
|
|
||||||
"ia32"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"win32"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@esbuild/win32-x64": {
|
|
||||||
"version": "0.24.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.24.2.tgz",
|
|
||||||
"integrity": "sha512-7VTgWzgMGvup6aSqDPLiW5zHaxYJGTO4OokMjIlrCtf+VpEL+cXKtCvg723iguPYI5oaUNdS+/V7OU2gvXVWEg==",
|
|
||||||
"cpu": [
|
|
||||||
"x64"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"win32"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@lezer/common": {
|
|
||||||
"version": "1.5.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/@lezer/common/-/common-1.5.2.tgz",
|
|
||||||
"integrity": "sha512-sxQE460fPZyU3sdc8lafxiPwJHBzZRy/udNFynGQky1SePYBdhkBl1kOagA9uT3pxR8K09bOrmTUqA9wb/PjSQ==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/@lezer/css": {
|
|
||||||
"version": "1.3.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/@lezer/css/-/css-1.3.3.tgz",
|
|
||||||
"integrity": "sha512-RzBo8r+/6QJeow7aPHIpGVIH59xTcJXp399820gZoMo9noQDRVpJLheIBUicYwKcsbOYoBRoLZlf2720dG/4Tg==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@lezer/common": "^1.2.0",
|
|
||||||
"@lezer/highlight": "^1.0.0",
|
|
||||||
"@lezer/lr": "^1.3.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@lezer/highlight": {
|
|
||||||
"version": "1.2.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/@lezer/highlight/-/highlight-1.2.3.tgz",
|
|
||||||
"integrity": "sha512-qXdH7UqTvGfdVBINrgKhDsVTJTxactNNxLk7+UMwZhU13lMHaOBlJe9Vqp907ya56Y3+ed2tlqzys7jDkTmW0g==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@lezer/common": "^1.3.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@lezer/html": {
|
|
||||||
"version": "1.3.13",
|
|
||||||
"resolved": "https://registry.npmjs.org/@lezer/html/-/html-1.3.13.tgz",
|
|
||||||
"integrity": "sha512-oI7n6NJml729m7pjm9lvLvmXbdoMoi2f+1pwSDJkl9d68zGr7a9Btz8NdHTGQZtW2DA25ybeuv/SyDb9D5tseg==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@lezer/common": "^1.2.0",
|
|
||||||
"@lezer/highlight": "^1.0.0",
|
|
||||||
"@lezer/lr": "^1.0.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@lezer/javascript": {
|
|
||||||
"version": "1.5.4",
|
|
||||||
"resolved": "https://registry.npmjs.org/@lezer/javascript/-/javascript-1.5.4.tgz",
|
|
||||||
"integrity": "sha512-vvYx3MhWqeZtGPwDStM2dwgljd5smolYD2lR2UyFcHfxbBQebqx8yjmFmxtJ/E6nN6u1D9srOiVWm3Rb4tmcUA==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@lezer/common": "^1.2.0",
|
|
||||||
"@lezer/highlight": "^1.1.3",
|
|
||||||
"@lezer/lr": "^1.3.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@lezer/lr": {
|
|
||||||
"version": "1.4.10",
|
|
||||||
"resolved": "https://registry.npmjs.org/@lezer/lr/-/lr-1.4.10.tgz",
|
|
||||||
"integrity": "sha512-rnCpTIBafOx4mRp43xOxDJbFipJm/c0cia/V5TiGlhmMa+wsSdoGmUN3w5Bqrks/09Q/D4tNAmWaT8p6NRi77A==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@lezer/common": "^1.0.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@lezer/markdown": {
|
|
||||||
"version": "1.6.4",
|
|
||||||
"resolved": "https://registry.npmjs.org/@lezer/markdown/-/markdown-1.6.4.tgz",
|
|
||||||
"integrity": "sha512-N0SxazMj4k65DBfaf1azqtMZd6u7MqluP84/NZnB/io8Td9aleFmAhz9hcbvSfsxT5tdYlJ5qgv5aMJGY4zEtA==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@lezer/common": "^1.5.0",
|
|
||||||
"@lezer/highlight": "^1.0.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@marijn/find-cluster-break": {
|
|
||||||
"version": "1.0.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/@marijn/find-cluster-break/-/find-cluster-break-1.0.2.tgz",
|
|
||||||
"integrity": "sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/crelt": {
|
|
||||||
"version": "1.0.6",
|
|
||||||
"resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.6.tgz",
|
|
||||||
"integrity": "sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/esbuild": {
|
|
||||||
"version": "0.24.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.24.2.tgz",
|
|
||||||
"integrity": "sha512-+9egpBW8I3CD5XPe0n6BfT5fxLzxrlDzqydF3aviG+9ni1lDC/OvMHcxqEFV0+LANZG5R1bFMWfUrjVsdwxJvA==",
|
|
||||||
"dev": true,
|
|
||||||
"hasInstallScript": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"bin": {
|
|
||||||
"esbuild": "bin/esbuild"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
},
|
|
||||||
"optionalDependencies": {
|
|
||||||
"@esbuild/aix-ppc64": "0.24.2",
|
|
||||||
"@esbuild/android-arm": "0.24.2",
|
|
||||||
"@esbuild/android-arm64": "0.24.2",
|
|
||||||
"@esbuild/android-x64": "0.24.2",
|
|
||||||
"@esbuild/darwin-arm64": "0.24.2",
|
|
||||||
"@esbuild/darwin-x64": "0.24.2",
|
|
||||||
"@esbuild/freebsd-arm64": "0.24.2",
|
|
||||||
"@esbuild/freebsd-x64": "0.24.2",
|
|
||||||
"@esbuild/linux-arm": "0.24.2",
|
|
||||||
"@esbuild/linux-arm64": "0.24.2",
|
|
||||||
"@esbuild/linux-ia32": "0.24.2",
|
|
||||||
"@esbuild/linux-loong64": "0.24.2",
|
|
||||||
"@esbuild/linux-mips64el": "0.24.2",
|
|
||||||
"@esbuild/linux-ppc64": "0.24.2",
|
|
||||||
"@esbuild/linux-riscv64": "0.24.2",
|
|
||||||
"@esbuild/linux-s390x": "0.24.2",
|
|
||||||
"@esbuild/linux-x64": "0.24.2",
|
|
||||||
"@esbuild/netbsd-arm64": "0.24.2",
|
|
||||||
"@esbuild/netbsd-x64": "0.24.2",
|
|
||||||
"@esbuild/openbsd-arm64": "0.24.2",
|
|
||||||
"@esbuild/openbsd-x64": "0.24.2",
|
|
||||||
"@esbuild/sunos-x64": "0.24.2",
|
|
||||||
"@esbuild/win32-arm64": "0.24.2",
|
|
||||||
"@esbuild/win32-ia32": "0.24.2",
|
|
||||||
"@esbuild/win32-x64": "0.24.2"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/style-mod": {
|
|
||||||
"version": "4.1.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/style-mod/-/style-mod-4.1.3.tgz",
|
|
||||||
"integrity": "sha512-i/n8VsZydrugj3Iuzll8+x/00GH2vnYsk1eomD8QiRrSAeW6ItbCQDtfXCeJHd0iwiNagqjQkvpvREEPtW3IoQ==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/w3c-keyname": {
|
|
||||||
"version": "2.2.8",
|
|
||||||
"resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz",
|
|
||||||
"integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "datascape-editor-build",
|
|
||||||
"private": true,
|
|
||||||
"version": "1.0.0",
|
|
||||||
"description": "One-time build tooling for the vendored CodeMirror 6 editor bundle. Dev-only: `go build` never runs Node, it only consumes the committed bundle artifact.",
|
|
||||||
"type": "module",
|
|
||||||
"scripts": {
|
|
||||||
"build": "node build.mjs"
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
|
||||||
"@codemirror/autocomplete": "^6.18.0",
|
|
||||||
"@codemirror/commands": "^6.7.0",
|
|
||||||
"@codemirror/lang-markdown": "^6.3.0",
|
|
||||||
"@codemirror/language": "^6.10.0",
|
|
||||||
"@codemirror/state": "^6.4.0",
|
|
||||||
"@codemirror/view": "^6.34.0",
|
|
||||||
"@lezer/highlight": "^1.2.0",
|
|
||||||
"esbuild": "^0.24.0"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-504
@@ -1,504 +0,0 @@
|
|||||||
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),
|
|
||||||
}}
|
|
||||||
}
|
|
||||||
@@ -1,10 +1,7 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
|
||||||
"crypto/sha256"
|
|
||||||
"embed"
|
"embed"
|
||||||
"encoding/hex"
|
|
||||||
"flag"
|
"flag"
|
||||||
"html/template"
|
"html/template"
|
||||||
"io/fs"
|
"io/fs"
|
||||||
@@ -15,61 +12,31 @@ import (
|
|||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
//go:embed assets
|
//go:embed assets
|
||||||
var assets embed.FS
|
var assets embed.FS
|
||||||
|
|
||||||
// editorBundleVersion is a short content hash of the vendored CodeMirror bundle,
|
|
||||||
// appended as ?v=… to its <script> src. The bundle is served immutable under a
|
|
||||||
// stable filename, so without this query a rebuilt bundle would never reach a
|
|
||||||
// client that already cached the old one (this is the editor cache-bust knob).
|
|
||||||
var editorBundleVersion = hashAsset("assets/editor/vendor/codemirror.bundle.js")
|
|
||||||
|
|
||||||
func hashAsset(name string) string {
|
|
||||||
b, err := assets.ReadFile(name)
|
|
||||||
if err != nil {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
sum := sha256.Sum256(b)
|
|
||||||
return hex.EncodeToString(sum[:])[:12]
|
|
||||||
}
|
|
||||||
|
|
||||||
var (
|
var (
|
||||||
pageTmpl = template.Must(template.ParseFS(assets, "assets/layout.html", "assets/page/main.html"))
|
pageTmpl = template.Must(template.ParseFS(assets, "assets/layout.html", "assets/page.html"))
|
||||||
editTmpl = template.Must(template.New("edit").Funcs(template.FuncMap{
|
editTmpl = template.Must(template.ParseFS(assets, "assets/layout.html", "assets/edit.html"))
|
||||||
"editorBundleVersion": func() string { return editorBundleVersion },
|
searchTmpl = template.Must(template.ParseFS(assets, "assets/layout.html", "assets/search.html"))
|
||||||
}).ParseFS(assets, "assets/layout.html", "assets/editor/main.html"))
|
|
||||||
searchTmpl = template.Must(template.ParseFS(assets, "assets/layout.html", "assets/search/main.html"))
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// specialPage is the result returned by a pageTypeHandler.
|
// specialPage is the result returned by a pageTypeHandler.
|
||||||
// Content is injected into the page after the standard markdown content.
|
// Content is injected into the page after the standard markdown content.
|
||||||
// SuppressContent hides the markdown-rendered content (handler owns rendering).
|
|
||||||
// SuppressListing hides the default file/folder listing.
|
// SuppressListing hides the default file/folder listing.
|
||||||
// Widget is a persistent sidebar widget rendered outside the main content area.
|
// Widget is a persistent sidebar widget rendered outside the main content area.
|
||||||
type specialPage struct {
|
type specialPage struct {
|
||||||
Content template.HTML
|
Content template.HTML
|
||||||
SuppressContent bool
|
|
||||||
SuppressListing bool
|
SuppressListing bool
|
||||||
SuppressTOC bool
|
|
||||||
Widget template.HTML
|
Widget template.HTML
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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. The
|
// handle returns nil when the handler does not apply to the given path.
|
||||||
// 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
|
|
||||||
// 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).
|
|
||||||
//
|
|
||||||
// When adding a new hook, prefer a sibling method here over folding logic
|
|
||||||
// into main.go or render.go.
|
|
||||||
type pageTypeHandler interface {
|
type pageTypeHandler interface {
|
||||||
handle(root, fsPath, urlPath string, r *http.Request) *specialPage
|
handle(root, fsPath, urlPath string) *specialPage
|
||||||
redirect(root, fsPath, urlPath string, r *http.Request) (target string, ok bool)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// pageTypeHandlers is the registry. Each type registers itself via init().
|
// pageTypeHandlers is the registry. Each type registers itself via init().
|
||||||
@@ -81,7 +48,6 @@ func main() {
|
|||||||
cacheDir := flag.String("cache", "./cache", "thumbnail cache directory")
|
cacheDir := flag.String("cache", "./cache", "thumbnail cache directory")
|
||||||
user := flag.String("user", "", "basic auth username (empty = no auth)")
|
user := flag.String("user", "", "basic auth username (empty = no auth)")
|
||||||
pass := flag.String("pass", "", "basic auth password")
|
pass := flag.String("pass", "", "basic auth password")
|
||||||
reindexInterval := flag.Duration("reindex-interval", 30*time.Minute, "periodic search index rebuild interval (0 disables)")
|
|
||||||
flag.Parse()
|
flag.Parse()
|
||||||
|
|
||||||
root, err := filepath.Abs(*wikiDir)
|
root, err := filepath.Abs(*wikiDir)
|
||||||
@@ -111,41 +77,14 @@ func main() {
|
|||||||
staticFS, _ := fs.Sub(assets, "assets")
|
staticFS, _ := fs.Sub(assets, "assets")
|
||||||
static := http.StripPrefix("/_/", http.FileServer(http.FS(staticFS)))
|
static := http.StripPrefix("/_/", http.FileServer(http.FS(staticFS)))
|
||||||
http.Handle("/_/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
http.Handle("/_/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
if strings.HasPrefix(r.URL.Path, "/_/fonts/") || strings.HasPrefix(r.URL.Path, "/_/editor/vendor/") {
|
if strings.HasPrefix(r.URL.Path, "/_/fonts/") {
|
||||||
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
|
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
|
||||||
}
|
}
|
||||||
static.ServeHTTP(w, r)
|
static.ServeHTTP(w, r)
|
||||||
}))
|
}))
|
||||||
http.HandleFunc("/_logout", h.handleLogout)
|
http.HandleFunc("/_logout", h.handleLogout)
|
||||||
http.HandleFunc("/_reindex", h.handleReindex)
|
|
||||||
http.HandleFunc("/_search", h.handleSearchSuggest)
|
|
||||||
http.HandleFunc("/quickadd", h.handleQuickAdd)
|
|
||||||
http.Handle("/", h)
|
http.Handle("/", h)
|
||||||
|
|
||||||
// Build the folder index off the request path so the listener can start
|
|
||||||
// accepting connections immediately. searchWiki blocks on folderIndex.ready
|
|
||||||
// so the first search after a cold start still returns correct results.
|
|
||||||
go func() {
|
|
||||||
folderIndex.buildMu.Lock()
|
|
||||||
entries := buildFolderIndex(root)
|
|
||||||
folderIndex.Lock()
|
|
||||||
folderIndex.entries = entries
|
|
||||||
folderIndex.builtAt = time.Now()
|
|
||||||
folderIndex.Unlock()
|
|
||||||
folderIndex.buildMu.Unlock()
|
|
||||||
close(folderIndex.ready)
|
|
||||||
}()
|
|
||||||
|
|
||||||
if *reindexInterval > 0 {
|
|
||||||
go func(interval time.Duration) {
|
|
||||||
t := time.NewTicker(interval)
|
|
||||||
defer t.Stop()
|
|
||||||
for range t.C {
|
|
||||||
rebuildFolderIndex(root)
|
|
||||||
}
|
|
||||||
}(*reindexInterval)
|
|
||||||
}
|
|
||||||
|
|
||||||
log.Printf("datascape listening on %s, wiki at %s", *addr, root)
|
log.Printf("datascape listening on %s, wiki at %s", *addr, root)
|
||||||
log.Fatal(http.ListenAndServe(*addr, nil))
|
log.Fatal(http.ListenAndServe(*addr, nil))
|
||||||
}
|
}
|
||||||
@@ -155,23 +94,7 @@ type handler struct {
|
|||||||
authKey []byte
|
authKey []byte
|
||||||
}
|
}
|
||||||
|
|
||||||
// reqStartKey marks the request start time stored in the request context
|
|
||||||
// so HTML templates can render total server-side processing time.
|
|
||||||
type reqStartKeyT struct{}
|
|
||||||
|
|
||||||
var reqStartKey = reqStartKeyT{}
|
|
||||||
|
|
||||||
// elapsedMS returns the milliseconds since the request entered ServeHTTP.
|
|
||||||
func elapsedMS(r *http.Request) int64 {
|
|
||||||
if start, ok := r.Context().Value(reqStartKey).(time.Time); ok {
|
|
||||||
return time.Since(start).Milliseconds()
|
|
||||||
}
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
func (h *handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||||
r = r.WithContext(context.WithValue(r.Context(), reqStartKey, time.Now()))
|
|
||||||
|
|
||||||
if !h.checkAuth(w, r) {
|
if !h.checkAuth(w, r) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -237,50 +160,36 @@ func (h *handler) serveDir(w http.ResponseWriter, r *http.Request, urlPath, fsPa
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, ph := range pageTypeHandlers {
|
|
||||||
if target, ok := ph.redirect(h.root, fsPath, urlPath, r); ok {
|
|
||||||
http.Redirect(w, r, target, http.StatusFound)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
indexPath := filepath.Join(fsPath, "index.md")
|
indexPath := filepath.Join(fsPath, "index.md")
|
||||||
rawMD, _ := os.ReadFile(indexPath)
|
rawMD, _ := os.ReadFile(indexPath)
|
||||||
|
|
||||||
// Determine section index (-1 = whole page).
|
// Determine section index (-1 = whole page).
|
||||||
sectionIndex := -1
|
sectionIndex := -1
|
||||||
insertBefore := -1
|
|
||||||
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 {
|
||||||
sectionIndex = n
|
sectionIndex = n
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if s := r.URL.Query().Get("insert_before"); s != "" {
|
}
|
||||||
if n, err := strconv.Atoi(s); err == nil && n >= 0 {
|
|
||||||
insertBefore = n
|
var rendered template.HTML
|
||||||
}
|
if len(rawMD) > 0 && !editMode {
|
||||||
}
|
rendered = renderMarkdown(rawMD)
|
||||||
}
|
}
|
||||||
|
|
||||||
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, r); special != nil {
|
if special = ph.handle(h.root, fsPath, urlPath); special != nil {
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var rendered template.HTML
|
|
||||||
if len(rawMD) > 0 && !editMode && (special == nil || !special.SuppressContent) {
|
|
||||||
rendered = renderMarkdown(rawMD)
|
|
||||||
}
|
|
||||||
|
|
||||||
view, sortKey, order := readPageSettings(fsPath).viewSettings()
|
|
||||||
var entries []entry
|
var entries []entry
|
||||||
if !editMode && (special == nil || !special.SuppressListing) {
|
if !editMode && (special == nil || !special.SuppressListing) {
|
||||||
entries = listEntries(fsPath, urlPath, sortKey, order)
|
entries = listEntries(fsPath, urlPath)
|
||||||
}
|
}
|
||||||
|
|
||||||
title := pageTitle(urlPath)
|
title := pageTitle(urlPath)
|
||||||
@@ -290,26 +199,13 @@ func (h *handler) serveDir(w http.ResponseWriter, r *http.Request, urlPath, fsPa
|
|||||||
|
|
||||||
var specialContent template.HTML
|
var specialContent template.HTML
|
||||||
var sidebarWidget template.HTML
|
var sidebarWidget template.HTML
|
||||||
suppressTOC := false
|
|
||||||
if special != nil {
|
if special != nil {
|
||||||
specialContent = special.Content
|
specialContent = special.Content
|
||||||
sidebarWidget = special.Widget
|
sidebarWidget = special.Widget
|
||||||
suppressTOC = special.SuppressTOC
|
|
||||||
}
|
}
|
||||||
|
|
||||||
rawContent := string(rawMD)
|
rawContent := string(rawMD)
|
||||||
if editMode && insertBefore >= 0 {
|
if editMode && sectionIndex >= 0 {
|
||||||
heading := r.URL.Query().Get("heading")
|
|
||||||
level := r.URL.Query().Get("level")
|
|
||||||
if level == "" {
|
|
||||||
level = "###"
|
|
||||||
}
|
|
||||||
if heading != "" {
|
|
||||||
rawContent = level + " " + heading + "\n\n"
|
|
||||||
} else {
|
|
||||||
rawContent = ""
|
|
||||||
}
|
|
||||||
} else if editMode && sectionIndex >= 0 {
|
|
||||||
sections := splitSections(rawMD)
|
sections := splitSections(rawMD)
|
||||||
if sectionIndex < len(sections) {
|
if sectionIndex < len(sections) {
|
||||||
rawContent = string(sections[sectionIndex])
|
rawContent = string(sections[sectionIndex])
|
||||||
@@ -318,28 +214,19 @@ func (h *handler) serveDir(w http.ResponseWriter, r *http.Request, urlPath, fsPa
|
|||||||
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,
|
Crumbs: buildCrumbs(urlPath),
|
||||||
CanEdit: true,
|
CanEdit: true,
|
||||||
EditMode: editMode,
|
EditMode: editMode,
|
||||||
IsRoot: urlPath == "/",
|
IsRoot: urlPath == "/",
|
||||||
SectionIndex: sectionIndex,
|
SectionIndex: sectionIndex,
|
||||||
InsertBefore: insertBefore,
|
|
||||||
PostURL: urlPath,
|
PostURL: urlPath,
|
||||||
RawContent: rawContent,
|
RawContent: rawContent,
|
||||||
Content: rendered,
|
Content: rendered,
|
||||||
Entries: entries,
|
Entries: entries,
|
||||||
View: view,
|
|
||||||
Sort: sortKey,
|
|
||||||
Order: order,
|
|
||||||
SpecialContent: specialContent,
|
SpecialContent: specialContent,
|
||||||
SidebarWidget: sidebarWidget,
|
SidebarWidget: sidebarWidget,
|
||||||
SuppressTOC: suppressTOC,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
@@ -347,7 +234,6 @@ func (h *handler) serveDir(w http.ResponseWriter, r *http.Request, urlPath, fsPa
|
|||||||
if editMode {
|
if editMode {
|
||||||
t = editTmpl
|
t = editTmpl
|
||||||
}
|
}
|
||||||
data.RenderMS = elapsedMS(r)
|
|
||||||
if err := t.ExecuteTemplate(w, "layout", data); err != nil {
|
if err := t.ExecuteTemplate(w, "layout", data); err != nil {
|
||||||
log.Printf("template error: %v", err)
|
log.Printf("template error: %v", err)
|
||||||
}
|
}
|
||||||
@@ -360,27 +246,7 @@ func (h *handler) handlePost(w http.ResponseWriter, r *http.Request, urlPath, fs
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
if _, ok := query["move"]; ok {
|
if _, ok := query["move"]; ok {
|
||||||
h.handleMove(w, r, urlPath, fsPath, query.Get("move"), query.Has("links"))
|
h.handleMove(w, r, urlPath, fsPath, query.Get("move"))
|
||||||
return
|
|
||||||
}
|
|
||||||
if query.Has("toggle") {
|
|
||||||
h.handleToggle(w, r, fsPath)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if query.Has("append") {
|
|
||||||
h.handleAppend(w, r, urlPath, fsPath)
|
|
||||||
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") {
|
|
||||||
h.handleSettings(w, r, urlPath, fsPath)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -392,33 +258,9 @@ 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
|
// If a section index was submitted, splice the edited section back into
|
||||||
// than replacing index N (used by the diary "create new day" flow).
|
// the full file rather than replacing the whole document.
|
||||||
// section replaces the section at index N (used by per-section edits).
|
if s := r.FormValue("section"); s != "" {
|
||||||
// Exactly one of insert_before / section should be set; insert_before
|
|
||||||
// wins if both are present.
|
|
||||||
if s := r.FormValue("insert_before"); s != "" {
|
|
||||||
insertIndex, err := strconv.Atoi(s)
|
|
||||||
if err != nil || insertIndex < 0 {
|
|
||||||
http.Error(w, "bad insert_before", http.StatusBadRequest)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
rawMD, _ := os.ReadFile(indexPath)
|
|
||||||
sections := splitSections(rawMD)
|
|
||||||
if insertIndex > len(sections) {
|
|
||||||
insertIndex = len(sections)
|
|
||||||
}
|
|
||||||
newSection := []byte(content)
|
|
||||||
inserted := make([][]byte, 0, len(sections)+1)
|
|
||||||
inserted = append(inserted, sections[:insertIndex]...)
|
|
||||||
inserted = append(inserted, newSection)
|
|
||||||
inserted = append(inserted, sections[insertIndex:]...)
|
|
||||||
content = string(joinSections(inserted))
|
|
||||||
ids := headingIDs([]byte(content))
|
|
||||||
if insertIndex-1 >= 0 && insertIndex-1 < len(ids) {
|
|
||||||
redirectTarget = urlPath + "#" + ids[insertIndex-1]
|
|
||||||
}
|
|
||||||
} else if s := r.FormValue("section"); s != "" {
|
|
||||||
sectionIndex, err := strconv.Atoi(s)
|
sectionIndex, err := strconv.Atoi(s)
|
||||||
if err != nil || sectionIndex < 0 {
|
if err != nil || sectionIndex < 0 {
|
||||||
http.Error(w, "bad section", http.StatusBadRequest)
|
http.Error(w, "bad section", http.StatusBadRequest)
|
||||||
@@ -447,10 +289,6 @@ func (h *handler) handlePost(w http.ResponseWriter, r *http.Request, urlPath, fs
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Stat first so we know whether MkdirAll actually created the folder
|
|
||||||
// — if it did, the search index needs a new entry.
|
|
||||||
_, statErr := os.Stat(fsPath)
|
|
||||||
newlyCreated := os.IsNotExist(statErr)
|
|
||||||
if err := os.MkdirAll(fsPath, 0755); err != nil {
|
if err := os.MkdirAll(fsPath, 0755); err != nil {
|
||||||
http.Error(w, "mkdir failed: "+err.Error(), http.StatusInternalServerError)
|
http.Error(w, "mkdir failed: "+err.Error(), http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
@@ -459,11 +297,6 @@ func (h *handler) handlePost(w http.ResponseWriter, r *http.Request, urlPath, fs
|
|||||||
http.Error(w, "write failed: "+err.Error(), http.StatusInternalServerError)
|
http.Error(w, "write failed: "+err.Error(), http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if newlyCreated {
|
|
||||||
if rel, err := filepath.Rel(h.root, fsPath); err == nil {
|
|
||||||
folderIndexAdd(filepath.ToSlash(rel))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
http.Redirect(w, r, redirectTarget, http.StatusSeeOther)
|
http.Redirect(w, r, redirectTarget, http.StatusSeeOther)
|
||||||
}
|
}
|
||||||
@@ -476,8 +309,7 @@ func readPageSettings(dir string) *pageSettings {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
// Defaults; overridden only by valid values present in the file.
|
s := &pageSettings{}
|
||||||
s := &pageSettings{View: viewList, Sort: sortName, Order: orderAsc}
|
|
||||||
for _, line := range strings.Split(string(data), "\n") {
|
for _, line := range strings.Split(string(data), "\n") {
|
||||||
line = strings.TrimSpace(line)
|
line = strings.TrimSpace(line)
|
||||||
if line == "" || strings.HasPrefix(line, "#") {
|
if line == "" || strings.HasPrefix(line, "#") {
|
||||||
@@ -487,16 +319,9 @@ func readPageSettings(dir string) *pageSettings {
|
|||||||
if len(parts) != 2 {
|
if len(parts) != 2 {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
value := strings.TrimSpace(parts[1])
|
|
||||||
switch strings.TrimSpace(parts[0]) {
|
switch strings.TrimSpace(parts[0]) {
|
||||||
case "type":
|
case "type":
|
||||||
s.Type = value
|
s.Type = strings.TrimSpace(parts[1])
|
||||||
case "view":
|
|
||||||
s.View = validateView(value)
|
|
||||||
case "sort":
|
|
||||||
s.Sort = validateSort(value)
|
|
||||||
case "order":
|
|
||||||
s.Order = validateOrder(value)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return s
|
return s
|
||||||
|
|||||||
@@ -10,11 +10,10 @@ import (
|
|||||||
"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 and
|
||||||
// updateLinks is true it also rewrites every [[...]] wiki link across the
|
// rewrites every [[...]] wiki link across the tree that targets the old path
|
||||||
// tree that targets the old path or any descendant; rewritten files are held
|
// or any descendant. All rewritten files are held in memory for rollback.
|
||||||
// in memory for rollback.
|
func (h *handler) handleMove(w http.ResponseWriter, r *http.Request, srcURL, srcFsPath, dstURL string) {
|
||||||
func (h *handler) handleMove(w http.ResponseWriter, r *http.Request, srcURL, srcFsPath, dstURL string, updateLinks bool) {
|
|
||||||
oldPath := normalizeMovePath(srcURL)
|
oldPath := normalizeMovePath(srcURL)
|
||||||
if oldPath == "/" {
|
if oldPath == "/" {
|
||||||
http.Error(w, "cannot move wiki root", http.StatusBadRequest)
|
http.Error(w, "cannot move wiki root", http.StatusBadRequest)
|
||||||
@@ -45,38 +44,35 @@ func (h *handler) handleMove(w http.ResponseWriter, r *http.Request, srcURL, src
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Phase 1: optionally walk the tree and rewrite every index.md that
|
// Phase 1: walk the tree and rewrite every index.md that references the
|
||||||
// references the moved path. Keep the pre-rewrite bytes in memory so we
|
// moved path. Keep the pre-rewrite bytes in memory so we can revert on
|
||||||
// can revert on failure. The walker only reads directory listings and
|
// failure. The walker only reads directory listings and files literally
|
||||||
// files literally named index.md; hidden directories are pruned. A cheap
|
// named index.md; hidden directories are pruned. A cheap substring check
|
||||||
// substring check skips parsing files that cannot contain a relevant
|
// skips parsing files that cannot contain a relevant link.
|
||||||
// link.
|
|
||||||
rewritten := map[string][]byte{}
|
rewritten := map[string][]byte{}
|
||||||
if updateLinks {
|
needle := []byte("[[" + oldPath)
|
||||||
needle := []byte("[[" + oldPath)
|
walkErr := walkIndexFiles(h.root, func(fsPath string) error {
|
||||||
walkErr := walkIndexFiles(h.root, func(fsPath string) error {
|
orig, err := os.ReadFile(fsPath)
|
||||||
orig, err := os.ReadFile(fsPath)
|
if err != nil {
|
||||||
if err != nil {
|
return err
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
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
|
||||||
}
|
}
|
||||||
|
|
||||||
// Phase 2: create intermediate parent folders for the destination.
|
// Phase 2: create intermediate parent folders for the destination.
|
||||||
@@ -94,7 +90,6 @@ func (h *handler) handleMove(w http.ResponseWriter, r *http.Request, srcURL, src
|
|||||||
http.Error(w, "rename failed: "+err.Error(), http.StatusInternalServerError)
|
http.Error(w, "rename failed: "+err.Error(), http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
folderIndexRenameSubtree(strings.TrimPrefix(oldPath, "/"), strings.TrimPrefix(newPath, "/"))
|
|
||||||
|
|
||||||
http.Redirect(w, r, wikiTargetHref(newPath), http.StatusSeeOther)
|
http.Redirect(w, r, wikiTargetHref(newPath), http.StatusSeeOther)
|
||||||
}
|
}
|
||||||
@@ -114,7 +109,6 @@ func (h *handler) handleDelete(w http.ResponseWriter, r *http.Request, urlPath,
|
|||||||
http.Error(w, "delete failed: "+err.Error(), http.StatusInternalServerError)
|
http.Error(w, "delete failed: "+err.Error(), http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
folderIndexRemoveSubtree(strings.TrimPrefix(normalizeMovePath(urlPath), "/"))
|
|
||||||
http.Redirect(w, r, parentURL(urlPath), http.StatusSeeOther)
|
http.Redirect(w, r, parentURL(urlPath), http.StatusSeeOther)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -153,7 +147,7 @@ func validateAndNormalizeNewPath(raw string) (string, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// rewriteWikiLinks returns (newContent, changed). Any [[target]] or
|
// rewriteWikiLinks returns (newContent, changed). Any [[target]] or
|
||||||
// [[target::display]] whose target equals oldPath or begins with oldPath+"/"
|
// [[target|display]] whose target equals oldPath or begins with oldPath+"/"
|
||||||
// has its target rewritten to the corresponding position under newPath.
|
// has its target rewritten to the corresponding position under newPath.
|
||||||
func rewriteWikiLinks(content []byte, oldPath, newPath string) ([]byte, bool) {
|
func rewriteWikiLinks(content []byte, oldPath, newPath string) ([]byte, bool) {
|
||||||
changed := false
|
changed := false
|
||||||
@@ -176,7 +170,7 @@ func rewriteWikiLinks(content []byte, oldPath, newPath string) ([]byte, bool) {
|
|||||||
changed = true
|
changed = true
|
||||||
suffix := ""
|
suffix := ""
|
||||||
if len(parts[2]) > 0 {
|
if len(parts[2]) > 0 {
|
||||||
suffix = "::" + string(parts[2])
|
suffix = string(parts[2])
|
||||||
}
|
}
|
||||||
return []byte("[[" + newTarget + suffix + "]]")
|
return []byte("[[" + newTarget + suffix + "]]")
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,89 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"net/http"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
|
||||||
)
|
|
||||||
|
|
||||||
// handleSettings persists the listing view/sort/order to the folder's
|
|
||||||
// .page-settings file. Values are validated against the allowed sets (unknown
|
|
||||||
// values fall back to defaults). Triggered by POST /{path}?settings.
|
|
||||||
func (h *handler) handleSettings(w http.ResponseWriter, r *http.Request, urlPath, fsPath string) {
|
|
||||||
if err := r.ParseForm(); err != nil {
|
|
||||||
http.Error(w, "bad request", http.StatusBadRequest)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
view := validateView(r.FormValue("view"))
|
|
||||||
sortKey := validateSort(r.FormValue("sort"))
|
|
||||||
order := validateOrder(r.FormValue("order"))
|
|
||||||
|
|
||||||
if err := writePageSettings(fsPath, view, sortKey, order); err != nil {
|
|
||||||
http.Error(w, "write failed: "+err.Error(), http.StatusInternalServerError)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
http.Redirect(w, r, urlPath, http.StatusSeeOther)
|
|
||||||
}
|
|
||||||
|
|
||||||
// writePageSettings performs a read-modify-write of <dir>/.page-settings,
|
|
||||||
// updating the view/sort/order lines while preserving every other line
|
|
||||||
// (other keys, comments, blank lines, ordering) verbatim. Missing keys are
|
|
||||||
// appended. The write is atomic (temp file + rename).
|
|
||||||
func writePageSettings(dir, view, sortKey, order string) error {
|
|
||||||
if err := os.MkdirAll(dir, 0755); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
p := filepath.Join(dir, ".page-settings")
|
|
||||||
existing, err := os.ReadFile(p)
|
|
||||||
if err != nil && !os.IsNotExist(err) {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
updated := updateSettingsLines(existing, view, sortKey, order)
|
|
||||||
return writeFileAtomic(p, updated, 0644)
|
|
||||||
}
|
|
||||||
|
|
||||||
// updateSettingsLines rewrites the view/sort/order lines in existing while
|
|
||||||
// leaving all other lines untouched. Every occurrence of a known key is
|
|
||||||
// updated (so the reader's last-wins parse stays consistent); keys absent from
|
|
||||||
// the file are appended in a stable order. The result always ends in a newline.
|
|
||||||
func updateSettingsLines(existing []byte, view, sortKey, order string) []byte {
|
|
||||||
targets := map[string]string{"view": view, "sort": sortKey, "order": order}
|
|
||||||
appendOrder := []string{"view", "sort", "order"}
|
|
||||||
seen := map[string]bool{}
|
|
||||||
|
|
||||||
var lines []string
|
|
||||||
if len(existing) > 0 {
|
|
||||||
s := string(existing)
|
|
||||||
s = strings.TrimSuffix(s, "\n")
|
|
||||||
lines = strings.Split(s, "\n")
|
|
||||||
}
|
|
||||||
|
|
||||||
for i, line := range lines {
|
|
||||||
trimmed := strings.TrimSpace(line)
|
|
||||||
if trimmed == "" || strings.HasPrefix(trimmed, "#") {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
eq := strings.IndexByte(line, '=')
|
|
||||||
if eq < 0 {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
key := strings.TrimSpace(line[:eq])
|
|
||||||
if val, ok := targets[key]; ok {
|
|
||||||
lines[i] = key + " = " + val
|
|
||||||
seen[key] = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, k := range appendOrder {
|
|
||||||
if !seen[k] {
|
|
||||||
lines = append(lines, k+" = "+targets[k])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
out := strings.Join(lines, "\n")
|
|
||||||
if out != "" {
|
|
||||||
out += "\n"
|
|
||||||
}
|
|
||||||
return []byte(out)
|
|
||||||
}
|
|
||||||
-134
@@ -1,134 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"html/template"
|
|
||||||
"log"
|
|
||||||
"net/http"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
var quickAddTmpl = template.Must(template.ParseFS(assets, "assets/quickadd.html"))
|
|
||||||
|
|
||||||
type quickAddData struct {
|
|
||||||
To, URL, Title string
|
|
||||||
}
|
|
||||||
|
|
||||||
// handleQuickAdd serves the bookmarklet popup at /quickadd.
|
|
||||||
func (h *handler) handleQuickAdd(w http.ResponseWriter, r *http.Request) {
|
|
||||||
if !h.checkAuth(w, r) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if r.Method != http.MethodGet {
|
|
||||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
q := r.URL.Query()
|
|
||||||
to := strings.TrimSpace(q.Get("to"))
|
|
||||||
if to == "" {
|
|
||||||
http.Error(w, "missing to", http.StatusBadRequest)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if !strings.HasPrefix(to, "/") {
|
|
||||||
to = "/" + to
|
|
||||||
}
|
|
||||||
data := quickAddData{
|
|
||||||
To: to,
|
|
||||||
URL: q.Get("url"),
|
|
||||||
Title: q.Get("title"),
|
|
||||||
}
|
|
||||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
||||||
if err := quickAddTmpl.Execute(w, data); err != nil {
|
|
||||||
log.Printf("quickadd template: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// handleAppend appends one link entry to index.md at fsPath. Creates the
|
|
||||||
// folder and index.md if missing. Body is form-encoded with `url` (required),
|
|
||||||
// `title` and `comment` (both optional).
|
|
||||||
func (h *handler) handleAppend(w http.ResponseWriter, r *http.Request, urlPath, fsPath string) {
|
|
||||||
if err := r.ParseForm(); err != nil {
|
|
||||||
http.Error(w, "bad request", http.StatusBadRequest)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
rawURL := strings.TrimSpace(r.FormValue("url"))
|
|
||||||
title := strings.TrimSpace(r.FormValue("title"))
|
|
||||||
comment := strings.TrimSpace(r.FormValue("comment"))
|
|
||||||
if rawURL == "" {
|
|
||||||
http.Error(w, "missing url", http.StatusBadRequest)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if title == "" {
|
|
||||||
title = rawURL
|
|
||||||
}
|
|
||||||
|
|
||||||
entry := formatAppendEntry(title, rawURL, comment, time.Now())
|
|
||||||
|
|
||||||
_, statErr := os.Stat(fsPath)
|
|
||||||
newlyCreated := os.IsNotExist(statErr)
|
|
||||||
if err := os.MkdirAll(fsPath, 0755); err != nil {
|
|
||||||
http.Error(w, "mkdir failed: "+err.Error(), http.StatusInternalServerError)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
indexPath := filepath.Join(fsPath, "index.md")
|
|
||||||
existing, err := os.ReadFile(indexPath)
|
|
||||||
if err != nil && !os.IsNotExist(err) {
|
|
||||||
http.Error(w, "read failed: "+err.Error(), http.StatusInternalServerError)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
var buf bytes.Buffer
|
|
||||||
if len(existing) > 0 {
|
|
||||||
buf.Write(existing)
|
|
||||||
if existing[len(existing)-1] != '\n' {
|
|
||||||
buf.WriteByte('\n')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
buf.WriteString(entry)
|
|
||||||
|
|
||||||
if err := os.WriteFile(indexPath, buf.Bytes(), 0644); err != nil {
|
|
||||||
http.Error(w, "write failed: "+err.Error(), http.StatusInternalServerError)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if newlyCreated {
|
|
||||||
if rel, err := filepath.Rel(h.root, fsPath); err == nil {
|
|
||||||
folderIndexAdd(filepath.ToSlash(rel))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
w.WriteHeader(http.StatusNoContent)
|
|
||||||
}
|
|
||||||
|
|
||||||
// formatAppendEntry builds a CommonMark multi-line list item: a link with a
|
|
||||||
// continuation-indented timestamp line and an optional comment line. Lines
|
|
||||||
// after the first share an indent so goldmark folds them into one paragraph.
|
|
||||||
func formatAppendEntry(title, rawURL, comment string, ts time.Time) string {
|
|
||||||
var b strings.Builder
|
|
||||||
b.WriteString("- [")
|
|
||||||
b.WriteString(escapeLinkLabel(title))
|
|
||||||
b.WriteString("](")
|
|
||||||
b.WriteString(rawURL)
|
|
||||||
b.WriteString(")</br>")
|
|
||||||
b.WriteString(ts.Format("2006-01-02 15:04"))
|
|
||||||
b.WriteString("</br>")
|
|
||||||
if comment != "" {
|
|
||||||
b.WriteString(" ")
|
|
||||||
b.WriteString(comment)
|
|
||||||
b.WriteByte('\n')
|
|
||||||
}
|
|
||||||
return b.String()
|
|
||||||
}
|
|
||||||
|
|
||||||
// escapeLinkLabel backslash-escapes the brackets that would otherwise close
|
|
||||||
// the markdown link label early. The label text is rendered verbatim, so we
|
|
||||||
// keep all other characters as-is.
|
|
||||||
func escapeLinkLabel(s string) string {
|
|
||||||
s = strings.ReplaceAll(s, `\`, `\\`)
|
|
||||||
s = strings.ReplaceAll(s, `[`, `\[`)
|
|
||||||
s = strings.ReplaceAll(s, `]`, `\]`)
|
|
||||||
return s
|
|
||||||
}
|
|
||||||
@@ -4,12 +4,10 @@ import (
|
|||||||
"bytes"
|
"bytes"
|
||||||
"fmt"
|
"fmt"
|
||||||
"html/template"
|
"html/template"
|
||||||
"net/url"
|
|
||||||
"os"
|
"os"
|
||||||
"path"
|
"path"
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/yuin/goldmark"
|
"github.com/yuin/goldmark"
|
||||||
"github.com/yuin/goldmark/extension"
|
"github.com/yuin/goldmark/extension"
|
||||||
@@ -30,96 +28,33 @@ func initMarkdown(root string) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type crumb struct{ Name, URL string }
|
||||||
type entry struct {
|
type entry struct {
|
||||||
Icon template.HTML
|
Icon template.HTML
|
||||||
Name, URL, Meta string
|
Name, URL, Meta string
|
||||||
// ThumbURL is set for thumbnailable files; the thumbnail view renders an
|
|
||||||
// <img> when it is non-empty and falls back to Icon otherwise.
|
|
||||||
ThumbURL string
|
|
||||||
// modTime/size carry the raw sort keys; the template only reads the
|
|
||||||
// formatted Meta string.
|
|
||||||
modTime time.Time
|
|
||||||
size int64
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type pageData struct {
|
type pageData struct {
|
||||||
Title string
|
Title string
|
||||||
ParentURL string
|
Crumbs []crumb
|
||||||
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
|
|
||||||
PostURL string
|
PostURL string
|
||||||
RawContent string
|
RawContent string
|
||||||
Content template.HTML
|
Content template.HTML
|
||||||
Entries []entry
|
Entries []entry
|
||||||
View string // listing view style: "list" or "thumbnail"
|
|
||||||
Sort string // listing sort key: "name" / "modified" / "size"
|
|
||||||
Order string // listing sort order: "asc" / "desc"
|
|
||||||
SpecialContent template.HTML
|
SpecialContent template.HTML
|
||||||
SidebarWidget template.HTML
|
SidebarWidget template.HTML
|
||||||
SuppressTOC bool
|
|
||||||
RenderMS int64
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Allowed values for the listing view settings. Unknown values in the file or
|
// pageSettings holds the parsed contents of a .page-settings file.
|
||||||
// a POST body fall back to the first (default) value of each set.
|
|
||||||
const (
|
|
||||||
viewList = "list"
|
|
||||||
viewThumbnail = "thumbnail"
|
|
||||||
|
|
||||||
sortName = "name"
|
|
||||||
sortModified = "modified"
|
|
||||||
sortSize = "size"
|
|
||||||
|
|
||||||
orderAsc = "asc"
|
|
||||||
orderDesc = "desc"
|
|
||||||
)
|
|
||||||
|
|
||||||
// pageSettings holds the parsed contents of a .page-settings file. View, Sort,
|
|
||||||
// and Order are always valid once parsed (defaults applied on read).
|
|
||||||
type pageSettings struct {
|
type pageSettings struct {
|
||||||
Type string
|
Type string
|
||||||
View string
|
|
||||||
Sort string
|
|
||||||
Order string
|
|
||||||
}
|
|
||||||
|
|
||||||
// viewSettings returns the listing view/sort/order, applying defaults when the
|
|
||||||
// receiver is nil (no .page-settings file).
|
|
||||||
func (s *pageSettings) viewSettings() (view, sortKey, order string) {
|
|
||||||
if s == nil {
|
|
||||||
return viewList, sortName, orderAsc
|
|
||||||
}
|
|
||||||
return s.View, s.Sort, s.Order
|
|
||||||
}
|
|
||||||
|
|
||||||
func validateView(v string) string {
|
|
||||||
if v == viewThumbnail {
|
|
||||||
return viewThumbnail
|
|
||||||
}
|
|
||||||
return viewList
|
|
||||||
}
|
|
||||||
|
|
||||||
func validateSort(v string) string {
|
|
||||||
switch v {
|
|
||||||
case sortModified, sortSize:
|
|
||||||
return v
|
|
||||||
default:
|
|
||||||
return sortName
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func validateOrder(v string) string {
|
|
||||||
if v == orderDesc {
|
|
||||||
return orderDesc
|
|
||||||
}
|
|
||||||
return orderAsc
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var (
|
var (
|
||||||
iconUp = readIcon("up")
|
|
||||||
iconFolder = readIcon("folder")
|
iconFolder = readIcon("folder")
|
||||||
iconDoc = readIcon("doc")
|
iconDoc = readIcon("doc")
|
||||||
iconImage = readIcon("image")
|
iconImage = readIcon("image")
|
||||||
@@ -135,11 +70,7 @@ func renderMarkdown(raw []byte) template.HTML {
|
|||||||
if err := md.Convert(raw, &buf); err != nil {
|
if err := md.Convert(raw, &buf); err != nil {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
out := rewriteTaskCheckboxes(buf.Bytes())
|
return template.HTML(buf.String())
|
||||||
// Goldmark emits a bare `<table>`; tag it so it picks up the shared
|
|
||||||
// .data-table styling with the grid modifier (per-cell borders + header).
|
|
||||||
out = bytes.ReplaceAll(out, []byte("<table>"), []byte(`<table class="data-table data-table-grid">`))
|
|
||||||
return template.HTML(out)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// extractFirstHeading returns the text of the first ATX heading in raw markdown,
|
// extractFirstHeading returns the text of the first ATX heading in raw markdown,
|
||||||
@@ -179,7 +110,7 @@ func parentURL(urlPath string) string {
|
|||||||
return parent + "/"
|
return parent + "/"
|
||||||
}
|
}
|
||||||
|
|
||||||
func listEntries(fsPath, urlPath, sortKey, order string) []entry {
|
func listEntries(fsPath, urlPath string) []entry {
|
||||||
entries, err := os.ReadDir(fsPath)
|
entries, err := os.ReadDir(fsPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil
|
return nil
|
||||||
@@ -198,87 +129,28 @@ func listEntries(fsPath, urlPath, sortKey, order string) []entry {
|
|||||||
entryURL := path.Join(urlPath, name)
|
entryURL := path.Join(urlPath, name)
|
||||||
if e.IsDir() {
|
if e.IsDir() {
|
||||||
folders = append(folders, entry{
|
folders = append(folders, entry{
|
||||||
Icon: iconFolder,
|
Icon: iconFolder,
|
||||||
Name: name,
|
Name: name,
|
||||||
URL: entryURL + "/",
|
URL: entryURL + "/",
|
||||||
Meta: info.ModTime().Format("2006-01-02"),
|
Meta: info.ModTime().Format("2006-01-02"),
|
||||||
modTime: info.ModTime(),
|
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
if name == "index.md" {
|
if name == "index.md" {
|
||||||
continue // rendered above, don't list it
|
continue // rendered above, don't list it
|
||||||
}
|
}
|
||||||
f := entry{
|
files = append(files, entry{
|
||||||
Icon: fileIcon(name),
|
Icon: fileIcon(name),
|
||||||
Name: name,
|
Name: name,
|
||||||
URL: entryURL,
|
URL: entryURL,
|
||||||
Meta: formatSize(info.Size()) + " · " + info.ModTime().Format("2006-01-02"),
|
Meta: formatSize(info.Size()) + " · " + info.ModTime().Format("2006-01-02"),
|
||||||
modTime: info.ModTime(),
|
})
|
||||||
size: info.Size(),
|
|
||||||
}
|
|
||||||
if hasThumbnail(name) {
|
|
||||||
f.ThumbURL = thumbURL(path.Join(urlPath, url.PathEscape(name)), 300)
|
|
||||||
}
|
|
||||||
files = append(files, f)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Folders always sort by name regardless of the chosen key (they have no
|
sort.Slice(folders, func(i, j int) bool { return folders[i].Name < folders[j].Name })
|
||||||
// meaningful byte size); files honor the chosen key. The chosen order
|
sort.Slice(files, func(i, j int) bool { return files[i].Name < files[j].Name })
|
||||||
// applies to both groups.
|
|
||||||
sortEntries(folders, sortName, order)
|
|
||||||
sortEntries(files, sortKey, order)
|
|
||||||
|
|
||||||
// `..` row mirrors the header Up button so the listing itself is
|
return append(folders, files...)
|
||||||
// navigable without reaching for the header on mobile. Prepended after
|
|
||||||
// sort so it always sits at the top regardless of folder names.
|
|
||||||
var out []entry
|
|
||||||
if urlPath != "/" {
|
|
||||||
out = append(out, entry{
|
|
||||||
Icon: iconUp,
|
|
||||||
Name: "..",
|
|
||||||
URL: parentURL(urlPath),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
out = append(out, folders...)
|
|
||||||
out = append(out, files...)
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
// sortEntries sorts a single group (folders or files) in place by the given
|
|
||||||
// key, breaking ties on case-insensitive name, then reverses for descending
|
|
||||||
// order. The stable sort keeps the name tiebreak meaningful.
|
|
||||||
func sortEntries(group []entry, sortKey, order string) {
|
|
||||||
sort.SliceStable(group, func(i, j int) bool {
|
|
||||||
a, b := group[i], group[j]
|
|
||||||
cmp := 0
|
|
||||||
switch sortKey {
|
|
||||||
case sortModified:
|
|
||||||
if a.modTime.Before(b.modTime) {
|
|
||||||
cmp = -1
|
|
||||||
} else if a.modTime.After(b.modTime) {
|
|
||||||
cmp = 1
|
|
||||||
}
|
|
||||||
case sortSize:
|
|
||||||
if a.size < b.size {
|
|
||||||
cmp = -1
|
|
||||||
} else if a.size > b.size {
|
|
||||||
cmp = 1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if cmp == 0 {
|
|
||||||
an, bn := strings.ToLower(a.Name), strings.ToLower(b.Name)
|
|
||||||
if an < bn {
|
|
||||||
cmp = -1
|
|
||||||
} else if an > bn {
|
|
||||||
cmp = 1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if order == orderDesc {
|
|
||||||
return cmp > 0
|
|
||||||
}
|
|
||||||
return cmp < 0
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func readIcon(name string) template.HTML {
|
func readIcon(name string) template.HTML {
|
||||||
@@ -315,6 +187,21 @@ func formatSize(b int64) string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func buildCrumbs(urlPath string) []crumb {
|
||||||
|
if urlPath == "/" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
parts := strings.Split(strings.Trim(urlPath, "/"), "/")
|
||||||
|
crumbs := make([]crumb, len(parts))
|
||||||
|
for i, p := range parts {
|
||||||
|
crumbs[i] = crumb{
|
||||||
|
Name: p,
|
||||||
|
URL: "/" + strings.Join(parts[:i+1], "/") + "/",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return crumbs
|
||||||
|
}
|
||||||
|
|
||||||
func pageTitle(urlPath string) string {
|
func pageTitle(urlPath string) string {
|
||||||
if urlPath == "/" {
|
if urlPath == "/" {
|
||||||
return "Datascape"
|
return "Datascape"
|
||||||
|
|||||||
@@ -1,227 +1,72 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
|
||||||
"io/fs"
|
"io/fs"
|
||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
|
||||||
"time"
|
|
||||||
"unicode"
|
"unicode"
|
||||||
)
|
)
|
||||||
|
|
||||||
type searchResult struct {
|
type searchResult struct {
|
||||||
Name string
|
Name string
|
||||||
URL string
|
URL string
|
||||||
Path string
|
Path string
|
||||||
Score int
|
Score int // number of query tokens that hit
|
||||||
|
NameHit bool // at least one hit came from the folder name
|
||||||
|
Snippet string // ~300 chars around first body hit, or page stub for name-only hits
|
||||||
}
|
}
|
||||||
|
|
||||||
type searchPageData struct {
|
type searchPageData struct {
|
||||||
Title string
|
Title string
|
||||||
ParentURL string
|
Crumbs []crumb
|
||||||
EditMode bool
|
EditMode bool
|
||||||
Query string
|
Query string
|
||||||
Results []searchResult
|
Results []searchResult
|
||||||
IndexBuiltAt time.Time
|
|
||||||
RenderMS int64
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// folderEntry is a single indexed directory: its forward-slash relative path
|
// handleSearch walks the wiki root and renders a search results page for the
|
||||||
// plus pre-tokenized basename so the per-query scoring loop avoids redoing
|
// query in r.URL.Query().Get("q"). Only invoked when path is "/" and "q" is
|
||||||
// the lowercasing and tokenization on every keystroke.
|
// present.
|
||||||
type folderEntry struct {
|
|
||||||
Path string
|
|
||||||
NameLower string
|
|
||||||
NameTokens []string
|
|
||||||
}
|
|
||||||
|
|
||||||
// folderIndex holds the in-memory directory index used by search. Writers
|
|
||||||
// always replace the entries slice wholesale so a reader that snapshots the
|
|
||||||
// header under RLock can score without holding the lock.
|
|
||||||
var folderIndex struct {
|
|
||||||
sync.RWMutex
|
|
||||||
entries []folderEntry
|
|
||||||
builtAt time.Time
|
|
||||||
buildMu sync.Mutex
|
|
||||||
ready chan struct{}
|
|
||||||
}
|
|
||||||
|
|
||||||
func init() {
|
|
||||||
folderIndex.ready = make(chan struct{})
|
|
||||||
}
|
|
||||||
|
|
||||||
// handleSearch renders the search results page for the query in
|
|
||||||
// r.URL.Query().Get("q"). Only invoked when path is "/" and "q" is present.
|
|
||||||
func (h *handler) handleSearch(w http.ResponseWriter, r *http.Request) {
|
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)
|
results := searchWiki(h.root, query)
|
||||||
|
|
||||||
title := "Search"
|
title := "Search"
|
||||||
if query != "" {
|
if query != "" {
|
||||||
title = "Search: " + query
|
title = "Search: " + query
|
||||||
}
|
}
|
||||||
data := searchPageData{
|
data := searchPageData{
|
||||||
Title: title,
|
Title: title,
|
||||||
ParentURL: "/",
|
Crumbs: []crumb{{Name: "search", URL: "/?q=" + query}},
|
||||||
Query: query,
|
Query: query,
|
||||||
Results: results,
|
Results: results,
|
||||||
IndexBuiltAt: builtAt,
|
|
||||||
}
|
}
|
||||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
data.RenderMS = elapsedMS(r)
|
|
||||||
if err := searchTmpl.ExecuteTemplate(w, "layout", data); err != nil {
|
if err := searchTmpl.ExecuteTemplate(w, "layout", data); err != nil {
|
||||||
log.Printf("search template error: %v", err)
|
log.Printf("search template error: %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// searchWiki scores the cached folder index against query. Blocks on the
|
// searchWiki walks root and scores each directory by how many whitespace-split
|
||||||
// initial build so the very first request after startup serves correct
|
// query tokens hit a word in either the folder name or its index.md body.
|
||||||
// results rather than an empty list. Returns the snapshot's builtAt so the
|
// A word "hits" a token via case-insensitive equality or Levenshtein ≤ 2.
|
||||||
// UI can show how fresh the index is.
|
// Folder-name hits break score ties above content-only hits.
|
||||||
func searchWiki(query string) ([]searchResult, time.Time) {
|
func searchWiki(root, query string) []searchResult {
|
||||||
<-folderIndex.ready
|
|
||||||
folderIndex.RLock()
|
|
||||||
entries := folderIndex.entries
|
|
||||||
builtAt := folderIndex.builtAt
|
|
||||||
folderIndex.RUnlock()
|
|
||||||
|
|
||||||
if query == "" {
|
if query == "" {
|
||||||
return nil, builtAt
|
return nil
|
||||||
}
|
}
|
||||||
qLower := strings.ToLower(query)
|
qTokens := tokenize(query)
|
||||||
qTokens := tokenize(qLower)
|
|
||||||
if len(qTokens) == 0 {
|
if len(qTokens) == 0 {
|
||||||
return nil, builtAt
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
var results []searchResult
|
|
||||||
for _, e := range entries {
|
|
||||||
score := scoreName(e.NameLower, e.NameTokens, qLower, qTokens)
|
|
||||||
if score == 0 {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
results = append(results, searchResult{
|
|
||||||
Name: filepath.Base(e.Path),
|
|
||||||
URL: "/" + e.Path + "/",
|
|
||||||
Path: e.Path,
|
|
||||||
Score: score,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
sort.SliceStable(results, func(i, j int) bool {
|
|
||||||
if results[i].Score != results[j].Score {
|
|
||||||
return results[i].Score > results[j].Score
|
|
||||||
}
|
|
||||||
di, dj := strings.Count(results[i].Path, "/"), strings.Count(results[j].Path, "/")
|
|
||||||
if di != dj {
|
|
||||||
return di < dj
|
|
||||||
}
|
|
||||||
return strings.ToLower(results[i].Name) < strings.ToLower(results[j].Name)
|
|
||||||
})
|
|
||||||
return results, builtAt
|
|
||||||
}
|
|
||||||
|
|
||||||
// scoreName ranks how well nameLower matches the query. Whole-name exact
|
|
||||||
// match dominates; otherwise score is the sum of each token's best match
|
|
||||||
// against the words in the name. nameTokens is precomputed by the index.
|
|
||||||
func scoreName(nameLower string, nameTokens []string, qLower string, qTokens []string) int {
|
|
||||||
if nameLower == qLower {
|
|
||||||
return 1000
|
|
||||||
}
|
|
||||||
score := 0
|
|
||||||
for _, qt := range qTokens {
|
|
||||||
best := 0
|
|
||||||
for _, w := range nameTokens {
|
|
||||||
switch {
|
|
||||||
case w == qt:
|
|
||||||
if best < 100 {
|
|
||||||
best = 100
|
|
||||||
}
|
|
||||||
case strings.HasPrefix(w, qt):
|
|
||||||
if best < 50 {
|
|
||||||
best = 50
|
|
||||||
}
|
|
||||||
case strings.Contains(w, qt):
|
|
||||||
if best < 20 {
|
|
||||||
best = 20
|
|
||||||
}
|
|
||||||
case levenshtein(w, qt) <= 2:
|
|
||||||
if best < 5 {
|
|
||||||
best = 5
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
score += best
|
|
||||||
}
|
|
||||||
return score
|
|
||||||
}
|
|
||||||
|
|
||||||
// handleSearchSuggest serves the JSON typeahead for the header dropdown and
|
|
||||||
// the editor's link picker. Caps results at 5; reports total so the UI can
|
|
||||||
// surface a "show all" footer when more matches exist. Empty/whitespace query
|
|
||||||
// is a no-op (200 with empty results), not a 400 — every keystroke fires this.
|
|
||||||
func (h *handler) handleSearchSuggest(w http.ResponseWriter, r *http.Request) {
|
|
||||||
if !h.checkAuth(w, r) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
query := strings.TrimSpace(r.URL.Query().Get("q"))
|
|
||||||
type suggestResult struct {
|
|
||||||
Name string `json:"name"`
|
|
||||||
Path string `json:"path"`
|
|
||||||
URL string `json:"url"`
|
|
||||||
}
|
|
||||||
type suggestResp struct {
|
|
||||||
Query string `json:"query"`
|
|
||||||
Results []suggestResult `json:"results"`
|
|
||||||
Total int `json:"total"`
|
|
||||||
}
|
|
||||||
resp := suggestResp{Query: query, Results: []suggestResult{}}
|
|
||||||
if query != "" {
|
|
||||||
all, _ := searchWiki(query)
|
|
||||||
resp.Total = len(all)
|
|
||||||
limit := 5
|
|
||||||
if len(all) < limit {
|
|
||||||
limit = len(all)
|
|
||||||
}
|
|
||||||
for i := 0; i < limit; i++ {
|
|
||||||
resp.Results = append(resp.Results, suggestResult{
|
|
||||||
Name: all[i].Name,
|
|
||||||
Path: all[i].Path,
|
|
||||||
URL: all[i].URL,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
|
||||||
if err := json.NewEncoder(w).Encode(resp); err != nil {
|
|
||||||
log.Printf("search suggest encode error: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// handleReindex rebuilds the folder index synchronously and returns 204.
|
|
||||||
// The frontend reloads the page on success. Serialized via buildMu so a
|
|
||||||
// double-click waits rather than running two walks in parallel.
|
|
||||||
func (h *handler) handleReindex(w http.ResponseWriter, r *http.Request) {
|
|
||||||
if !h.checkAuth(w, r) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if r.Method != http.MethodPost {
|
|
||||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
rebuildFolderIndex(h.root)
|
|
||||||
w.WriteHeader(http.StatusNoContent)
|
|
||||||
}
|
|
||||||
|
|
||||||
// buildFolderIndex walks root and returns a fresh slice of folder entries.
|
|
||||||
// Hidden directories (`.git`, `.thumbs`, …) are pruned; the root itself is
|
|
||||||
// excluded since it cannot be a search match.
|
|
||||||
func buildFolderIndex(root string) []folderEntry {
|
|
||||||
walkRoot := resolveWalkRoot(root)
|
walkRoot := resolveWalkRoot(root)
|
||||||
var entries []folderEntry
|
var results []searchResult
|
||||||
_ = 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
|
||||||
@@ -232,107 +77,55 @@ func buildFolderIndex(root string) []folderEntry {
|
|||||||
if !d.IsDir() || fsPath == walkRoot {
|
if !d.IsDir() || fsPath == walkRoot {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
name := d.Name()
|
||||||
|
body, _ := os.ReadFile(filepath.Join(fsPath, "index.md"))
|
||||||
|
|
||||||
|
nameWords := tokenize(name)
|
||||||
|
bodyStr := string(body)
|
||||||
|
bodyLower := strings.ToLower(bodyStr)
|
||||||
|
bodyWords := tokenize(bodyLower)
|
||||||
|
|
||||||
|
score := 0
|
||||||
|
nameHit := false
|
||||||
|
for _, qt := range qTokens {
|
||||||
|
inName := tokenInWords(qt, nameWords)
|
||||||
|
inBody := tokenInWords(qt, bodyWords)
|
||||||
|
if inName || inBody {
|
||||||
|
score++
|
||||||
|
}
|
||||||
|
if inName {
|
||||||
|
nameHit = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if score == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
rel, relErr := filepath.Rel(walkRoot, fsPath)
|
rel, relErr := filepath.Rel(walkRoot, fsPath)
|
||||||
if relErr != nil {
|
if relErr != nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
entries = append(entries, newFolderEntry(filepath.ToSlash(rel)))
|
results = append(results, searchResult{
|
||||||
|
Name: name,
|
||||||
|
URL: "/" + filepath.ToSlash(rel) + "/",
|
||||||
|
Path: filepath.ToSlash(rel),
|
||||||
|
Score: score,
|
||||||
|
NameHit: nameHit,
|
||||||
|
Snippet: makeSnippet(bodyStr, bodyLower, qTokens),
|
||||||
|
})
|
||||||
return nil
|
return nil
|
||||||
})
|
})
|
||||||
return entries
|
|
||||||
}
|
|
||||||
|
|
||||||
// newFolderEntry builds a folderEntry from a forward-slash relative path,
|
sort.SliceStable(results, func(i, j int) bool {
|
||||||
// computing the lowercased basename and its tokens once so search scoring
|
if results[i].Score != results[j].Score {
|
||||||
// doesn't have to redo it per query.
|
return results[i].Score > results[j].Score
|
||||||
func newFolderEntry(relPath string) folderEntry {
|
|
||||||
name := relPath
|
|
||||||
if i := strings.LastIndex(relPath, "/"); i >= 0 {
|
|
||||||
name = relPath[i+1:]
|
|
||||||
}
|
|
||||||
nameLower := strings.ToLower(name)
|
|
||||||
return folderEntry{
|
|
||||||
Path: relPath,
|
|
||||||
NameLower: nameLower,
|
|
||||||
NameTokens: tokenize(nameLower),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// rebuildFolderIndex walks root and replaces the index entries atomically.
|
|
||||||
// buildMu serializes overlapping rebuilds (manual + ticker + startup) so
|
|
||||||
// the WalkDir cost is paid once even under contention.
|
|
||||||
func rebuildFolderIndex(root string) {
|
|
||||||
folderIndex.buildMu.Lock()
|
|
||||||
defer folderIndex.buildMu.Unlock()
|
|
||||||
entries := buildFolderIndex(root)
|
|
||||||
folderIndex.Lock()
|
|
||||||
folderIndex.entries = entries
|
|
||||||
folderIndex.builtAt = time.Now()
|
|
||||||
folderIndex.Unlock()
|
|
||||||
}
|
|
||||||
|
|
||||||
// folderIndexAdd appends relPath as a new entry. No-op for empty/root paths.
|
|
||||||
func folderIndexAdd(relPath string) {
|
|
||||||
relPath = strings.Trim(relPath, "/")
|
|
||||||
if relPath == "" {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
folderIndex.Lock()
|
|
||||||
folderIndex.entries = append(folderIndex.entries, newFolderEntry(relPath))
|
|
||||||
folderIndex.Unlock()
|
|
||||||
}
|
|
||||||
|
|
||||||
// folderIndexRemoveSubtree drops the entry at relPath plus every descendant.
|
|
||||||
// Replaces the slice rather than mutating in place so any in-flight search
|
|
||||||
// reader keeps a valid snapshot.
|
|
||||||
func folderIndexRemoveSubtree(relPath string) {
|
|
||||||
relPath = strings.Trim(relPath, "/")
|
|
||||||
if relPath == "" {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
prefix := relPath + "/"
|
|
||||||
folderIndex.Lock()
|
|
||||||
defer folderIndex.Unlock()
|
|
||||||
old := folderIndex.entries
|
|
||||||
out := make([]folderEntry, 0, len(old))
|
|
||||||
for _, e := range old {
|
|
||||||
if e.Path == relPath || strings.HasPrefix(e.Path, prefix) {
|
|
||||||
continue
|
|
||||||
}
|
}
|
||||||
out = append(out, e)
|
if results[i].NameHit != results[j].NameHit {
|
||||||
}
|
return results[i].NameHit
|
||||||
folderIndex.entries = out
|
|
||||||
}
|
|
||||||
|
|
||||||
// folderIndexRenameSubtree rewrites the path prefix for every entry under
|
|
||||||
// oldRel. The renamed root entry's basename may have changed so its
|
|
||||||
// NameLower/NameTokens are recomputed; descendants keep their basenames.
|
|
||||||
func folderIndexRenameSubtree(oldRel, newRel string) {
|
|
||||||
oldRel = strings.Trim(oldRel, "/")
|
|
||||||
newRel = strings.Trim(newRel, "/")
|
|
||||||
if oldRel == "" || newRel == "" {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
oldPrefix := oldRel + "/"
|
|
||||||
folderIndex.Lock()
|
|
||||||
defer folderIndex.Unlock()
|
|
||||||
old := folderIndex.entries
|
|
||||||
out := make([]folderEntry, len(old))
|
|
||||||
for i, e := range old {
|
|
||||||
switch {
|
|
||||||
case e.Path == oldRel:
|
|
||||||
out[i] = newFolderEntry(newRel)
|
|
||||||
case strings.HasPrefix(e.Path, oldPrefix):
|
|
||||||
out[i] = folderEntry{
|
|
||||||
Path: newRel + "/" + strings.TrimPrefix(e.Path, oldPrefix),
|
|
||||||
NameLower: e.NameLower,
|
|
||||||
NameTokens: e.NameTokens,
|
|
||||||
}
|
|
||||||
default:
|
|
||||||
out[i] = e
|
|
||||||
}
|
}
|
||||||
}
|
return strings.ToLower(results[i].Name) < strings.ToLower(results[j].Name)
|
||||||
folderIndex.entries = out
|
})
|
||||||
|
return results
|
||||||
}
|
}
|
||||||
|
|
||||||
// resolveWalkRoot resolves symlinks so WalkDir descends into the real tree
|
// resolveWalkRoot resolves symlinks so WalkDir descends into the real tree
|
||||||
@@ -379,6 +172,117 @@ func tokenize(s string) []string {
|
|||||||
return tokens
|
return tokens
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// tokenInWords reports whether qt matches any word exactly or within
|
||||||
|
// Levenshtein distance 2. qt and words must already be lowercase.
|
||||||
|
func tokenInWords(qt string, words []string) bool {
|
||||||
|
for _, w := range words {
|
||||||
|
if w == qt {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if levenshtein(w, qt) <= 2 {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
var snippetWS = regexp.MustCompile(`\s+`)
|
||||||
|
|
||||||
|
const snippetWindow = 300
|
||||||
|
|
||||||
|
// makeSnippet returns ~300 characters of body around the earliest substring
|
||||||
|
// match of any query token. When no token has an exact substring span (e.g.
|
||||||
|
// matched only via Levenshtein, or the hit was folder-name-only), it falls
|
||||||
|
// back to the first ~300 chars of the body with the leading heading stripped.
|
||||||
|
// Returns "" only when the body itself is empty.
|
||||||
|
func makeSnippet(body, bodyLower string, tokens []string) string {
|
||||||
|
pos := -1
|
||||||
|
for _, t := range tokens {
|
||||||
|
i := strings.Index(bodyLower, t)
|
||||||
|
if i < 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if pos < 0 || i < pos {
|
||||||
|
pos = i
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if pos < 0 {
|
||||||
|
return makeStub(body)
|
||||||
|
}
|
||||||
|
|
||||||
|
half := snippetWindow / 2
|
||||||
|
start := pos - half
|
||||||
|
if start < 0 {
|
||||||
|
start = 0
|
||||||
|
}
|
||||||
|
end := pos + half
|
||||||
|
if end > len(body) {
|
||||||
|
end = len(body)
|
||||||
|
}
|
||||||
|
start, end = expandToWordBoundaries(body, start, end)
|
||||||
|
out := snippetWS.ReplaceAllString(body[start:end], " ")
|
||||||
|
out = strings.TrimSpace(out)
|
||||||
|
if start > 0 {
|
||||||
|
out = "…" + out
|
||||||
|
}
|
||||||
|
if end < len(body) {
|
||||||
|
out = out + "…"
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// makeStub returns ~snippetWindow chars from the start of body, with the
|
||||||
|
// leading "# Heading" line stripped. Returns "" for an empty body.
|
||||||
|
func makeStub(body string) string {
|
||||||
|
stripped := string(stripFirstHeading([]byte(body)))
|
||||||
|
stripped = strings.TrimSpace(stripped)
|
||||||
|
if stripped == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
end := snippetWindow
|
||||||
|
if end > len(stripped) {
|
||||||
|
end = len(stripped)
|
||||||
|
}
|
||||||
|
_, end = expandToWordBoundaries(stripped, 0, end)
|
||||||
|
out := snippetWS.ReplaceAllString(stripped[:end], " ")
|
||||||
|
out = strings.TrimSpace(out)
|
||||||
|
if end < len(stripped) {
|
||||||
|
out = out + "…"
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// expandToWordBoundaries adjusts start/end so they don't split a word and
|
||||||
|
// don't fall in the middle of a UTF-8 sequence. start moves forward past
|
||||||
|
// any partial word at the beginning; end moves backward to the previous
|
||||||
|
// word boundary.
|
||||||
|
func expandToWordBoundaries(s string, start, end int) (int, int) {
|
||||||
|
for start > 0 && start < len(s) && s[start]&0xC0 == 0x80 {
|
||||||
|
start--
|
||||||
|
}
|
||||||
|
for end < len(s) && s[end]&0xC0 == 0x80 {
|
||||||
|
end++
|
||||||
|
}
|
||||||
|
if start > 0 && start < len(s) && isWordByte(s[start-1]) && isWordByte(s[start]) {
|
||||||
|
for start < end && isWordByte(s[start]) {
|
||||||
|
start++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if end < len(s) && isWordByte(s[end-1]) && isWordByte(s[end]) {
|
||||||
|
for end > start && isWordByte(s[end-1]) {
|
||||||
|
end--
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return start, end
|
||||||
|
}
|
||||||
|
|
||||||
|
func isWordByte(b byte) bool {
|
||||||
|
if b&0x80 != 0 {
|
||||||
|
return true // assume any multibyte char is part of a word
|
||||||
|
}
|
||||||
|
return (b >= 'a' && b <= 'z') || (b >= 'A' && b <= 'Z') || (b >= '0' && b <= '9')
|
||||||
|
}
|
||||||
|
|
||||||
// levenshtein returns the edit distance between a and b. Operates on runes so
|
// levenshtein returns the edit distance between a and b. Operates on runes so
|
||||||
// multi-byte characters count as one edit.
|
// multi-byte characters count as one edit.
|
||||||
func levenshtein(a, b string) int {
|
func levenshtein(a, b string) int {
|
||||||
|
|||||||
@@ -1,311 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"net/http"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"regexp"
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
)
|
|
||||||
|
|
||||||
// taskCheckboxRe matches the <input> tag goldmark's GFM extension emits for a
|
|
||||||
// task list checkbox. Used to enumerate and rewrite checkboxes in rendered HTML.
|
|
||||||
var taskCheckboxRe = regexp.MustCompile(`<input(?: checked="")? disabled="" type="checkbox">`)
|
|
||||||
|
|
||||||
// taskLineRe matches a markdown task list line: leading whitespace, a bullet,
|
|
||||||
// then a `[ ]` / `[x]` / `[X]` checkbox marker.
|
|
||||||
var taskLineRe = regexp.MustCompile(`^(\s*[-*+]\s+)\[([ xX])\]`)
|
|
||||||
|
|
||||||
// rewriteTaskCheckboxes enables and indexes the task checkboxes in rendered
|
|
||||||
// HTML so JS can wire them up. Each checkbox gains a data-task-index matching
|
|
||||||
// its position among task list items in source order; the disabled attribute
|
|
||||||
// is removed so the user can toggle them.
|
|
||||||
func rewriteTaskCheckboxes(in []byte) []byte {
|
|
||||||
idx := 0
|
|
||||||
return taskCheckboxRe.ReplaceAllFunc(in, func(match []byte) []byte {
|
|
||||||
checked := bytes.Contains(match, []byte("checked"))
|
|
||||||
var out bytes.Buffer
|
|
||||||
out.WriteString(`<input type="checkbox" class="task-checkbox" data-task-index="`)
|
|
||||||
out.WriteString(strconv.Itoa(idx))
|
|
||||||
out.WriteByte('"')
|
|
||||||
if checked {
|
|
||||||
out.WriteString(` checked=""`)
|
|
||||||
}
|
|
||||||
out.WriteByte('>')
|
|
||||||
idx++
|
|
||||||
return out.Bytes()
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// handleToggle flips the Nth task list checkbox in index.md based on the
|
|
||||||
// `toggle` query param and `checked` form value. Indices match the order in
|
|
||||||
// which goldmark emits checkboxes, which is source order excluding fenced
|
|
||||||
// code blocks.
|
|
||||||
func (h *handler) handleToggle(w http.ResponseWriter, r *http.Request, fsPath string) {
|
|
||||||
n, err := strconv.Atoi(r.URL.Query().Get("toggle"))
|
|
||||||
if err != nil || n < 0 {
|
|
||||||
http.Error(w, "bad toggle index", http.StatusBadRequest)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if err := r.ParseForm(); err != nil {
|
|
||||||
http.Error(w, "bad request", http.StatusBadRequest)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
checked := r.FormValue("checked") == "true"
|
|
||||||
|
|
||||||
indexPath := filepath.Join(fsPath, "index.md")
|
|
||||||
raw, err := os.ReadFile(indexPath)
|
|
||||||
if err != nil {
|
|
||||||
http.Error(w, "read failed: "+err.Error(), http.StatusInternalServerError)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
updated, ok := flipTaskLine(raw, n, checked)
|
|
||||||
if !ok {
|
|
||||||
http.Error(w, "task not found", http.StatusNotFound)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := writeFileAtomic(indexPath, updated, 0644); err != nil {
|
|
||||||
http.Error(w, "write failed: "+err.Error(), http.StatusInternalServerError)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
w.WriteHeader(http.StatusNoContent)
|
|
||||||
}
|
|
||||||
|
|
||||||
// flipTaskLine returns raw with the Nth task list bullet's `[ ]`/`[x]` marker
|
|
||||||
// set according to checked. Lines inside fenced code blocks are skipped so
|
|
||||||
// they do not consume an index. Returns ok=false when there is no Nth task.
|
|
||||||
func flipTaskLine(raw []byte, n int, checked bool) ([]byte, bool) {
|
|
||||||
lines := bytes.Split(raw, []byte("\n"))
|
|
||||||
inFence := false
|
|
||||||
count := 0
|
|
||||||
target := -1
|
|
||||||
for i, line := range lines {
|
|
||||||
trimmed := bytes.TrimLeft(line, " \t")
|
|
||||||
if bytes.HasPrefix(trimmed, []byte("```")) || bytes.HasPrefix(trimmed, []byte("~~~")) {
|
|
||||||
inFence = !inFence
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if inFence {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if !taskLineRe.Match(line) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if count == n {
|
|
||||||
target = i
|
|
||||||
break
|
|
||||||
}
|
|
||||||
count++
|
|
||||||
}
|
|
||||||
if target == -1 {
|
|
||||||
return nil, false
|
|
||||||
}
|
|
||||||
replacement := []byte("${1}[ ]")
|
|
||||||
if checked {
|
|
||||||
replacement = []byte("${1}[x]")
|
|
||||||
}
|
|
||||||
lines[target] = taskLineRe.ReplaceAll(lines[target], replacement)
|
|
||||||
return bytes.Join(lines, []byte("\n")), true
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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)
|
|
||||||
}
|
|
||||||
+8
-10
@@ -16,15 +16,14 @@ import (
|
|||||||
"github.com/yuin/goldmark/util"
|
"github.com/yuin/goldmark/util"
|
||||||
)
|
)
|
||||||
|
|
||||||
// wikiLinkRe matches [[target]] and [[target::display]] anchored at the start
|
// wikiLinkRe matches [[target]] and [[target|display]] anchored at the start
|
||||||
// of the current inline reader. Target and display forbid newlines and
|
// of the current inline reader. Target and display forbid newlines and
|
||||||
// brackets; the target is non-greedy so the first `::` separates target from
|
// brackets; target additionally forbids the pipe separator.
|
||||||
// display when 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.
|
// wikiLinkPattern matches wiki-link tokens anywhere in a markdown source.
|
||||||
// Used by the move-endpoint rewriter; not by the goldmark parser.
|
// Used by the move-endpoint rewriter; not by the goldmark parser.
|
||||||
var wikiLinkPattern = regexp.MustCompile(`\[\[([^\[\]\n]+?)(?:::([^\[\]\n]+))?\]\]`)
|
var wikiLinkPattern = regexp.MustCompile(`\[\[([^\[\]\n\|]+)(\|[^\[\]\n]+)?\]\]`)
|
||||||
|
|
||||||
// wikiLinkNode is the AST node produced by wikiLinkParser.
|
// wikiLinkNode is the AST node produced by wikiLinkParser.
|
||||||
type wikiLinkNode struct {
|
type wikiLinkNode struct {
|
||||||
@@ -115,14 +114,13 @@ func wikiTargetHref(target string) string {
|
|||||||
return b.String()
|
return b.String()
|
||||||
}
|
}
|
||||||
|
|
||||||
// wikiTargetExists reports whether the on-disk path backing the target exists
|
// wikiTargetExists reports whether the on-disk folder backing the target
|
||||||
// under root. Any existing path — file or folder — counts as resolved; only a
|
// exists under root.
|
||||||
// missing path is treated as broken.
|
|
||||||
func wikiTargetExists(root, target string) bool {
|
func wikiTargetExists(root, target string) bool {
|
||||||
target = normalizeWikiTarget(target)
|
target = normalizeWikiTarget(target)
|
||||||
fsPath := filepath.Join(root, filepath.FromSlash(strings.TrimPrefix(target, "/")))
|
fsPath := filepath.Join(root, filepath.FromSlash(strings.TrimPrefix(target, "/")))
|
||||||
_, err := os.Stat(fsPath)
|
info, err := os.Stat(fsPath)
|
||||||
return err == nil
|
return err == nil && info.IsDir()
|
||||||
}
|
}
|
||||||
|
|
||||||
// wikiDefaultDisplay returns the last segment of a target, or "/" for the root.
|
// wikiDefaultDisplay returns the last segment of a target, or "/" for the root.
|
||||||
|
|||||||
Reference in New Issue
Block a user