Compare commits

..
121 Commits
Author SHA1 Message Date
luxick b48d476b39 JSON canvas initial 2026-09-22 21:28:28 +02:00
luxick 7b451bae86 Hide companion features on mobile 2026-09-22 21:24:29 +02:00
luxick 1925987459 Remove agent files 2026-09-22 21:24:15 +02:00
luxick b57e5d454b Rework wikilinks feature 2026-09-17 17:46:36 +02:00
luxick b14941b3e0 Adjust sidebar tree 2026-09-07 16:23:38 +02:00
luxick adf1540cf9 Update link colors 2026-09-07 15:37:43 +02:00
luxick c0b7ebf0c2 Increase dropdown button on mobile 2026-09-01 20:05:23 +02:00
luxick 07bb3b002e Remove remaining mentions of fabs 2026-09-01 19:55:40 +02:00
luxick 96a58ca472 reorganize header menu 2026-09-01 19:44:08 +02:00
luxick 5ad715426d Update section behavior 2026-08-23 14:25:12 +02:00
luxick c612cdd28d Remove unnecessary scrollbar CSS 2026-08-23 13:41:10 +02:00
luxick a0b4f49915 Merge TOC into page content 2026-08-23 13:38:45 +02:00
luxick dddacf9262 Update Headline rendering 2026-08-23 11:36:10 +02:00
luxick f78ebe7d5d Remove auto new tab for external links 2026-08-05 18:26:09 +02:00
luxick d943fc5596 Update CLAUDE.md 2026-08-05 18:25:52 +02:00
luxick e9061a221c Hide console on windows
The companion does no longer show a terminal window pop up
2026-08-03 19:29:27 +02:00
luxick 5a5305dd42 Update CLAUDE.md 2026-08-03 19:28:03 +02:00
luxick ab57377034 Fix toolbar on mobile. 2026-07-24 16:14:20 +02:00
luxick 65983ec6be Missing changes 2026-07-24 16:10:44 +02:00
luxick 52bc694ffa Allow image embedding 2026-07-24 16:10:29 +02:00
luxick e37dde1e40 preserve scroll position when entering page editor 2026-07-24 15:24:40 +02:00
luxick 3abcc94eca Improve desktop editing experience 2026-07-24 15:16:02 +02:00
luxick 8b9f47f793 Adjust saving button 2026-07-24 12:10:16 +02:00
luxick 85527cfde8 Improve table editing 2026-07-24 11:38:15 +02:00
luxick 5f835a8a4e Omit Files section when no files are present 2026-07-24 11:26:23 +02:00
luxick 1071dbaa09 "Today" link no longer goes directly to editor 2026-07-24 11:21:40 +02:00
luxick da30bb8fc3 Fix back-to-cancel behavior of editor 2026-07-24 10:55:29 +02:00
luxick a849474b26 Remove obsolete parts from overlay 2026-07-23 18:06:12 +02:00
luxick 49c6240416 Remove TODO.txt integration 2026-07-23 17:38:13 +02:00
luxick ddfd29ea0e Prevent page deletion on save 2026-07-21 17:26:31 +02:00
luxick 3f2beafd94 Adjust header spacing 2026-07-21 17:26:15 +02:00
luxick 28d22c040f Do not consider edit mode as a history entry 2026-07-20 17:31:59 +02:00
luxick 34f750beff Exact page matches in search 2026-07-20 09:08:35 +02:00
luxick 5303645173 Improve search result page 2026-07-20 08:54:12 +02:00
luxick d0325fdec5 Center align link favicons 2026-07-05 18:13:22 +02:00
luxick 22059a0b06 Update todo buttons 2026-07-05 18:10:22 +02:00
luxick aa95d04d9e Add todo to the Readme 2026-07-03 12:28:19 +02:00
luxick 08be8c68fe Add quick actions toolbar for todo 2026-07-03 12:24:50 +02:00
luxick a407a2eeaa Hanging indent for todos 2026-07-03 12:11:30 +02:00
luxick 75d6c4d430 Auto refresh todo editor. 2026-07-03 12:02:16 +02:00
luxick 5e72b073b8 todo.txt integration 2026-07-03 11:08:27 +02:00
luxick 8c9b448bdc Render all months in sidebar 2026-07-01 11:00:41 +02:00
luxick ecaf3f4e12 Clean up CSS 2026-07-01 10:09:14 +02:00
luxick 1055c110f4 Rework app-shell layout 2026-07-01 09:59:32 +02:00
luxick 5d069683c4 Enable Hardwraps 2026-06-25 14:23:15 +02:00
luxick 9f6a611dff Video thumbnail generation 2026-06-24 08:36:03 +02:00
luxick fa22244709 Use companion for diary photos 2026-06-22 17:25:11 +02:00
luxick d92c2f008d No hover highlighting on mobile 2026-06-16 18:27:42 +02:00
luxick 7346050b51 Tree styling update
More dense display
2026-06-16 18:05:48 +02:00
luxick 1619240905 Fix missing label 2026-06-16 17:49:34 +02:00
luxick db17f94627 Adjust app layout 2026-06-16 15:24:21 +02:00
luxick 9ed6475775 Folder Sidebar 2026-06-16 15:03:18 +02:00
luxick 7fe0013a5c Sticky Header 2026-06-16 14:00:32 +02:00
luxick f8b09e18bd Remove obsolete functions 2026-06-16 10:25:24 +02:00
luxick 404ce088f3 Allow merging when moving pages 2026-06-16 10:00:41 +02:00
luxick d719b53404 Add weekly averages for weight 2026-06-12 14:10:00 +02:00
luxick f870a12cd5 Adjust button sizes 2026-06-11 14:48:04 +02:00
luxick 0b62cd50f3 Fitness dashboard without calorie tracker
Does not compute correctly yet
2026-06-11 13:03:54 +02:00
luxick fde4eff12d Fitness dashboard v1 2026-06-11 12:55:43 +02:00
luxick 4f14b39d16 Improve Editor on mobile 2026-06-09 22:18:16 +02:00
luxick 204e89dbce Improve wiki linking 2026-06-09 18:36:40 +02:00
luxick 5525a03179 Pin toolbar to the bottom on mobile 2026-06-05 10:47:38 +02:00
luxick 11cae7df36 Update editor layout. add delete line button 2026-06-05 10:34:40 +02:00
luxick 7be8bec446 Improve mobile editing 2026-06-05 10:27:56 +02:00
luxick de3abed6d7 Use CodeMirror editor 2026-06-05 10:10:58 +02:00
luxick 4e24f876c9 Allow compainion opening in thumbnail view 2026-06-04 09:22:53 +02:00
luxick 0478bc6305 Add Readme info for view settings 2026-06-04 08:16:55 +02:00
luxick f85c29ba42 View switching feature 2026-05-29 09:21:19 +02:00
luxick 5844a870ce Cleanup 2026-05-28 21:34:46 +02:00
luxick 51bf489449 Refactoring of calendar widget 2026-05-28 21:28:16 +02:00
luxick 61e50c033f Rework diary to focus on yearly pages 2026-05-28 13:54:44 +02:00
luxick 20a6bac3d6 Unify tables and listings 2026-05-27 20:09:52 +02:00
luxick e089e0b2c3 CSS Refactor 2026-05-27 10:52:47 +02:00
luxick 2af26ac1bc Update Claude.md for CSS 2026-05-27 10:36:19 +02:00
luxick bf16f2ec3c improve task list handling 2026-05-23 08:44:19 +02:00
luxick a25d5434ac Improve serach function with quick suggestions 2026-05-18 15:10:34 +02:00
luxick 8244874fe3 Update sidebar.
It now is an overlay on mobile
2026-05-17 11:25:05 +02:00
luxick fe3522b93b Update Layout 2026-05-17 11:04:52 +02:00
luxick d3e896d74e Fix rendering of new rail 2026-05-17 10:36:23 +02:00
luxick 3e765aa54f Inital attempt 2026-05-15 11:26:07 +02:00
luxick a9519f747e Quickadd feature for link saving 2026-05-11 12:03:59 +02:00
luxick 30b5e36cd7 Allow customizing companion commands 2026-05-08 21:25:41 +02:00
luxick 7209aebc62 Add companion application 2026-05-08 20:47:02 +02:00
luxick 5fcca77d58 Change File listing sort order 2026-05-08 18:22:03 +02:00
luxick e060faa39f Add persitent URLs for year/month/day in the diary 2026-05-08 18:15:09 +02:00
luxick 34e2a8972a Remove whitespace on diary headings 2026-05-08 15:22:56 +02:00
luxickandClaude Opus 4.7 c2c4960ee7 Prompt user for OMDb API key and persist in localStorage
Removes the hardcoded key in favour of a per-browser key entered via
modal on first movie import. An Invalid API key response from OMDb
clears the stored key and re-prompts, preserving the typed title.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-08 15:01:28 +02:00
luxick a30fa8b061 Update Readme 2026-05-08 10:16:40 +02:00
luxick 60ef401427 Update dropdown styling 2026-05-08 09:56:30 +02:00
luxick 5a015d438b Update editor toolbar.
Unify dropdown handling, refactor crowded toolbar
2026-05-08 09:29:31 +02:00
luxick 69037a77ed Reorganize assets folder 2026-05-08 08:34:23 +02:00
luxick 892e4860b4 Make page rewrite optional 2026-05-07 19:35:10 +02:00
luxick 73743ced78 TOC refinements 2026-05-07 18:56:00 +02:00
luxick 80b93127dc Include Listings in TOC 2026-05-07 14:16:18 +02:00
luxick 1e54ec1f16 Disble tabbing trough breadcrumbs 2026-05-07 09:47:14 +02:00
luxick 3d3a121fa6 Add search index 2026-05-07 09:41:20 +02:00
luxick 2787c15d40 Add page footer with request time display 2026-05-07 08:19:39 +02:00
luxick 0a30325b96 Update wikilink syntax
This prevents interference with markdown tables
2026-05-07 07:55:33 +02:00
luxick dc79557bdb Replace textarea.value calls 2026-05-04 11:54:58 +02:00
luxick 6083a36824 Refinements for tree picker 2026-05-04 11:49:42 +02:00
luxick 4c55bd050f Allow checking off tasks from rendered pages 2026-05-04 11:37:38 +02:00
luxick 80f6abcbaa Move actions into floatin action button 2026-05-04 10:10:20 +02:00
luxick 72edf7b258 Update search function 2026-05-04 10:03:53 +02:00
luxick c016dcabaa Add separator for search header 2026-05-04 09:49:43 +02:00
luxick 762d86b84a Refactor CSS styling 2026-04-29 19:38:41 +02:00
luxick 903f21e7f1 Refactor Layout and improve search 2026-04-29 19:26:01 +02:00
luxick bb137e9c93 Full-text search v1 2026-04-29 14:30:37 +02:00
luxick c53701eb87 exclude .zed dir 2026-04-29 14:18:27 +02:00
luxick 511b65df22 Resolve symlink root in search 2026-04-29 14:17:52 +02:00
luxick dd3906855d Search phase 1 2026-04-29 14:12:18 +02:00
luxick 6db0f0774c Force external links into new tabs 2026-04-29 13:17:42 +02:00
luxick 2d0ab6ae0e Adjust anchor icon style 2026-04-28 17:49:00 +02:00
luxick adbe10fa41 Improve sections 2026-04-28 17:43:20 +02:00
luxick 4415a391e2 Use placeholder svg 2026-04-23 20:58:33 +02:00
luxick 337ec9ef6e Add thumbnailing for photo grids 2026-04-23 20:43:05 +02:00
luxick df41cfa8c8 Prefill Empty pages 2026-04-23 14:34:07 +02:00
luxick 207c16ed29 Migrate movie info downloader 2026-04-23 14:20:53 +02:00
luxick 8b13938290 Add Tree Selector 2026-04-23 14:02:41 +02:00
luxick c6a292754f Minor fixes for TOC 2026-04-23 11:03:33 +02:00
luxick 4751a99e8e Refactor calendar widget changes 2026-04-23 10:56:05 +02:00
luxick 910be60ed5 Calendar widget v1 2026-04-23 10:48:10 +02:00
90 changed files with 12429 additions and 1614 deletions
+10
View File
@@ -1,6 +1,16 @@
.claude/
.zed/
wiki/
cache/
# Binaries
datascape
*.exe
bin/
companion/datascape-companion-*
# Editor build tooling deps (the built bundle is committed; node_modules is not)
editor-build/node_modules/
AGENTS.md
CLAUDE.md
-86
View File
@@ -1,86 +0,0 @@
# CLAUDE.md
## Project Overview
`datascape` is a minimal personal wiki where **the folder structure is the wiki**.
No database, no CMS, no abstraction layer — every folder is a page, and `index.md`
in a folder is that page's content.
## Build & Deploy
```bash
# Local build (host architecture)
go build .
# Deploy to NAS
make deploy
```
## HTTP API Surface
| Method | Path | Behaviour |
|--------|------|-----------|
| GET | `/{path}/` | If folder exists: render `index.md` + list contents. If not: show empty create prompt. |
| GET | `/{path}/?edit` | 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 |
Non-existent paths without a trailing slash redirect to the slash form (GET only — POSTs
are not redirected because `path.Clean` strips the trailing slash from `PostURL` and the
content would be lost).
Do not add new endpoints without a concrete stated need.
## Code Structure
When adding a new special folder type, create a new `.go` file. Do not add type-specific logic to `main.go` or `render.go`.
Prefer separate, human-readable `.html` files over inlined HTML strings in Go. Embed them via `embed.FS` if needed.
## Architecture Rules
- **Single binary** — no installer, no runtime dependencies, no Docker
- **Go stdlib `net/http`** only — no web framework
- **`goldmark`** for Markdown rendering — no other Markdown libraries
- **`embed.FS`** for all assets — no external serving, no CDN
- **No database** of any kind
- **No indexing or caching** unless explicitly requested and justified
- Keep dependencies to an absolute minimum; if stdlib can do it, use stdlib
## Frontend Rules
- Vanilla JS only — no frameworks, no build pipeline
- Each feature gets its own JS file; global behaviour goes in `global-shortcuts.js`
- Do not inline JS in templates or merge unrelated features into one file
- `ALT+SHIFT` is the modifier for all keyboard shortcuts — do not introduce others
- Editor toolbar buttons use `data-action` + `data-key`; adding `data-key` auto-registers the shortcut
- 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.
- Where possible, re-use existing CSS classes
## Development Priorities
When building features, apply this order:
1. Correctness on the filesystem — never corrupt or lose files
2. Mobile usability (primary editing device is Android over Wireguard VPN)
3. Simplicity of implementation, adhere to KISS
4. Performance
## Date Formatting
- General UI dates (file listings, metadata): ISO `YYYY-MM-DD`
- Diary 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`
## What to Avoid
- Any parallel folder structure (e.g. a separate `media/` tree mirroring `pages/`)
- Over-engineering auth — Basic auth is sufficient for a personal VPN tool
- Heavy payloads or expensive rendering (target CPU: ARMv7 32-bit NAS)
- Suggesting Docker (plain binary is preferred)
## Out of Scope (do not implement unless explicitly asked)
- Full-text search
- Browser-based file upload
- Version history / git integration
- Multi-user support
- Tagging or metadata beyond `index.md` content
+37 -2
View File
@@ -1,7 +1,42 @@
NAS := luxick@192.168.3.3
.PHONY: deploy
deploy:
COMPANION_WIN := companion/datascape-companion-windows-amd64.exe
COMPANION_LIN := companion/datascape-companion-linux-amd64
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 .
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
+159 -26
View File
@@ -2,12 +2,23 @@
Minimal self-hosted personal wiki. Folders are pages.
## Run
## Features
```bash
go run . -dir ./wiki -addr :8080
go run . -dir ./wiki -addr :8080 -user me -pass secret
```
- **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.
- **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.
- **Search** search across all page names (folder names) in the wiki, accessible from the navigation bar.
- **Wikilinks** link between pages with `[[search query]]` syntax, or `[[query::display text]]` to override the label. The link opens the search page for that query, so it keeps resolving after a page is renamed or moved. Files embed with standard Markdown image syntax — `![caption](photo.jpg "left")` — relative to the page they sit on.
- **Canvases** an infinite pan/zoom board stored as a [JSON Canvas](https://jsoncanvas.org/) `.canvas` file next to the page's `index.md`. Canvases in a folder appear as tabs above the page content, so a canvas is an alternative *view* of the page rather than a separate document. See the [Canvases](#canvases) section.
- **Movie import** import movie entries via the OMDb API. Fetches title, year, runtime, genre, director, cast, plot, and poster, and pre-fills a new page with that metadata.
- **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
@@ -21,20 +32,36 @@ GOOS=linux GOARCH=arm go build -o datascape .
## Usage
| Action | How |
|--------|-----|
| 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 |
```bash
go run . -dir ./wiki -addr :8080
go run . -dir ./wiki -addr :8080 -user me -pass secret
```
Navigating to a URL that does not exist shows an empty page with a **[CREATE]** prompt.
| Flag | Default | Description |
|------|---------|-------------|
| `-addr` | `:8080` | Listen address |
| `-dir` | `./wiki` | Wiki root directory |
| `-cache` | `./cache` | Thumbnail cache directory |
| `-user` | _(none)_ | Basic auth username — omit to disable auth |
| `-pass` | _(none)_ | Basic auth password |
| `-reindex-interval` | `30m` | Periodic search index rebuild interval (`0` disables) |
## 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
A folder can opt into special rendering by adding a `.page-settings` file:
A folder can opt into special rendering by adding a `.page-settings` file. The
same file also holds the [View Settings](#view-settings) keys; only the `type`
key selects a special renderer:
```
type = diary
@@ -42,22 +69,128 @@ type = diary
### Diary
Designed for a chronological photo diary. Expected structure:
Designed for a chronological photo diary. The whole year lives in a single
file as ISO-headed sections; photos are loose JPEGs named with a date prefix.
```
FolderName/
.page-settings ← type = diary
YYYY/
YYYY-MM-DD Desc.jpg ← photos named with date prefix
MM/
DD/
index.md ← diary entry for that day
index.md ← `# YYYY` + `## YYYY-MM` + `### YYYY-MM-DD` sections
YYYY-MM-DD Desc.jpg ← photos named with the date they belong to
```
| View | What renders |
|------|-------------|
| Year (`YYYY/`) | Section per month with link and photo count |
| Month (`MM/`) | Section per day with entry content and photo grid |
| Day (`DD/`) | Entry content and photo grid |
The year page (`YYYY/`) renders every section in the file with photos
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.
Days with photos but no `index.md` still appear in the month view and can be created by clicking their heading link.
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) |
| `<diary>/this-month/` | `<diary>/YYYY/#YYYY-MM` |
| `<diary>/this-year/` | `<diary>/YYYY/` |
Legacy `YYYY/MM/` and `YYYY/MM/DD/` URLs (no longer the canonical form) redirect to the matching anchor on the year page.
## Canvases
A canvas is a `<Name>.canvas` file in a page folder holding a
[JSON Canvas 1.0](https://jsoncanvas.org/spec/1.0/) document. Every canvas in a
folder renders as a tab above that page's content, and the page itself is the
first tab — so a page and its diagrams are one thing with several views.
| URL | What it does |
|------|-------------|
| `<page>/?canvas=<Name>` | the canvas view (canonical) |
| `<page>/<Name>.canvas` | redirects to the canonical form |
| `<page>/<Name>.canvas?raw` | the file itself |
Create one from the **NEW CANVAS** action or the `+` in the tab strip.
### Nodes
All four spec node types render:
| Type | Rendered as |
|------|-------------|
| `text` | markdown, rendered server-side — wikilinks, `![[embeds]]`, tables and task lists all behave as they do on a page |
| `file` | a transclusion of another wiki path: markdown pages (narrowed to a heading when `subpath` is set), images, video, PDFs |
| `link` | a card for an external URL |
| `group` | a labelled frame; dragging it carries the nodes inside it |
Node content is rendered by the server and cached in the browser by *content*,
so the whole canvas arrives pre-rendered in the initial page load (no per-node
requests) and undo/redo of a text edit costs nothing.
### Controls
| Gesture | Action |
|---------|--------|
| drag background | pan |
| ctrl/⌘ + wheel, or pinch | zoom |
| shift + drag background | marquee select |
| drag a node | move it (snaps to a 20px grid; hold alt to bypass) |
| drag a corner handle | resize |
| drag a side port | draw an edge to another node |
| double-click a node | edit it |
| double-click empty space | new text node there |
Keys: `Ctrl+S` save · `Ctrl+Z` / `Ctrl+Shift+Z` undo/redo · `Del` delete
selection · `Ctrl+A` select all · arrows nudge (shift for single pixels) ·
`T`/`F`/`L`/`G` add a node · `+`/`-`/`0` zoom, `9` fit · `Esc` cancel.
Undo covers the whole session; a drag or resize is one entry no matter how many
events it spanned.
### Saving
Saving is explicit (**SAVE**, or `Ctrl+S`) and uploads the whole document. The
server parses and validates it before writing anything, so a malformed payload
can never land on disk, and four things are checked in order:
1. the JSON parses into the spec's shape;
2. node ids are unique, every edge endpoint resolves, and enums/colours/sizes are in range;
3. the `X-Canvas-Version` token matches the file on disk — a mismatch is a `409` and you are asked whether to reload or overwrite;
4. a save that would empty a populated canvas is refused until you confirm it.
The previous contents are kept beside the file as `.<Name>.canvas.bak`, and the
write itself is atomic. Leaving the page with unsaved changes prompts first.
`[[wikilinks]]` inside text nodes are search queries, like on a page, so they
keep resolving after a move. File nodes and group backgrounds hold literal
paths and are not rewritten: moving or renaming their target leaves them
rendered as broken references.
Canvas *contents* are not searchable; only the file name is indexed.
## Quick-Add Bookmarklet
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
```
+182
View File
@@ -0,0 +1,182 @@
// doc.js — the canvas document plus its undo stack.
//
// Every mutation passes through begin()/commit(). A gesture (a drag, a resize,
// a nudge-repeat) spans hundreds of pointer events but has to collapse into a
// single undo entry, so the snapshot is taken once at begin() and pushed once
// at commit() — and only when the document actually changed, so a click that
// moves nothing leaves no trace.
//
// Undo is snapshot-based rather than a stack of inverse operations: the
// document is a small pure-JSON object, cloning it costs microseconds, and a
// snapshot cannot be subtly wrong the way a hand-written inverse can. The
// stack is capped so a long session cannot grow without bound.
window.Canvas = window.Canvas || {};
Canvas.Doc = (function () {
var MAX_UNDO = 100;
var doc = { nodes: [], edges: [] };
var undoStack = [];
var redoStack = [];
var pending = null;
var dirty = false;
var version = '';
var listeners = [];
function clone(v) { return JSON.parse(JSON.stringify(v)); }
function emit() {
for (var i = 0; i < listeners.length; i++) listeners[i]();
}
// newId mints a 16-hex-character node/edge id, the shape the format uses in
// the wild. Collisions are checked against the live document anyway.
function newId() {
var bytes = new Uint8Array(8);
window.crypto.getRandomValues(bytes);
var out = '';
for (var i = 0; i < bytes.length; i++) {
out += (bytes[i] + 0x100).toString(16).slice(1);
}
if (find(doc.nodes, out) || find(doc.edges, out)) return newId();
return out;
}
function find(list, id) {
for (var i = 0; i < list.length; i++) {
if (list[i].id === id) return list[i];
}
return null;
}
return {
init: function (d, hash) {
doc = (d && typeof d === 'object') ? d : {};
if (!Array.isArray(doc.nodes)) doc.nodes = [];
if (!Array.isArray(doc.edges)) doc.edges = [];
undoStack = [];
redoStack = [];
pending = null;
dirty = false;
version = hash || '';
},
get: function () { return doc; },
nodes: function () { return doc.nodes; },
edges: function () { return doc.edges; },
node: function (id) { return find(doc.nodes, id); },
edge: function (id) { return find(doc.edges, id); },
newId: newId,
// edgesFor returns every edge touching any of the given node ids.
edgesFor: function (nodeIds) {
var want = {};
nodeIds.forEach(function (id) { want[id] = true; });
return doc.edges.filter(function (e) {
return want[e.fromNode] || want[e.toNode];
});
},
// --- gesture bracketing ---
//
// Nested begin() calls join the outer gesture rather than starting a
// new one, so a composite action (drag a group, which drags its
// members) still produces exactly one undo entry.
begin: function (label) {
if (pending) { pending.depth++; return; }
pending = { label: label || '', before: clone(doc), depth: 0 };
},
commit: function () {
if (!pending) return;
if (pending.depth > 0) { pending.depth--; return; }
var before = pending.before;
var label = pending.label;
pending = null;
// A gesture that changed nothing (a click that selected without
// moving) must not push an undo entry the user would have to press
// Ctrl+Z through.
if (JSON.stringify(before) === JSON.stringify(doc)) return;
undoStack.push({ label: label, doc: before });
if (undoStack.length > MAX_UNDO) undoStack.shift();
redoStack.length = 0;
dirty = true;
emit();
},
// abort rolls the document back to the gesture's starting state, for
// Escape mid-drag.
abort: function () {
if (!pending) return;
if (pending.depth > 0) { pending.depth--; return; }
doc = pending.before;
pending = null;
emit();
},
inGesture: function () { return pending !== null; },
undo: function () {
if (!undoStack.length) return false;
var entry = undoStack.pop();
redoStack.push({ label: entry.label, doc: clone(doc) });
doc = entry.doc;
dirty = true;
emit();
return true;
},
redo: function () {
if (!redoStack.length) return false;
var entry = redoStack.pop();
undoStack.push({ label: entry.label, doc: clone(doc) });
doc = entry.doc;
dirty = true;
emit();
return true;
},
canUndo: function () { return undoStack.length > 0; },
canRedo: function () { return redoStack.length > 0; },
// --- structural helpers ---
addNode: function (node) {
doc.nodes.push(node);
return node;
},
addEdge: function (edge) {
doc.edges.push(edge);
return edge;
},
// remove drops the named nodes and edges. Every edge touching a removed
// node goes too — a dangling endpoint would fail validation on save, so
// it can never be allowed to exist even transiently.
remove: function (nodeIds, edgeIds) {
var deadNodes = {};
(nodeIds || []).forEach(function (id) { deadNodes[id] = true; });
var deadEdges = {};
(edgeIds || []).forEach(function (id) { deadEdges[id] = true; });
doc.nodes = doc.nodes.filter(function (n) { return !deadNodes[n.id]; });
doc.edges = doc.edges.filter(function (e) {
return !deadEdges[e.id] && !deadNodes[e.fromNode] && !deadNodes[e.toNode];
});
},
// --- save state ---
isDirty: function () { return dirty; },
markSaved: function (hash) {
dirty = false;
if (hash) version = hash;
emit();
},
version: function () { return version; },
onChange: function (fn) { listeners.push(fn); },
notify: emit
};
})();
+237
View File
@@ -0,0 +1,237 @@
// edges.js — the SVG edge layer.
//
// Edges are cubic beziers anchored at the midpoint of a node side, with control
// points pushed out along that side's normal. When a side is unspecified the
// dominant axis between the two centres picks one, which keeps auto-routed
// edges predictable instead of hunting for a global minimum.
//
// The SVG sits inside the transformed world and draws in world coordinates, so
// it pans and zooms with the nodes for free.
window.Canvas = window.Canvas || {};
Canvas.Edges = (function () {
var Doc = Canvas.Doc;
var Select = Canvas.Select;
var SVGNS = 'http://www.w3.org/2000/svg';
var ARROW = 11;
var svg = null;
var groups = {}; // edge id -> <g>
var temp = null; // the rubber-band path while drawing a new edge
function el(name, attrs) {
var node = document.createElementNS(SVGNS, name);
for (var k in attrs) {
if (Object.prototype.hasOwnProperty.call(attrs, k)) node.setAttribute(k, attrs[k]);
}
return node;
}
// sideAnchor returns the connection point on a node side together with that
// side's outward normal, which the control points and arrowheads both need.
function sideAnchor(n, side) {
switch (side) {
case 'top': return { x: n.x + n.width / 2, y: n.y, nx: 0, ny: -1 };
case 'bottom': return { x: n.x + n.width / 2, y: n.y + n.height, nx: 0, ny: 1 };
case 'left': return { x: n.x, y: n.y + n.height / 2, nx: -1, ny: 0 };
default: return { x: n.x + n.width, y: n.y + n.height / 2, nx: 1, ny: 0 };
}
}
// autoSides picks the facing sides for an edge with no explicit ones: the
// dominant axis between the node centres wins. Simple and stable — the edge
// does not flip sides every time a node moves a few pixels.
function autoSides(a, b) {
var dx = (b.x + b.width / 2) - (a.x + a.width / 2);
var dy = (b.y + b.height / 2) - (a.y + a.height / 2);
if (Math.abs(dx) >= Math.abs(dy)) {
return dx >= 0 ? ['right', 'left'] : ['left', 'right'];
}
return dy >= 0 ? ['bottom', 'top'] : ['top', 'bottom'];
}
function geometry(e) {
var a = Doc.node(e.fromNode);
var b = Doc.node(e.toNode);
if (!a || !b) return null;
var auto = autoSides(a, b);
var fromSide = e.fromSide || auto[0];
// A self-loop has no meaningful "facing" side, so it gets a fixed pair
// that always produces a visible arc out of and back into the node.
var toSide = e.toSide || (a === b ? 'top' : auto[1]);
var p0 = sideAnchor(a, fromSide);
var p3 = sideAnchor(b, toSide);
var dist = Math.sqrt((p3.x - p0.x) * (p3.x - p0.x) + (p3.y - p0.y) * (p3.y - p0.y));
var k = Math.max(40, dist / 3);
return {
p0: p0,
p3: p3,
c1: { x: p0.x + p0.nx * k, y: p0.y + p0.ny * k },
c2: { x: p3.x + p3.nx * k, y: p3.y + p3.ny * k }
};
}
function curve(g) {
return 'M' + g.p0.x + ',' + g.p0.y +
'C' + g.c1.x + ',' + g.c1.y + ' ' + g.c2.x + ',' + g.c2.y +
' ' + g.p3.x + ',' + g.p3.y;
}
// midpoint of a cubic at t=0.5, closed form — no DOM measurement needed to
// place a label.
function midpoint(g) {
return {
x: (g.p0.x + 3 * g.c1.x + 3 * g.c2.x + g.p3.x) / 8,
y: (g.p0.y + 3 * g.c1.y + 3 * g.c2.y + g.p3.y) / 8
};
}
// arrowhead draws a triangle with its tip at pt, its base offset along the
// outward normal — so the arrow points into the node it terminates on.
function arrowhead(pt) {
var bx = pt.x + pt.nx * ARROW;
var by = pt.y + pt.ny * ARROW;
var px = -pt.ny * ARROW * 0.42;
var py = pt.nx * ARROW * 0.42;
return 'M' + pt.x + ',' + pt.y +
'L' + (bx + px) + ',' + (by + py) +
'L' + (bx - px) + ',' + (by - py) + 'Z';
}
function colorVar(c) {
if (!c) return 'var(--canvas-edge)';
if (c.charAt(0) === '#') return c;
return 'var(--canvas-' + c + ')';
}
function build(e) {
var g = el('g', { 'class': 'canvas-edge' });
g.dataset.id = e.id;
// A transparent fat stroke under the visible line is the hit target:
// a 2px curve is essentially unclickable otherwise.
g.appendChild(el('path', { 'class': 'canvas-edge-hit', fill: 'none' }));
g.appendChild(el('path', { 'class': 'canvas-edge-line', fill: 'none' }));
g.appendChild(el('path', { 'class': 'canvas-edge-head canvas-edge-head-to' }));
g.appendChild(el('path', { 'class': 'canvas-edge-head canvas-edge-head-from' }));
var label = el('text', { 'class': 'canvas-edge-label', 'text-anchor': 'middle', 'dominant-baseline': 'middle' });
g.appendChild(label);
return g;
}
function update(g, e) {
var geo = geometry(e);
if (!geo) { g.setAttribute('display', 'none'); return; }
g.removeAttribute('display');
var d = curve(geo);
var stroke = colorVar(e.color);
g.querySelector('.canvas-edge-hit').setAttribute('d', d);
var line = g.querySelector('.canvas-edge-line');
line.setAttribute('d', d);
line.setAttribute('stroke', stroke);
// Spec defaults: fromEnd is "none", toEnd is "arrow".
var toHead = g.querySelector('.canvas-edge-head-to');
if ((e.toEnd || 'arrow') === 'arrow') {
toHead.setAttribute('d', arrowhead(geo.p3));
toHead.setAttribute('fill', stroke);
toHead.removeAttribute('display');
} else {
toHead.setAttribute('display', 'none');
}
var fromHead = g.querySelector('.canvas-edge-head-from');
if ((e.fromEnd || 'none') === 'arrow') {
fromHead.setAttribute('d', arrowhead(geo.p0));
fromHead.setAttribute('fill', stroke);
fromHead.removeAttribute('display');
} else {
fromHead.setAttribute('display', 'none');
}
var label = g.querySelector('.canvas-edge-label');
if (e.label) {
var m = midpoint(geo);
label.setAttribute('x', m.x);
label.setAttribute('y', m.y);
label.textContent = e.label;
label.removeAttribute('display');
} else {
label.setAttribute('display', 'none');
}
g.classList.toggle('is-selected', Select.hasEdge(e.id));
}
return {
init: function (svgEl) { svg = svgEl; },
sideAnchor: sideAnchor,
autoSides: autoSides,
render: function () {
var seen = {};
Doc.edges().forEach(function (e) {
seen[e.id] = true;
var g = groups[e.id];
if (!g) {
g = build(e);
groups[e.id] = g;
svg.appendChild(g);
}
update(g, e);
});
Object.keys(groups).forEach(function (id) {
if (seen[id]) return;
groups[id].remove();
delete groups[id];
});
},
// syncFor repaints only the edges touching the given nodes. Called on
// every frame of a drag, where re-running the full reconcile would be
// pure overhead.
syncFor: function (nodeIds) {
Doc.edgesFor(nodeIds).forEach(function (e) {
var g = groups[e.id];
if (g) update(g, e);
});
},
syncSelection: function () {
Doc.edges().forEach(function (e) {
var g = groups[e.id];
if (g) g.classList.toggle('is-selected', Select.hasEdge(e.id));
});
},
// --- rubber band shown while dragging a new edge out of a port ---
beginTemp: function () {
if (temp) temp.remove();
temp = el('path', { 'class': 'canvas-edge-temp', fill: 'none' });
svg.appendChild(temp);
},
updateTemp: function (node, side, to) {
if (!temp) return;
var p0 = sideAnchor(node, side);
var dist = Math.sqrt((to.x - p0.x) * (to.x - p0.x) + (to.y - p0.y) * (to.y - p0.y));
var k = Math.max(40, dist / 3);
temp.setAttribute('d',
'M' + p0.x + ',' + p0.y +
'C' + (p0.x + p0.nx * k) + ',' + (p0.y + p0.ny * k) +
' ' + to.x + ',' + to.y + ' ' + to.x + ',' + to.y);
},
endTemp: function () {
if (!temp) return;
temp.remove();
temp = null;
}
};
})();
+204
View File
@@ -0,0 +1,204 @@
// edit.js — editing a node's content.
//
// Text nodes get the vendored CodeMirror editor in place, positioned over the
// node in *screen* coordinates rather than mounted inside the zoomed world.
// CodeMirror measures its own layout in device pixels, so a CSS-scaled parent
// wrecks caret placement and selection maths; a fixed-position panel sidesteps
// that entirely and stays sharp at every zoom level.
//
// The other node types have nothing to type into, so they reuse the existing
// tree picker and modal instead of an editor.
window.Canvas = window.Canvas || {};
Canvas.Edit = (function () {
var Doc = Canvas.Doc;
var View = Canvas.View;
var MIN_WIDTH = 360;
var MIN_HEIGHT = 200;
var panel = null;
var view = null;
var currentId = null;
var original = '';
function destroy() {
if (view) { view.destroy(); view = null; }
if (panel) { panel.remove(); panel = null; }
currentId = null;
original = '';
document.removeEventListener('pointerdown', onOutside, true);
}
function commit() {
if (!panel || !view) return;
var text = view.state.doc.toString();
var id = currentId;
destroy();
var n = Doc.node(id);
if (!n || text === original) return;
Doc.begin('edit text');
n.text = text;
Doc.commit();
}
function onOutside(e) {
if (!panel || panel.contains(e.target)) return;
commit();
}
// screenRect places the editor over the node it edits, clamped to a usable
// minimum and kept inside the viewport — a 60x60 node still needs a panel
// big enough to type in.
function screenRect(n) {
var s = View.scale();
var tl = View.toScreen(n.x, n.y);
var w = Math.max(MIN_WIDTH, n.width * s);
var h = Math.max(MIN_HEIGHT, n.height * s);
w = Math.min(w, window.innerWidth - 16);
h = Math.min(h, window.innerHeight - 16);
var left = Math.max(8, Math.min(tl.x, window.innerWidth - w - 8));
var top = Math.max(8, Math.min(tl.y, window.innerHeight - h - 8));
return { left: left, top: top, width: w, height: h };
}
function openText(n) {
var rect = screenRect(n);
panel = document.createElement('div');
panel.className = 'canvas-editor panel panel-floating';
panel.style.left = rect.left + 'px';
panel.style.top = rect.top + 'px';
panel.style.width = rect.width + 'px';
panel.style.height = rect.height + 'px';
var hint = document.createElement('div');
hint.className = 'panel-header';
hint.textContent = 'Ctrl+Enter or click away to apply · Escape to cancel';
panel.appendChild(hint);
var mount = document.createElement('div');
mount.className = 'canvas-editor-mount';
panel.appendChild(mount);
document.body.appendChild(panel);
currentId = n.id;
original = n.text || '';
view = new CM.EditorView({
state: CM.EditorState.create({
doc: original,
selection: { anchor: original.length },
extensions: [
CM.history(),
CM.drawSelection(),
CM.indentOnInput(),
CM.EditorView.lineWrapping,
CM.EditorView.contentAttributes.of({ spellcheck: 'true', autocapitalize: 'sentences' }),
CM.markdown({ base: CM.markdownLanguage }),
CM.syntaxHighlighting(CM.highlightStyle),
CM.closeBrackets(),
CM.autocompletion({ override: [WikiComplete.source] }),
CM.theme,
// Mod-Enter goes first (nothing else claims it); Escape goes
// last so an open completion popup still gets first refusal
// on it and closes itself instead of discarding the edit.
CM.keymap.of([{ key: 'Mod-Enter', run: function () { commit(); return true; } }]
.concat(
CM.closeBracketsKeymap,
CM.completionKeymap,
CM.markdownKeymap,
CM.defaultKeymap,
CM.historyKeymap,
[CM.indentWithTab],
[{ key: 'Escape', run: function () { destroy(); return true; } }]
))
]
}),
parent: mount
});
view.focus();
// Capture phase: a click on a toolbar button should apply the edit
// before that button's own handler reads the document.
document.addEventListener('pointerdown', onOutside, true);
}
function openFile(n) {
var start = (n.file || '/').replace(/\/+$/, '') || '/';
openTreePicker({
title: 'File node — pick a target',
mode: 'any',
initialPath: start,
preselect: start,
confirmLabel: 'USE',
onSelect: function (p) {
if (!p || p === n.file) return;
Doc.begin('retarget file node');
n.file = p;
n.subpath = '';
Doc.commit();
}
});
}
function promptValue(title, label, value, onValue) {
var input = document.createElement('input');
input.type = 'text';
input.className = 'input';
input.placeholder = label;
input.value = value || '';
openModal({
title: title,
body: input,
confirm: {
label: 'OK',
onConfirm: function () {
closeModal();
onValue(input.value.trim());
}
}
});
}
function openLink(n) {
promptValue('Link node', 'https://example.com', n.url, function (v) {
if (!v || v === n.url) return;
Doc.begin('edit link');
n.url = v;
Doc.commit();
});
}
function openGroup(n) {
promptValue('Group label', 'Label', n.label, function (v) {
if (v === (n.label || '')) return;
Doc.begin('edit group label');
if (v) n.label = v; else delete n.label;
Doc.commit();
});
}
// close applies whatever is currently open, if anything.
function close() {
if (panel) commit();
}
return {
open: function (id) {
var n = Doc.node(id);
if (!n) return;
close();
switch (n.type) {
case 'text': openText(n); break;
case 'file': openFile(n); break;
case 'link': openLink(n); break;
case 'group': openGroup(n); break;
}
},
// Called from the gesture layer on any pointerdown on the surface, so
// starting a drag never silently drops an edit.
close: close,
isOpen: function () { return panel !== null; }
};
})();
+461
View File
@@ -0,0 +1,461 @@
// interact.js — pointer and keyboard gestures.
//
// Every gesture is bracketed by Doc.begin()/Doc.commit(), so a drag that spans
// hundreds of pointermove events collapses into one undo entry. Within a
// gesture the document is mutated directly and only the affected elements are
// re-synced; the full reconcile happens once, on commit.
//
// Gesture routing on pointerdown, in priority order:
// resize handle -> resize port -> draw an edge
// node -> select + drag edge -> select
// background -> pan (shift: marquee select; two fingers: pinch zoom)
window.Canvas = window.Canvas || {};
Canvas.Interact = (function () {
var Doc = Canvas.Doc;
var View = Canvas.View;
var Select = Canvas.Select;
var Nodes = Canvas.Nodes;
var Edges = Canvas.Edges;
var MIN_SIZE = 40;
var DRAG_SLOP = 3;
var surface = null;
var marquee = null;
var gesture = null;
var pointers = {};
function snap(v) {
return Math.round(v / View.GRID) * View.GRID;
}
function pointerCount() {
return Object.keys(pointers).length;
}
// interactive reports whether the pointer landed on something the browser
// should handle itself (a link, a video control), which must not become the
// start of a node drag.
function interactive(target) {
return !!target.closest('a, button, input, select, textarea, video, embed, iframe');
}
// groupMembers returns the nodes whose centre sits inside a group, which is
// what "inside" means for dragging: a node straddling the border belongs to
// whichever side holds most of it.
function groupMembers(g) {
return Doc.nodes().filter(function (n) {
if (n.id === g.id) return false;
var cx = n.x + n.width / 2;
var cy = n.y + n.height / 2;
return cx >= g.x && cx <= g.x + g.width && cy >= g.y && cy <= g.y + g.height;
}).map(function (n) { return n.id; });
}
// withGroupMembers expands a selection so dragging a group carries whatever
// it contains.
function withGroupMembers(ids) {
var out = {};
ids.forEach(function (id) {
out[id] = true;
var n = Doc.node(id);
if (n && n.type === 'group') {
groupMembers(n).forEach(function (m) { out[m] = true; });
}
});
return Object.keys(out);
}
function selectOnPress(id, additive) {
if (additive) {
Select.toggleNode(id);
return;
}
if (!Select.hasNode(id)) Select.set([id], []);
}
// --- gesture starts ---
function startDrag(id, e) {
selectOnPress(id, e.shiftKey);
if (!Select.hasNode(id)) return; // shift-click deselected it
var ids = withGroupMembers(Select.nodes());
Doc.begin('move');
gesture = {
kind: 'drag',
ids: ids,
start: View.toWorld(e.clientX, e.clientY),
origin: ids.map(function (nid) {
var n = Doc.node(nid);
return { id: nid, x: n.x, y: n.y };
}),
moved: false
};
}
function startResize(id, corner, e) {
Select.set([id], []);
var n = Doc.node(id);
Doc.begin('resize');
gesture = {
kind: 'resize',
id: id,
corner: corner,
start: View.toWorld(e.clientX, e.clientY),
origin: { x: n.x, y: n.y, width: n.width, height: n.height },
moved: false
};
}
function startEdge(id, side) {
Edges.beginTemp();
gesture = { kind: 'edge', fromId: id, fromSide: side, target: null, moved: false };
}
function startPan(e) {
gesture = { kind: 'pan', last: { x: e.clientX, y: e.clientY }, moved: false };
}
function startMarquee(e) {
var r = surface.getBoundingClientRect();
gesture = {
kind: 'marquee',
origin: { x: e.clientX - r.left, y: e.clientY - r.top },
additive: e.ctrlKey || e.metaKey,
moved: false
};
marquee.hidden = false;
updateMarquee(e);
}
function startPinch() {
var ids = Object.keys(pointers);
var a = pointers[ids[0]];
var b = pointers[ids[1]];
gesture = {
kind: 'pinch',
dist: Math.hypot(b.x - a.x, b.y - a.y),
mid: { x: (a.x + b.x) / 2, y: (a.y + b.y) / 2 },
moved: true
};
}
// --- gesture updates ---
function moveDrag(e) {
var p = View.toWorld(e.clientX, e.clientY);
var lead = gesture.origin[0];
if (!lead) return;
var dx = p.x - gesture.start.x;
var dy = p.y - gesture.start.y;
// Snap the lead node to the grid and shift the rest by the same amount,
// so a multi-node drag keeps its relative layout. Alt opts out.
if (!e.altKey) {
dx = snap(lead.x + dx) - lead.x;
dy = snap(lead.y + dy) - lead.y;
}
gesture.origin.forEach(function (o) {
var n = Doc.node(o.id);
if (!n) return;
n.x = Math.round(o.x + dx);
n.y = Math.round(o.y + dy);
});
gesture.ids.forEach(Nodes.syncGeom);
Edges.syncFor(gesture.ids);
}
function moveResize(e) {
var n = Doc.node(gesture.id);
if (!n) return;
var p = View.toWorld(e.clientX, e.clientY);
var o = gesture.origin;
var dx = p.x - gesture.start.x;
var dy = p.y - gesture.start.y;
var right = o.x + o.width;
var bottom = o.y + o.height;
var x = o.x, y = o.y, w = o.width, h = o.height;
if (gesture.corner.indexOf('w') !== -1) {
x = e.altKey ? o.x + dx : snap(o.x + dx);
w = right - x;
} else {
w = e.altKey ? o.width + dx : snap(right + dx) - o.x;
}
if (gesture.corner.indexOf('n') !== -1) {
y = e.altKey ? o.y + dy : snap(o.y + dy);
h = bottom - y;
} else {
h = e.altKey ? o.height + dy : snap(bottom + dy) - o.y;
}
// Clamp against the fixed edge so shrinking past the minimum pins the
// node instead of flipping it inside out.
if (w < MIN_SIZE) {
if (gesture.corner.indexOf('w') !== -1) x = right - MIN_SIZE;
w = MIN_SIZE;
}
if (h < MIN_SIZE) {
if (gesture.corner.indexOf('n') !== -1) y = bottom - MIN_SIZE;
h = MIN_SIZE;
}
n.x = Math.round(x);
n.y = Math.round(y);
n.width = Math.round(w);
n.height = Math.round(h);
Nodes.syncGeom(n.id);
Edges.syncFor([n.id]);
}
function moveEdge(e) {
var from = Doc.node(gesture.fromId);
if (!from) return;
Edges.updateTemp(from, gesture.fromSide, View.toWorld(e.clientX, e.clientY));
var under = document.elementFromPoint(e.clientX, e.clientY);
var nodeEl = under && under.closest ? under.closest('.canvas-node') : null;
var id = nodeEl ? nodeEl.dataset.id : null;
if (id === gesture.target) return;
if (gesture.target) {
var prev = Nodes.el(gesture.target);
if (prev) prev.classList.remove('is-edge-target');
}
gesture.target = id;
if (id && nodeEl) nodeEl.classList.add('is-edge-target');
}
function updateMarquee(e) {
var r = surface.getBoundingClientRect();
var x = e.clientX - r.left;
var y = e.clientY - r.top;
marquee.style.left = Math.min(x, gesture.origin.x) + 'px';
marquee.style.top = Math.min(y, gesture.origin.y) + 'px';
marquee.style.width = Math.abs(x - gesture.origin.x) + 'px';
marquee.style.height = Math.abs(y - gesture.origin.y) + 'px';
}
function movePinch() {
var ids = Object.keys(pointers);
if (ids.length < 2) return;
var a = pointers[ids[0]];
var b = pointers[ids[1]];
var dist = Math.hypot(b.x - a.x, b.y - a.y);
var mid = { x: (a.x + b.x) / 2, y: (a.y + b.y) / 2 };
if (gesture.dist > 0) View.zoomAt(mid.x, mid.y, dist / gesture.dist);
View.pan(mid.x - gesture.mid.x, mid.y - gesture.mid.y);
gesture.dist = dist;
gesture.mid = mid;
}
// --- gesture ends ---
// Both finishers take the gesture as an argument rather than reading the
// module-level one: endGesture clears it before dispatching so a finisher
// cannot accidentally leave a half-torn-down gesture in place.
function finishMarquee(g) {
marquee.hidden = true;
var r = surface.getBoundingClientRect();
var a = View.toWorld(r.left + parseFloat(marquee.style.left),
r.top + parseFloat(marquee.style.top));
var b = View.toWorld(r.left + parseFloat(marquee.style.left) + parseFloat(marquee.style.width),
r.top + parseFloat(marquee.style.top) + parseFloat(marquee.style.height));
var hit = Doc.nodes().filter(function (n) {
return n.x < b.x && n.x + n.width > a.x && n.y < b.y && n.y + n.height > a.y;
}).map(function (n) { return n.id; });
if (g.additive) {
hit.forEach(Select.addNode);
} else {
Select.set(hit, []);
}
}
function finishEdge(g) {
if (g.target) {
var el = Nodes.el(g.target);
if (el) el.classList.remove('is-edge-target');
}
Edges.endTemp();
if (!g.target) return;
Doc.begin('connect');
Doc.addEdge({
id: Doc.newId(),
fromNode: g.fromId,
fromSide: g.fromSide,
toNode: g.target
});
Doc.commit();
}
// --- event wiring ---
function onDown(e) {
if (Canvas.Edit) Canvas.Edit.close();
pointers[e.pointerId] = { x: e.clientX, y: e.clientY };
if (pointerCount() === 2 && (!gesture || gesture.kind === 'pan')) {
if (gesture) endGesture(true);
startPinch();
return;
}
if (gesture) return; // one gesture at a time
var t = e.target;
var handle = t.closest('.canvas-handle');
var port = t.closest('.canvas-port');
var nodeEl = t.closest('.canvas-node');
var edgeEl = t.closest('.canvas-edge');
if (handle && nodeEl) {
startResize(nodeEl.dataset.id, handle.dataset.corner, e);
} else if (port && nodeEl) {
startEdge(nodeEl.dataset.id, port.dataset.side);
} else if (nodeEl) {
if (interactive(t)) return;
startDrag(nodeEl.dataset.id, e);
} else if (edgeEl) {
if (e.shiftKey) Select.toggleEdge(edgeEl.dataset.id);
else Select.set([], [edgeEl.dataset.id]);
return;
} else if (e.shiftKey) {
startMarquee(e);
} else {
startPan(e);
}
if (gesture) {
// Capture keeps the gesture alive when the pointer leaves the
// surface mid-drag. It throws when the pointer is already gone (a
// release that raced the handler), which must not abort the
// gesture — that would leave it stuck with no pointerup to end it.
try { surface.setPointerCapture(e.pointerId); } catch (err) { /* pointer already released */ }
e.preventDefault();
}
}
function onMove(e) {
if (pointers[e.pointerId]) {
pointers[e.pointerId].x = e.clientX;
pointers[e.pointerId].y = e.clientY;
}
if (!gesture) return;
if (gesture.kind === 'pinch') { movePinch(); return; }
if (!gesture.moved) {
// Ignore sub-pixel jitter so a plain click does not register as a
// drag (and so a click on the background still clears the selection).
var dx = Math.abs(e.movementX || 0);
var dy = Math.abs(e.movementY || 0);
if (dx + dy < DRAG_SLOP && gesture.kind !== 'edge') return;
gesture.moved = true;
}
switch (gesture.kind) {
case 'drag': moveDrag(e); break;
case 'resize': moveResize(e); break;
case 'edge': moveEdge(e); break;
case 'marquee': updateMarquee(e); break;
case 'pan':
View.pan(e.clientX - gesture.last.x, e.clientY - gesture.last.y);
gesture.last = { x: e.clientX, y: e.clientY };
break;
}
}
function endGesture(silent) {
if (!gesture) return;
var g = gesture;
gesture = null;
switch (g.kind) {
case 'drag':
case 'resize':
Doc.commit();
break;
case 'edge':
if (!silent) finishEdge(g); else Edges.endTemp();
break;
case 'marquee':
if (!silent) finishMarquee(g); else marquee.hidden = true;
break;
case 'pan':
// A press on the background that never moved is a click, and a
// click on empty space clears the selection.
if (!silent && !g.moved) Select.clear();
break;
}
}
function onUp(e) {
delete pointers[e.pointerId];
if (gesture && gesture.kind === 'pinch' && pointerCount() < 2) {
gesture = null;
return;
}
endGesture(false);
}
function onWheel(e) {
// Ctrl/meta+wheel is the zoom gesture every drawing tool uses, and is
// also what a trackpad pinch reports. A plain wheel scrolls the canvas.
if (e.ctrlKey || e.metaKey) {
e.preventDefault();
View.zoomAt(e.clientX, e.clientY, Math.pow(0.999, e.deltaY));
return;
}
e.preventDefault();
if (e.shiftKey) View.pan(-e.deltaY, 0);
else View.pan(-e.deltaX, -e.deltaY);
}
function onDblClick(e) {
var nodeEl = e.target.closest('.canvas-node');
if (nodeEl) {
if (interactive(e.target)) return;
e.preventDefault();
Canvas.Edit.open(nodeEl.dataset.id);
return;
}
if (e.target.closest('.canvas-edge')) return;
// Double-clicking empty space drops a new text node there and opens it,
// which is how a canvas normally gets its first node.
var p = View.toWorld(e.clientX, e.clientY);
var node = Canvas.Actions.addText(p);
if (node) Canvas.Edit.open(node.id);
}
return {
init: function (surfaceEl, marqueeEl) {
surface = surfaceEl;
marquee = marqueeEl;
surface.addEventListener('pointerdown', onDown);
surface.addEventListener('pointermove', onMove);
surface.addEventListener('pointerup', onUp);
surface.addEventListener('pointercancel', onUp);
surface.addEventListener('wheel', onWheel, { passive: false });
surface.addEventListener('dblclick', onDblClick);
// Native drag would fight the pointer gestures on images and links.
surface.addEventListener('dragstart', function (e) { e.preventDefault(); });
surface.addEventListener('contextmenu', function (e) {
if (e.target.closest('.canvas-node, .canvas-edge')) e.preventDefault();
});
},
// cancel aborts an in-flight gesture and rolls the document back, for
// the Escape key.
cancel: function () {
if (!gesture) return false;
var kind = gesture.kind;
if (kind === 'drag' || kind === 'resize') {
gesture = null;
Doc.abort();
return true;
}
endGesture(true);
return true;
},
active: function () { return gesture !== null; }
};
})();
+88
View File
@@ -0,0 +1,88 @@
{{define "headerActions"}}
<a class="btn" href="{{.PostURL}}" title="Back to the page">PAGE</a>
<span class="dropdown">
<button type="button" class="btn dropdown-toggle" data-action="canvas-menu" title="Canvas actions">CANVAS▾</button>
<div class="dropdown-menu align-right">
<button type="button" class="btn btn-block" data-action="canvas-rename">Rename…</button>
<button type="button" class="btn btn-block danger" data-action="canvas-delete">Delete canvas</button>
</div>
</span>
<button class="btn" type="button" data-action="canvas-save" title="Save (Ctrl+S)">SAVE</button>
{{end}}
{{define "content"}}
<script>
// Full-bleed view mode: the canvas surface owns the viewport instead of the
// centred reading column. Set here rather than server-side for the same
// reason the editor does it — one class, no extra template plumbing.
document.body.classList.add('canvas-mode');
</script>
{{if .CanvasError}}
<div class="content">
<h1>{{.ActiveCanvas}}</h1>
<p class="canvas-broken">This canvas could not be opened — {{.CanvasError}}</p>
<p>Fix it in the file itself, then reload.
<a href="{{.ActiveCanvas}}.canvas?raw">view the raw file</a> ·
<a href="{{.PostURL}}">back to the page</a></p>
</div>
{{else}}
<div class="canvas-app">
<nav class="canvas-tabs row" aria-label="Page views">
<a class="canvas-tab" href="{{.PostURL}}">PAGE</a>
{{range .Canvases}}<a class="canvas-tab{{if .Active}} is-active{{end}}" href="{{.URL}}">{{.Name}}</a>{{end}}
</nav>
<div class="canvas-toolbar row">
<button type="button" class="btn btn-tool" data-action="undo" title="Undo (Ctrl+Z)" disabled>↶</button>
<button type="button" class="btn btn-tool" data-action="redo" title="Redo (Ctrl+Shift+Z)" disabled>↷</button>
<span class="toolbar-sep"></span>
<button type="button" class="btn btn-tool" data-action="add-text" title="Add text node (T)">TEXT</button>
<button type="button" class="btn btn-tool" data-action="add-file" title="Add file node (F)">FILE</button>
<button type="button" class="btn btn-tool" data-action="add-link" title="Add link node (L)">LINK</button>
<button type="button" class="btn btn-tool" data-action="add-group" title="Add group (G)">GROUP</button>
<span class="toolbar-sep"></span>
<span class="dropdown">
<button type="button" class="btn btn-tool dropdown-toggle" data-action="color-menu" title="Colour of the selection">■▾</button>
<div class="dropdown-menu canvas-color-menu">
<button type="button" class="btn btn-tool btn-block" data-color="">Default</button>
<button type="button" class="btn btn-tool btn-block" data-color="1"><span class="canvas-swatch" data-swatch="1"></span> Red</button>
<button type="button" class="btn btn-tool btn-block" data-color="2"><span class="canvas-swatch" data-swatch="2"></span> Orange</button>
<button type="button" class="btn btn-tool btn-block" data-color="3"><span class="canvas-swatch" data-swatch="3"></span> Yellow</button>
<button type="button" class="btn btn-tool btn-block" data-color="4"><span class="canvas-swatch" data-swatch="4"></span> Green</button>
<button type="button" class="btn btn-tool btn-block" data-color="5"><span class="canvas-swatch" data-swatch="5"></span> Cyan</button>
<button type="button" class="btn btn-tool btn-block" data-color="6"><span class="canvas-swatch" data-swatch="6"></span> Purple</button>
</div>
</span>
<button type="button" class="btn btn-tool" data-action="delete-selection" title="Delete selection (Del)">×</button>
<span class="toolbar-sep"></span>
<button type="button" class="btn btn-tool" data-action="zoom-out" title="Zoom out (−)">−</button>
<button type="button" class="btn btn-tool" data-action="zoom-reset" title="Reset zoom to 100% (0)"><span data-canvas-zoom>100%</span></button>
<button type="button" class="btn btn-tool" data-action="zoom-in" title="Zoom in (+)">+</button>
<button type="button" class="btn btn-tool" data-action="fit" title="Fit to content (9)">FIT</button>
<span class="canvas-status muted" data-canvas-status></span>
</div>
<div class="canvas-surface" id="canvas-surface" tabindex="0">
<div class="canvas-world" id="canvas-world">
<div class="canvas-layer" id="canvas-groups"></div>
<svg class="canvas-edges" id="canvas-edges" xmlns="http://www.w3.org/2000/svg"></svg>
<div class="canvas-layer" id="canvas-nodes"></div>
</div>
<div class="canvas-marquee" id="canvas-marquee" hidden></div>
<div class="canvas-hint muted" data-canvas-hint hidden>
Empty canvas — use the toolbar to add a node. Drag the background to pan, ctrl+wheel to zoom.
</div>
</div>
</div>
<script>window.CANVAS_DATA = {{.CanvasJSON}};</script>
<script src="/_/editor/vendor/codemirror.bundle.js?v={{editorBundleVersion}}"></script>
<script src="/_/editor/wikicomplete.js"></script>
<script src="/_/canvas/doc.js"></script>
<script src="/_/canvas/view.js"></script>
<script src="/_/canvas/select.js"></script>
<script src="/_/canvas/nodes.js"></script>
<script src="/_/canvas/edges.js"></script>
<script src="/_/canvas/interact.js"></script>
<script src="/_/canvas/edit.js"></script>
<script src="/_/canvas/save.js"></script>
<script src="/_/canvas/main.js"></script>
{{end}}
{{end}}
+389
View File
@@ -0,0 +1,389 @@
// main.js — bootstrap, toolbar, and keyboard shortcuts.
//
// The payload the server inlined (document + pre-rendered node HTML + the
// version token) is the only input; nothing else is fetched on load.
(function () {
var data = window.CANVAS_DATA;
var surface = document.getElementById('canvas-surface');
if (!data || !surface) return;
var Doc = Canvas.Doc;
var View = Canvas.View;
var Select = Canvas.Select;
var Nodes = Canvas.Nodes;
var Edges = Canvas.Edges;
var Save = Canvas.Save;
var world = document.getElementById('canvas-world');
var marquee = document.getElementById('canvas-marquee');
var hint = document.querySelector('[data-canvas-hint]');
var statusEl = document.querySelector('[data-canvas-status]');
var zoomEl = document.querySelector('[data-canvas-zoom]');
// Default sizes for new nodes, in world pixels. Multiples of the 20px grid
// so a fresh node lands aligned.
var SIZES = {
text: { width: 260, height: 120 },
file: { width: 400, height: 400 },
link: { width: 400, height: 140 },
group: { width: 520, height: 400 }
};
function snap(v) { return Math.round(v / View.GRID) * View.GRID; }
// --- document actions ---
Canvas.Actions = {
// add drops a node centred on a world point (the viewport centre when
// called from the toolbar, the cursor when called from a double-click).
add: function (type, extra, at) {
var size = SIZES[type];
var p = at || View.centreOfViewport();
var node = {
id: Doc.newId(),
type: type,
x: snap(p.x - size.width / 2),
y: snap(p.y - size.height / 2),
width: size.width,
height: size.height
};
for (var k in extra) {
if (Object.prototype.hasOwnProperty.call(extra, k)) node[k] = extra[k];
}
Doc.begin('add ' + type);
Doc.addNode(node);
Doc.commit();
Select.set([node.id], []);
return node;
},
addText: function (at) {
return Canvas.Actions.add('text', { text: '' }, at);
},
addGroup: function (at) {
return Canvas.Actions.add('group', { label: 'Group' }, at);
},
// A file node is meaningless without a target and a link node without a
// url, so both ask first and only then create — validation on the server
// rejects the empty forms anyway.
addFile: function () {
openTreePicker({
title: 'File node — pick a target',
mode: 'any',
initialPath: data.postUrl,
confirmLabel: 'ADD',
onSelect: function (p) {
if (p) Canvas.Actions.add('file', { file: p });
}
});
},
addLink: function () {
var input = document.createElement('input');
input.type = 'url';
input.className = 'input';
input.placeholder = 'https://example.com';
openModal({
title: 'Link node',
body: input,
confirm: {
label: 'ADD',
onConfirm: function () {
var v = input.value.trim();
if (!v) return;
closeModal();
Canvas.Actions.add('link', { url: v });
}
}
});
},
deleteSelection: function () {
if (Select.isEmpty()) return;
Doc.begin('delete');
Doc.remove(Select.nodes(), Select.edges());
Doc.commit();
Select.clear();
},
setColor: function (color) {
if (Select.isEmpty()) return;
Doc.begin('colour');
Select.nodes().forEach(function (id) {
var n = Doc.node(id);
if (!n) return;
if (color) n.color = color; else delete n.color;
});
Select.edges().forEach(function (id) {
var e = Doc.edge(id);
if (!e) return;
if (color) e.color = color; else delete e.color;
});
Doc.commit();
},
// nudge moves the selection by whole grid steps, or single pixels with
// shift held for the cases where the grid is in the way.
nudge: function (dx, dy, fine) {
var ids = Select.nodes();
if (!ids.length) return;
var step = fine ? 1 : View.GRID;
Doc.begin('nudge');
ids.forEach(function (id) {
var n = Doc.node(id);
if (!n) return;
n.x += dx * step;
n.y += dy * step;
});
Doc.commit();
},
selectAll: function () {
Select.set(
Doc.nodes().map(function (n) { return n.id; }),
Doc.edges().map(function (e) { return e.id; })
);
}
};
// --- chrome ---
function setStatus(text) {
if (statusEl) statusEl.textContent = text || '';
}
function updateToolbar() {
var undoBtn = document.querySelector('[data-action="undo"]');
var redoBtn = document.querySelector('[data-action="redo"]');
var delBtn = document.querySelector('[data-action="delete-selection"]');
if (undoBtn) undoBtn.disabled = !Doc.canUndo();
if (redoBtn) redoBtn.disabled = !Doc.canRedo();
if (delBtn) delBtn.disabled = Select.isEmpty();
var saveBtn = document.querySelector('[data-action="canvas-save"]');
if (saveBtn) saveBtn.classList.toggle('is-active', Doc.isDirty());
if (hint) hint.hidden = Doc.nodes().length > 0;
}
function updateZoom() {
if (zoomEl) zoomEl.textContent = Math.round(View.scale() * 100) + '%';
}
// refresh is the single re-render path: prune the selection against the
// (possibly undone) document, reconcile both layers, update the chrome.
function refresh() {
Select.prune(Doc.get());
Nodes.render();
Edges.render();
updateToolbar();
}
// --- canvas-level actions (rename / delete the file itself) ---
function leaveGuard() {
if (!Doc.isDirty()) return true;
return window.confirm('This canvas has unsaved changes. Leave anyway?');
}
function canvasAction(url, then) {
fetch(url, { method: 'POST', credentials: 'same-origin', redirect: 'manual' })
.then(function (res) {
if (res.type === 'opaqueredirect' || res.ok) { then(); return; }
return res.text().then(function (msg) {
alert(msg || ('Request failed (' + res.status + ')'));
});
})
.catch(function () { alert('Network error'); });
}
function renameCanvas() {
var input = document.createElement('input');
input.type = 'text';
input.className = 'input';
input.value = data.name;
openModal({
title: 'Rename canvas',
body: input,
confirm: {
label: 'RENAME',
onConfirm: function () {
var v = input.value.trim();
if (!v || v === data.name) { closeModal(); return; }
closeModal();
canvasAction(
data.postUrl + '?canvas=' + encodeURIComponent(data.name) +
'&rename=' + encodeURIComponent(v),
function () {
window.location.href = data.postUrl + '?canvas=' + encodeURIComponent(v);
}
);
}
}
});
}
function deleteCanvas() {
openModal({
title: 'Delete canvas',
body: 'Delete "' + data.name + '"? The page itself is not affected.',
confirm: {
label: 'DELETE',
danger: true,
onConfirm: function () {
closeModal();
canvasAction(
data.postUrl + '?canvas=' + encodeURIComponent(data.name) + '&delete=1',
function () { window.location.href = data.postUrl; }
);
}
}
});
}
// --- wiring ---
function wireToolbar() {
var handlers = {
'undo': function () { Doc.undo(); },
'redo': function () { Doc.redo(); },
'add-text': function () { Canvas.Edit.open(Canvas.Actions.addText().id); },
'add-file': function () { Canvas.Actions.addFile(); },
'add-link': function () { Canvas.Actions.addLink(); },
'add-group': function () { Canvas.Actions.addGroup(); },
'delete-selection': function () { Canvas.Actions.deleteSelection(); },
'zoom-in': function () { View.zoomBy(1.2); },
'zoom-out': function () { View.zoomBy(1 / 1.2); },
'zoom-reset': function () { View.resetZoom(); },
'fit': function () { View.fit(Doc.nodes()); },
'canvas-save': function () { Save.run(); },
'canvas-rename': renameCanvas,
'canvas-delete': deleteCanvas
};
document.addEventListener('click', function (e) {
var btn = e.target.closest('[data-action]');
if (!btn) return;
var fn = handlers[btn.dataset.action];
if (!fn) return;
e.preventDefault();
fn();
});
document.querySelectorAll('.canvas-color-menu [data-color]').forEach(function (btn) {
btn.addEventListener('click', function () {
Canvas.Actions.setColor(btn.dataset.color);
});
});
// The header dropdowns are wired on demand: the auto-wiring in
// global-shortcuts.js only covers the page's ACTIONS menu, which this
// view replaces.
['canvas-menu', 'color-menu'].forEach(function (action) {
wireDropdown(document.querySelector('[data-action="' + action + '"]'));
});
document.querySelectorAll('.canvas-tabs a').forEach(function (a) {
a.addEventListener('click', function (e) {
if (!leaveGuard()) e.preventDefault();
});
});
}
// typing reports whether the keystroke belongs to a text field, an open
// modal, or the node editor — in which case the canvas must keep its hands
// off it.
function typing(e) {
var t = e.target;
if (!t || !t.closest) return false;
return !!t.closest('input, textarea, select, .cm-editor, .modal, [contenteditable="true"]');
}
function wireKeys() {
document.addEventListener('keydown', function (e) {
if (typing(e)) return;
var mod = e.ctrlKey || e.metaKey;
if (mod && e.key.toLowerCase() === 's') {
e.preventDefault();
Save.run();
return;
}
if (mod && e.key.toLowerCase() === 'z') {
e.preventDefault();
if (e.shiftKey) Doc.redo(); else Doc.undo();
return;
}
if (mod && e.key.toLowerCase() === 'y') {
e.preventDefault();
Doc.redo();
return;
}
if (mod && e.key.toLowerCase() === 'a') {
e.preventDefault();
Canvas.Actions.selectAll();
return;
}
if (mod) return;
switch (e.key) {
case 'Delete':
case 'Backspace':
e.preventDefault();
Canvas.Actions.deleteSelection();
break;
case 'Escape':
if (!Canvas.Interact.cancel()) Select.clear();
break;
case 'Enter':
var sel = Select.nodes();
if (sel.length === 1) {
e.preventDefault();
Canvas.Edit.open(sel[0]);
}
break;
case 'ArrowUp': e.preventDefault(); Canvas.Actions.nudge(0, -1, e.shiftKey); break;
case 'ArrowDown': e.preventDefault(); Canvas.Actions.nudge(0, 1, e.shiftKey); break;
case 'ArrowLeft': e.preventDefault(); Canvas.Actions.nudge(-1, 0, e.shiftKey); break;
case 'ArrowRight': e.preventDefault(); Canvas.Actions.nudge(1, 0, e.shiftKey); break;
case 't': Canvas.Edit.open(Canvas.Actions.addText().id); break;
case 'f': Canvas.Actions.addFile(); break;
case 'l': Canvas.Actions.addLink(); break;
case 'g': Canvas.Actions.addGroup(); break;
case '9': View.fit(Doc.nodes()); break;
case '0': View.resetZoom(); break;
case '+':
case '=': View.zoomBy(1.2); break;
case '-': View.zoomBy(1 / 1.2); break;
}
});
}
// --- boot ---
Doc.init(data.doc, data.hash);
Nodes.seed(data.rendered, Doc.get());
Nodes.init(document.getElementById('canvas-nodes'), document.getElementById('canvas-groups'));
Edges.init(document.getElementById('canvas-edges'));
View.init(surface, world);
Canvas.Interact.init(surface, marquee);
Save.init(data.postUrl, data.name);
Doc.onChange(refresh);
Select.onChange(function () {
Nodes.syncSelection();
Edges.syncSelection();
updateToolbar();
});
View.onChange(updateZoom);
Save.onState(function (state, message) { setStatus(message); });
wireToolbar();
wireKeys();
refresh();
View.fit(Doc.nodes());
updateZoom();
surface.focus();
})();
Binary file not shown.
+125
View File
@@ -0,0 +1,125 @@
// save.js — writing the canvas back.
//
// The whole document goes up in one request: a canvas is a single JSON object
// whose edges reference node ids, so a partial patch would have to reason about
// cross-references the server would then have to re-validate anyway.
//
// Two server rejections are worth handling rather than dumping on the user:
// a 409 (someone else changed the file) and the empty-canvas guard (which asks
// for explicit confirmation before letting a save blank a populated canvas).
window.Canvas = window.Canvas || {};
Canvas.Save = (function () {
var Doc = Canvas.Doc;
var action = '';
var saving = false;
var listeners = [];
function emit(state, message) {
for (var i = 0; i < listeners.length; i++) listeners[i](state, message);
}
function post(url) {
return fetch(url, {
method: 'POST',
credentials: 'same-origin',
headers: {
'Content-Type': 'application/json',
'X-Canvas-Version': Doc.version()
},
body: JSON.stringify(Doc.get())
});
}
function run(force) {
if (saving) return Promise.resolve(false);
if (!Doc.isDirty() && !force) {
emit('clean', 'no changes');
return Promise.resolve(true);
}
saving = true;
emit('saving', 'saving…');
var url = action + (force ? '&empty=1' : '');
return post(url).then(function (res) {
if (res.status === 204) {
Doc.markSaved(res.headers.get('X-Canvas-Version') || '');
emit('clean', 'saved');
return true;
}
return res.text().then(function (msg) {
if (res.status === 409) {
conflict(msg);
} else if (res.status === 400 && msg.indexOf('empty canvas') !== -1) {
confirmEmpty(msg);
} else {
emit('error', msg || ('save failed (' + res.status + ')'));
alert(msg || ('Save failed (' + res.status + ')'));
}
return false;
});
}).catch(function () {
emit('error', 'network error');
alert('Could not reach the server — your changes are still here, try again.');
return false;
}).finally(function () {
saving = false;
});
}
// conflict: the file changed underneath us. Overwriting silently would
// discard whatever the other writer did, so the choice is the user's — and
// reloading would discard *their* work, so that is spelled out too.
function conflict(msg) {
emit('error', 'conflict');
openModal({
title: 'Canvas changed on disk',
body: msg + '\n\nReload to take the version on disk (your unsaved changes here are lost), ' +
'or keep editing and save again to overwrite it.',
confirm: {
label: 'RELOAD',
danger: true,
onConfirm: function () {
closeModal();
window.location.reload();
}
},
cancel: { label: 'KEEP EDITING', autofocus: true }
});
}
function confirmEmpty(msg) {
emit('error', 'refused');
openModal({
title: 'Save an empty canvas?',
body: msg + '\n\nThis removes every node from the file.',
confirm: {
label: 'EMPTY IT',
danger: true,
onConfirm: function () {
closeModal();
run(true);
}
}
});
}
return {
init: function (postURL, name) {
action = postURL + '?canvas=' + encodeURIComponent(name);
// Unsaved work must not vanish on a stray Back or tab close. The
// browser shows its own generic wording; the return value is only a
// signal that there is something to lose.
window.addEventListener('beforeunload', function (e) {
if (!Doc.isDirty()) return;
e.preventDefault();
e.returnValue = '';
});
},
run: function () { return run(false); },
isSaving: function () { return saving; },
onState: function (fn) { listeners.push(fn); }
};
})();
+77
View File
@@ -0,0 +1,77 @@
// select.js — which nodes and edges are selected.
//
// Kept apart from the document so selection never lands in the undo stack or
// on disk: undoing an edit should restore geometry, not re-select whatever
// happened to be highlighted at the time.
window.Canvas = window.Canvas || {};
Canvas.Select = (function () {
var nodes = {};
var edges = {};
var listeners = [];
function emit() {
for (var i = 0; i < listeners.length; i++) listeners[i]();
}
function keys(obj) { return Object.keys(obj); }
return {
nodes: function () { return keys(nodes); },
edges: function () { return keys(edges); },
hasNode: function (id) { return !!nodes[id]; },
hasEdge: function (id) { return !!edges[id]; },
isEmpty: function () { return !keys(nodes).length && !keys(edges).length; },
count: function () { return keys(nodes).length + keys(edges).length; },
set: function (nodeIds, edgeIds) {
nodes = {};
edges = {};
(nodeIds || []).forEach(function (id) { nodes[id] = true; });
(edgeIds || []).forEach(function (id) { edges[id] = true; });
emit();
},
addNode: function (id) {
if (nodes[id]) return;
nodes[id] = true;
emit();
},
toggleNode: function (id) {
if (nodes[id]) delete nodes[id]; else nodes[id] = true;
emit();
},
toggleEdge: function (id) {
if (edges[id]) delete edges[id]; else edges[id] = true;
emit();
},
clear: function () {
if (!keys(nodes).length && !keys(edges).length) return;
nodes = {};
edges = {};
emit();
},
// prune drops ids that no longer exist. Called after undo/redo, where
// the document can lose nodes the selection still refers to.
prune: function (doc) {
var changed = false;
var live = {};
doc.nodes.forEach(function (n) { live[n.id] = true; });
keys(nodes).forEach(function (id) {
if (!live[id]) { delete nodes[id]; changed = true; }
});
var liveEdges = {};
doc.edges.forEach(function (e) { liveEdges[e.id] = true; });
keys(edges).forEach(function (id) {
if (!liveEdges[id]) { delete edges[id]; changed = true; }
});
if (changed) emit();
},
onChange: function (fn) { listeners.push(fn); }
};
})();
+147
View File
@@ -0,0 +1,147 @@
// view.js — pan and zoom.
//
// The whole scene lives in one transformed <div>, so panning and zooming cost a
// single CSS transform: no relayout, no re-render of node content, nothing that
// touches the DOM per node. That is what keeps a few hundred nodes usable on
// the mobile/VPN path.
//
// The dot grid is painted on the (untransformed) surface and its size/offset
// follow the transform, which keeps it crisp at every zoom level instead of
// scaling a bitmap.
window.Canvas = window.Canvas || {};
Canvas.View = (function () {
var MIN_SCALE = 0.05;
var MAX_SCALE = 4;
var GRID = 20;
var surface = null;
var world = null;
var tx = 0, ty = 0, scale = 1;
var listeners = [];
function apply() {
world.style.transform = 'translate(' + tx + 'px,' + ty + 'px) scale(' + scale + ')';
var step = GRID * scale;
surface.style.backgroundSize = step + 'px ' + step + 'px';
surface.style.backgroundPosition = tx + 'px ' + ty + 'px';
// Below a few pixels apart the dots stop reading as a grid and start
// reading as noise, so zooming far out drops them entirely.
surface.classList.toggle('is-grid-hidden', step < 6);
for (var i = 0; i < listeners.length; i++) listeners[i]();
}
function clampScale(s) {
return Math.max(MIN_SCALE, Math.min(MAX_SCALE, s));
}
// zoomAt scales about a screen point, so the world coordinate under the
// cursor stays put — the behaviour that makes wheel-zoom feel anchored
// rather than lurching toward the origin.
function zoomAt(clientX, clientY, factor) {
var next = clampScale(scale * factor);
if (next === scale) return;
var r = surface.getBoundingClientRect();
var px = clientX - r.left;
var py = clientY - r.top;
var ratio = next / scale;
tx = px - (px - tx) * ratio;
ty = py - (py - ty) * ratio;
scale = next;
apply();
}
function bounds(nodes) {
if (!nodes.length) return null;
var minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
nodes.forEach(function (n) {
minX = Math.min(minX, n.x);
minY = Math.min(minY, n.y);
maxX = Math.max(maxX, n.x + n.width);
maxY = Math.max(maxY, n.y + n.height);
});
return { x: minX, y: minY, width: maxX - minX, height: maxY - minY };
}
return {
GRID: GRID,
init: function (surfaceEl, worldEl) {
surface = surfaceEl;
world = worldEl;
apply();
},
get: function () { return { x: tx, y: ty, scale: scale }; },
scale: function () { return scale; },
toWorld: function (clientX, clientY) {
var r = surface.getBoundingClientRect();
return {
x: (clientX - r.left - tx) / scale,
y: (clientY - r.top - ty) / scale
};
},
toScreen: function (x, y) {
var r = surface.getBoundingClientRect();
return { x: x * scale + tx + r.left, y: y * scale + ty + r.top };
},
pan: function (dx, dy) {
tx += dx;
ty += dy;
apply();
},
zoomAt: zoomAt,
// zoomBy scales about the viewport centre, for the toolbar buttons and
// the +/- shortcuts where there is no cursor to anchor to.
zoomBy: function (factor) {
var r = surface.getBoundingClientRect();
zoomAt(r.left + r.width / 2, r.top + r.height / 2, factor);
},
resetZoom: function () {
var r = surface.getBoundingClientRect();
zoomAt(r.left + r.width / 2, r.top + r.height / 2, 1 / scale);
},
// fit frames the whole document with a margin, never zooming past 1:1 —
// a canvas with three small nodes should not open comically magnified.
fit: function (nodes) {
var b = bounds(nodes);
var r = surface.getBoundingClientRect();
if (!b || !r.width || !r.height) {
tx = 0; ty = 0; scale = 1;
apply();
return;
}
var pad = 60;
var s = clampScale(Math.min(
(r.width - pad * 2) / Math.max(b.width, 1),
(r.height - pad * 2) / Math.max(b.height, 1),
1
));
scale = s;
tx = r.width / 2 - (b.x + b.width / 2) * s;
ty = r.height / 2 - (b.y + b.height / 2) * s;
apply();
},
// centreOfViewport is where a newly added node goes: wherever the user
// is currently looking.
centreOfViewport: function () {
var r = surface.getBoundingClientRect();
return {
x: (r.width / 2 - tx) / scale,
y: (r.height / 2 - ty) / scale
};
},
bounds: bounds,
onChange: function (fn) { listeners.push(fn); },
refresh: apply
};
})();
+215
View File
@@ -0,0 +1,215 @@
// 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 every listing style: table rows expose the file link inside
// a .list-item row; thumbnail tiles are bare a.thumb-tile anchors;
// diary photo grids wrap each thumbnail in a .photo-grid anchor.
// The left tree rail's file anchors (a.tree-file) join in too;
// folder anchors there end in "/" and fall through to navigation.
// Search file-result anchors join in too: page results end in "/"
// and fall through to navigation, file results don't and open locally.
// Embedded images (a.embed-link inside rendered content) wrap the
// raw file href, so they open the full-size original locally too.
var anchor = e.target.closest('.list-item a, a.thumb-tile, .photo-grid a, aside.tree-sidebar a.tree-file, .search-card a, .content a.embed-link');
if (!anchor) return;
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 wireFileRevealButtons() {
// Per-result "open location" buttons on the search page. Unlike the
// page reveal button (which reveals window.location), each carries its
// own file path so it reveals that specific file in its folder.
if (!state.available) return;
var btns = document.querySelectorAll('[data-companion-file-reveal]');
btns.forEach(function (btn) {
btn.hidden = false;
btn.addEventListener('click', function () {
var rel = btn.getAttribute('data-companion-file-reveal');
if (!rel) return;
companionGET('/open-folder', { path: rel }).catch(function () { });
});
});
}
function probeStatus() {
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 isMobileDevice() {
// The companion only ships for Windows and Linux desktops, so skip it
// on phones/tablets. Checks the UA and falls back to "touch is the only
// pointer" so narrow desktop windows still get the companion.
if (/Android|iPhone|iPad|iPod|Mobi/i.test(navigator.userAgent)) return true;
return window.matchMedia('(pointer: coarse)').matches &&
!window.matchMedia('(any-pointer: fine)').matches;
}
function init() {
// Every companion element is rendered hidden, so bailing out here
// leaves the status icon and reveal buttons invisible on mobile.
if (isMobileDevice()) return;
probeStatus().then(function () {
updateFooterIcon();
wireFileLinks();
wireRevealButton();
wireFileRevealButtons();
});
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
})();
+27
View File
@@ -0,0 +1,27 @@
<div class="diary-cal panel panel-sidebar"
data-display-year="{{.DisplayYear}}"
data-display-month="{{.DisplayMonth}}">
<div class="panel-header row">
<a href="{{.DiaryURL}}">Chronological</a>
<div class="dropdown diary-cal-drop">
<button type="button" class="btn" data-action="cal-year-drop" aria-expanded="false" title="Jahr wählen">{{.DisplayYear}} ▾</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 $i, $m := .Months}}
{{if $i}}<hr/>{{end}}
<div class="diary-cal-month"><a href="{{$m.AnchorURL}}">{{$m.Name}}</a></div>
<table class="diary-cal-grid" data-cal-month="{{$m.Num}}">
<thead>
<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 $m.Weeks}}<tr>{{range .}}<td class="{{if .IsCurrent}}cal-current{{else if .IsToday}}cal-today{{end}}{{if and .Num (not .HasEntry)}} cal-empty{{end}}">{{if .Num}}<a href="{{.URL}}">{{.Num}}</a>{{end}}</td>{{end}}</tr>
{{end}}
</tbody>
</table>
{{end}}
</div>
<script src="/_/diary/calendar.js"></script>
+45
View File
@@ -0,0 +1,45 @@
(function () {
var cal = document.querySelector(".diary-cal");
if (!cal) return;
cal.querySelectorAll(".dropdown > button").forEach(wireDropdown);
var displayMonth = parseInt(cal.dataset.displayMonth, 10);
var months = {};
cal.querySelectorAll("[data-cal-month]").forEach(function (t) {
months[parseInt(t.dataset.calMonth, 10)] = t;
});
// Centering only applies inside the persistent desktop rail (.sidebar). On
// mobile the widget is re-parented into .overlay-body, where we leave the
// grids stacked from January and do not scroll (design decision).
function railContainer() {
var el = cal.parentElement;
while (el) {
if (el.classList) {
if (el.classList.contains("overlay-body")) return null;
if (el.classList.contains("sidebar")) return el;
}
el = el.parentElement;
}
return null;
}
// Scroll only the rail so the given month grid sits in its vertical center;
// the main document/window position is untouched (priority: don't disturb
// the reading position).
function centerMonth(m) {
var grid = months[m];
if (!grid) return;
var container = railContainer();
if (!container) return;
var offset = grid.getBoundingClientRect().top -
container.getBoundingClientRect().top + container.scrollTop;
container.scrollTop = offset - (container.clientHeight - grid.clientHeight) / 2;
}
// Center the current month once, on initial render. Anchor navigation
// (#YYYY-MM / #YYYY-MM-DD) deliberately does NOT re-center the rail —
// re-scrolling on every hop is disorienting; the today/current cell
// highlighting is enough to keep bearings.
centerMonth(displayMonth);
})();
+13
View File
@@ -0,0 +1,13 @@
{{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 btn-float">edit</a>{{end}}</h3>{{end}}
{{if .Body}}{{.Body}}{{end}}
{{if .Photos}}
<div class="photo-grid">
{{range .Photos}}
<a href="{{.URL}}"><img src="{{.ThumbURL}}" alt="" loading="lazy"></a>
{{end}}
</div>
{{end}}
{{end}}
-7
View File
@@ -1,7 +0,0 @@
{{if .Photos}}
<div class="photo-grid">
{{range .Photos}}
<a href="{{.URL}}" target="_blank"><img src="{{.URL}}" alt="{{.Name}}" loading="lazy"></a>
{{end}}
</div>
{{end}}
-14
View File
@@ -1,14 +0,0 @@
{{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="{{.URL}}" alt="{{.Name}}" loading="lazy"></a>
{{end}}
</div>
{{end}}
{{end}}
-7
View File
@@ -1,7 +0,0 @@
<h2 id="months">Months</h2>
{{range .Months}}
<h3 id="{{.ID}}">
<a href="{{.URL}}">{{.Name}}</a>
{{if .PhotoCount}}<span class="muted">({{.PhotoCount}} photos)</span>{{end}}
</h3>
{{end}}
-204
View File
@@ -1,204 +0,0 @@
(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 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'); },
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()); },
};
// --- 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;
var action = keyMap[e.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' },
]);
}
})();
-86
View File
@@ -1,86 +0,0 @@
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 };
})();
+87
View File
@@ -0,0 +1,87 @@
{{define "headerActions"}}
<a class="btn" href="{{.PostURL}}">CANCEL</a>
<button class="btn" type="button" 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 .Span}}<input type="hidden" name="span" value="1">{{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/G)">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>
<button type="button" class="btn btn-tool btn-block" data-action="embed" data-key="G" title="Embed file (G)">Embed file</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)">&gt;</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}}
+453
View File
@@ -0,0 +1,453 @@
(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;
};
}
// Enter/Tab cell navigation must yield to an open autocomplete popup so those
// keys still accept a suggestion (e.g. a [[wiki-link]] typed inside a cell).
// The table keymap sits at Prec.highest, so without this guard it would
// swallow the key before the completion keymap ever sees it. Detect the popup
// via its tooltip element rather than exporting completionStatus from the
// vendored bundle (which would force a bundle rebuild).
function completionOpen() {
return !!document.querySelector('.cm-tooltip-autocomplete');
}
function tableNavKey(fn) {
var handler = tableKey(fn);
return function (view) {
if (completionOpen()) return false;
return handler(view);
};
}
var tableKeymap = [
{ key: 'Shift-Enter', run: tableKey(T.insertRowBelow) },
{ key: 'Shift-Delete', run: tableKey(T.deleteRow) },
{ key: 'Enter', run: tableNavKey(T.nextRowSameColumn) },
{ key: 'Tab', run: tableNavKey(T.nextCell) },
{ key: 'Shift-Tab', run: tableNavKey(T.prevCell) },
];
var state = CM.EditorState.create({
doc: hidden.value,
// Start with the caret at the end of the existing content — editing an
// existing page almost always means appending, and the default (start
// of doc) buries the caret behind the leading heading.
selection: { anchor: hidden.value.length },
extensions: [
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();
}
// Save POSTs via fetch and then rewrites the *editor's* history entry with
// the resulting page, so the edit session and its result share a single
// entry (see history-nav.js). A plain form submit would push a second one
// and leave the editor sitting in history behind the saved page.
//
// The server answers 204 + X-Target instead of a 303 because the target may
// carry a #section anchor the client cannot compute, and fetch drops the
// fragment from a followed redirect.
function postSave() {
syncContent();
var body = new URLSearchParams(new FormData(form)).toString();
fetch(form.action, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'X-Save-Mode': 'replace',
},
body: body,
}).then(function (res) {
if (!res.ok) {
return res.text().then(function (msg) {
alert(msg || ('Save failed (' + res.status + ')'));
});
}
var target = res.headers.get('X-Target') || form.action;
// replaceState + reload rather than location.replace: if target
// differs from the current URL only by fragment the browser would
// skip the re-fetch and show pre-save content.
window.history.replaceState(null, '', target);
window.location.reload();
}).catch(function () {
alert('Network error — the page was not saved');
});
}
form.addEventListener('submit', function (e) {
e.preventDefault();
postSave();
});
// --- Actions ---
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,
embed: insertEmbed,
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); },
};
// Wiki link button (ALT+SHIFT+P): a query field backed by /_search typeahead
// plus an optional display-text field, inserting [[query]] or
// [[query::display]] at the cursor. Picking a suggestion only fills in that
// page's name — the link resolves through search at click time, so a path
// never enters the document. Inline `[[` typing uses the same suggestions
// via wikicomplete.js.
function insertWikilink() {
var sel = view.state.selection.main;
var selectedText = view.state.sliceDoc(sel.from, sel.to).trim();
var container = document.createElement('div');
var queryWrap = document.createElement('div');
var queryInput = document.createElement('input');
queryInput.type = 'text';
queryInput.className = 'input';
queryInput.placeholder = 'Search query';
// A selection is the query, not the label: [[selection]] renders with
// the same visible text anyway, and it seeds the typeahead.
queryInput.value = selectedText;
queryWrap.appendChild(queryInput);
var displayInput = document.createElement('input');
displayInput.type = 'text';
displayInput.className = 'input';
displayInput.placeholder = 'Display text (optional)';
container.appendChild(queryWrap);
container.appendChild(displayInput);
var handle = openModal({
title: 'Insert link',
body: container,
confirm: {
label: 'INSERT',
initiallyDisabled: !selectedText,
onConfirm: function () {
var query = queryInput.value.trim();
if (!query) return;
var display = displayInput.value.trim();
handle.close();
insertAtCursor(display ? '[[' + query + '::' + display + ']]' : '[[' + query + ']]');
}
}
});
function updateConfirm() {
handle.setConfirmDisabled(!queryInput.value.trim());
}
queryInput.addEventListener('input', updateConfirm);
window.attachSuggestions(queryInput, {
showFooter: false,
container: queryWrap,
onPick: function (r) {
queryInput.value = r.name;
updateConfirm();
displayInput.focus();
}
});
}
// embedDest turns the absolute wiki path the tree picker hands back into the
// destination written to the document: relative to the page being edited
// when the file lives under it, absolute otherwise. Pages render at a
// slash-terminated URL, so a relative destination resolves against the page
// in the browser.
function embedDest(absPath) {
var base = decodeURIComponent(window.location.pathname);
if (base.slice(-1) !== '/') base += '/';
return absPath.indexOf(base) === 0 ? absPath.slice(base.length) : absPath;
}
// embedToken formats a Markdown image. A destination carrying spaces or
// brackets goes in <angle brackets> — CommonMark's way of keeping it in one
// piece — and the title slot holds the alignment keyword the renderer reads.
// Right is the renderer's default, so it is left off.
function embedToken(dest, caption, align) {
if (/[\s()<>]/.test(dest)) dest = '<' + dest + '>';
var title = (align && align !== 'right') ? ' "' + align + '"' : '';
return '![' + caption.replace(/([\[\]\\])/g, '\\$1') + '](' + dest + title + ')';
}
// Embed button (ALT+SHIFT+G): pick a file from the tree, then give it a
// caption and an alignment. Images render as a floated figure; anything
// else degrades to a link.
function insertEmbed() {
var sel = view.state.selection.main;
var selectedText = view.state.sliceDoc(sel.from, sel.to).trim();
var here = decodeURIComponent(window.location.pathname).replace(/\/+$/, '') || '/';
openTreePicker({
title: 'Embed — pick a file',
mode: 'file',
initialPath: here,
confirmLabel: 'NEXT',
onSelect: function (absPath) {
var container = document.createElement('div');
var captionInput = document.createElement('input');
captionInput.type = 'text';
captionInput.className = 'input';
captionInput.placeholder = 'Caption (optional)';
captionInput.value = selectedText;
var alignSelect = document.createElement('select');
alignSelect.className = 'input';
[['right', 'Float right'], ['left', 'Float left'], ['center', 'Centered']]
.forEach(function (opt) {
var o = document.createElement('option');
o.value = opt[0];
o.textContent = opt[1];
alignSelect.appendChild(o);
});
container.appendChild(captionInput);
container.appendChild(alignSelect);
var handle = openModal({
title: 'Embed — caption?',
body: container,
confirm: {
label: 'INSERT',
onConfirm: function () {
handle.close();
insertAtCursor(embedToken(
embedDest(absPath),
captionInput.value.trim(),
alignSelect.value
));
}
}
});
}
});
}
// --- 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); });
});
})();
+177
View File
@@ -0,0 +1,177 @@
window.EditorMovie = (function () {
'use strict';
var STORAGE_KEY = 'omdb-api-key';
var BEGIN = '<!-- BEGIN MOVIE -->';
var END = '<!-- END MOVIE -->';
function firstHeading(text) {
var m = text.match(/^#{1,6}\s+(.+?)\s*$/m);
return m ? m[1].trim() : '';
}
function parseTitleYear(raw) {
var m = raw.match(/^(.+?)\s*\((\d{4})\)\s*$/);
return m ? { title: m[1].trim(), year: m[2] } : { title: raw.trim(), year: null };
}
function safe(v) { return (!v || v === 'N/A') ? '' : String(v); }
function esc(s) {
return String(s)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
function buildBlock(m) {
var out = [BEGIN, '<aside class="movie-info">'];
if (m.Poster && m.Poster !== 'N/A') {
out.push('<img class="movie-poster" src="' + esc(m.Poster) +
'" alt="' + esc(safe(m.Title)) + ' poster">');
}
out.push('<table>');
[
['Title', m.Title],
['Year', m.Year],
['Runtime', m.Runtime],
['Genre', m.Genre],
['Director', m.Director],
['Cast', m.Actors],
['Plot', m.Plot],
].forEach(function (r) {
out.push('<tr><th>' + r[0] + '</th><td>' + esc(safe(r[1])) + '</td></tr>');
});
out.push('</table>', '</aside>', END);
return out.join('\n');
}
// ctx is the CM adapter from main.js: { getValue(), replace(start,end,text) }.
function insertOrReplace(ctx, markup) {
var t = ctx.getValue() || '';
var b = t.indexOf(BEGIN);
var e = t.indexOf(END);
if (b !== -1 && e !== -1 && e > b) {
ctx.replace(b, e + END.length, markup);
} else {
var h = t.match(/^#{1,6}\s+.+?\s*$/m);
if (h) {
var idx = t.indexOf(h[0]) + h[0].length;
ctx.replace(idx, idx, '\n\n' + markup);
} else {
ctx.replace(0, 0, t ? markup + '\n\n' : markup);
}
}
}
function fetchMovie(key, title, year) {
var url = 'https://www.omdbapi.com/?apikey=' + encodeURIComponent(key) +
'&type=movie&t=' + encodeURIComponent(title);
if (year) url += '&y=' + encodeURIComponent(year);
return fetch(url).then(function (r) { return r.json(); });
}
function showMessage(title, msg) {
openModal({ title: title, body: msg, confirm: { label: 'OK' } });
}
function promptForKey(rejected, onSaved) {
var body = document.createDocumentFragment();
if (rejected) {
var notice = document.createElement('p');
notice.textContent = 'The previously stored key was rejected by OMDb.';
body.appendChild(notice);
}
var info = document.createElement('p');
info.appendChild(document.createTextNode('Enter your OMDb API key. Get one at '));
var link = document.createElement('a');
link.href = 'https://www.omdbapi.com/apikey.aspx';
link.target = '_blank';
link.rel = 'noopener';
link.textContent = 'omdbapi.com/apikey.aspx';
info.appendChild(link);
info.appendChild(document.createTextNode('.'));
body.appendChild(info);
var input = document.createElement('input');
input.type = 'text';
input.className = '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.value = initialTitle;
openModal({
title: 'Import movie',
body: input,
confirm: {
label: 'IMPORT',
onConfirm: function () {
var raw = input.value.trim();
if (!raw) return;
var parsed = parseTitleYear(raw);
closeModal();
fetchMovie(key, parsed.title, parsed.year)
.then(function (data) {
if (data && data.Response === 'False' &&
data.Error === 'Invalid API key!') {
localStorage.removeItem(STORAGE_KEY);
promptForKey(true, function (newKey) {
importWithKey(ctx, newKey, raw);
});
return;
}
if (!data || data.Response === 'False') {
showMessage('Not found',
(data && data.Error) || 'Movie not found.');
return;
}
insertOrReplace(ctx, buildBlock(data));
})
.catch(function () {
showMessage('Import failed', 'OMDb lookup failed.');
});
},
},
});
}
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 };
})();
+86
View File
@@ -262,8 +262,94 @@ window.EditorTables = (function () {
return formatTableText(newLines.join('\n'), Math.min(newCursor, newLines.join('\n').length));
}
// Absolute cursor offset at the start of the `col`-th cell's content on
// `lineIdx` of a *formatted* table (i.e. just past the "| " / " | " that
// opens the cell). Clamps to the last cell if col overflows the row.
function cellCursor(text, lineIdx, col) {
var lines = text.split('\n');
var offset = 0;
for (var i = 0; i < lineIdx; i++) offset += lines[i].length + 1;
var line = lines[lineIdx] || '';
var pipes = [];
for (var c = 0; c < line.length; c++) if (line.charAt(c) === '|') pipes.push(c);
if (pipes.length === 0) return Math.min(offset, text.length);
var p = pipes[Math.min(col, pipes.length - 1)];
return Math.min(offset + p + 2, text.length);
}
// Shared engine for the Enter / Tab / Shift-Tab cell navigation. Reformats
// the table and drops the cursor into the target cell, appending an empty
// row when navigation runs past the last row. Returns null when the cursor
// is not in a table (so the caller can fall back to the editor default).
function moveInTable(text, cursorPos, mode) {
var range = findTableRange(text, cursorPos);
if (!range) return null;
var colIdx = getCursorColumn(text, cursorPos);
if (colIdx === null) return null;
var sepRel = -1, colCount = 0;
for (var i = range.start; i <= range.end; i++) {
var cells = parseTableRow(range.lines[i]);
if (cells.length > colCount) colCount = cells.length;
if (sepRel === -1 && isSeparatorRow(cells)) sepRel = i - range.start;
}
if (sepRel === -1) return null;
var lastRel = range.end - range.start;
var rowRel = range.cursorLine - range.start;
var targetRow = rowRel, targetCol = colIdx;
if (mode === 'enter') {
targetRow = rowRel + 1;
if (targetRow === sepRel) targetRow++;
} else if (mode === 'tab') {
targetCol = colIdx + 1;
if (targetCol >= colCount) {
targetCol = 0;
targetRow = rowRel + 1;
if (targetRow === sepRel) targetRow++;
}
} else if (mode === 'shifttab') {
targetCol = colIdx - 1;
if (targetCol < 0) {
targetCol = colCount - 1;
targetRow = rowRel - 1;
if (targetRow === sepRel) targetRow--;
}
if (targetRow < 0) return null;
}
var lines = range.lines.slice();
if (targetRow > lastRel) {
var emptyCells = [];
for (var c = 0; c < colCount; c++) emptyCells.push('');
var emptyLine = '| ' + emptyCells.join(' | ') + ' |';
while (lastRel < targetRow) {
lines.splice(range.start + lastRel + 1, 0, emptyLine);
lastRel++;
}
}
var tableStartOffset = 0;
for (var i = 0; i < range.start; i++) tableStartOffset += lines[i].length + 1;
var formatted = formatTableText(lines.join('\n'), tableStartOffset);
if (!formatted) return null;
return {
text: formatted.text,
cursor: cellCursor(formatted.text, range.start + targetRow, targetCol),
};
}
function nextRowSameColumn(text, cursorPos) { return moveInTable(text, cursorPos, 'enter'); }
function nextCell(text, cursorPos) { return moveInTable(text, cursorPos, 'tab'); }
function prevCell(text, cursorPos) { return moveInTable(text, cursorPos, 'shifttab'); }
return {
formatTableText: formatTableText,
nextRowSameColumn: nextRowSameColumn,
nextCell: nextCell,
prevCell: prevCell,
setColumnAlignment: setColumnAlignment,
insertColumn: insertColumn,
deleteColumn: deleteColumn,
File diff suppressed because one or more lines are too long
+73
View File
@@ -0,0 +1,73 @@
// wikicomplete.js — the `[[` autocomplete source for CodeMirror.
//
// Wiki links are search queries, so the popup is the same typeahead the header
// search box uses: the text typed after `[[` goes to `/_search` and each match
// is offered by name. Picking one replaces the query segment with that page's
// name — never a path — so what lands in the document is still a query. The
// trailing `]]` and any `::display` tail are left untouched.
//
// Exposes window.WikiComplete.source for main.js to register via CM's
// autocompletion().
window.WikiComplete = (function () {
// Matches search-suggest.js so both surfaces fire at the same point.
var MIN_QUERY_LEN = 2;
function fetchSuggestions(query) {
return 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();
});
}
// queryEnd returns where the current query segment ends: the first `]`, the
// `::` that starts the display text, or end of line. Replacing only up to
// there keeps a re-edit from eating an existing alias or the closing `]]`.
function queryEnd(context, pos) {
var line = context.state.doc.lineAt(pos);
var to = pos;
while (to < line.to) {
var ch = context.state.sliceDoc(to, to + 1);
if (ch === ']') break;
if (ch === ':' && context.state.sliceDoc(to, to + 2) === '::') break;
to++;
}
return to;
}
// CM completion source. Activates on `[[` followed by at least
// MIN_QUERY_LEN characters; a cursor past a `::` is in the display tail and
// gets nothing.
function source(context) {
var match = context.matchBefore(/\[\[[^\]\n]*/);
if (!match) return null;
var content = context.state.sliceDoc(match.from + 2, context.pos);
if (content.indexOf('::') !== -1) return null;
var query = content.trim();
if (query.length < MIN_QUERY_LEN) return null;
var from = match.from + 2;
var to = queryEnd(context, context.pos);
return new Promise(function (resolve) {
if (context.aborted) { resolve(null); return; }
fetchSuggestions(query).then(function (resp) {
if (context.aborted) { resolve(null); return; }
var options = (resp.results || []).map(function (r) {
return { label: r.name, detail: '/' + r.path, apply: r.name };
});
// filter:false — the server already ranked these fuzzily, and
// CM's own prefix filter would drop the near-misses (typos,
// token reorderings) that make the fuzzy search worth having.
// No validFor either: every keystroke re-queries from scratch.
resolve({ from: from, to: to, options: options, filter: false });
}).catch(function () {
resolve(null);
});
});
}
return { source: source };
})();
+10
View File
@@ -0,0 +1,10 @@
// Fitness dashboard range dropdowns: changing one reloads the page with that
// chart's query parameter updated. Plain GET navigation — each range is a
// distinct, bookmarkable view, so no postReplace/history handling is needed.
document.addEventListener('change', function (e) {
var sel = e.target.closest('[data-fitness-range]');
if (!sel) return;
var url = new URL(window.location.href);
url.searchParams.set(sel.dataset.fitnessRange, sel.value);
window.location.href = url.toString();
});
+43
View File
@@ -0,0 +1,43 @@
{{define "fitnessChart"}}
<section class="fitness-chart panel">
<div class="fitness-chart-header row space-between">
<span class="caption">{{.Title}}</span>
<select class="input fitness-range" data-fitness-range="{{.Param}}" aria-label="{{.Title}} time range">
{{range .Options}}<option value="{{.Value}}"{{if .Selected}} selected{{end}}>{{.Label}}</option>{{end}}
</select>
</div>
{{if .Empty}}
<p class="fitness-empty is-empty">No data in this range.</p>
{{else}}
<svg class="fitness-svg" viewBox="0 0 {{.ViewW}} {{.ViewH}}" role="img" aria-label="{{.Title}}">
{{range .YTicks}}
<line class="chart-grid" x1="{{$.PlotX}}" y1="{{.Pos}}" x2="{{$.PlotR}}" y2="{{.Pos}}"/>
<text class="chart-label" x="{{$.YLabelX}}" y="{{.Pos}}" text-anchor="end" dominant-baseline="middle">{{.Label}}</text>
{{end}}
{{range .XTicks}}
<text class="chart-label" x="{{.Pos}}" y="{{$.XLabelY}}" text-anchor="{{.Anchor}}">{{.Label}}</text>
{{end}}
<line class="chart-axis" x1="{{.PlotX}}" y1="{{.PlotY}}" x2="{{.PlotX}}" y2="{{.PlotB}}"/>
<line class="chart-axis" x1="{{.PlotX}}" y1="{{.PlotB}}" x2="{{.PlotR}}" y2="{{.PlotB}}"/>
{{range .Lines}}
<polyline class="chart-line" points="{{.}}"/>
{{end}}
{{range .Dots}}
<circle class="chart-dot" cx="{{.X}}" cy="{{.Y}}" r="2.5"><title>{{.Title}}</title></circle>
{{end}}
{{if .Goal}}
<line class="chart-goal" x1="{{.PlotX}}" y1="{{.Goal.Y}}" x2="{{.PlotR}}" y2="{{.Goal.Y}}"/>
<text class="chart-goal-label" x="{{.PlotR}}" y="{{.Goal.LabelY}}" text-anchor="end">{{.Goal.Label}}</text>
{{end}}
</svg>
{{end}}
</section>
{{end}}
<div class="fitness-dash col">
{{if .Notice}}
<p class="muted">{{.Notice}}</p>
{{else}}
{{range .Charts}}{{template "fitnessChart" .}}{{end}}
{{end}}
</div>
<script src="/_/fitness/fitness.js"></script>
+50
View File
@@ -4,6 +4,8 @@
switch (e.key) {
case 'E':
e.preventDefault();
// assign, not replace — opening the editor pushes a history
// entry so Back cancels the edit (see history-nav.js).
window.location.href = window.location.pathname + '?edit';
break;
case 'N':
@@ -14,6 +16,54 @@
e.preventDefault();
if (window.location.pathname !== '/' && typeof movePage === 'function') movePage();
break;
case 'F':
var input = document.querySelector('.search-input');
if (!input) return;
e.preventDefault();
input.focus();
input.select();
break;
}
});
})();
// Wire a dropdown: clicking the trigger toggles its sibling .dropdown-menu;
// clicking outside or pressing Escape closes all wired menus. Safe to call
// multiple times on the same trigger (no-op on re-registration).
function wireDropdown(trigger) {
if (!trigger || trigger.dataset.wired) return;
var menu = trigger.parentElement && trigger.parentElement.querySelector('.dropdown-menu');
if (!menu) return;
trigger.dataset.wired = '1';
trigger.addEventListener('click', function (e) {
e.stopPropagation();
document.querySelectorAll('.dropdown-menu.is-open').forEach(function (m) {
if (m !== menu) m.classList.remove('is-open');
});
menu.classList.toggle('is-open');
});
menu.addEventListener('click', function () { menu.classList.remove('is-open'); });
}
document.addEventListener('click', function (e) {
document.querySelectorAll('.dropdown-menu.is-open').forEach(function (menu) {
if (!menu.parentElement.contains(e.target)) menu.classList.remove('is-open');
});
});
document.addEventListener('keydown', function (e) {
if (e.key !== 'Escape') return;
document.querySelectorAll('.dropdown-menu.is-open').forEach(function (menu) {
menu.classList.remove('is-open');
});
});
document.addEventListener('DOMContentLoaded', function () {
var trigger = document.querySelector('[data-action="actions-menu"]');
if (!trigger) return;
wireDropdown(trigger);
var menu = trigger.parentElement.querySelector('.dropdown-menu');
if (!menu) return;
trigger.addEventListener('click', function () {
trigger.setAttribute('aria-expanded', menu.classList.contains('is-open') ? true : false);
});
});
+37
View File
@@ -0,0 +1,37 @@
// Keeps the editor from lingering in history once you leave it — while still
// letting the browser/Android Back button CANCEL an edit session.
//
// Opening the editor pushes a normal history entry, so Back exits the editor
// and returns to the page (the primary "back to cancel" gesture on mobile).
//
// Leaving the editor by CANCEL is the one transition we rewrite: the CANCEL
// link points back at the same page, so we replace the editor entry instead of
// pushing a second page entry on top of it. Without this, page -> edit -> CANCEL
// would leave [page, editor, page] and Back would walk straight back into the
// editor. SAVE does the equivalent from editor/main.js (replaceState + reload).
(function () {
function isEdit(loc) {
return new URLSearchParams(loc.search).has('edit');
}
document.addEventListener('click', function (e) {
if (e.defaultPrevented || e.button !== 0) return;
if (e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return;
var a = e.target.closest ? e.target.closest('a[href]') : null;
if (!a || a.target || a.hasAttribute('download')) return;
// Only act while inside the editor. Entering the editor stays a normal
// push so Back can cancel it.
if (!isEdit(window.location)) return;
var url = new URL(a.href, window.location.href);
if (url.origin !== window.location.origin) return;
if (url.pathname !== window.location.pathname) return;
// Leaving the editor to another page (e.g. a wikilink) keeps its normal
// push; only the same-page exit (CANCEL) is collapsed.
if (isEdit(url)) return;
e.preventDefault();
window.location.replace(url.href);
});
}());
+4
View File
@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="1em" height="1em"
fill="none" stroke="currentColor" stroke-width="1.5" stroke-linejoin="miter" shape-rendering="crispEdges">
<path d="M1 2h6v5H1zM9 9h6v5H9zM4 7v4h5"/>
</svg>

After

Width:  |  Height:  |  Size: 249 B

+6
View File
@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"
fill="none" stroke="#cfcfcf" stroke-width="1.5" stroke-linejoin="miter" shape-rendering="crispEdges">
<rect x="1" y="2" width="14" height="12"/>
<path d="M1 11l4-4 3 3 2-2 5 5"/>
<rect x="10" y="4" width="2" height="2" fill="#cfcfcf" stroke="none"/>
</svg>

After

Width:  |  Height:  |  Size: 328 B

+4
View File
@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="1em" height="1em"
fill="none" stroke="currentColor" stroke-width="1.5" stroke-linejoin="miter" stroke-linecap="square">
<path d="M8 13V3M3 8l5-5 5 5"/>
</svg>

After

Width:  |  Height:  |  Size: 233 B

+66
View File
@@ -0,0 +1,66 @@
{{define "layout"}}<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, interactive-widget=resizes-content" />
<title>{{.Title}}</title>
<link rel="icon" href="/_/favicon.ico" />
<link rel="preload" href="/_/fonts/IosevkaEtoile.woff2" as="font" type="font/woff2" crossorigin />
<link rel="preload" href="/_/fonts/IosevkaSlab.woff2" as="font" type="font/woff2" crossorigin />
<link rel="stylesheet" href="/_/style.css" />
<script src="/_/modal.js"></script>
<script src="/_/global-shortcuts.js"></script>
<script src="/_/history-nav.js"></script>
<script src="/_/scroll-sync.js" defer></script>
<script src="/_/search-suggest.js" defer></script>
<script src="/_/tree-picker.js"></script>
<script src="/_/companion.js" defer></script>
{{if .ShowPageChrome}}<script src="/_/overlay.js" defer></script>
<script src="/_/tree-sidebar.js" defer></script>{{end}}
{{block "headScripts" .}}{{end}}
</head>
<body>
<header>
<nav class="breadcrumb row">
<a href="/" tabindex="-1" title="Home"><svg class="logo" viewBox="0 0 26.052269 26.052269" xmlns="http://www.w3.org/2000/svg"><g fill="none" stroke="currentColor" stroke-linejoin="miter" transform="matrix(0.05463483,8.1519706e-6,-8.1519706e-6,0.05463483,-64.560546,-24.6949)"><rect x="1188.537" y="457.92056" width="461.87488" height="462.15189" stroke-width="20.2288"/><path d="m1348.9955 456.59572.046 309.36839" stroke-width="19.6849"/><path d="m1200.3996 765.80237 441.8362-.0659" stroke-width="19.6849"/><path d="m1648.2897 620.244-299.2012.0446" stroke-width="20.5676"/><path d="m1491.6148 909.24806-.021-136.93117" stroke-width="19.6849"/><rect x="1191.6504" y="461.66092" width="457.09634" height="457.09634" stroke-width="19.6761"/></g></svg><span class="app-name"> datascape</span></a>
</nav>
{{if .ShowPageChrome}}
<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" />
</form>
{{end}}
<div class="header-actions row">
{{if not .ShowPageChrome}}{{block "headerActions" .}}{{end}}{{else}}
<span class="dropdown actions">
<button type="button" class="btn actions-toggle" data-action="actions-menu" aria-haspopup="true" aria-expanded="false" title="Actions">ACTIONS</button>
<div class="dropdown-menu align-right actions-menu">
<div class="actions-special"></div>
{{template "headerActions" . }}
</div>
</span>
{{end}}
</div>
</header>
<div class="shell">
{{if .ShowPageChrome}}<aside class="tree-sidebar col">
<div class="tree-scroll"></div>
</aside>{{end}}
<div class="center">
<main>
{{block "content" .}}{{end}}
</main>
<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>
</div>
{{if .ShowPageChrome}}{{block "aside" .}}{{end}}{{end}}
</div>
{{block "extras" .}}{{end}}
</body>
</html>
{{end}}
+57 -4
View File
@@ -15,20 +15,20 @@
backdrop.className = 'modal-backdrop';
modal = document.createElement('div');
modal.className = 'modal';
modal.className = 'modal panel panel-floating';
modal.setAttribute('role', 'dialog');
modal.setAttribute('aria-modal', 'true');
var header = document.createElement('div');
header.className = 'modal-header';
header.className = 'panel-header';
titleEl = document.createElement('span');
header.appendChild(titleEl);
bodyEl = document.createElement('div');
bodyEl.className = 'modal-body';
bodyEl.className = 'panel-body';
footerEl = document.createElement('div');
footerEl.className = 'modal-footer';
footerEl.className = 'panel-footer';
cancelBtn = document.createElement('button');
cancelBtn.type = 'button';
@@ -43,6 +43,7 @@
backdrop.addEventListener('mousedown', function (e) {
if (e.target === backdrop) close();
});
wireDrag(header);
cancelBtn.addEventListener('click', close);
confirmBtn.addEventListener('click', function () {
if (confirmBtn.disabled) return;
@@ -52,6 +53,53 @@
});
}
// wireDrag makes the modal draggable by `handle`. Dragging switches the
// modal to fixed positioning so flexbox alignment doesn't fight us.
function wireDrag(handle) {
var dragging = false;
var startX, startY, originX, originY;
function onDown(e) {
if (e.button !== undefined && e.button !== 0) return;
var pt = e.touches ? e.touches[0] : e;
var rect = modal.getBoundingClientRect();
modal.classList.add('is-dragged');
modal.style.position = 'fixed';
modal.style.left = rect.left + 'px';
modal.style.top = rect.top + 'px';
dragging = true;
startX = pt.clientX;
startY = pt.clientY;
originX = rect.left;
originY = rect.top;
e.preventDefault();
}
function onMove(e) {
if (!dragging) return;
var pt = e.touches ? e.touches[0] : e;
var dx = pt.clientX - startX;
var dy = pt.clientY - startY;
var w = modal.offsetWidth;
var h = modal.offsetHeight;
var maxX = window.innerWidth - w;
var maxY = window.innerHeight - h;
var x = Math.max(0, Math.min(maxX, originX + dx));
var y = Math.max(0, Math.min(maxY, originY + dy));
modal.style.left = x + 'px';
modal.style.top = y + 'px';
}
function onUp() { dragging = false; }
handle.addEventListener('mousedown', onDown);
handle.addEventListener('touchstart', onDown, { passive: false });
document.addEventListener('mousemove', onMove);
document.addEventListener('touchmove', onMove, { passive: false });
document.addEventListener('mouseup', onUp);
document.addEventListener('touchend', onUp);
}
function open(opts) {
if (backdrop && backdrop.parentNode) close();
if (!backdrop) build();
@@ -59,6 +107,11 @@
currentOpts = opts;
prevFocus = document.activeElement;
modal.classList.remove('is-dragged');
modal.style.position = '';
modal.style.left = '';
modal.style.top = '';
titleEl.textContent = opts.title || '';
bodyEl.textContent = '';
+96
View File
@@ -0,0 +1,96 @@
// Full-viewport Overlay component (mobile). Distinct from the .modal dialog:
// this hosts a whole sidebar at full screen with its own scroll and a top-right close control.
// It reuses the low-level focus/Escape/bfcache patterns from modal.js rather than that component's panel chrome.
//
// openOverlay(node) MOVES `node` (an existing rail <aside>) into the overlay
// body and restores it to its original DOM position on close, so the rail keeps
// its state (expanded tree folders, rendered TOC). Only one overlay is open at
// a time.
(function () {
var overlay = null;
var bodyEl = null;
var closeBtn = null;
var hosted = null; // the moved node
var placeholder = null; // marks where to restore it
var prevFocus = null;
var onKeydown = null;
function build() {
overlay = document.createElement('div');
overlay.className = 'overlay';
overlay.setAttribute('role', 'dialog');
overlay.setAttribute('aria-modal', 'true');
var bar = document.createElement('div');
bar.className = 'overlay-bar';
closeBtn = document.createElement('button');
closeBtn.type = 'button';
closeBtn.className = 'btn overlay-close';
closeBtn.title = 'Close';
closeBtn.setAttribute('aria-label', 'Close');
closeBtn.textContent = '×';
closeBtn.addEventListener('click', close);
bar.appendChild(closeBtn);
bodyEl = document.createElement('div');
bodyEl.className = 'overlay-body';
bodyEl.addEventListener('click', function (e) {
if (e.target.closest('a')) close();
});
overlay.appendChild(bar);
overlay.appendChild(bodyEl);
}
function open(node) {
if (overlay && overlay.parentNode) close();
if (!overlay) build();
prevFocus = document.activeElement;
hosted = node;
placeholder = document.createComment('overlay-slot');
node.parentNode.insertBefore(placeholder, node);
bodyEl.appendChild(node);
document.body.appendChild(overlay);
overlay.classList.add('is-open');
onKeydown = function (e) {
if (e.key === 'Escape') { e.preventDefault(); close(); }
};
document.addEventListener('keydown', onKeydown);
setTimeout(function () { closeBtn.focus(); }, 0);
}
function close() {
if (!overlay || !overlay.parentNode) return;
if (onKeydown) {
document.removeEventListener('keydown', onKeydown);
onKeydown = null;
}
// Restore the hosted rail to its original spot in the shell.
if (hosted && placeholder && placeholder.parentNode) {
placeholder.parentNode.insertBefore(hosted, placeholder);
placeholder.parentNode.removeChild(placeholder);
}
hosted = null;
placeholder = null;
overlay.classList.remove('is-open');
overlay.parentNode.removeChild(overlay);
var toRestore = prevFocus;
prevFocus = null;
if (toRestore && toRestore.focus) {
try { toRestore.focus(); } catch (e) {}
}
}
// Belt-and-suspenders bfcache guard: force the overlay closed (restoring
// the rail) if the page is being hidden while it is still open.
window.addEventListener('pagehide', close);
window.openOverlay = open;
window.closeOverlay = close;
})();
-91
View File
@@ -1,91 +0,0 @@
function newPage() {
var input = document.createElement('input');
input.type = 'text';
input.className = 'modal-input';
input.placeholder = 'New page name';
openModal({
title: 'New page',
body: input,
confirm: {
label: 'CREATE',
onConfirm: function () {
var name = input.value.trim();
if (!name) return;
window.location.href = window.location.pathname +
encodeURIComponent(name) + '/?edit';
}
}
});
}
function movePage() {
var input = document.createElement('input');
input.type = 'text';
input.className = 'modal-input';
input.value = decodeURIComponent(window.location.pathname);
openModal({
title: 'Move page',
body: input,
confirm: {
label: 'MOVE',
onConfirm: function () {
var target = input.value.trim();
if (!target || !target.startsWith('/')) return;
var form = document.createElement('form');
form.method = 'POST';
form.action = window.location.pathname + '?move=' +
encodeURIComponent(target);
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 () {
var trigger = document.querySelector('[data-action="actions-drop"]');
if (!trigger) return;
var menu = trigger.parentElement.querySelector('.dropdown-menu');
if (!menu) return;
trigger.addEventListener('click', function (e) {
e.stopPropagation();
menu.classList.toggle('is-open');
});
menu.addEventListener('click', function () {
menu.classList.remove('is-open');
});
document.addEventListener('click', function (e) {
if (!trigger.contains(e.target) && !menu.contains(e.target)) {
menu.classList.remove('is-open');
}
});
document.addEventListener('keydown', function (e) {
if (e.key === 'Escape') menu.classList.remove('is-open');
});
});
-103
View File
@@ -1,103 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>{{.Title}}</title>
<link rel="icon" href="/_/favicon.ico" />
<link rel="preload" href="/_/fonts/IosevkaEtoile.woff2" as="font" type="font/woff2" crossorigin />
<link rel="preload" href="/_/fonts/IosevkaSlab.woff2" as="font" type="font/woff2" crossorigin />
<link rel="stylesheet" href="/_/style.css" />
<script src="/_/modal.js"></script>
<script src="/_/global-shortcuts.js"></script>
<script src="/_/page-actions.js"></script>
</head>
<body>
<header>
<nav class="breadcrumb">
<a href="/"><svg class="logo" viewBox="0 0 26.052269 26.052269" xmlns="http://www.w3.org/2000/svg"><g fill="none" stroke="currentColor" stroke-linejoin="miter" transform="matrix(0.05463483,8.1519706e-6,-8.1519706e-6,0.05463483,-64.560546,-24.6949)"><rect x="1188.537" y="457.92056" width="461.87488" height="462.15189" stroke-width="20.2288"/><path d="m1348.9955 456.59572.046 309.36839" stroke-width="19.6849"/><path d="m1200.3996 765.80237 441.8362-.0659" stroke-width="19.6849"/><path d="m1648.2897 620.244-299.2012.0446" stroke-width="20.5676"/><path d="m1491.6148 909.24806-.021-136.93117" stroke-width="19.6849"/><rect x="1191.6504" y="461.66092" width="457.09634" height="457.09634" stroke-width="19.6761"/></g></svg></a>
{{range .Crumbs}}
<span class="sep">/</span><a href="{{.URL}}">{{.Name}}</a>
{{end}}
</nav>
{{if .EditMode}}
<a class="btn" href="{{.PostURL}}">CANCEL</a>
<button class="btn" type="submit" form="edit-form" data-action="save" data-key="S" title="Save (S)">SAVE</button>
{{else if .CanEdit}}
<div class="dropdown">
<button class="btn" data-action="actions-drop" title="Actions">ACTIONS ▾</button>
<div class="dropdown-menu align-right">
<button class="btn dropdown-item" onclick="newPage()" title="New page (N)">NEW</button>
<a class="btn dropdown-item" href="?edit" title="Edit page (E)">EDIT</a>
{{if not .IsRoot}}
<button class="btn dropdown-item" onclick="movePage()" title="Move page (M)">MOVE</button>
<button class="btn dropdown-item danger" onclick="deletePage()" title="Delete page">DELETE</button>
{{end}}
</div>
</div>
{{end}}
</header>
<main>
{{if .EditMode}}
<form id="edit-form" class="edit-form" method="POST" action="{{.PostURL}}">
{{if ge .SectionIndex 0}}<input type="hidden" name="section" value="{{.SectionIndex}}">{{end}}
<div class="editor-toolbar">
<button type="button" class="btn btn-tool" data-action="bold" data-key="B" title="Bold (B)">**</button>
<button type="button" class="btn btn-tool" data-action="italic" data-key="I" title="Italic (I)">*</button>
<span class="toolbar-sep"></span>
<button type="button" class="btn btn-tool" data-action="h1" data-key="1" title="Heading 1 (1)">#</button>
<button type="button" class="btn btn-tool" data-action="h2" data-key="2" title="Heading 2 (2)">##</button>
<button type="button" class="btn btn-tool" data-action="h3" data-key="3" title="Heading 3 (3)">###</button>
<span class="toolbar-sep"></span>
<button type="button" class="btn btn-tool" data-action="code" data-key="C" title="Inline code (C)">`</button>
<button type="button" class="btn btn-tool" data-action="codeblock" data-key="K" title="Code block (K)">```</button>
<span class="toolbar-sep"></span>
<button type="button" class="btn btn-tool" data-action="link" data-key="L" title="Link (L)">[]</button>
<button type="button" class="btn btn-tool" data-action="quote" data-key="Q" title="Blockquote (Q)">&gt;</button>
<button type="button" class="btn btn-tool" data-action="ul" data-key="U" title="Unordered list (U)">-</button>
<button type="button" class="btn btn-tool" data-action="ol" data-key="O" title="Ordered list (O)">1.</button>
<button type="button" class="btn btn-tool" data-action="hr" data-key="R" title="Horizontal rule (R)">---</button>
<span class="toolbar-sep"></span>
<button type="button" class="btn btn-tool dropdown" data-action="tbldrop" title="Table (T)">T▾</button>
<button type="button" class="btn btn-tool dropdown" data-action="datedrop" title="Insert date (D/W)">D▾</button>
</div>
<textarea name="content" id="editor" autofocus>{{.RawContent}}</textarea>
</form>
<script src="/_/editor/lists.js"></script>
<script src="/_/editor/tables.js"></script>
<script src="/_/editor/dates.js"></script>
<script src="/_/editor.js"></script>
{{else}}
{{if .Content}}
<div class="content">{{.Content}}</div>
{{end}}
{{if .SpecialContent}}
<div class="content">{{.SpecialContent}}</div>
{{end}}
{{if or .Content .SpecialContent}}
<script src="/_/content.js"></script>
<script src="/_/toc.js"></script>
{{end}}
{{if .Content}}
<script src="/_/sections.js"></script>
{{end}}
{{if .Entries}}
<div class="listing">
<div class="listing-header">Contents</div>
{{range .Entries}}
<div class="listing-item">
<span class="icon">{{.Icon}}</span>
<a href="{{.URL}}">{{.Name}}</a>
<span class="meta">{{.Meta}}</span>
</div>
{{end}}
</div>
{{else if not .Content}}
{{if not .SpecialContent}}
<p class="empty">Empty folder — <a href="?edit">[CREATE]</a></p>
{{end}}
{{end}}
{{end}}
</main>
</body>
</html>
+175
View File
@@ -0,0 +1,175 @@
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 navigateReplace(target) {
window.history.replaceState(null, '', target);
window.location.reload();
}
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) {
navigateReplace(target);
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';
});
}
});
}
// submitMove POSTs a move and navigates on success. When the server reports
// the destination folder already exists but can be merged (the source carries
// a page and the destination has none), it asks the user to confirm and
// retries the same move with &merge=1.
function submitMove(action, target) {
fetch(action, { method: 'POST', redirect: 'manual' }).then(function (res) {
if (res.type === 'opaqueredirect' || res.ok) {
navigateReplace(target);
return;
}
if (res.status === 409 && res.headers.get('X-Merge-Available') === '1') {
openModal({
title: 'Merge folders?',
body: 'The destination folder already exists but has no page of its own. Merge this page and its contents into it?',
confirm: {
label: 'MERGE',
onConfirm: function () {
closeModal();
submitMove(action + '&merge=1', target);
}
}
});
return;
}
return res.text().then(function (msg) {
alert(msg || ('Request failed (' + res.status + ')'));
});
}).catch(function () {
alert('Network error');
});
}
function movePage() {
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;
openModal({
title: 'Move — new name?',
body: input,
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);
var target = encodePickedPath(dest) + '/';
closeModal();
submitMove(action, 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
});
}
+43
View File
@@ -0,0 +1,43 @@
// canvas-actions.js — creating a canvas from a normal page.
//
// The canvas view itself owns everything else (rename, delete, editing); this
// only covers the one action that has to start from the page, since a folder
// with no canvases has no canvas view to reach.
//
// Reuses openModal and postReplace from page/actions.js, which the page
// template loads first.
// validCanvasName mirrors canvasNameRe in canvas.go. Checking here only buys a
// better error message — the server rejects the same set regardless.
function validCanvasName(name) {
if (!name || name.length > 100) return false;
if (name.charAt(0) === '.') return false;
return !/[\/\\]/.test(name);
}
function newCanvas() {
var input = document.createElement('input');
input.type = 'text';
input.className = 'input';
input.placeholder = 'Canvas name';
openModal({
title: 'New canvas',
body: input,
confirm: {
label: 'CREATE',
onConfirm: function () {
var name = input.value.trim();
if (!validCanvasName(name)) {
alert('A canvas name cannot be empty, start with a dot, or contain a slash.');
return;
}
closeModal();
var base = window.location.pathname;
if (base.slice(-1) !== '/') base += '/';
var target = base + '?canvas=' + encodeURIComponent(name);
postReplace(target + '&create=1', null, target);
}
}
});
}
+1 -1
View File
@@ -5,7 +5,7 @@
img.src = 'https://icons.duckduckgo.com/ip3/' + hostname + '.ico';
img.width = 16;
img.height = 16;
img.style.verticalAlign = 'middle';
img.style.verticalAlign = 'center';
img.style.marginRight = '3px';
a.prepend(img);
});
+15
View File
@@ -0,0 +1,15 @@
document.addEventListener("DOMContentLoaded", function () {
var aside = document.querySelector("aside.sidebar");
var specials = document.querySelector(".actions-special");
if (!aside || !aside.children.length || !specials) return;
var item = document.createElement("button");
item.type = "button";
item.className = "btn btn-block";
item.title = "Contents";
item.textContent = "CONTENTS";
item.addEventListener("click", function () {
if (typeof openOverlay === "function") openOverlay(aside);
});
specials.appendChild(item);
});
+73
View File
@@ -0,0 +1,73 @@
{{define "headScripts"}}<script src="/_/page/actions.js"></script>
<script src="/_/page/canvas-actions.js"></script>{{end}}
{{define "headerActions"}}{{if .CanEdit}}
<button class="btn btn-block" onclick="newPage()" title="New page (N)">NEW</button>
<button class="btn btn-block" data-companion-reveal hidden title="Reveal in file manager">REVEAL</button>
{{if not .IsRoot}}
<button class="btn btn-block" onclick="movePage()" title="Move page (M)">MOVE</button>
<button class="btn btn-block danger" onclick="deletePage()" title="Delete page">DELETE</button>
{{end}}
<a class="btn btn-block" href="?edit" title="Edit page (E)">EDIT</a>
<button class="btn btn-block" onclick="newCanvas()" title="New canvas">NEW CANVAS</button>
{{end}}{{end}}
{{define "content"}}
{{/* Canvases in this folder are alternative views of the same page, so they
read as a tab strip above the content rather than as rows in the file
listing (listEntries filters them out for exactly this reason). */}}
{{if .Canvases}}
<nav class="canvas-tabs row" aria-label="Page views">
<span class="canvas-tab is-active">PAGE</span>
{{range .Canvases}}<a class="canvas-tab" href="{{.URL}}">{{.Name}}</a>{{end}}
{{if .CanEdit}}<button type="button" class="canvas-tab canvas-tab-add" onclick="newCanvas()" title="New canvas">+</button>{{end}}
</nav>
{{end}}
{{if .Content}}
<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 &amp; 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}}<span class="thumb-media"><img class="thumb-img" src="{{.ThumbURL}}" alt="" loading="lazy" width="300">{{if .IsVideo}}<span class="thumb-play" aria-hidden="true">▶</span>{{end}}</span>{{else}}<span class="thumb-icon">{{.Icon}}</span>{{end}}
<span class="thumb-label truncate">{{.Name}}</span>
</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>
{{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/contents.js"></script>
{{end}}
{{define "aside"}}{{if .SidebarWidget}}<aside class="sidebar">{{.SidebarWidget}}</aside>{{end}}{{end}}
+11 -4
View File
@@ -4,12 +4,19 @@
var headings = content.querySelectorAll('h1, h2, h3, h4, h5, h6');
if (!headings.length) return;
// Section 0 is pre-heading content, editable via full-page edit.
// Sections 1..N each start at a heading; that is the index sent to the server.
headings.forEach(function (h, i) {
if (h.querySelector('a.btn')) return;
var a = document.createElement('a');
a.href = '?edit&section=' + (i + 1);
a.className = 'btn btn-small';
if (h.tagName === 'H1') {
a.href = '?edit';
} else if (h.tagName === 'H2') {
a.href = '?edit&section=' + (i + 1) + '&span';
} else {
return;
}
a.className = 'btn btn-small btn-float';
a.textContent = 'edit';
h.appendChild(document.createTextNode(' '))
h.appendChild(a);
+24
View File
@@ -0,0 +1,24 @@
(function () {
document.querySelectorAll('input.task-checkbox[data-task-index]').forEach(function (cb) {
cb.addEventListener('change', function () {
var idx = cb.dataset.taskIndex;
var checked = cb.checked;
cb.disabled = true;
fetch(window.location.pathname + '?toggle=' + idx, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: 'checked=' + checked
}).then(function (res) {
if (!res.ok) {
cb.checked = !checked;
alert('Failed to save task state (' + res.status + ')');
}
}).catch(function () {
cb.checked = !checked;
alert('Failed to save task state');
}).finally(function () {
cb.disabled = false;
});
});
});
})();
+32
View File
@@ -0,0 +1,32 @@
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";
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, .dropdown").forEach(function (el) { el.remove(); });
a.textContent = clone.textContent.trim();
li.appendChild(a);
list.appendChild(li);
});
nav.appendChild(list);
content.insertBefore(nav, content.firstChild);
});
+85
View File
@@ -0,0 +1,85 @@
// 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);
}
}
});
}
+93
View File
@@ -0,0 +1,93 @@
<!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>
+61
View File
@@ -0,0 +1,61 @@
// Roughly preserve reading position when entering the editor.
//
// While reading a page we record how far the center column is scrolled (as a
// fraction of its scrollable height, keyed by path). When the same path opens
// in the editor we restore that fraction, so you land near where you were
// reading instead of at the top. It is fraction-based — the rendered page and
// the raw-markdown editor have different heights — so the match is deliberately
// approximate. This only moves the viewport; the caret default (end of doc) is
// unchanged, so a click still decides where you actually edit.
(function () {
var center = document.querySelector('.center');
if (!center) return;
var key = 'scrollsync:' + location.pathname;
// Only restore a freshly-recorded position: a stale entry from earlier in
// the tab session would jump a direct ?edit open to the wrong place.
var MAX_AGE_MS = 5 * 60 * 1000;
var params = new URLSearchParams(location.search);
var inEditor = params.has('edit');
// Section / insert edits load only a slice of the document into the editor,
// so a whole-page reading fraction does not map onto them — restore only for
// a full-page ?edit.
var fullPageEdit = inEditor && !params.has('section') && !params.has('insert_before');
function maxScroll() { return Math.max(0, center.scrollHeight - center.clientHeight); }
if (inEditor) {
if (!fullPageEdit) return;
var raw = sessionStorage.getItem(key);
if (!raw) return;
var data;
try { data = JSON.parse(raw); } catch (e) { return; }
if (!data || typeof data.f !== 'number' || Date.now() - (data.t || 0) > MAX_AGE_MS) return;
// Wait for CodeMirror to mount and the column to reach full height, then
// map the fraction onto it. A double rAF after load lets CM's initial
// layout and focus scroll settle so ours wins.
window.addEventListener('load', function () {
requestAnimationFrame(function () {
requestAnimationFrame(function () {
center.scrollTop = data.f * maxScroll();
});
});
});
return;
}
// Reading mode: keep the latest scroll fraction recorded (throttled to one
// write per frame) so it is ready the moment the editor opens.
var pending = false;
center.addEventListener('scroll', function () {
if (pending) return;
pending = true;
requestAnimationFrame(function () {
pending = false;
var m = maxScroll();
try {
sessionStorage.setItem(key, JSON.stringify({ f: m > 0 ? center.scrollTop / m : 0, t: Date.now() }));
} catch (e) {}
});
}, { passive: true });
})();
+323
View File
@@ -0,0 +1,323 @@
// 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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
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; },
});
});
})();
+74
View File
@@ -0,0 +1,74 @@
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' } });
});
}
function encodeSearchPath(p) {
if (p === '/' || p === '') return '/';
return '/' + p.replace(/^\/+/, '').split('/').map(encodeURIComponent).join('/');
}
// createSearchPage runs the new-page flow (pick a parent folder, then confirm
// the name) starting from the search query, so a query with no exact page can
// be turned into that page in one step. The name field is pre-filled with the
// query but stays editable.
function createSearchPage(name) {
var current = decodeURIComponent(window.location.pathname).replace(/\/+$/, '') || '/';
openTreePicker({
title: 'New page — where?',
mode: 'folder',
initialPath: current,
preselect: current,
hideFiles: true,
confirmLabel: 'NEXT',
onSelect: function (parentPath) {
var input = document.createElement('input');
input.type = 'text';
input.className = 'input';
input.placeholder = 'Page name';
input.value = name || '';
openModal({
title: 'New page — name?',
body: input,
confirm: {
label: 'CREATE',
onConfirm: function () {
var finalName = input.value.trim();
if (!finalName) return;
var base = parentPath === '/' ? '/' : encodeSearchPath(parentPath) + '/';
window.location.href = base + encodeURIComponent(finalName) + '/?edit';
}
}
});
}
});
}
document.addEventListener('DOMContentLoaded', function () {
var createLink = document.querySelector('[data-create-page]');
if (createLink) {
createLink.addEventListener('click', function (e) {
e.preventDefault();
createSearchPage(createLink.getAttribute('data-create-page'));
});
}
// Focus the search input on results pages so Tab steps directly into the
// 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) {}
}
});
+51
View File
@@ -0,0 +1,51 @@
{{define "headScripts"}}<script src="/_/search/actions.js"></script>{{end}}
{{define "searchQuery"}}{{.Query}}{{end}}
{{define "content"}}
{{if .Query}}
{{if .Exact}}
<h2 class="search-section">Exact Match</h2>
{{range .Exact}}
<article class="search-card">
<a href="{{.URL}}">{{.Name}}</a>
<div class="muted">/{{.Path}}</div>
</article>
{{end}}
{{else}}
<p class="muted">No page named &ldquo;{{.Query}}&rdquo; — <button class="btn btn-small" data-create-page="{{.Query}}">create it</button></p>
{{end}}
{{if .Pages}}
<h2 class="search-section">Matching Pages <span class="muted">{{.PageTotal}}</span></h2>
{{range .Pages}}
<article class="search-card">
<a href="{{.URL}}">{{.Name}}</a>
<div class="muted">/{{.Path}}</div>
</article>
{{end}}
{{end}}
{{if .Files}}
<h2 class="search-section">Matching Files <span class="muted">{{.FileTotal}}</span></h2>
{{range .Files}}
<article class="search-card">
<div class="row">
{{fileIcon .Name}}
<a href="{{.URL}}">{{.Name}}</a>
<button class="btn btn-small" data-companion-file-reveal="{{.Path}}" hidden title="Open location in file manager">open</button>
</div>
<div class="muted">/{{.Path}} · {{.Meta}}</div>
</article>
{{end}}
{{end}}
{{else}}
<p class="empty">Enter a query above.</p>
{{end}}
{{end}}
{{define "headerActions"}}
<button class="btn btn-block" onclick="rebuildIndex()" title="Rebuild search index">REBUILD INDEX</button>
{{end}}
{{define "footerExtras"}}
{{if not .IndexBuiltAt.IsZero}}<span class="muted">· Index: {{.IndexBuiltAt.Format "2006-01-02 15:04"}}</span>{{end}}
{{end}}
+1040 -400
View File
File diff suppressed because it is too large Load Diff
-57
View File
@@ -1,57 +0,0 @@
(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 = "toc-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;
a.textContent = h.textContent;
li.appendChild(a);
list.appendChild(li);
});
nav.appendChild(list);
var toggle = document.createElement("button");
toggle.type = "button";
toggle.className = "toc-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();
})();
+283
View File
@@ -0,0 +1,283 @@
(function () {
function joinPath(parent, name) {
if (parent === '/' || parent === '') return '/' + name;
return parent.replace(/\/+$/, '') + '/' + name;
}
function encodePath(p) {
if (p === '/' || p === '') return '/';
return '/' + p.replace(/^\/+/, '').split('/').map(encodeURIComponent).join('/') + '/';
}
function fetchFolder(path) {
return fetch(encodePath(path) + '?tree=1', { credentials: 'same-origin' })
.then(function (r) {
if (!r.ok) throw new Error('HTTP ' + r.status);
return r.json();
});
}
function openTreePicker(opts) {
opts = opts || {};
var mode = opts.mode || 'folder';
var initialPath = opts.initialPath || '/';
var allowRoot = opts.allowRoot !== false;
var hideFiles = !!opts.hideFiles;
var preselect = opts.preselect || null;
var container = document.createElement('div');
var treeEl = document.createElement('div');
treeEl.className = 'tree-picker panel';
var selectedPathEl = document.createElement('div');
selectedPathEl.className = 'tree-selected-path muted';
selectedPathEl.textContent = '\u00a0';
container.appendChild(treeEl);
container.appendChild(selectedPathEl);
var selected = null; // { path, kind, rowEl }
var handle = openModal({
title: opts.title || 'Pick',
body: container,
confirm: {
label: opts.confirmLabel || 'SELECT',
initiallyDisabled: true,
onConfirm: function () {
if (!selected) return;
handle.close();
if (opts.onSelect) opts.onSelect(selected.path, selected.kind);
}
}
});
function setSelected(path, kind, rowEl) {
if (selected && selected.rowEl) selected.rowEl.classList.remove('is-selected');
selected = { path: path, kind: kind, rowEl: rowEl };
rowEl.classList.add('is-selected');
selectedPathEl.textContent = path;
handle.setConfirmDisabled(false);
}
function isSelectable(kind) {
if (mode === 'any') return true;
if (mode === 'folder') return kind === 'folder';
if (mode === 'file') return kind === 'file';
return false;
}
// buildRow returns { rowEl, expand(): Promise }. `expand` is a no-op
// for files and idempotent for folders (resolves with the already-
// loaded children on repeat calls).
function buildRow(parentPath, name, kind) {
var row = document.createElement('div');
row.className = 'tree-row';
if (!isSelectable(kind)) row.classList.add('is-disabled');
var chevron = document.createElement('span');
chevron.className = 'tree-chevron';
if (kind === 'folder') {
chevron.textContent = '\u25b8'; // ▸
} else {
chevron.classList.add('is-leaf');
}
row.appendChild(chevron);
var label = document.createElement('span');
label.className = 'tree-name';
label.textContent = name;
row.appendChild(label);
var fullPath = joinPath(parentPath, name);
var childrenEl = null;
var loadPromise = null;
var isOpen = false;
function expand() {
if (kind !== 'folder') return Promise.resolve(null);
if (isOpen) return loadPromise || Promise.resolve(childrenEl);
if (!childrenEl) {
childrenEl = document.createElement('div');
childrenEl.className = 'tree-children';
}
row.parentNode.insertBefore(childrenEl, row.nextSibling);
chevron.textContent = '\u25be'; // ▾
isOpen = true;
if (!loadPromise) {
loadPromise = loadInto(fullPath, childrenEl);
}
return loadPromise;
}
function collapse() {
if (kind !== 'folder' || !isOpen) return;
if (childrenEl && childrenEl.parentNode) {
childrenEl.parentNode.removeChild(childrenEl);
}
chevron.textContent = '\u25b8';
isOpen = false;
}
chevron.addEventListener('click', function (e) {
e.stopPropagation();
if (isOpen) collapse(); else expand();
});
row.addEventListener('click', function () {
if (isSelectable(kind)) {
setSelected(fullPath, kind, row);
} else if (kind === 'folder') {
if (isOpen) collapse(); else expand();
}
});
if (kind === 'folder') {
row.addEventListener('dblclick', function (e) {
e.preventDefault();
if (isOpen) collapse(); else expand();
});
}
return {
rowEl: row,
name: name,
kind: kind,
childrenEl: function () { return childrenEl; },
expand: expand
};
}
// loadInto fetches folderPath and populates `target` with rows. Returns
// an array of the row objects on success, or [] on failure.
function loadInto(folderPath, target) {
target.textContent = '';
var loading = document.createElement('div');
loading.className = 'tree-row is-disabled';
loading.textContent = '\u2026';
target.appendChild(loading);
return fetchFolder(folderPath).then(function (resp) {
target.textContent = '';
var rows = [];
(resp.entries || []).forEach(function (e) {
if (hideFiles && e.kind !== 'folder') return;
var r = buildRow(resp.path, e.name, e.kind);
target.appendChild(r.rowEl);
rows.push(r);
});
if (rows.length === 0) {
var empty = document.createElement('div');
empty.className = 'tree-row is-disabled';
empty.textContent = '(empty)';
target.appendChild(empty);
}
return rows;
}).catch(function () {
target.textContent = '';
var err = document.createElement('div');
err.className = 'tree-row';
err.textContent = '(failed — tap to retry)';
err.addEventListener('click', function () {
loadInto(folderPath, target);
});
target.appendChild(err);
return [];
});
}
// Root selection row — visible when allowRoot and mode accepts folders.
if (allowRoot && isSelectable('folder')) {
var rootRow = document.createElement('div');
rootRow.className = 'tree-row';
var rootChev = document.createElement('span');
rootChev.className = 'tree-chevron is-leaf';
rootRow.appendChild(rootChev);
var rootLabel = document.createElement('span');
rootLabel.className = 'tree-name';
rootLabel.textContent = '/';
rootRow.appendChild(rootLabel);
rootRow.addEventListener('click', function () {
setSelected('/', 'folder', rootRow);
});
treeEl.appendChild(rootRow);
}
var rootChildren = document.createElement('div');
treeEl.appendChild(rootChildren);
// Expand the ancestor chain of initialPath so the user lands in
// context. For root, just load root children.
var segs = (initialPath || '/').split('/').filter(Boolean);
var preselectSegs = preselect
? preselect.split('/').filter(Boolean)
: null;
loadInto('/', rootChildren).then(function (rows) {
return expandChain(rows, segs, '/');
}).then(function () {
if (preselectSegs === null) return;
if (preselectSegs.length === 0) {
// Preselect root itself (if allowed).
if (allowRoot && isSelectable('folder')) {
var root = treeEl.querySelector('.tree-row');
if (root) setSelected('/', 'folder', root);
}
return;
}
selectByPath(preselectSegs);
});
// expandChain walks `segments`, looking up each by name in the current
// row list, expanding it, and recursing into its children.
function expandChain(rows, segments, basePath) {
if (segments.length === 0) return Promise.resolve();
var seg = segments[0];
var match = null;
for (var i = 0; i < rows.length; i++) {
if (rows[i].kind === 'folder' && rows[i].name === seg) {
match = rows[i];
break;
}
}
if (!match) return Promise.resolve();
return match.expand().then(function (childRows) {
return expandChain(childRows || [], segments.slice(1), joinPath(basePath, seg));
});
}
// selectByPath walks the visible tree rows to locate the row matching
// `segments` and marks it selected. Assumes its ancestors are already
// expanded (expandChain ran first).
function selectByPath(segments) {
var container = rootChildren;
var path = '/';
for (var i = 0; i < segments.length; i++) {
var seg = segments[i];
var kids = container.children;
var found = null;
for (var j = 0; j < kids.length; j++) {
var row = kids[j];
if (!row.classList || !row.classList.contains('tree-row')) continue;
var nm = row.querySelector('.tree-name');
if (nm && nm.textContent === seg) { found = row; break; }
}
if (!found) return;
path = joinPath(path, seg);
if (i === segments.length - 1) {
if (isSelectable('folder')) setSelected(path, 'folder', found);
try { found.scrollIntoView({ block: 'nearest' }); } catch (e) {}
return;
}
var next = found.nextSibling;
if (!next || !next.classList || !next.classList.contains('tree-children')) return;
container = next;
}
}
return handle;
}
window.openTreePicker = openTreePicker;
})();
+215
View File
@@ -0,0 +1,215 @@
;(function () {
// This script owns the .tree-scroll child of the aside, not the aside itself.
var container = document.querySelector("aside.tree-sidebar .tree-scroll")
if (!container) return
function joinPath(parent, name) {
if (parent === "/" || parent === "") return "/" + name
return parent.replace(/\/+$/, "") + "/" + name
}
function encodeSegments(p) {
return p.replace(/^\/+/, "").split("/").map(encodeURIComponent).join("/")
}
function folderHref(p) {
if (p === "/" || p === "") return "/"
return "/" + encodeSegments(p) + "/"
}
function fetchFolder(path, expandTo) {
var url = folderHref(path) + "?tree=1&foldersOnly=1"
if (expandTo) url += "&expandTo=" + encodeURIComponent(expandTo)
return fetch(url, { credentials: "same-origin" }).then(function (r) {
if (!r.ok) throw new Error("HTTP " + r.status)
return r.json()
})
}
// Current page as a canonical, DECODED wiki path ("/" for root, no trailing
// slash otherwise). pathname is percent-encoded; the tree's entry names (and
// thus fullPath) are decoded, so we must decode here too or paths containing
// spaces / non-ASCII never match a row and the chain never expands.
var activePath = (function () {
var p = window.location.pathname.replace(/\/+$/, "")
if (p === "") return "/"
try {
return decodeURIComponent(p)
} catch (e) {
return p
}
})()
var activeRow = null
function disabledRow(text) {
var row = document.createElement("div")
row.className = "tree-row is-disabled"
row.textContent = text
return row
}
// renderInto fills containerEl with rows for `entries` (children of
// parentPath). Folders carrying a `children` array (the pre-expanded
// ancestor chain from ?expandTo) are opened immediately and recurse.
function renderInto(containerEl, parentPath, entries) {
entries.forEach(function (entry) {
var built = buildRow(parentPath, entry)
containerEl.appendChild(built.row)
if (built.preExpand) built.open()
})
}
function buildRow(parentPath, entry) {
var fullPath = joinPath(parentPath, entry.name)
var row = document.createElement("div")
row.className = "tree-row"
var chevron = document.createElement("span")
chevron.className = "tree-chevron"
chevron.textContent = "▸"
row.appendChild(chevron)
var link = document.createElement("a")
link.className = "tree-name tree-folder"
link.textContent = entry.name
link.href = folderHref(fullPath)
row.appendChild(link)
if (fullPath === activePath) {
row.classList.add("is-active")
activeRow = row
}
var preChildren = entry.children ? entry.children : null
var childrenEl = null
var loaded = false
var isOpen = false
var isLeaf = false
function ensureChildrenEl() {
if (!childrenEl) {
childrenEl = document.createElement("div")
childrenEl.className = "tree-children"
}
return childrenEl
}
function markLeaf() {
isLeaf = true
isOpen = false
chevron.textContent = "▸"
chevron.classList.add("is-leaf")
if (childrenEl && childrenEl.parentNode) {
childrenEl.parentNode.removeChild(childrenEl)
}
}
function loadChildren() {
var el = ensureChildrenEl()
el.textContent = ""
el.appendChild(disabledRow("…"))
fetchFolder(fullPath)
.then(function (resp) {
el.textContent = ""
loaded = true
if (!resp.entries || resp.entries.length === 0) {
markLeaf()
return
}
renderInto(el, fullPath, resp.entries)
})
.catch(function () {
el.textContent = ""
var err = document.createElement("div")
err.className = "tree-row"
err.textContent = "(failed — tap to retry)"
err.addEventListener("click", function (e) {
e.stopPropagation()
loadChildren()
})
el.appendChild(err)
})
}
function open() {
if (isOpen || isLeaf) return
var el = ensureChildrenEl()
row.parentNode.insertBefore(el, row.nextSibling)
chevron.textContent = "▾" // ▾
isOpen = true
if (!loaded) {
if (preChildren) {
renderInto(el, fullPath, preChildren)
loaded = true
} else {
loadChildren()
}
}
}
function close() {
if (!isOpen) return
if (childrenEl && childrenEl.parentNode) {
childrenEl.parentNode.removeChild(childrenEl)
}
chevron.textContent = "▸"
isOpen = false
}
// Clicking the name link navigates (folder) or opens the file; clicking
// anywhere else on the row toggles expansion for folders.
row.addEventListener("click", function (e) {
if (e.target.closest("a.tree-name")) return
if (isOpen) close()
else open()
})
return { row: row, open: open, preExpand: !!preChildren }
}
function render() {
container.textContent = ""
var listing = document.createElement("div")
container.appendChild(listing)
listing.appendChild(disabledRow("…"))
fetchFolder("/", activePath === "/" ? "" : activePath)
.then(function (resp) {
listing.textContent = ""
renderInto(listing, "/", resp.entries)
if (activeRow) {
try {
activeRow.scrollIntoView({ block: "nearest" })
} catch (e) {}
}
})
.catch(function () {
listing.textContent = ""
var err = document.createElement("div")
err.className = "tree-row"
err.textContent = "(failed — tap to retry)"
err.addEventListener("click", function () {
render()
})
listing.appendChild(err)
})
}
function setupMenuItem() {
var specials = document.querySelector(".actions-special")
if (!specials) return
var item = document.createElement("button")
item.type = "button"
item.className = "btn btn-block"
item.title = "Folders"
item.textContent = "FOLDERS"
item.addEventListener("click", function () {
if (typeof openOverlay === "function") openOverlay(container)
})
specials.appendChild(item)
}
render()
setupMenuItem()
})()
+699
View File
@@ -0,0 +1,699 @@
package main
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"html"
"html/template"
"io"
"log"
"net/http"
"net/url"
"os"
"path"
"path/filepath"
"regexp"
"sort"
"strings"
)
// JSON Canvas (https://jsoncanvas.org/spec/1.0/) support.
//
// A canvas is a `<Name>.canvas` file sitting in a page folder, addressed as an
// alternative *view* of that page rather than as a standalone file:
//
// /Topics/Ideas/?canvas=Architecture the canvas view
// /Topics/Ideas/Architecture.canvas 302 -> the line above
// /Topics/Ideas/Architecture.canvas?raw the file bytes
//
// Routing through the folder keeps the page's identity (breadcrumb, tree,
// header actions) and lets every canvas in a folder render as a tab strip above
// the page content. The redirect mirrors how diary.go collapses its virtual
// month/day URLs onto one canonical form.
//
// The server owns the format: it parses and validates on every read and write,
// re-marshals from its own structs on save, and never writes bytes it has not
// round-tripped through canvasDoc. Keys it does not model are dropped rather
// than preserved — this wiki is the only writer.
const canvasExt = ".canvas"
// canvasMaxBytes caps a save payload. A canvas is coordinates and prose; a
// megabyte is already far past any hand-built diagram and well short of
// anything that could pressure the NAS.
const canvasMaxBytes = 4 << 20
// canvasRenderMaxBytes caps a /_canvas/render body. Only one node's content
// travels at a time.
const canvasRenderMaxBytes = 1 << 20
// Structural caps. These exist so a looping client cannot grow a file until it
// stops loading, not because the format has limits.
const (
canvasMaxNodes = 5000
canvasMaxEdges = 10000
canvasMaxCoord = 1 << 20
)
// canvasNameRe restricts a ?canvas= value to a single safe path component: the
// name is joined onto the folder path, so separators, dot-segments, control
// characters, and leading dots (which would create a hidden file the listing
// filters away) must all be rejected before the filesystem sees it.
var canvasNameRe = regexp.MustCompile(`^[^./\\\x00-\x1f][^/\\\x00-\x1f]{0,99}$`)
// canvasHexRe matches the hex form of a canvasColor. The spec also allows the
// preset digits "1".."6"; see validCanvasColor.
var canvasHexRe = regexp.MustCompile(`^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$`)
// Node types defined by the spec.
const (
canvasNodeText = "text"
canvasNodeFile = "file"
canvasNodeLink = "link"
canvasNodeGroup = "group"
)
// canvasDoc is a whole `.canvas` file. Both arrays are optional per the spec,
// so an empty document marshals to `{}`.
type canvasDoc struct {
Nodes []canvasNode `json:"nodes,omitempty"`
Edges []canvasEdge `json:"edges,omitempty"`
}
// canvasNode carries every node variant in one struct. The spec's four types
// share six required attributes and add one to three of their own, so a flat
// struct with omitempty round-trips all of them without the ceremony of a
// custom unmarshaller; validate() enforces which extras a given Type may use.
type canvasNode struct {
ID string `json:"id"`
Type string `json:"type"`
X int `json:"x"`
Y int `json:"y"`
Width int `json:"width"`
Height int `json:"height"`
Color string `json:"color,omitempty"`
Text string `json:"text,omitempty"` // type=text
File string `json:"file,omitempty"` // type=file
Subpath string `json:"subpath,omitempty"` // type=file
URL string `json:"url,omitempty"` // type=link
Label string `json:"label,omitempty"` // type=group
Background string `json:"background,omitempty"` // type=group
BackgroundStyle string `json:"backgroundStyle,omitempty"` // type=group
}
type canvasEdge struct {
ID string `json:"id"`
FromNode string `json:"fromNode"`
FromSide string `json:"fromSide,omitempty"`
FromEnd string `json:"fromEnd,omitempty"` // defaults to "none"
ToNode string `json:"toNode"`
ToSide string `json:"toSide,omitempty"`
ToEnd string `json:"toEnd,omitempty"` // defaults to "arrow"
Color string `json:"color,omitempty"`
Label string `json:"label,omitempty"`
}
// canvasRef is one entry in the page's view switcher.
type canvasRef struct {
Name string
URL string
Active bool
}
// canvasPayload is the single blob inlined into the canvas page. Shipping the
// document *and* its server-rendered node HTML together means opening a canvas
// costs exactly one request no matter how many nodes it holds — the obvious
// alternative (one content request per node on load) fans out badly over the
// mobile/VPN path.
type canvasPayload struct {
Name string `json:"name"`
PostURL string `json:"postUrl"`
Hash string `json:"hash"`
Doc *canvasDoc `json:"doc"`
Rendered map[string]template.HTML `json:"rendered"`
}
// --- parsing and validation ---
// parseCanvas unmarshals and validates raw canvas bytes. Unknown keys are
// ignored rather than rejected so a hand-edited file with a stray attribute
// still opens; they are dropped on the next save.
func parseCanvas(raw []byte) (*canvasDoc, error) {
doc := &canvasDoc{}
if len(strings.TrimSpace(string(raw))) == 0 {
return doc, nil
}
if err := json.Unmarshal(raw, doc); err != nil {
return nil, fmt.Errorf("invalid JSON: %w", err)
}
if err := doc.validate(); err != nil {
return nil, err
}
return doc, nil
}
// validate rejects anything that would render as a broken or invisible canvas.
// It runs on read as well as write: a file edited by hand outside the app gets
// the same diagnosis the editor would give, naming the offending node.
func (c *canvasDoc) validate() error {
if len(c.Nodes) > canvasMaxNodes {
return fmt.Errorf("too many nodes (%d, max %d)", len(c.Nodes), canvasMaxNodes)
}
if len(c.Edges) > canvasMaxEdges {
return fmt.Errorf("too many edges (%d, max %d)", len(c.Edges), canvasMaxEdges)
}
ids := make(map[string]bool, len(c.Nodes))
for i := range c.Nodes {
n := &c.Nodes[i]
if n.ID == "" {
return fmt.Errorf("node %d: missing id", i)
}
if ids[n.ID] {
return fmt.Errorf("duplicate node id %q", n.ID)
}
ids[n.ID] = true
switch n.Type {
case canvasNodeText:
// An empty text node is a node the user just created and has not
// typed into yet, so emptiness is allowed here even though the spec
// calls `text` required. A missing *file* or *url*, by contrast, is
// a node that can never render anything.
case canvasNodeFile:
if n.File == "" {
return fmt.Errorf("node %q: file node has no file", n.ID)
}
if n.Subpath != "" && !strings.HasPrefix(n.Subpath, "#") {
return fmt.Errorf("node %q: subpath must start with #", n.ID)
}
case canvasNodeLink:
if n.URL == "" {
return fmt.Errorf("node %q: link node has no url", n.ID)
}
case canvasNodeGroup:
if n.BackgroundStyle != "" {
switch n.BackgroundStyle {
case "cover", "ratio", "repeat":
default:
return fmt.Errorf("node %q: bad backgroundStyle %q", n.ID, n.BackgroundStyle)
}
}
case "":
return fmt.Errorf("node %q: missing type", n.ID)
default:
return fmt.Errorf("node %q: unknown type %q", n.ID, n.Type)
}
if n.Width <= 0 || n.Height <= 0 {
return fmt.Errorf("node %q: width and height must be positive", n.ID)
}
if n.Width > canvasMaxCoord || n.Height > canvasMaxCoord ||
abs(n.X) > canvasMaxCoord || abs(n.Y) > canvasMaxCoord {
return fmt.Errorf("node %q: coordinates out of range", n.ID)
}
if !validCanvasColor(n.Color) {
return fmt.Errorf("node %q: bad color %q", n.ID, n.Color)
}
}
edgeIDs := make(map[string]bool, len(c.Edges))
for i := range c.Edges {
e := &c.Edges[i]
if e.ID == "" {
return fmt.Errorf("edge %d: missing id", i)
}
if edgeIDs[e.ID] {
return fmt.Errorf("duplicate edge id %q", e.ID)
}
edgeIDs[e.ID] = true
if !ids[e.FromNode] {
return fmt.Errorf("edge %q: fromNode %q does not exist", e.ID, e.FromNode)
}
if !ids[e.ToNode] {
return fmt.Errorf("edge %q: toNode %q does not exist", e.ID, e.ToNode)
}
if !validCanvasSide(e.FromSide) {
return fmt.Errorf("edge %q: bad fromSide %q", e.ID, e.FromSide)
}
if !validCanvasSide(e.ToSide) {
return fmt.Errorf("edge %q: bad toSide %q", e.ID, e.ToSide)
}
if !validCanvasEnd(e.FromEnd) {
return fmt.Errorf("edge %q: bad fromEnd %q", e.ID, e.FromEnd)
}
if !validCanvasEnd(e.ToEnd) {
return fmt.Errorf("edge %q: bad toEnd %q", e.ID, e.ToEnd)
}
if !validCanvasColor(e.Color) {
return fmt.Errorf("edge %q: bad color %q", e.ID, e.Color)
}
}
return nil
}
func abs(n int) int {
if n < 0 {
return -n
}
return n
}
// validCanvasColor accepts an absent color, a preset digit "1".."6" (red,
// orange, yellow, green, cyan, purple), or a hex string.
func validCanvasColor(c string) bool {
switch c {
case "", "1", "2", "3", "4", "5", "6":
return true
}
return canvasHexRe.MatchString(c)
}
func validCanvasSide(s string) bool {
switch s {
case "", "top", "right", "bottom", "left":
return true
}
return false
}
func validCanvasEnd(e string) bool {
switch e {
case "", "none", "arrow":
return true
}
return false
}
// canvasHash is the conflict token exchanged with the client. It covers the
// exact bytes on disk, so any edit made outside this editor invalidates it.
func canvasHash(raw []byte) string {
sum := sha256.Sum256(raw)
return hex.EncodeToString(sum[:])[:16]
}
// --- paths and discovery ---
func canvasPath(fsPath, name string) string {
return filepath.Join(fsPath, name+canvasExt)
}
// canvasBackupPath is the previous-contents copy kept beside a canvas. The dot
// prefix keeps it out of the file listing and the search index.
func canvasBackupPath(fsPath, name string) string {
return filepath.Join(fsPath, "."+name+canvasExt+".bak")
}
// canvasURL builds the canonical view URL for a canvas in the folder at urlPath.
func canvasURL(urlPath, name string) string {
if !strings.HasSuffix(urlPath, "/") {
urlPath += "/"
}
return urlPath + "?canvas=" + url.QueryEscape(name)
}
// canvasViewURL maps a direct `/A/B/Name.canvas` request onto the canonical
// `/A/B/?canvas=Name` form. Segments are re-encoded because urlPath arrives
// already percent-decoded.
func canvasViewURL(urlPath string) (string, bool) {
base := path.Base(urlPath)
name := strings.TrimSuffix(base, canvasExt)
if name == "" || name == base || !canvasNameRe.MatchString(name) {
return "", false
}
return canvasURL(fileURL(strings.Trim(path.Dir(urlPath), "/")), name), true
}
// listCanvases returns the canvases in a folder as view-switcher tabs, sorted
// case-insensitively by name. active marks the one currently being viewed.
func listCanvases(fsPath, urlPath, active string) []canvasRef {
entries, err := os.ReadDir(fsPath)
if err != nil {
return nil
}
var refs []canvasRef
for _, e := range entries {
name := e.Name()
if e.IsDir() || strings.HasPrefix(name, ".") || !strings.HasSuffix(name, canvasExt) {
continue
}
base := strings.TrimSuffix(name, canvasExt)
if !canvasNameRe.MatchString(base) {
continue
}
refs = append(refs, canvasRef{
Name: base,
URL: canvasURL(urlPath, base),
Active: base == active,
})
}
sort.Slice(refs, func(i, j int) bool {
return strings.ToLower(refs[i].Name) < strings.ToLower(refs[j].Name)
})
return refs
}
// isCanvasFile reports whether a listing entry is a canvas. Used to keep
// canvases out of the Files listing — they are surfaced as tabs above the
// content instead, the same reasoning that hides index.md.
func isCanvasFile(name string) bool {
return strings.HasSuffix(name, canvasExt)
}
// --- server-side node rendering ---
// prerenderNodes renders every node whose content the server owns, keyed by
// node id. Link and group nodes are built entirely in the browser and are
// absent from the map.
func (h *handler) prerenderNodes(c *canvasDoc) map[string]template.HTML {
out := make(map[string]template.HTML, len(c.Nodes))
for i := range c.Nodes {
n := &c.Nodes[i]
switch n.Type {
case canvasNodeText:
out[n.ID] = renderMarkdown([]byte(n.Text))
case canvasNodeFile:
out[n.ID] = h.renderFileNode(n.File, n.Subpath)
}
}
return out
}
// renderFileNode renders a file node's transclusion. Markdown is rendered
// through the shared goldmark instance so wikilinks, embeds, tables, and task
// checkboxes behave exactly as they do on a page; media gets a player or an
// <img> pointed at the existing thumbnail endpoint; anything else degrades to
// a link. A missing target renders visibly broken rather than blank.
func (h *handler) renderFileNode(file, subpath string) template.HTML {
target := "/" + strings.Trim(file, "/")
fsPath := filepath.Join(h.root, filepath.FromSlash(strings.TrimPrefix(target, "/")))
if rel, err := filepath.Rel(h.root, fsPath); err != nil || strings.HasPrefix(rel, "..") {
return canvasBrokenRef(file, "outside the wiki")
}
info, err := os.Stat(fsPath)
if err != nil {
return canvasBrokenRef(file, "not found")
}
if info.IsDir() {
// A folder is a page; transclude its markdown.
fsPath = filepath.Join(fsPath, "index.md")
if _, err := os.Stat(fsPath); err != nil {
return canvasBrokenRef(file, "page has no content")
}
return h.renderMarkdownFile(fsPath, file, subpath)
}
name := path.Base(target)
href := html.EscapeString(fileURL(strings.TrimPrefix(target, "/")))
switch {
case isImageFile(name):
return template.HTML(`<a class="canvas-media" href="` + href + `"><img src="` +
html.EscapeString(thumbURL(fileURL(strings.TrimPrefix(target, "/")), 800)) + `" alt="" loading="lazy"></a>`)
case isVideoFile(name):
return template.HTML(`<video class="canvas-media" controls preload="metadata" src="` + href + `"></video>`)
case strings.EqualFold(path.Ext(name), ".pdf"):
return template.HTML(`<embed class="canvas-media" type="application/pdf" src="` + href + `">`)
case strings.EqualFold(path.Ext(name), ".md"):
return h.renderMarkdownFile(fsPath, file, subpath)
default:
return template.HTML(`<p class="canvas-file-fallback"><a href="` + href + `">` +
html.EscapeString(name) + `</a></p>`)
}
}
// renderMarkdownFile renders a markdown file, optionally narrowed to the
// section named by subpath (`#Heading`). The narrowing reuses the same section
// machinery the page editor uses, so a heading spans its subsections.
func (h *handler) renderMarkdownFile(fsPath, file, subpath string) template.HTML {
raw, err := os.ReadFile(fsPath)
if err != nil {
return canvasBrokenRef(file, "unreadable")
}
if subpath != "" {
section, ok := canvasSubpathSection(raw, subpath)
if !ok {
return canvasBrokenRef(file+subpath, "no such heading")
}
raw = section
}
return renderMarkdown(raw)
}
// canvasSubpathSection narrows raw markdown to the section whose heading
// matches subpath. The match accepts either the heading text (what a human
// types) or goldmark's generated anchor id (what a copied link contains).
func canvasSubpathSection(raw []byte, subpath string) ([]byte, bool) {
want := strings.TrimSpace(strings.TrimPrefix(subpath, "#"))
if want == "" {
return raw, true
}
sections := splitSections(raw)
ids := headingIDs(raw)
for i := 1; i < len(sections); i++ {
_, text := sectionHeading(sections[i])
id := ""
if i-1 < len(ids) {
id = ids[i-1]
}
if strings.EqualFold(text, want) || id == want {
return joinSections(sections[i:secionSpanEnd(sections, i)]), true
}
}
return nil, false
}
func canvasBrokenRef(target, reason string) template.HTML {
return template.HTML(`<p class="canvas-broken">` + html.EscapeString(target) +
` <span class="muted">(` + html.EscapeString(reason) + `)</span></p>`)
}
// --- request handlers ---
// serveCanvas renders the canvas view of a page. Reached from serveDir when
// the request carries ?canvas=<name>.
func (h *handler) serveCanvas(w http.ResponseWriter, r *http.Request, urlPath, fsPath, name string) {
if !canvasNameRe.MatchString(name) {
http.Error(w, "bad canvas name", http.StatusBadRequest)
return
}
raw, err := os.ReadFile(canvasPath(fsPath, name))
if err != nil {
if os.IsNotExist(err) {
http.NotFound(w, r)
return
}
http.Error(w, "read failed: "+err.Error(), http.StatusInternalServerError)
return
}
data := pageData{
Title: name + " — " + pageTitle(urlPath),
CanEdit: true,
IsRoot: urlPath == "/",
SectionIndex: -1,
InsertBefore: -1,
PostURL: urlPath,
ActiveCanvas: name,
Canvases: listCanvases(fsPath, urlPath, name),
}
// A parse failure is reported in-page rather than as a bare error: the file
// is hand-editable, so the user needs to see which node is wrong and still
// reach the raw bytes.
doc, parseErr := parseCanvas(raw)
if parseErr != nil {
data.CanvasError = parseErr.Error()
} else {
payload := canvasPayload{
Name: name,
PostURL: urlPath,
Hash: canvasHash(raw),
Doc: doc,
Rendered: h.prerenderNodes(doc),
}
// encoding/json escapes <, >, & and the U+2028/U+2029 line separators,
// so the result is safe to inline verbatim in a <script> block.
blob, err := json.Marshal(payload)
if err != nil {
http.Error(w, "encode failed: "+err.Error(), http.StatusInternalServerError)
return
}
data.CanvasJSON = template.JS(blob)
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
data.RenderMS = elapsedMS(r)
if err := canvasTmpl.ExecuteTemplate(w, "layout", data); err != nil {
log.Printf("template error: %v", err)
}
}
// handleCanvasPost dispatches every POST carrying ?canvas=. It is checked
// before the page-level delete/move actions in handlePost so `?canvas=X&delete`
// removes the canvas rather than the whole page.
func (h *handler) handleCanvasPost(w http.ResponseWriter, r *http.Request, urlPath, fsPath, name string) {
if !canvasNameRe.MatchString(name) {
http.Error(w, "bad canvas name", http.StatusBadRequest)
return
}
query := r.URL.Query()
switch {
case query.Has("create"):
h.handleCanvasCreate(w, r, urlPath, fsPath, name)
case query.Has("delete"):
h.handleCanvasDelete(w, r, urlPath, fsPath, name)
case query.Has("rename"):
h.handleCanvasRename(w, r, urlPath, fsPath, name, query.Get("rename"))
default:
h.handleCanvasSave(w, r, urlPath, fsPath, name)
}
}
// handleCanvasSave replaces a canvas with the posted document. The write is
// gated on four checks in order — parse, validate, version, non-empty — so a
// malformed or stale payload never reaches the file.
func (h *handler) handleCanvasSave(w http.ResponseWriter, r *http.Request, urlPath, fsPath, name string) {
body, err := io.ReadAll(http.MaxBytesReader(w, r.Body, canvasMaxBytes))
if err != nil {
http.Error(w, "request too large or truncated", http.StatusBadRequest)
return
}
doc, err := parseCanvas(body)
if err != nil {
http.Error(w, "rejected: "+err.Error(), http.StatusBadRequest)
return
}
p := canvasPath(fsPath, name)
current, readErr := os.ReadFile(p)
if readErr != nil && !os.IsNotExist(readErr) {
http.Error(w, "read failed: "+readErr.Error(), http.StatusInternalServerError)
return
}
if want := r.Header.Get("X-Canvas-Version"); want != "" && want != canvasHash(current) {
http.Error(w, "the canvas changed since you opened it — reload to get the current version",
http.StatusConflict)
return
}
// A save must never blank a canvas. An empty document is indistinguishable
// from a client that lost its state, so emptying is only allowed when the
// caller says so explicitly — the same reasoning as the empty-content guard
// on page saves.
if len(doc.Nodes) == 0 && !r.URL.Query().Has("empty") {
if cur, err := parseCanvas(current); err == nil && len(cur.Nodes) > 0 {
http.Error(w, fmt.Sprintf("refusing to replace %d nodes with an empty canvas", len(cur.Nodes)),
http.StatusBadRequest)
return
}
}
out, err := json.MarshalIndent(doc, "", "\t")
if err != nil {
http.Error(w, "encode failed: "+err.Error(), http.StatusInternalServerError)
return
}
out = append(out, '\n')
if len(current) > 0 {
if err := writeFileAtomic(canvasBackupPath(fsPath, name), current, 0644); err != nil {
log.Printf("canvas backup %s: %v", p, err)
}
}
if err := writeFileAtomic(p, out, 0644); err != nil {
http.Error(w, "write failed: "+err.Error(), http.StatusInternalServerError)
return
}
// Hand back the new token so the client can keep editing without reloading.
w.Header().Set("X-Canvas-Version", canvasHash(out))
w.WriteHeader(http.StatusNoContent)
}
func (h *handler) handleCanvasCreate(w http.ResponseWriter, r *http.Request, urlPath, fsPath, name string) {
if err := os.MkdirAll(fsPath, 0755); err != nil {
http.Error(w, "mkdir failed: "+err.Error(), http.StatusInternalServerError)
return
}
p := canvasPath(fsPath, name)
if _, err := os.Stat(p); err == nil {
http.Error(w, "a canvas with that name already exists", http.StatusConflict)
return
}
if err := writeFileAtomic(p, []byte("{}\n"), 0644); err != nil {
http.Error(w, "write failed: "+err.Error(), http.StatusInternalServerError)
return
}
http.Redirect(w, r, canvasURL(urlPath, name), http.StatusSeeOther)
}
func (h *handler) handleCanvasDelete(w http.ResponseWriter, r *http.Request, urlPath, fsPath, name string) {
if err := os.Remove(canvasPath(fsPath, name)); err != nil {
if os.IsNotExist(err) {
http.NotFound(w, r)
return
}
http.Error(w, "delete failed: "+err.Error(), http.StatusInternalServerError)
return
}
_ = os.Remove(canvasBackupPath(fsPath, name))
http.Redirect(w, r, urlPath, http.StatusSeeOther)
}
func (h *handler) handleCanvasRename(w http.ResponseWriter, r *http.Request, urlPath, fsPath, name, newName string) {
if !canvasNameRe.MatchString(newName) {
http.Error(w, "bad canvas name", http.StatusBadRequest)
return
}
if newName == name {
http.Redirect(w, r, canvasURL(urlPath, name), http.StatusSeeOther)
return
}
dst := canvasPath(fsPath, newName)
if _, err := os.Stat(dst); err == nil {
http.Error(w, "a canvas with that name already exists", http.StatusConflict)
return
}
if err := os.Rename(canvasPath(fsPath, name), dst); err != nil {
http.Error(w, "rename failed: "+err.Error(), http.StatusInternalServerError)
return
}
_ = os.Remove(canvasBackupPath(fsPath, name))
http.Redirect(w, r, canvasURL(urlPath, newName), http.StatusSeeOther)
}
// handleCanvasRender renders one node's content on demand. It is stateless and
// takes the content in the request rather than reading it from the canvas file:
// the node being re-rendered has just been edited in the browser and is not on
// disk yet. Everything already on disk arrives pre-rendered with the page.
func (h *handler) handleCanvasRender(w http.ResponseWriter, r *http.Request) {
if !h.checkAuth(w, r) {
return
}
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
r.Body = http.MaxBytesReader(w, r.Body, canvasRenderMaxBytes)
if err := r.ParseForm(); err != nil {
http.Error(w, "bad request", http.StatusBadRequest)
return
}
var out template.HTML
switch r.FormValue("type") {
case canvasNodeFile:
out = h.renderFileNode(r.FormValue("file"), r.FormValue("subpath"))
case canvasNodeText:
out = renderMarkdown([]byte(r.FormValue("text")))
default:
http.Error(w, "bad node type", http.StatusBadRequest)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
_, _ = io.WriteString(w, string(out))
}
+98
View File
@@ -0,0 +1,98 @@
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)
}
cmd := exec.Command(tokens[0], tokens[1:]...)
hideConsole(cmd)
return cmd.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
}
+8
View File
@@ -0,0 +1,8 @@
//go:build !windows
package main
import "os/exec"
// Allows running commands without showing a terminal window on startup when not running on windows
func hideConsole(*exec.Cmd) {}
+18
View File
@@ -0,0 +1,18 @@
package main
import (
"os/exec"
"syscall"
)
// This is the windows CREATE_NO_WINDOW syscall.
// We have no golang.org/x/sys dependency, so this will suffice
const createNoWindow = 0x08000000
// Allows running commands without showing a terminal window on startup on windows
func hideConsole(cmd *exec.Cmd) {
cmd.SysProcAttr = &syscall.SysProcAttr{
HideWindow: true,
CreationFlags: createNoWindow,
}
}
+94
View File
@@ -0,0 +1,94 @@
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)
}
+153
View File
@@ -0,0 +1,153 @@
<!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&#10;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>
+10
View File
@@ -0,0 +1,10 @@
package main
import (
"io/fs"
"os"
)
func statPath(p string) (fs.FileInfo, error) {
return os.Stat(p)
}
+65
View File
@@ -0,0 +1,65 @@
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
}
+38
View File
@@ -0,0 +1,38 @@
package main
import (
"flag"
"log"
"path/filepath"
)
const version = "2"
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)
}
}
+297
View File
@@ -0,0 +1,297 @@
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
}
+53
View File
@@ -0,0 +1,53 @@
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)
}
}
+664 -216
View File
@@ -5,6 +5,7 @@ import (
"fmt"
"html/template"
"log"
"net/http"
"net/url"
"os"
"path"
@@ -21,88 +22,521 @@ func init() {
type diaryHandler struct{}
func (d *diaryHandler) handle(root, fsPath, urlPath string) *specialPage {
depth, ok := findDiaryContext(root, fsPath)
if !ok || depth == 0 {
return nil
// redirect handles diary-specific redirect cases. The year page is the only
// 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 (never the editor, even when today's section does
// not exist yet).
// 2. A virtual month URL (/diary/<root>/YYYY/MM/) redirects to
// /diary/<root>/YYYY/#YYYY-MM (or to ?edit&section=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)
_, _, 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)
return yearURL + "#" + 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&section=%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&section=%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)
}
var content template.HTML
switch depth {
case 1:
content = renderDiaryYear(fsPath, urlPath)
return parts[0], "", "", true
case 2:
content = renderDiaryMonth(fsPath, urlPath)
return parts[0], parts[1], "", true
case 3:
content = renderDiaryDay(fsPath, urlPath)
return parts[0], parts[1], parts[2], true
}
return &specialPage{Content: content, SuppressListing: true}
return "", "", "", false
}
func (d *diaryHandler) handle(root, fsPath, urlPath string, _ *http.Request) *specialPage {
depth, diaryRootFS, diaryRootURL, ok := findDiaryContext(root, fsPath, urlPath)
if !ok {
return nil
}
widget := computeCalendarWidget(diaryRootFS, diaryRootURL, fsPath, depth)
if depth == 0 {
return &specialPage{Widget: widget, SuppressTOC: true}
}
year, _, _, ok := parseDiaryURLParts(fsPath, depth)
if !ok {
return &specialPage{Widget: widget, SuppressTOC: true}
}
if depth == 1 {
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
// .page-settings file with type=diary. Returns the depth of fsPath
// relative to the diary root, and whether one was found.
// depth=0 means fsPath itself is the diary root.
func findDiaryContext(root, fsPath string) (int, bool) {
current := fsPath
for depth := 0; ; depth++ {
s := readPageSettings(current)
// relative to the diary root, the diary root fs path, its URL, and
// whether a diary root was found. depth=0 means fsPath itself is the root.
func findDiaryContext(root, fsPath, urlPath string) (depth int, diaryRootFS, diaryRootURL string, ok bool) {
currentFS := fsPath
currentURL := urlPath
for d := 0; ; d++ {
s := readPageSettings(currentFS)
if s != nil && s.Type == "diary" {
return depth, true
return d, currentFS, currentURL, true
}
if current == root {
if currentFS == root {
break
}
parent := filepath.Dir(current)
if parent == current {
parent := filepath.Dir(currentFS)
if parent == currentFS {
break
}
current = parent
currentFS = parent
currentURL = parentURL(currentURL)
}
return 0, "", "", false
}
// 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 {
Num int
URL string
HasEntry bool
IsToday bool
IsCurrent bool
}
type calYear struct {
Num int
URL string
IsCurrent bool
}
// calMonthGrid carries everything the template needs to render one month's
// grid plus the caption that links to it. The calendar widget renders all 12
// stacked vertically; JS centers the current month in the rail on load.
type calMonthGrid struct {
Num int
Name string
AnchorURL string // "#YYYY-MM" on a year page, full URL otherwise
Weeks [][]calDay
}
type calendarData struct {
DisplayYear int
DisplayMonth int
DiaryURL string
Months []calMonthGrid
Years []calYear
}
var diaryCalTmpl = template.Must(template.ParseFS(assets, "assets/diary/calendar.html"))
func computeCalendarWidget(diaryRootFS, diaryRootURL, fsPath string, depth int) template.HTML {
today := time.Now()
var displayYear, displayMonth, currentDay, currentMonth int
switch depth {
case 0:
displayYear = today.Year()
displayMonth = int(today.Month())
case 1:
y, err := strconv.Atoi(filepath.Base(fsPath))
if err != nil {
return ""
}
displayYear = y
if y == today.Year() {
displayMonth = int(today.Month())
} else {
displayMonth = 1
}
case 2:
m, err := strconv.Atoi(filepath.Base(fsPath))
if err != nil || m < 1 || m > 12 {
return ""
}
y, err := strconv.Atoi(filepath.Base(filepath.Dir(fsPath)))
if err != nil {
return ""
}
displayYear = y
displayMonth = m
case 3:
d, err := strconv.Atoi(filepath.Base(fsPath))
if err != nil || d < 1 || d > 31 {
return ""
}
monthFS := filepath.Dir(fsPath)
m, err := strconv.Atoi(filepath.Base(monthFS))
if err != nil || m < 1 || m > 12 {
return ""
}
y, err := strconv.Atoi(filepath.Base(filepath.Dir(monthFS)))
if err != nil {
return ""
}
displayYear = y
displayMonth = m
currentDay = d
currentMonth = m
default:
return ""
}
yearFS := filepath.Join(diaryRootFS, fmt.Sprintf("%d", displayYear))
hasDayEntryByMonth := daysWithEntriesByMonth(yearFS, 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).
yearEntries, _ := os.ReadDir(diaryRootFS)
var years []calYear
yearSet := map[int]bool{}
for _, e := range yearEntries {
if !e.IsDir() {
continue
}
y, err := strconv.Atoi(e.Name())
if err != nil {
continue
}
yearSet[y] = true
years = append(years, calYear{
Num: y,
URL: path.Join(diaryRootURL, e.Name()) + "/",
IsCurrent: y == displayYear,
})
}
if !yearSet[displayYear] {
years = append(years, calYear{
Num: displayYear,
URL: yearURL,
IsCurrent: true,
})
}
sort.Slice(years, func(i, j int) bool { return years[i].Num > years[j].Num })
data := calendarData{
DisplayYear: displayYear,
DisplayMonth: displayMonth,
DiaryURL: diaryRootURL,
Months: months,
Years: years,
}
var buf bytes.Buffer
if err := diaryCalTmpl.Execute(&buf, data); err != nil {
log.Printf("diary calendar template: %v", err)
return ""
}
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.
type diaryPhoto struct {
Date time.Time
Name string
URL string
ThumbURL string
}
type diaryMonthSummary struct {
ID string
Name string
URL string
PhotoCount int
}
type diaryDaySection struct {
ID string
Heading string
URL string
EditURL string
Content template.HTML
// diarySection is one rendered section of the diary content (year, month, or
// day). Edit URLs point back into the year file's section editor so per-day
// editing works from any slice page.
type diarySection struct {
Level int // 1, 2, or 3
ID string // anchor id (e.g. "2026-05-28")
Heading string // displayed heading text
EditURL string // year-file section edit URL ("" = no edit button)
Body template.HTML // rendered markdown body (excludes the heading line)
Photos []diaryPhoto
}
type diaryYearData struct {
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",
type diaryContentData struct {
Sections []diarySection
}
var germanMonths = map[time.Month]string{
@@ -120,15 +554,6 @@ var germanMonths = map[time.Month]string{
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{
".jpg": true, ".jpeg": true, ".png": true, ".gif": true, ".webp": true,
}
@@ -156,188 +581,211 @@ func yearPhotos(yearFsPath, yearURLPath string) []diaryPhoto {
if err != nil {
continue
}
photoURL := path.Join(yearURLPath, url.PathEscape(name))
thumb := photoURL
if hasThumbnail(name) {
thumb = thumbURL(photoURL, 300)
}
photos = append(photos, diaryPhoto{
Date: t,
Name: name,
URL: path.Join(yearURLPath, url.PathEscape(name)),
URL: photoURL,
ThumbURL: thumb,
})
}
return photos
}
// renderDiaryYear renders month sections with photo counts for a year folder.
func renderDiaryYear(fsPath, urlPath string) template.HTML {
year, err := strconv.Atoi(filepath.Base(fsPath))
var diaryContentTmpl = template.Must(template.ParseFS(assets, "assets/diary/content.html"))
// sectionBody strips the first heading line and returns the rendered body.
// 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 {
return ""
}
raw, _ := os.ReadFile(filepath.Join(yearFS, "index.md"))
sections := splitSections(raw)
photos := yearPhotos(yearFS, yearURL)
photos := yearPhotos(fsPath, urlPath)
out := buildFileSections(sections, photos, yearURL)
out = appendVirtualEntries(out, sections, photos, year, yearURL)
return renderDiaryContent(out)
}
entries, err := os.ReadDir(fsPath)
if err != nil {
return ""
}
var months []diaryMonthSummary
for _, e := range entries {
if !e.IsDir() {
// buildFileSections converts the year file's sections (skipping the
// pre-heading section 0) into rendered diarySection entries. Photos are
// attached to level-3 headings whose text parses as `YYYY-MM-DD`.
func buildFileSections(sections [][]byte, photos []diaryPhoto, yearURL string) []diarySection {
var out []diarySection
for i := 1; i < len(sections); i++ {
level, text := sectionHeading(sections[i])
if level == 0 {
continue
}
monthNum, err := strconv.Atoi(e.Name())
if err != nil || monthNum < 1 || monthNum > 12 {
continue
sec := diarySection{
Level: level,
ID: text,
Heading: text,
EditURL: fmt.Sprintf("%s?edit&section=%d", yearURL, i),
Body: sectionBody(sections[i]),
}
count := 0
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 && int(p.Date.Month()) == monthNum {
count++
}
}
monthDate := time.Date(year, time.Month(monthNum), 1, 0, 0, 0, 0, time.UTC)
months = append(months, diaryMonthSummary{
ID: monthDate.Format("2006-01"),
Name: monthDate.Format("January 2006"),
URL: path.Join(urlPath, e.Name()) + "/",
PhotoCount: count,
})
}
var buf bytes.Buffer
if err := diaryYearTmpl.Execute(&buf, diaryYearData{Months: months, Year: year}); err != nil {
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() {
if p.Date.Year() != year {
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
photoByDay[p.Date.Format("2006-01-02")] = append(photoByDay[p.Date.Format("2006-01-02")], p)
}
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 := formatGermanDate(date)
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 {
if h := extractFirstHeading(raw); h != "" {
heading = h
raw = stripFirstHeading(raw)
}
content = renderMarkdown(raw)
}
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 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 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 {
if p.Date.Year() == year && int(p.Date.Month()) == month && p.Date.Day() == day {
out = append(out, p)
}
}
return out
}
func renderDiaryContent(sections []diarySection) template.HTML {
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)
if err := diaryContentTmpl.Execute(&buf, diaryContentData{Sections: sections}); err != nil {
log.Printf("diary content template: %v", err)
return ""
}
return template.HTML(buf.String())
+22
View File
@@ -0,0 +1,22 @@
// 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");
+107
View File
@@ -0,0 +1,107 @@
// 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,
};
+730
View File
@@ -0,0 +1,730 @@
{
"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"
}
}
}
+20
View File
@@ -0,0 +1,20 @@
{
"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"
}
}
+186
View File
@@ -0,0 +1,186 @@
package main
import (
"bytes"
"path"
"strconv"
"strings"
"github.com/yuin/goldmark"
"github.com/yuin/goldmark/ast"
"github.com/yuin/goldmark/parser"
"github.com/yuin/goldmark/renderer"
"github.com/yuin/goldmark/text"
"github.com/yuin/goldmark/util"
)
// Embeds are ordinary Markdown images: ![caption](photo.jpg "left").
//
// Destinations are relative to the page they sit on. Pages always render at a
// URL ending in "/" (ServeHTTP redirects the slashless form), so the browser
// resolves the destination and nothing here builds a URL or touches the
// filesystem. Goldmark's own image parser produces the nodes; only rendering
// is overridden. The title slot carries the float alignment.
const (
alignLeft = "left"
alignRight = "right"
alignCenter = "center"
// embedThumbW is the width embeds request through the ?w= form of the
// file's own URL, which keeps the <img src> relative.
embedThumbW = 300
)
// embedAlign maps an image title to its float class. A title that is not an
// alignment keyword is ignored and the embed floats right.
func embedAlign(title []byte) string {
switch strings.TrimSpace(string(title)) {
case alignLeft:
return alignLeft
case alignCenter:
return alignCenter
}
return alignRight
}
// embedIsImage reports whether a destination names an image, i.e. whether it
// renders as a <figure> rather than degrading to a link. Extension-only — no
// stat, so a destination gone stale simply 404s when clicked.
func embedIsImage(dest []byte) bool {
return isImageFile(path.Base(string(dest)))
}
type embedRenderer struct{}
func (r *embedRenderer) RegisterFuncs(reg renderer.NodeRendererFuncRegisterer) {
reg.Register(ast.KindImage, r.render)
}
// render writes a <figure> holding a thumbnail that links to the full file,
// with the image's inline children as <figcaption>. Children are rendered by
// goldmark — WalkContinue, not WalkSkipChildren — so a caption may carry
// emphasis or a link. A non-image destination renders as a plain anchor
// labelled with the caption, or with the file name when there is none.
func (r *embedRenderer) render(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
n := node.(*ast.Image)
name := path.Base(string(n.Destination))
dest := util.EscapeHTML(util.URLEscape(n.Destination, true))
captioned := n.FirstChild() != nil
if !embedIsImage(n.Destination) {
if entering {
w.WriteString(`<a href="`)
w.Write(dest)
w.WriteString(`">`)
if !captioned {
w.Write(util.EscapeHTML([]byte(name)))
}
} else {
w.WriteString(`</a>`)
}
return ast.WalkContinue, nil
}
if entering {
w.WriteString(`<figure class="embed embed-`)
w.WriteString(embedAlign(n.Title))
w.WriteString(`"><a class="embed-link" href="`)
w.Write(dest)
w.WriteString(`"><img src="`)
w.Write(dest)
w.WriteString(`?w=`)
w.WriteString(strconv.Itoa(embedThumbW))
w.WriteString(`" alt="`)
w.Write(util.EscapeHTML([]byte(name)))
w.WriteString(`"></a>`)
if captioned {
w.WriteString(`<figcaption>`)
}
return ast.WalkContinue, nil
}
if captioned {
w.WriteString(`</figcaption>`)
}
w.WriteString(`</figure>`)
return ast.WalkContinue, nil
}
// embedBlockTransformer lifts an embed that sits alone in a paragraph up to
// block level. Goldmark wraps inline content in <p>, but the embed renders a
// <figure> (block), and a <figure> inside a <p> is auto-closed by the browser —
// which strands empty <p> elements (they still carry .content paragraph margins)
// and breaks the float layout once several embeds share a page. Dissolving the
// wrapping paragraph makes each embed render as a clean block sibling with no
// stray <p>.
type embedBlockTransformer struct{}
func (t embedBlockTransformer) Transform(doc *ast.Document, reader text.Reader, pc parser.Context) {
source := reader.Source()
var paras []*ast.Paragraph
ast.Walk(doc, func(n ast.Node, entering bool) (ast.WalkStatus, error) {
if entering {
if p, ok := n.(*ast.Paragraph); ok && t.paragraphFiguresOnly(p, source) {
paras = append(paras, p)
}
}
return ast.WalkContinue, nil
})
for _, p := range paras {
parent := p.Parent()
if parent == nil {
continue
}
// InsertBefore isolates the embed from p first, so hoist each embed to a
// block sibling ahead of the (soon removed) paragraph, then drop p.
for c := p.FirstChild(); c != nil; {
next := c.NextSibling()
if _, ok := c.(*ast.Image); ok {
parent.InsertBefore(parent, p, c)
}
c = next
}
parent.RemoveChild(parent, p)
}
}
// paragraphFiguresOnly reports whether p holds at least one figure-rendering
// image and nothing else visible — only such images and whitespace/line-break
// text. Only these are safe to dissolve: a paragraph carrying prose, or an
// image that degrades to an inline link (non-image destination), stays wrapped
// so the fallback anchor keeps its paragraph.
func (t embedBlockTransformer) paragraphFiguresOnly(p *ast.Paragraph, source []byte) bool {
hasFigure := false
for c := p.FirstChild(); c != nil; c = c.NextSibling() {
switch n := c.(type) {
case *ast.Image:
if !embedIsImage(n.Destination) {
return false
}
hasFigure = true
case *ast.Text:
if len(bytes.TrimSpace(n.Segment.Value(source))) != 0 {
return false
}
default:
return false
}
}
return hasFigure
}
type embedExt struct{}
// newEmbedExt returns a goldmark extension that renders Markdown images as
// wiki embeds. It adds no parser of its own: goldmark parses the images, and
// the renderer registered at 500 wins over the default HTML renderer
// (registered at 1000 — lowest priority number is applied last and wins).
func newEmbedExt() goldmark.Extender { return &embedExt{} }
func (e *embedExt) Extend(m goldmark.Markdown) {
m.Parser().AddOptions(parser.WithASTTransformers(
util.Prioritized(embedBlockTransformer{}, 100),
))
m.Renderer().AddOptions(renderer.WithNodeRenderers(
util.Prioritized(&embedRenderer{}, 500),
))
}
+504
View File
@@ -0,0 +1,504 @@
package main
import (
"bytes"
"encoding/json"
"fmt"
"html/template"
"log"
"math"
"net/http"
"os"
"path/filepath"
"sort"
"strconv"
"strings"
"time"
)
func init() {
pageTypeHandlers = append(pageTypeHandlers, &fitnessHandler{})
}
// waistlineExportFile is the exact filename the user copies into the folder.
// The single file is always the latest export — no glob, no multi-file merge.
const waistlineExportFile = "waistline_export.json"
type fitnessHandler struct{}
// redirect: the fitness dashboard has no virtual URLs; everything renders
// inside the normal GET /{path}/ flow.
func (f *fitnessHandler) redirect(root, fsPath, urlPath string, r *http.Request) (string, bool) {
return "", false
}
// handle renders the dashboard for folders whose .page-settings declares
// type = fitness. Markdown content and the folder listing stay visible so
// the user can verify an uploaded export arrived.
func (f *fitnessHandler) handle(root, fsPath, urlPath string, r *http.Request) *specialPage {
s := readPageSettings(fsPath)
if s == nil || s.Type != "fitness" {
return nil
}
weightSel := validFitnessRange(r.URL.Query().Get("weight"), "3m")
weeklySel := validFitnessRange(r.URL.Query().Get("weekly"), "1y")
return &specialPage{
Content: renderFitnessDashboard(fsPath, weightSel, weeklySel),
SuppressTOC: true,
}
}
// === Time ranges ===
type fitnessRange struct {
Value string
Label string
Months int // 0 = all data
}
var fitnessRanges = []fitnessRange{
{"1m", "1 month", 1},
{"3m", "3 months", 3},
{"1y", "1 year", 12},
{"all", "All", 0},
}
func validFitnessRange(v, fallback string) string {
for _, r := range fitnessRanges {
if r.Value == v {
return v
}
}
return fallback
}
func rangeMonths(v string) int {
for _, r := range fitnessRanges {
if r.Value == v {
return r.Months
}
}
return 0
}
// === Waistline export parsing ===
// wlNum is a number in a Waistline export: values appear as JSON numbers,
// numeric strings, null, or are absent. Unparsable values read as not-ok
// instead of failing the whole export parse.
type wlNum struct {
val float64
ok bool
}
func (n *wlNum) UnmarshalJSON(b []byte) error {
s := strings.Trim(string(b), `"`)
if s == "" || s == "null" {
return nil
}
v, err := strconv.ParseFloat(s, 64)
if err != nil {
return nil
}
n.val = v
n.ok = true
return nil
}
// Calorie tracking was removed pending a rethink of the (undocumented)
// per-item formula; only the weight series is read from the export.
type wlExport struct {
Diary []wlDiaryEntry `json:"diary"`
Settings json.RawMessage `json:"settings"`
}
type wlDiaryEntry struct {
DateTime string `json:"dateTime"`
Stats struct {
Weight wlNum `json:"weight"`
} `json:"stats"`
}
// exportDate extracts the calendar day from a Waistline dateTime. The values
// are UTC-midnight timestamps; taking the first 10 characters avoids time
// zone conversions shifting the day.
func exportDate(s string) (time.Time, bool) {
if len(s) < 10 {
return time.Time{}, false
}
t, err := time.Parse("2006-01-02", s[:10])
if err != nil {
return time.Time{}, false
}
return t, true
}
// goalValue extracts settings.goals.<key>.goal-list[0].goal[0] — the first
// weekday slot of the shared goal. Best-effort: any missing or unparsable
// level means "no goal line", never an error.
func goalValue(settings json.RawMessage, key string) (float64, bool) {
var s struct {
Goals map[string]json.RawMessage `json:"goals"`
}
if json.Unmarshal(settings, &s) != nil {
return 0, false
}
var g struct {
GoalList []struct {
Goal []wlNum `json:"goal"`
} `json:"goal-list"`
}
if json.Unmarshal(s.Goals[key], &g) != nil {
return 0, false
}
if len(g.GoalList) == 0 || len(g.GoalList[0].Goal) == 0 {
return 0, false
}
n := g.GoalList[0].Goal[0]
return n.val, n.ok
}
func exportWeightUnit(settings json.RawMessage) string {
var s struct {
Units struct {
Weight string `json:"weight"`
} `json:"units"`
}
if json.Unmarshal(settings, &s) == nil && s.Units.Weight != "" {
return s.Units.Weight
}
return "kg"
}
// === Series extraction ===
type weightPoint struct {
date time.Time
value float64
days int // >0: weekly mean over this many measured days
}
// extractWeights computes the weight series from the export: one point per
// diary entry with stats.weight. Duplicate dates (shouldn't happen) — last
// one wins.
func extractWeights(ex *wlExport) []weightPoint {
byDate := map[time.Time]float64{}
for _, e := range ex.Diary {
day, ok := exportDate(e.DateTime)
if !ok || !e.Stats.Weight.ok {
continue
}
byDate[day] = e.Stats.Weight.val
}
weights := make([]weightPoint, 0, len(byDate))
for d, v := range byDate {
weights = append(weights, weightPoint{date: d, value: v})
}
sort.Slice(weights, func(i, j int) bool { return weights[i].date.Before(weights[j].date) })
return weights
}
func mondayOf(t time.Time) time.Time {
return t.AddDate(0, 0, -((int(t.Weekday()) + 6) % 7))
}
// weeklyMeanWeights buckets the daily weight series into ISO weeks (Monday
// start) and averages over the days that have a measurement. Weeks without
// any measurement produce no point.
func weeklyMeanWeights(points []weightPoint) []weightPoint {
type acc struct {
sum float64
n int
}
byWeek := map[time.Time]*acc{}
for _, p := range points {
w := mondayOf(p.date)
a := byWeek[w]
if a == nil {
a = &acc{}
byWeek[w] = a
}
a.sum += p.value
a.n++
}
out := make([]weightPoint, 0, len(byWeek))
for w, a := range byWeek {
out = append(out, weightPoint{date: w, value: a.sum / float64(a.n), days: a.n})
}
sort.Slice(out, func(i, j int) bool { return out[i].date.Before(out[j].date) })
return out
}
// === SVG geometry ===
// Chart canvas in viewBox units. The SVG scales to container width via
// viewBox + width:100%, so these only set proportions and text size.
const (
chartW = 560.0
chartH = 240.0
chartLeft = 46.0
chartRight = 8.0
chartTop = 10.0
chartBottom = 24.0
)
// svgNum formats a coordinate or display value: rounded to 2 decimals,
// trailing zeros trimmed.
func svgNum(v float64) string {
return strconv.FormatFloat(math.Round(v*100)/100, 'f', -1, 64)
}
type chartScale struct {
x0, x1 time.Time
y0, y1 float64
}
func (s chartScale) x(t time.Time) float64 {
span := s.x1.Sub(s.x0).Seconds()
if span <= 0 {
return chartLeft
}
return chartLeft + (chartW-chartLeft-chartRight)*t.Sub(s.x0).Seconds()/span
}
func (s chartScale) y(v float64) float64 {
span := s.y1 - s.y0
if span <= 0 {
return chartH - chartBottom
}
return chartH - chartBottom - (chartH-chartBottom-chartTop)*(v-s.y0)/span
}
// === View models ===
type fitnessOptVM struct {
Value, Label string
Selected bool
}
type fitnessTickVM struct {
Pos, Label, Anchor string
}
type fitnessDotVM struct {
X, Y, Title string
}
type fitnessGoalVM struct {
Y, LabelY, Label string
}
type fitnessChartVM struct {
Title string
Param string
Options []fitnessOptVM
Empty bool
ViewW, ViewH string
PlotX, PlotY, PlotR, PlotB string
YLabelX, XLabelY string
YTicks, XTicks []fitnessTickVM
Goal *fitnessGoalVM
Lines []string // polyline points attributes
Dots []fitnessDotVM
}
type fitnessDashVM struct {
Notice string
Charts []fitnessChartVM
}
func newChartVM(title, param, sel string) fitnessChartVM {
opts := make([]fitnessOptVM, len(fitnessRanges))
for i, r := range fitnessRanges {
opts[i] = fitnessOptVM{r.Value, r.Label, r.Value == sel}
}
return fitnessChartVM{
Title: title, Param: param, Options: opts,
ViewW: svgNum(chartW), ViewH: svgNum(chartH),
PlotX: svgNum(chartLeft), PlotY: svgNum(chartTop),
PlotR: svgNum(chartW - chartRight), PlotB: svgNum(chartH - chartBottom),
YLabelX: svgNum(chartLeft - 5), XLabelY: svgNum(chartH - chartBottom + 14),
}
}
// yTickVMs places gridlines at fixed multiples of step across the y domain
// (always 5 kg guides on weight charts, regardless of range).
func yTickVMs(sc chartScale, step float64) []fitnessTickVM {
var out []fitnessTickVM
for v := math.Ceil(sc.y0/step) * step; v <= sc.y1+step/1e6; v += step {
out = append(out, fitnessTickVM{Pos: svgNum(sc.y(v)), Label: svgNum(v)})
}
return out
}
func (vm *fitnessChartVM) setGoal(sc chartScale, goal float64, label string) {
gy := sc.y(goal)
ly := gy - 4
if ly < chartTop+10 {
ly = gy + 12
}
vm.Goal = &fitnessGoalVM{Y: svgNum(gy), LabelY: svgNum(ly), Label: label}
}
// === Chart builders ===
// buildWeightChart renders a weight line chart. It serves both the per-day
// series and the weekly-mean series (points carrying days > 0); the line is
// drawn continuous across days/weeks without a measurement.
func buildWeightChart(all []weightPoint, goal float64, hasGoal bool, sel, param, title, unit string, today time.Time) fitnessChartVM {
vm := newChartVM(title, param, sel)
points := all
var x0, x1 time.Time
if m := rangeMonths(sel); m > 0 {
x0 = today.AddDate(0, -m, 0)
points = filterWeights(all, x0)
if len(points) == 0 {
vm.Empty = true
return vm
}
x1 = today
if last := points[len(points)-1].date; last.After(x1) {
x1 = last
}
} else {
if len(points) == 0 {
vm.Empty = true
return vm
}
x0 = points[0].date
x1 = points[len(points)-1].date
}
// Degenerate domain (single point on All): widen so the dot sits inside
// the plot instead of on its edge.
if !x0.Before(x1) {
x0 = x0.AddDate(0, 0, -1)
x1 = x1.AddDate(0, 0, 1)
}
lo, hi := points[0].value, points[0].value
for _, p := range points {
lo = min(lo, p.value)
hi = max(hi, p.value)
}
if hasGoal {
lo = min(lo, goal)
hi = max(hi, goal)
}
pad := (hi - lo) * 0.05
if pad == 0 {
pad = 1
}
sc := chartScale{x0, x1, lo - pad, hi + pad}
vm.YTicks = yTickVMs(sc, 5)
vm.XTicks = timeXTicks(sc, 4)
// One continuous polyline through every point in range — days without a
// measurement do not break the line. Point markers carry the hover
// <title>; on dense ranges they are dropped and the bare line stays
// legible. A single point in range renders as a dot.
dot := func(p weightPoint) fitnessDotVM {
label := p.date.Format("2006-01-02") + ": " + svgNum(p.value) + " " + unit
if p.days > 0 {
label = fmt.Sprintf("Week of %s: %s %s (%d days)",
p.date.Format("2006-01-02"), svgNum(p.value), unit, p.days)
}
return fitnessDotVM{X: svgNum(sc.x(p.date)), Y: svgNum(sc.y(p.value)), Title: label}
}
if len(points) >= 2 {
var b strings.Builder
for i, p := range points {
if i > 0 {
b.WriteByte(' ')
}
b.WriteString(svgNum(sc.x(p.date)))
b.WriteByte(',')
b.WriteString(svgNum(sc.y(p.value)))
}
vm.Lines = append(vm.Lines, b.String())
}
if len(points) <= 100 {
for _, p := range points {
vm.Dots = append(vm.Dots, dot(p))
}
}
if hasGoal {
vm.setGoal(sc, goal, "goal "+svgNum(goal))
}
return vm
}
func filterWeights(points []weightPoint, from time.Time) []weightPoint {
var out []weightPoint
for _, p := range points {
if !p.date.Before(from) {
out = append(out, p)
}
}
return out
}
// timeXTicks places n+1 evenly spaced date labels across a continuous time
// axis; the last label is end-anchored so it stays inside the viewBox.
func timeXTicks(sc chartScale, n int) []fitnessTickVM {
span := sc.x1.Sub(sc.x0)
var out []fitnessTickVM
prev := ""
for i := 0; i <= n; i++ {
t := sc.x0.Add(time.Duration(float64(span) * float64(i) / float64(n)))
label := t.Format("2006-01-02")
if label == prev {
continue
}
prev = label
anchor := "middle"
if i == n {
anchor = "end"
}
out = append(out, fitnessTickVM{Pos: svgNum(sc.x(t)), Label: label, Anchor: anchor})
}
return out
}
// === Rendering ===
var fitnessTmpl = template.Must(template.ParseFS(assets, "assets/fitness/main.html"))
func renderFitnessDashboard(fsPath, weightSel, weeklySel string) template.HTML {
data := buildFitnessDash(fsPath, weightSel, weeklySel, time.Now())
var buf bytes.Buffer
if err := fitnessTmpl.Execute(&buf, data); err != nil {
log.Printf("fitness template: %v", err)
return ""
}
return template.HTML(buf.String())
}
// buildFitnessDash reads and parses the export per request — no caching, no
// indexes. A read or parse failure (including a truncated mid-upload file)
// becomes an inline notice; the page itself always renders.
func buildFitnessDash(fsPath, weightSel, weeklySel string, now time.Time) fitnessDashVM {
raw, err := os.ReadFile(filepath.Join(fsPath, waistlineExportFile))
if err != nil {
return fitnessDashVM{Notice: "No Waistline export found — upload " + waistlineExportFile + " to this folder."}
}
var ex wlExport
if err := json.Unmarshal(raw, &ex); err != nil {
return fitnessDashVM{Notice: "Could not read " + waistlineExportFile + ": " + err.Error()}
}
today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.UTC)
weights := extractWeights(&ex)
wGoal, hasWGoal := goalValue(ex.Settings, "weight")
unit := exportWeightUnit(ex.Settings)
return fitnessDashVM{Charts: []fitnessChartVM{
buildWeightChart(weights, wGoal, hasWGoal, weightSel,
"weight", "Weight ("+unit+")", unit, today),
buildWeightChart(weeklyMeanWeights(weights), wGoal, hasWGoal, weeklySel,
"weekly", "Weekly average weight ("+unit+")", unit, today),
}}
}
+350 -33
View File
@@ -1,36 +1,82 @@
package main
import (
"context"
"crypto/sha256"
"embed"
"encoding/hex"
"flag"
"html/template"
"io/fs"
"log"
"net/http"
"net/url"
"os"
"path"
"path/filepath"
"strconv"
"strings"
"time"
)
//go:embed assets
var assets embed.FS
var tmpl = template.Must(template.New("page.html").ParseFS(assets, "assets/page.html"))
// 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]
}
// tmplFuncs is shared by every layout-based template: the edit template appends
// editorBundleVersion to its CodeMirror <script> src to cache-bust the bundle.
var tmplFuncs = template.FuncMap{
"editorBundleVersion": func() string { return editorBundleVersion },
"fileIcon": fileIcon,
}
var (
pageTmpl = template.Must(template.New("page").Funcs(tmplFuncs).ParseFS(assets, "assets/layout.html", "assets/page/main.html"))
editTmpl = template.Must(template.New("edit").Funcs(tmplFuncs).ParseFS(assets, "assets/layout.html", "assets/editor/main.html"))
searchTmpl = template.Must(template.New("search").Funcs(tmplFuncs).ParseFS(assets, "assets/layout.html", "assets/search/main.html"))
canvasTmpl = template.Must(template.New("canvas").Funcs(tmplFuncs).ParseFS(assets, "assets/layout.html", "assets/canvas/main.html"))
)
// specialPage is the result returned by a pageTypeHandler.
// 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.
// Widget is a persistent sidebar widget rendered outside the main content area.
type specialPage struct {
Content template.HTML
SuppressContent bool
SuppressListing bool
SuppressTOC bool
Widget template.HTML
}
// pageTypeHandler is implemented by each special folder type (diary, gallery, …).
// handle returns nil when the handler does not apply to the given path.
// handle returns nil when the handler does not apply to the given path. The
// request is passed read-only (e.g. query params selecting a view variant);
// mutations belong in the POST flow, not here.
// redirect returns ok=true with an absolute URL when the request should be
// 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 {
handle(root, fsPath, urlPath string) *specialPage
handle(root, fsPath, urlPath string, r *http.Request) *specialPage
redirect(root, fsPath, urlPath string, r *http.Request) (target string, ok bool)
}
// pageTypeHandlers is the registry. Each type registers itself via init().
@@ -39,8 +85,10 @@ var pageTypeHandlers []pageTypeHandler
func main() {
addr := flag.String("addr", ":8080", "listen address")
wikiDir := flag.String("dir", "./wiki", "wiki root directory")
cacheDir := flag.String("cache", "./cache", "thumbnail cache directory")
user := flag.String("user", "", "basic auth username (empty = no auth)")
pass := flag.String("pass", "", "basic auth password")
reindexInterval := flag.Duration("reindex-interval", 30*time.Minute, "periodic search index rebuild interval (0 disables)")
flag.Parse()
root, err := filepath.Abs(*wikiDir)
@@ -51,7 +99,15 @@ func main() {
log.Fatal(err)
}
initMarkdown(root)
thumbCacheDir, err = filepath.Abs(*cacheDir)
if err != nil {
log.Fatal(err)
}
if err := os.MkdirAll(thumbCacheDir, 0755); err != nil {
log.Fatal(err)
}
initMarkdown()
authKey, err := loadOrCreateAuthKey(root)
if err != nil {
@@ -62,14 +118,48 @@ func main() {
staticFS, _ := fs.Sub(assets, "assets")
static := http.StripPrefix("/_/", http.FileServer(http.FS(staticFS)))
http.Handle("/_/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if strings.HasPrefix(r.URL.Path, "/_/fonts/") {
if strings.HasPrefix(r.URL.Path, "/_/fonts/") || strings.HasPrefix(r.URL.Path, "/_/editor/vendor/") {
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
}
static.ServeHTTP(w, r)
}))
http.HandleFunc("/_logout", h.handleLogout)
http.HandleFunc("/_reindex", h.handleReindex)
http.HandleFunc("/_search", h.handleSearchSuggest)
http.HandleFunc("/_canvas/render", h.handleCanvasRender)
http.HandleFunc("/quickadd", h.handleQuickAdd)
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()
folders, files := buildIndexes(root)
now := time.Now()
folderIndex.Lock()
folderIndex.entries = folders
folderIndex.builtAt = now
folderIndex.Unlock()
fileIndex.Lock()
fileIndex.entries = files
fileIndex.builtAt = now
fileIndex.Unlock()
folderIndex.buildMu.Unlock()
close(folderIndex.ready)
close(fileIndex.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.Fatal(http.ListenAndServe(*addr, nil))
}
@@ -79,11 +169,32 @@ type handler struct {
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) {
r = r.WithContext(context.WithValue(r.Context(), reqStartKey, time.Now()))
if !h.checkAuth(w, r) {
return
}
if strings.HasPrefix(r.URL.Path, thumbURLPrefix+"/") {
h.handleThumb(w, r)
return
}
urlPath := path.Clean("/" + r.URL.Path)
fsPath := filepath.Join(h.root, filepath.FromSlash(urlPath))
@@ -94,6 +205,26 @@ func (h *handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
return
}
if r.Method == http.MethodGet && r.URL.Query().Has("tree") {
h.handleTree(w, r, urlPath, fsPath)
return
}
if r.Method == http.MethodGet && urlPath == "/" && r.URL.Query().Has("q") {
h.handleSearch(w, r)
return
}
// A canvas is addressed as a view of its folder (?canvas=Name), so a direct
// hit on the file collapses onto that canonical form — the same shape as
// diary.go's virtual-URL redirects. ?raw keeps the bytes reachable.
if r.Method == http.MethodGet && strings.HasSuffix(urlPath, canvasExt) && !r.URL.Query().Has("raw") {
if target, ok := canvasViewURL(urlPath); ok {
http.Redirect(w, r, target, http.StatusFound)
return
}
}
info, err := os.Stat(fsPath)
if err != nil {
if os.IsNotExist(err) {
@@ -102,7 +233,7 @@ func (h *handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
// POSTs must not be redirected — the form action has no trailing
// slash (path.Clean strips it) and the content would be lost.
if !strings.HasSuffix(r.URL.Path, "/") && r.Method != http.MethodPost {
http.Redirect(w, r, r.URL.Path+"/", http.StatusMovedPermanently)
http.Redirect(w, r, slashedURL(r.URL), http.StatusMovedPermanently)
return
}
h.serveDir(w, r, urlPath, fsPath)
@@ -114,14 +245,39 @@ func (h *handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
if info.IsDir() {
if urlPath != "/" {
// Pages must live at a slash-terminated URL: embed destinations are
// relative, so "/Trips/Japan" would resolve them against "/Trips/".
if !strings.HasSuffix(r.URL.Path, "/") && r.Method != http.MethodPost {
http.Redirect(w, r, slashedURL(r.URL), http.StatusMovedPermanently)
return
}
urlPath += "/"
}
h.serveDir(w, r, urlPath, fsPath)
} else {
// ?w= on a file's own URL serves a thumbnail. Embeds use this form so
// their <img src> can stay relative to the page; /_thumb/<abs path>
// stays for listings, which have absolute paths on hand anyway. A file
// with no thumbnailer ignores the parameter and serves normally.
if r.URL.Query().Has("w") && hasThumbnail(fsPath) {
h.serveThumbnail(w, r, fsPath)
return
}
http.ServeFile(w, r, fsPath)
}
}
// slashedURL returns u's path with a trailing slash appended and the query
// string intact — the canonical page form that relative destinations resolve
// against.
func slashedURL(u *url.URL) string {
target := u.Path + "/"
if u.RawQuery != "" {
target += "?" + u.RawQuery
}
return target
}
func (h *handler) serveDir(w http.ResponseWriter, r *http.Request, urlPath, fsPath string) {
_, editMode := r.URL.Query()["edit"]
@@ -130,36 +286,73 @@ func (h *handler) serveDir(w http.ResponseWriter, r *http.Request, urlPath, fsPa
return
}
// An explicit ?canvas= selects this page's canvas view. Checked ahead of
// the pageTypeHandler redirect loop so an explicit view request always wins
// over a folder-type redirect (e.g. a canvas kept in a diary year folder).
//
// This is deliberately not a pageTypeHandler: that interface injects content
// into the standard page body, while a canvas replaces the whole body with a
// full-bleed surface and its own controls — a view mode like ?edit, which is
// likewise a template branch here rather than a handler.
if name := r.URL.Query().Get("canvas"); name != "" {
h.serveCanvas(w, r, urlPath, fsPath, name)
return
}
for _, ph := range pageTypeHandlers {
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")
rawMD, _ := os.ReadFile(indexPath)
// Determine section index (-1 = whole page).
sectionIndex := -1
insertBefore := -1
sectionSpan := false
if editMode {
if s := r.URL.Query().Get("section"); s != "" {
if n, err := strconv.Atoi(s); err == nil && n >= 0 {
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)
}
sectionSpan = r.URL.Query().Has("span")
}
var special *specialPage
if !editMode {
for _, ph := range pageTypeHandlers {
if special = ph.handle(h.root, fsPath, urlPath); special != nil {
if special = ph.handle(h.root, fsPath, urlPath, r); special != nil {
break
}
}
}
var rendered template.HTML
if len(rawMD) > 0 && !editMode && (special == nil || !special.SuppressContent) {
rendered = renderMarkdown(rawMD)
}
// Canvases in this folder surface as a view switcher above the content, so
// a page and its diagrams read as one thing. Suppressed in the editor, which
// is about the markdown only.
var canvases []canvasRef
if !editMode {
canvases = listCanvases(fsPath, urlPath, "")
}
view, sortKey, order := readPageSettings(fsPath).viewSettings()
var entries []entry
if !editMode && (special == nil || !special.SuppressListing) {
entries = listEntries(fsPath, urlPath)
entries = listEntries(fsPath, urlPath, sortKey, order)
}
title := pageTitle(urlPath)
@@ -168,46 +361,97 @@ func (h *handler) serveDir(w http.ResponseWriter, r *http.Request, urlPath, fsPa
}
var specialContent template.HTML
var sidebarWidget template.HTML
suppressTOC := false
if special != nil {
specialContent = special.Content
sidebarWidget = special.Widget
suppressTOC = special.SuppressTOC
}
rawContent := string(rawMD)
if editMode && sectionIndex >= 0 {
if editMode && insertBefore >= 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)
if sectionIndex < len(sections) {
rawContent = string(sections[sectionIndex])
end := sectionIndex + 1
if sectionSpan {
end = secionSpanEnd(sections, sectionIndex)
}
rawContent = string(joinSections(sections[sectionIndex:end]))
}
} else if editMode && rawContent == "" && urlPath != "/" {
rawContent = "# " + pageTitle(urlPath) + "\n\n"
}
data := pageData{
Title: title,
Crumbs: buildCrumbs(urlPath),
CanEdit: true,
EditMode: editMode,
IsRoot: urlPath == "/",
SectionIndex: sectionIndex,
InsertBefore: insertBefore,
Span: sectionSpan,
PostURL: urlPath,
RawContent: rawContent,
Content: rendered,
Entries: entries,
View: view,
Sort: sortKey,
Order: order,
SpecialContent: specialContent,
SidebarWidget: sidebarWidget,
SuppressTOC: suppressTOC,
Canvases: canvases,
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if err := tmpl.Execute(w, data); err != nil {
t := pageTmpl
if editMode {
t = editTmpl
}
data.RenderMS = elapsedMS(r)
if err := t.ExecuteTemplate(w, "layout", data); err != nil {
log.Printf("template error: %v", err)
}
}
func (h *handler) handlePost(w http.ResponseWriter, r *http.Request, urlPath, fsPath string) {
query := r.URL.Query()
// Checked first so ?canvas=X&delete removes that canvas rather than falling
// through to the page-level delete below.
if name := query.Get("canvas"); name != "" {
h.handleCanvasPost(w, r, urlPath, fsPath, name)
return
}
if query.Has("delete") {
h.handleDelete(w, r, urlPath, fsPath)
return
}
if _, ok := query["move"]; ok {
h.handleMove(w, r, urlPath, fsPath, query.Get("move"))
h.handleMove(w, r, urlPath, fsPath, query.Get("move"), query.Has("merge"))
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("settings") {
h.handleSettings(w, r, urlPath, fsPath)
return
}
@@ -217,10 +461,31 @@ func (h *handler) handlePost(w http.ResponseWriter, r *http.Request, urlPath, fs
}
content := r.FormValue("content")
indexPath := filepath.Join(fsPath, "index.md")
redirectTarget := urlPath
// If a section index was submitted, splice the edited section back into
// the full file rather than replacing the whole document.
if s := r.FormValue("section"); s != "" {
// handle section saving
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)
if err != nil || sectionIndex < 0 {
http.Error(w, "bad section", http.StatusBadRequest)
@@ -228,18 +493,49 @@ func (h *handler) handlePost(w http.ResponseWriter, r *http.Request, urlPath, fs
}
rawMD, _ := os.ReadFile(indexPath)
sections := splitSections(rawMD)
if sectionIndex < len(sections) {
sections[sectionIndex] = []byte(content)
}
content = string(joinSections(sections))
}
if strings.TrimSpace(content) == "" {
if err := os.Remove(indexPath); err != nil && !os.IsNotExist(err) {
http.Error(w, "delete failed: "+err.Error(), http.StatusInternalServerError)
// Out of range means the file changed under the editor (or the index
// never matched it). Writing back the untouched file would swallow the
// edit silently, so refuse and keep the editor's content in the browser.
if sectionIndex >= len(sections) {
http.Error(w, "section no longer exists — the page changed since you opened the editor", http.StatusConflict)
return
}
} else {
// handling of section editing that spans until the next heading of the same level
end := sectionIndex + 1
if r.FormValue("span") != "" {
end = secionSpanEnd(sections, sectionIndex)
}
merged := make([][]byte, 0, len(sections)-(end-sectionIndex)+1)
merged = append(merged, sections[:sectionIndex]...)
merged = append(merged, []byte(content))
merged = append(merged, sections[end:]...)
content = string(joinSections(merged))
// Section index ≥ 1 is a heading-anchored section. Redirect to its
// anchor so the user lands on the section they just saved, even if
// the heading text changed.
if sectionIndex >= 1 {
ids := headingIDs([]byte(content))
if sectionIndex-1 < len(ids) {
redirectTarget = urlPath + "#" + ids[sectionIndex-1]
}
}
}
// A save must never remove a page. An empty POST — a truncated mobile
// request, a lost `section` field, an editor that came up blank — is
// indistinguishable from "clear this page", and deleting index.md on that
// signal loses the whole file even though the user only edited one section.
// Removing a page is the explicit ?delete action's job (moves.go).
if strings.TrimSpace(content) == "" {
http.Error(w, "refusing to save empty content — use DELETE to remove this page", http.StatusBadRequest)
return
}
// 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 {
http.Error(w, "mkdir failed: "+err.Error(), http.StatusInternalServerError)
return
@@ -248,8 +544,21 @@ func (h *handler) handlePost(w http.ResponseWriter, r *http.Request, urlPath, fs
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))
}
http.Redirect(w, r, urlPath, http.StatusSeeOther)
}
// The editor saves via fetch so the save and its result share one history
// entry (see assets/history-nav.js). Hand it the target instead of a 303:
// the browser would follow the redirect into a second entry, and fetch
// drops the #section fragment from a followed redirect anyway.
if r.Header.Get("X-Save-Mode") == "replace" {
w.Header().Set("X-Target", redirectTarget)
w.WriteHeader(http.StatusNoContent)
return
}
http.Redirect(w, r, redirectTarget, http.StatusSeeOther)
}
// readPageSettings parses a .page-settings file in dir.
@@ -260,7 +569,8 @@ func readPageSettings(dir string) *pageSettings {
if err != nil {
return nil
}
s := &pageSettings{}
// Defaults; overridden only by valid values present in the file.
s := &pageSettings{View: viewList, Sort: sortName, Order: orderAsc}
for _, line := range strings.Split(string(data), "\n") {
line = strings.TrimSpace(line)
if line == "" || strings.HasPrefix(line, "#") {
@@ -270,9 +580,16 @@ func readPageSettings(dir string) *pageSettings {
if len(parts) != 2 {
continue
}
value := strings.TrimSpace(parts[1])
switch strings.TrimSpace(parts[0]) {
case "type":
s.Type = strings.TrimSpace(parts[1])
s.Type = value
case "view":
s.View = validateView(value)
case "sort":
s.Sort = validateSort(value)
case "order":
s.Order = validateOrder(value)
}
}
return s
+101 -112
View File
@@ -1,19 +1,22 @@
package main
import (
"bytes"
"fmt"
"log"
"net/http"
"os"
"path/filepath"
"strings"
)
// handleMove moves the folder at srcFsPath (wiki URL srcURL) to dstURL and
// rewrites every [[...]] wiki link across the tree that targets the old path
// or any descendant. All rewritten files are held in memory for rollback.
func (h *handler) handleMove(w http.ResponseWriter, r *http.Request, srcURL, srcFsPath, dstURL string) {
// handleMove moves the folder at srcFsPath (wiki URL srcURL) to dstURL.
//
// If the destination folder already exists it is normally a hard conflict.
// The one exception is a merge: when the source carries a page (index.md) and
// the destination folder has none, the source's contents can fill the empty
// container without clobbering anything. That merge only proceeds when merge
// is true; otherwise the client is told a merge is available so it can ask the
// user to confirm.
func (h *handler) handleMove(w http.ResponseWriter, r *http.Request, srcURL, srcFsPath, dstURL string, merge bool) {
oldPath := normalizeMovePath(srcURL)
if oldPath == "/" {
http.Error(w, "cannot move wiki root", http.StatusBadRequest)
@@ -39,59 +42,115 @@ func (h *handler) handleMove(w http.ResponseWriter, r *http.Request, srcURL, src
return
}
dstFsPath := filepath.Join(h.root, filepath.FromSlash(strings.TrimPrefix(newPath, "/")))
if _, err := os.Stat(dstFsPath); err == nil {
// Decide between a plain rename and a merge into an existing destination.
merging := false
if dstInfo, err := os.Stat(dstFsPath); err == nil {
if !canMergeMove(srcFsPath, dstFsPath, dstInfo) {
http.Error(w, "destination already exists", http.StatusConflict)
return
}
// Phase 1: walk the tree and rewrite every index.md that references the
// moved path. Keep the pre-rewrite bytes in memory so we can revert on
// failure. The walker only reads directory listings and files literally
// named index.md; hidden directories are pruned. A cheap substring check
// skips parsing files that cannot contain a relevant link.
rewritten := map[string][]byte{}
needle := []byte("[[" + oldPath)
walkErr := walkIndexFiles(h.root, func(fsPath string) error {
orig, err := os.ReadFile(fsPath)
if err != nil {
return err
}
if !bytes.Contains(orig, needle) {
return nil
}
updated, changed := rewriteWikiLinks(orig, oldPath, newPath)
if !changed {
return nil
}
if err := writeFileAtomic(fsPath, updated, 0644); err != nil {
return fmt.Errorf("write %s: %w", fsPath, err)
}
rewritten[fsPath] = orig
return nil
})
if walkErr != nil {
rollbackRewrites(rewritten)
http.Error(w, "rewrite failed: "+walkErr.Error(), http.StatusInternalServerError)
if !merge {
// Ask the client to confirm the merge. The header lets it tell
// this apart from an ordinary conflict.
w.Header().Set("X-Merge-Available", "1")
http.Error(w, "destination already exists — merge folders?", http.StatusConflict)
return
}
conflict, err := firstMergeConflict(srcFsPath, dstFsPath)
if err != nil {
http.Error(w, "merge check failed: "+err.Error(), http.StatusInternalServerError)
return
}
if conflict != "" {
http.Error(w, "cannot merge: "+conflict+" exists in both folders", http.StatusConflict)
return
}
merging = true
}
// Phase 2: create intermediate parent folders for the destination.
// Phase 1: create intermediate parent folders for the destination.
if parent := filepath.Dir(dstFsPath); parent != "" {
if err := os.MkdirAll(parent, 0755); err != nil {
rollbackRewrites(rewritten)
http.Error(w, "mkdir failed: "+err.Error(), http.StatusInternalServerError)
return
}
}
// Phase 3: rename the source folder into place.
// Phase 2: move the source into place. A plain move renames the whole
// folder; a merge moves the source's entries into the existing
// destination and drops the emptied source.
if merging {
if err := mergeFolder(srcFsPath, dstFsPath); err != nil {
http.Error(w, "merge failed: "+err.Error(), http.StatusInternalServerError)
return
}
folderIndexMergeSubtree(strings.TrimPrefix(oldPath, "/"), strings.TrimPrefix(newPath, "/"))
} else {
if err := os.Rename(srcFsPath, dstFsPath); err != nil {
rollbackRewrites(rewritten)
http.Error(w, "rename failed: "+err.Error(), http.StatusInternalServerError)
return
}
folderIndexRenameSubtree(strings.TrimPrefix(oldPath, "/"), strings.TrimPrefix(newPath, "/"))
}
http.Redirect(w, r, wikiTargetHref(newPath), http.StatusSeeOther)
http.Redirect(w, r, fileURL(strings.TrimPrefix(newPath, "/"))+"/", http.StatusSeeOther)
}
// canMergeMove reports whether moving onto an existing destination should
// merge rather than conflict. Merging is allowed only when the destination is
// a folder with no page of its own (no index.md) and the source has one — so
// the source's page fills the empty destination without overwriting content.
func canMergeMove(srcFsPath, dstFsPath string, dstInfo os.FileInfo) bool {
if !dstInfo.IsDir() {
return false
}
return hasIndexFile(srcFsPath) && !hasIndexFile(dstFsPath)
}
// hasIndexFile reports whether dir contains a regular index.md.
func hasIndexFile(dir string) bool {
info, err := os.Stat(filepath.Join(dir, "index.md"))
return err == nil && info.Mode().IsRegular()
}
// firstMergeConflict returns the name of the first source entry that already
// exists in the destination, or "" when the merge can proceed without
// overwriting anything. index.md cannot collide here: canMergeMove already
// established the destination has none.
func firstMergeConflict(srcFsPath, dstFsPath string) (string, error) {
entries, err := os.ReadDir(srcFsPath)
if err != nil {
return "", err
}
for _, e := range entries {
_, err := os.Lstat(filepath.Join(dstFsPath, e.Name()))
if err == nil {
return e.Name(), nil
}
if !os.IsNotExist(err) {
return "", err
}
}
return "", nil
}
// mergeFolder moves every entry from srcFsPath into dstFsPath, then removes
// the now-empty source. Callers must run firstMergeConflict beforehand so no
// rename overwrites an existing destination entry.
func mergeFolder(srcFsPath, dstFsPath string) error {
entries, err := os.ReadDir(srcFsPath)
if err != nil {
return err
}
for _, e := range entries {
from := filepath.Join(srcFsPath, e.Name())
to := filepath.Join(dstFsPath, e.Name())
if err := os.Rename(from, to); err != nil {
return fmt.Errorf("move %s: %w", e.Name(), err)
}
}
return os.Remove(srcFsPath)
}
// handleDelete removes the folder at fsPath (URL urlPath) and redirects to
@@ -109,6 +168,7 @@ func (h *handler) handleDelete(w http.ResponseWriter, r *http.Request, urlPath,
http.Error(w, "delete failed: "+err.Error(), http.StatusInternalServerError)
return
}
folderIndexRemoveSubtree(strings.TrimPrefix(normalizeMovePath(urlPath), "/"))
http.Redirect(w, r, parentURL(urlPath), http.StatusSeeOther)
}
@@ -146,77 +206,6 @@ func validateAndNormalizeNewPath(raw string) (string, error) {
return "/" + trimmed, nil
}
// rewriteWikiLinks returns (newContent, changed). Any [[target]] or
// [[target|display]] whose target equals oldPath or begins with oldPath+"/"
// has its target rewritten to the corresponding position under newPath.
func rewriteWikiLinks(content []byte, oldPath, newPath string) ([]byte, bool) {
changed := false
out := wikiLinkPattern.ReplaceAllFunc(content, func(match []byte) []byte {
parts := wikiLinkPattern.FindSubmatch(match)
if parts == nil {
return match
}
target := strings.TrimSpace(string(parts[1]))
normTarget := normalizeMovePath(target)
var newTarget string
switch {
case normTarget == oldPath:
newTarget = newPath
case strings.HasPrefix(normTarget, oldPath+"/"):
newTarget = newPath + strings.TrimPrefix(normTarget, oldPath)
default:
return match
}
changed = true
suffix := ""
if len(parts[2]) > 0 {
suffix = string(parts[2])
}
return []byte("[[" + newTarget + suffix + "]]")
})
return out, changed
}
// rollbackRewrites restores the given files to their pre-rewrite contents.
// Errors are logged; best-effort since we're already in a failure path.
func rollbackRewrites(rewritten map[string][]byte) {
for path, orig := range rewritten {
if err := writeFileAtomic(path, orig, 0644); err != nil {
log.Printf("rollback %s: %v", path, err)
}
}
}
// walkIndexFiles visits every `index.md` under root, skipping hidden
// directories (names beginning with `.`). Unlike filepath.WalkDir this does
// not stat each regular file — on spinning disks that saves the bulk of the
// traversal cost when folders contain many non-page files (photos, archives).
func walkIndexFiles(root string, visit func(fsPath string) error) error {
entries, err := os.ReadDir(root)
if err != nil {
return err
}
for _, e := range entries {
name := e.Name()
if strings.HasPrefix(name, ".") {
continue
}
full := filepath.Join(root, name)
if e.IsDir() {
if err := walkIndexFiles(full, visit); err != nil {
return err
}
continue
}
if name == "index.md" {
if err := visit(full); err != nil {
return err
}
}
}
return nil
}
// writeFileAtomic writes data to a temp file in the same directory as path
// and renames it into place so readers never observe a partial file.
func writeFileAtomic(path string, data []byte, perm os.FileMode) error {
+89
View File
@@ -0,0 +1,89 @@
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
View File
@@ -0,0 +1,134 @@
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(")\n")
b.WriteString(ts.Format("2006-01-02 15:04"))
b.WriteString("\n")
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
}
+198 -34
View File
@@ -4,10 +4,12 @@ import (
"bytes"
"fmt"
"html/template"
"net/url"
"os"
"path"
"sort"
"strings"
"time"
"github.com/yuin/goldmark"
"github.com/yuin/goldmark/extension"
@@ -17,43 +19,143 @@ import (
var md goldmark.Markdown
// initMarkdown builds the package-level goldmark instance. Called once from
// main after the wiki root is known so the wiki-link extension can resolve
// targets against the filesystem.
func initMarkdown(root string) {
// initMarkdown builds the package-level goldmark instance. Neither extension
// resolves anything against the filesystem — wiki links are search queries and
// embed destinations are relative to the page — so this needs no wiki root.
func initMarkdown() {
md = goldmark.New(
goldmark.WithExtensions(extension.GFM, extension.Table, newWikiLinkExt(root)),
goldmark.WithExtensions(extension.GFM, extension.Table, newWikiLinkExt(), newEmbedExt()),
goldmark.WithParserOptions(parser.WithAutoHeadingID()),
goldmark.WithRendererOptions(html.WithUnsafe()),
goldmark.WithRendererOptions(html.WithUnsafe(), html.WithHardWraps()),
)
}
type crumb struct{ Name, URL string }
type entry struct {
Icon template.HTML
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
// IsVideo marks a thumbnail tile as a video so the grid can overlay a play
// badge. Only meaningful when ThumbURL is set.
IsVideo bool
// modTime/size carry the raw sort keys; the template only reads the
// formatted Meta string.
modTime time.Time
size int64
}
type pageData struct {
Title string
Crumbs []crumb
CanEdit bool
EditMode bool
IsRoot bool
SectionIndex int // -1 = whole page; >=0 = section being edited
InsertBefore int // -1 = no insert; >=0 = splice new section at this index
Span bool // causes section editing to also cover every sub-section to the next section of the same level
PostURL string
RawContent string
Content template.HTML
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
SidebarWidget template.HTML
SuppressTOC bool
RenderMS int64
// Canvases lists every .canvas file in this folder as a view-switcher tab.
// ActiveCanvas names the one currently being viewed ("" on a normal page),
// and is what makes this page render as a canvas rather than as markdown.
Canvases []canvasRef
ActiveCanvas string
// CanvasJSON is the whole canvas document plus its server-rendered node
// HTML, inlined into the page so opening a canvas costs one request.
CanvasJSON template.JS
// CanvasError carries a parse/validation failure to the template, which
// shows it in place of the canvas instead of failing the request.
CanvasError string
}
// pageSettings holds the parsed contents of a .page-settings file.
type pageSettings struct {
Type string
// layoutData is the contract every view built on assets/layout.html satisfies.
// The layout is shared by pageData and searchPageData, and a method only one of
// them has fails at render time with a half-written response rather than at
// build time — so each is asserted against the interface here.
type layoutData interface {
ShowPageChrome() bool
}
var (
_ layoutData = pageData{}
_ layoutData = searchPageData{}
)
// ShowPageChrome reports whether the standard page furniture — search box,
// folder-tree rail, right sidebar, and the collapsed ACTIONS dropdown — should
// render. The editor and the canvas view both take the full width and supply
// their own controls, so both opt out.
func (d pageData) ShowPageChrome() bool {
return !d.EditMode && d.ActiveCanvas == ""
}
// Allowed values for the listing view settings. Unknown values in the file or
// 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 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 (
iconUp = readIcon("up")
iconFolder = readIcon("folder")
iconDoc = readIcon("doc")
iconImage = readIcon("image")
@@ -61,6 +163,7 @@ var (
iconAudio = readIcon("audio")
iconArchive = readIcon("archive")
iconGeneric = readIcon("generic")
iconCanvas = readIcon("canvas")
)
// renderMarkdown converts raw markdown to trusted HTML.
@@ -69,7 +172,11 @@ func renderMarkdown(raw []byte) template.HTML {
if err := md.Convert(raw, &buf); err != nil {
return ""
}
return template.HTML(buf.String())
out := rewriteTaskCheckboxes(buf.Bytes())
// 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,
@@ -109,7 +216,7 @@ func parentURL(urlPath string) string {
return parent + "/"
}
func listEntries(fsPath, urlPath string) []entry {
func listEntries(fsPath, urlPath, sortKey, order string) []entry {
entries, err := os.ReadDir(fsPath)
if err != nil {
return nil
@@ -132,24 +239,94 @@ func listEntries(fsPath, urlPath string) []entry {
Name: name,
URL: entryURL + "/",
Meta: info.ModTime().Format("2006-01-02"),
modTime: info.ModTime(),
})
} else {
if name == "index.md" {
continue // rendered above, don't list it
}
files = append(files, entry{
if isCanvasFile(name) {
continue // surfaced as a view-switcher tab above the content
}
f := entry{
Icon: fileIcon(name),
Name: name,
URL: entryURL,
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)
f.IsVideo = isVideoFile(name)
}
files = append(files, f)
}
}
// Folders always sort by name regardless of the chosen key (they have no
// meaningful byte size); files honor the chosen key. The chosen order
// applies to both groups.
sortEntries(folders, sortName, order)
sortEntries(files, sortKey, order)
// Nothing to list (only index.md, which is rendered above): return no
// entries so the Files section is suppressed entirely rather than showing
// an empty listing with just the `..` row.
if len(folders) == 0 && len(files) == 0 {
return nil
}
// `..` row mirrors the header Up button so the listing itself is
// 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
}
sort.Slice(folders, func(i, j int) bool { return folders[i].Name < folders[j].Name })
sort.Slice(files, func(i, j int) bool { return files[i].Name < files[j].Name })
return append(folders, files...)
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 {
@@ -160,6 +337,8 @@ func readIcon(name string) template.HTML {
func fileIcon(name string) template.HTML {
ext := strings.ToLower(path.Ext(name))
switch ext {
case ".canvas":
return iconCanvas
case ".md", ".pdf":
return iconDoc
case ".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg":
@@ -186,21 +365,6 @@ 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 {
if urlPath == "/" {
return "Datascape"
+624
View File
@@ -0,0 +1,624 @@
package main
import (
"encoding/json"
"io/fs"
"log"
"net/http"
"net/url"
"path/filepath"
"sort"
"strings"
"sync"
"time"
"unicode"
)
// searchSectionCap bounds how many rows each results section renders. The
// section header still reports the true total so the user knows more exist.
const searchSectionCap = 10
// exactNameScore is the score scoreName assigns to a whole-name exact match.
// handleSearch peels these into the promoted "Exact Match" section.
const exactNameScore = 1000
type searchResult struct {
Name string
URL string
Path string
Score int
// Meta is the formatted "size · date" line for file results; empty for
// page results.
Meta string
}
type searchPageData struct {
Title string
EditMode bool
Query string
// Exact holds page(s) whose name equals the query exactly, promoted to
// their own section above the fuzzy matches.
Exact []searchResult
Pages []searchResult
Files []searchResult
// PageTotal/FileTotal are the true match totals; Pages/Files are capped at
// searchSectionCap for rendering.
PageTotal int
FileTotal int
IndexBuiltAt time.Time
RenderMS int64
}
// ShowPageChrome satisfies the shared layout's contract (see layoutData). The
// results page is always ordinary chrome — it is never the editor and never a
// canvas — but EditMode is honoured so the two views cannot drift apart.
func (d searchPageData) ShowPageChrome() bool { return !d.EditMode }
// indexEntry is the shared scoreable core of both folder and file index
// entries: a forward-slash relative path plus its pre-tokenized basename so
// the per-query scoring loop avoids redoing the lowercasing and tokenization
// on every request.
type indexEntry struct {
Path string
NameLower string
NameTokens []string
}
// folderEntry is a single indexed directory. It is exactly an indexEntry; the
// alias keeps the existing folder-index code readable while letting files and
// folders share the scoring loop.
type folderEntry = indexEntry
// fileEntry is a single indexed file: its scoreable core plus the size/modtime
// captured during the walk so results can show listing-parity metadata without
// a second stat.
type fileEntry struct {
indexEntry
Size int64
ModTime time.Time
}
// folderIndex holds the in-memory directory index used by search. Writers
// 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{}
}
// fileIndex mirrors folderIndex for files. It is held separately so file
// volume can't perturb the page index, and shares folderIndex.buildMu since
// both are populated by the same single-pass walk. It is refreshed only by the
// full rebuild (startup / ticker / manual), never by the incremental folder
// hooks — file freshness on disk lags until the next rebuild.
var fileIndex struct {
sync.RWMutex
entries []fileEntry
builtAt time.Time
ready chan struct{}
}
func init() {
folderIndex.ready = make(chan struct{})
fileIndex.ready = make(chan struct{})
}
// handleSearch renders the search results page for the query in
// r.URL.Query().Get("q"). Only invoked when path is "/" and "q" is present.
func (h *handler) handleSearch(w http.ResponseWriter, r *http.Request) {
query := strings.TrimSpace(r.URL.Query().Get("q"))
pages, builtAt := searchWiki(query)
files := searchFiles(query)
// searchWiki sorts by score desc, so exact-name matches (score 1000) are
// at the front; peel them into their own section and drop them from the
// fuzzy page list so they aren't shown twice.
var exact []searchResult
for len(pages) > 0 && pages[0].Score == exactNameScore {
exact = append(exact, pages[0])
pages = pages[1:]
}
title := "Search"
if query != "" {
title = "Search: " + query
}
data := searchPageData{
Title: title,
Query: query,
Exact: exact,
Pages: capResults(pages),
Files: capResults(files),
PageTotal: len(pages),
FileTotal: len(files),
IndexBuiltAt: builtAt,
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
data.RenderMS = elapsedMS(r)
if err := searchTmpl.ExecuteTemplate(w, "layout", data); err != nil {
log.Printf("search template error: %v", err)
}
}
// capResults truncates a section to searchSectionCap rows for rendering.
func capResults(results []searchResult) []searchResult {
if len(results) > searchSectionCap {
return results[:searchSectionCap]
}
return results
}
// scoredEntry pairs a matched index entry with its score before it is turned
// into a UI searchResult.
type scoredEntry[T any] struct {
entry T
score int
}
// scoreEntries scores every entry against query, drops non-matches, and returns
// the survivors sorted by score (desc), then path depth (asc), then basename
// (asc). core extracts the shared scoreable fields so the folder and file
// indexes reuse one loop. fuzzy toggles the levenshtein fallback (pages yes,
// files no). Returns nil for an empty/tokenless query.
func scoreEntries[T any](entries []T, query string, fuzzy bool, core func(T) indexEntry) []scoredEntry[T] {
qLower := strings.ToLower(query)
qTokens := tokenize(qLower)
if len(qTokens) == 0 {
return nil
}
var out []scoredEntry[T]
for _, e := range entries {
c := core(e)
score := scoreName(c.NameLower, c.NameTokens, qLower, qTokens, fuzzy)
if score == 0 {
continue
}
out = append(out, scoredEntry[T]{entry: e, score: score})
}
sort.SliceStable(out, func(i, j int) bool {
if out[i].score != out[j].score {
return out[i].score > out[j].score
}
ci, cj := core(out[i].entry), core(out[j].entry)
di, dj := strings.Count(ci.Path, "/"), strings.Count(cj.Path, "/")
if di != dj {
return di < dj
}
return ci.NameLower < cj.NameLower
})
return out
}
// searchWiki scores the cached folder index against query. Blocks on the
// initial build so the very first request after startup serves correct
// results rather than an empty list. Returns the snapshot's builtAt so the
// UI can show how fresh the index is.
func searchWiki(query string) ([]searchResult, time.Time) {
<-folderIndex.ready
folderIndex.RLock()
entries := folderIndex.entries
builtAt := folderIndex.builtAt
folderIndex.RUnlock()
if query == "" {
return nil, builtAt
}
scored := scoreEntries(entries, query, true, func(e folderEntry) indexEntry { return e })
results := make([]searchResult, 0, len(scored))
for _, s := range scored {
results = append(results, searchResult{
Name: filepath.Base(s.entry.Path),
URL: "/" + s.entry.Path + "/",
Path: s.entry.Path,
Score: s.score,
})
}
return results, builtAt
}
// searchFiles scores the cached file index against query, matching on filename
// only. Blocks on the initial build so the first request after startup doesn't
// serve an empty Files section while the walk is still running.
func searchFiles(query string) []searchResult {
<-fileIndex.ready
fileIndex.RLock()
entries := fileIndex.entries
fileIndex.RUnlock()
if query == "" {
return nil
}
scored := scoreEntries(entries, query, false, func(e fileEntry) indexEntry { return e.indexEntry })
results := make([]searchResult, 0, len(scored))
for _, s := range scored {
p := s.entry.Path
results = append(results, searchResult{
Name: filepath.Base(p),
URL: fileURL(p),
Path: p,
Score: s.score,
Meta: formatSize(s.entry.Size) + " · " + s.entry.ModTime.Format("2006-01-02"),
})
}
return results
}
// fileURL builds the browser URL for a file's forward-slash relative path,
// percent-escaping each segment so spaces/umlauts/&c. survive round-tripping
// through the companion's wikiPathFromHref decode.
func fileURL(relPath string) string {
parts := strings.Split(relPath, "/")
for i, p := range parts {
parts[i] = url.PathEscape(p)
}
return "/" + strings.Join(parts, "/")
}
// scoreName ranks how well nameLower matches the query. Whole-name exact
// 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. fuzzy
// enables the levenshtein near-match fallback; the file index passes false so
// large file volumes don't pay the edit-distance cost per query.
func scoreName(nameLower string, nameTokens []string, qLower string, qTokens []string, fuzzy bool) int {
if nameLower == qLower {
return exactNameScore
}
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 fuzzy && 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)
}
// buildIndexes walks root once and returns fresh folder and file entries. The
// single pass avoids a second full traversal on the ARMv7 NAS. Hidden
// directories (`.git`, `.thumbs`, …) are pruned and hidden files skipped; the
// root itself and every `index.md` (page content, not a browsable file) are
// excluded.
func buildIndexes(root string) ([]folderEntry, []fileEntry) {
walkRoot := resolveWalkRoot(root)
var folders []folderEntry
var files []fileEntry
_ = filepath.WalkDir(walkRoot, func(fsPath string, d fs.DirEntry, err error) error {
if err != nil {
return nil
}
if skip, walkErr := hiddenSkip(fsPath, walkRoot, d); skip {
return walkErr
}
rel, relErr := filepath.Rel(walkRoot, fsPath)
if relErr != nil {
return nil
}
relSlash := filepath.ToSlash(rel)
if d.IsDir() {
if fsPath != walkRoot {
folders = append(folders, newFolderEntry(relSlash))
}
return nil
}
if d.Name() == "index.md" {
return nil
}
info, infoErr := d.Info()
if infoErr != nil {
return nil
}
files = append(files, newFileEntry(relSlash, info.Size(), info.ModTime()))
return nil
})
return folders, files
}
// newFolderEntry builds a folderEntry from a forward-slash relative path,
// computing the lowercased basename and its tokens once so search scoring
// doesn't have to redo it per query.
func newFolderEntry(relPath string) folderEntry {
return newIndexEntry(relPath)
}
// newFileEntry builds a fileEntry, capturing the walk-time size/modtime so
// results show listing-parity metadata without a second stat.
func newFileEntry(relPath string, size int64, modTime time.Time) fileEntry {
return fileEntry{
indexEntry: newIndexEntry(relPath),
Size: size,
ModTime: modTime,
}
}
// newIndexEntry precomputes the lowercased basename and its tokens for the
// per-query scoring loop.
func newIndexEntry(relPath string) indexEntry {
name := relPath
if i := strings.LastIndex(relPath, "/"); i >= 0 {
name = relPath[i+1:]
}
nameLower := strings.ToLower(name)
return indexEntry{
Path: relPath,
NameLower: nameLower,
NameTokens: tokenize(nameLower),
}
}
// rebuildFolderIndex walks root once and atomically replaces both the folder
// and file indexes. 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()
folders, files := buildIndexes(root)
now := time.Now()
folderIndex.Lock()
folderIndex.entries = folders
folderIndex.builtAt = now
folderIndex.Unlock()
fileIndex.Lock()
fileIndex.entries = files
fileIndex.builtAt = now
fileIndex.Unlock()
}
// folderIndexAdd appends relPath as a new entry. No-op for empty/root paths.
func folderIndexAdd(relPath string) {
relPath = strings.Trim(relPath, "/")
if relPath == "" {
return
}
folderIndex.Lock()
folderIndex.entries = append(folderIndex.entries, newFolderEntry(relPath))
folderIndex.Unlock()
}
// folderIndexRemoveSubtree drops the entry at relPath plus every descendant.
// Replaces the slice rather than mutating in place so any in-flight search
// reader keeps a valid snapshot.
func folderIndexRemoveSubtree(relPath string) {
relPath = strings.Trim(relPath, "/")
if relPath == "" {
return
}
prefix := relPath + "/"
folderIndex.Lock()
defer folderIndex.Unlock()
old := folderIndex.entries
out := make([]folderEntry, 0, len(old))
for _, e := range old {
if e.Path == relPath || strings.HasPrefix(e.Path, prefix) {
continue
}
out = append(out, e)
}
folderIndex.entries = out
}
// folderIndexRenameSubtree rewrites the path prefix for every entry under
// oldRel. The renamed root entry's basename may have changed so its
// NameLower/NameTokens are recomputed; descendants keep their basenames.
func folderIndexRenameSubtree(oldRel, newRel string) {
oldRel = strings.Trim(oldRel, "/")
newRel = strings.Trim(newRel, "/")
if oldRel == "" || newRel == "" {
return
}
oldPrefix := oldRel + "/"
folderIndex.Lock()
defer folderIndex.Unlock()
old := folderIndex.entries
out := make([]folderEntry, len(old))
for i, e := range old {
switch {
case e.Path == oldRel:
out[i] = newFolderEntry(newRel)
case strings.HasPrefix(e.Path, oldPrefix):
out[i] = folderEntry{
Path: newRel + "/" + strings.TrimPrefix(e.Path, oldPrefix),
NameLower: e.NameLower,
NameTokens: e.NameTokens,
}
default:
out[i] = e
}
}
folderIndex.entries = out
}
// folderIndexMergeSubtree updates the index after a merge move: it drops the
// source root entry (that folder is gone) and rewrites every descendant's
// prefix to live under newRel. Unlike folderIndexRenameSubtree it does not add
// a newRel entry, since the destination folder already exists in the index.
func folderIndexMergeSubtree(oldRel, newRel string) {
oldRel = strings.Trim(oldRel, "/")
newRel = strings.Trim(newRel, "/")
if oldRel == "" || newRel == "" {
return
}
oldPrefix := oldRel + "/"
folderIndex.Lock()
defer folderIndex.Unlock()
old := folderIndex.entries
out := make([]folderEntry, 0, len(old))
for _, e := range old {
switch {
case e.Path == oldRel:
continue
case strings.HasPrefix(e.Path, oldPrefix):
out = append(out, folderEntry{
Path: newRel + "/" + strings.TrimPrefix(e.Path, oldPrefix),
NameLower: e.NameLower,
NameTokens: e.NameTokens,
})
default:
out = append(out, e)
}
}
folderIndex.entries = out
}
// resolveWalkRoot resolves symlinks so WalkDir descends into the real tree
// even when the configured wiki root is itself a symlink (as on the NAS).
func resolveWalkRoot(root string) string {
if r, err := filepath.EvalSymlinks(root); err == nil {
return r
}
return root
}
// hiddenSkip handles dotfile/dot-dir entries during a WalkDir. It returns
// (skipped, walkErr): skipped=true means the caller should `return walkErr`
// to either prune the subtree (hidden dir) or move past the entry (hidden
// file). When skipped=false the entry should be processed normally.
func hiddenSkip(fsPath, walkRoot string, d fs.DirEntry) (bool, error) {
if !strings.HasPrefix(d.Name(), ".") {
return false, nil
}
if d.IsDir() && fsPath != walkRoot {
return true, filepath.SkipDir
}
return true, nil
}
// tokenize splits s into lowercase word tokens, breaking on any rune that is
// not a letter or digit. Unicode-aware so umlauts etc. survive intact.
func tokenize(s string) []string {
var tokens []string
var b strings.Builder
for _, r := range s {
if unicode.IsLetter(r) || unicode.IsDigit(r) {
b.WriteRune(unicode.ToLower(r))
continue
}
if b.Len() > 0 {
tokens = append(tokens, b.String())
b.Reset()
}
}
if b.Len() > 0 {
tokens = append(tokens, b.String())
}
return tokens
}
// levenshtein returns the edit distance between a and b. Operates on runes so
// multi-byte characters count as one edit.
func levenshtein(a, b string) int {
ar, br := []rune(a), []rune(b)
if len(ar) == 0 {
return len(br)
}
if len(br) == 0 {
return len(ar)
}
prev := make([]int, len(br)+1)
curr := make([]int, len(br)+1)
for j := range prev {
prev[j] = j
}
for i := 1; i <= len(ar); i++ {
curr[0] = i
for j := 1; j <= len(br); j++ {
cost := 1
if ar[i-1] == br[j-1] {
cost = 0
}
del := prev[j] + 1
ins := curr[j-1] + 1
sub := prev[j-1] + cost
curr[j] = min3(del, ins, sub)
}
prev, curr = curr, prev
}
return prev[len(br)]
}
func min3(a, b, c int) int {
m := a
if b < m {
m = b
}
if c < m {
m = c
}
return m
}
+60
View File
@@ -3,10 +3,19 @@ package main
import (
"bytes"
"regexp"
"strings"
"github.com/yuin/goldmark/ast"
"github.com/yuin/goldmark/text"
)
var sectionHeadingRe = regexp.MustCompile(`(?m)^#{1,6} `)
// headingTextRe matches an ATX heading at the start of a section. The
// heading text is everything after the `#`s and the required space, on the
// first line.
var headingTextRe = regexp.MustCompile(`^(#{1,6})\s+([^\n]*)`)
// splitSections splits raw markdown into sections.
// Section 0 is any content before the first heading.
// Each subsequent section begins at a heading line and runs to the next.
@@ -25,6 +34,29 @@ func splitSections(raw []byte) [][]byte {
return sections
}
// headingIDs returns the auto-generated id of every heading in raw markdown,
// in document order. The kth heading (1-indexed) corresponds to section k from
// splitSections. Uses the package-level goldmark parser so duplicate-id
// numbering matches what the renderer emits.
func headingIDs(raw []byte) []string {
doc := md.Parser().Parse(text.NewReader(raw))
var ids []string
ast.Walk(doc, func(n ast.Node, entering bool) (ast.WalkStatus, error) {
if !entering {
return ast.WalkContinue, nil
}
if _, ok := n.(*ast.Heading); ok {
if v, ok := n.AttributeString("id"); ok {
if b, ok := v.([]byte); ok {
ids = append(ids, string(b))
}
}
}
return ast.WalkContinue, nil
})
return ids
}
// joinSections reassembles sections produced by splitSections.
// Inserts a newline between sections when a non-empty section lacks a
// trailing newline, so an edited section cannot inline the next heading.
@@ -38,3 +70,31 @@ func joinSections(sections [][]byte) []byte {
}
return buf.Bytes()
}
// sectionHeading returns the heading level (1..6) and trimmed text of a
// section produced by splitSections. Returns level=0 for the pre-heading
// section (index 0).
func sectionHeading(section []byte) (level int, text string) {
m := headingTextRe.FindSubmatch(section)
if m == nil {
return 0, ""
}
return len(m[1]), strings.TrimSpace(string(m[2]))
}
// sectionSpanEnd returns the exclusive end index of the section span starting
// at start: the first following section whose heading level is <= start's.
// Sections nested deeper than start (its subsections) belong to the span, so
// editing a `##` covers every `###`+ under it up to the next `##` or `#`.
func secionSpanEnd(sections [][]byte, start int) int {
level, _ := sectionHeading(sections[start])
if level == 0 {
return start + 1
}
for i := start + 1; i < len(sections); i++ {
if l, _ := sectionHeading(sections[i]); l > 0 && l <= level {
return i
}
}
return len(sections)
}
+112
View File
@@ -0,0 +1,112 @@
package main
import (
"bytes"
"net/http"
"os"
"path/filepath"
"regexp"
"strconv"
)
// taskCheckboxRe matches the <input> tag goldmark's GFM extension emits for a
// task list checkbox. Used to enumerate and rewrite checkboxes in rendered HTML.
var taskCheckboxRe = regexp.MustCompile(`<input(?: checked="")? disabled="" type="checkbox">`)
// taskLineRe matches a markdown task list line: leading whitespace, a bullet,
// then a `[ ]` / `[x]` / `[X]` checkbox marker.
var taskLineRe = regexp.MustCompile(`^(\s*[-*+]\s+)\[([ xX])\]`)
// rewriteTaskCheckboxes enables and indexes the task checkboxes in rendered
// HTML so JS can wire them up. Each checkbox gains a data-task-index matching
// its position among task list items in source order; the disabled attribute
// is removed so the user can toggle them.
func rewriteTaskCheckboxes(in []byte) []byte {
idx := 0
return taskCheckboxRe.ReplaceAllFunc(in, func(match []byte) []byte {
checked := bytes.Contains(match, []byte("checked"))
var out bytes.Buffer
out.WriteString(`<input type="checkbox" class="task-checkbox" data-task-index="`)
out.WriteString(strconv.Itoa(idx))
out.WriteByte('"')
if checked {
out.WriteString(` checked=""`)
}
out.WriteByte('>')
idx++
return out.Bytes()
})
}
// handleToggle flips the Nth task list checkbox in index.md based on the
// `toggle` query param and `checked` form value. Indices match the order in
// which goldmark emits checkboxes, which is source order excluding fenced
// code blocks.
func (h *handler) handleToggle(w http.ResponseWriter, r *http.Request, fsPath string) {
n, err := strconv.Atoi(r.URL.Query().Get("toggle"))
if err != nil || n < 0 {
http.Error(w, "bad toggle index", http.StatusBadRequest)
return
}
if err := r.ParseForm(); err != nil {
http.Error(w, "bad request", http.StatusBadRequest)
return
}
checked := r.FormValue("checked") == "true"
indexPath := filepath.Join(fsPath, "index.md")
raw, err := os.ReadFile(indexPath)
if err != nil {
http.Error(w, "read failed: "+err.Error(), http.StatusInternalServerError)
return
}
updated, ok := flipTaskLine(raw, n, checked)
if !ok {
http.Error(w, "task not found", http.StatusNotFound)
return
}
if err := writeFileAtomic(indexPath, updated, 0644); err != nil {
http.Error(w, "write failed: "+err.Error(), http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusNoContent)
}
// flipTaskLine returns raw with the Nth task list bullet's `[ ]`/`[x]` marker
// set according to checked. Lines inside fenced code blocks are skipped so
// they do not consume an index. Returns ok=false when there is no Nth task.
func flipTaskLine(raw []byte, n int, checked bool) ([]byte, bool) {
lines := bytes.Split(raw, []byte("\n"))
inFence := false
count := 0
target := -1
for i, line := range lines {
trimmed := bytes.TrimLeft(line, " \t")
if bytes.HasPrefix(trimmed, []byte("```")) || bytes.HasPrefix(trimmed, []byte("~~~")) {
inFence = !inFence
continue
}
if inFence {
continue
}
if !taskLineRe.Match(line) {
continue
}
if count == n {
target = i
break
}
count++
}
if target == -1 {
return nil, false
}
replacement := []byte("${1}[ ]")
if checked {
replacement = []byte("${1}[x]")
}
lines[target] = taskLineRe.ReplaceAll(lines[target], replacement)
return bytes.Join(lines, []byte("\n")), true
}
+262
View File
@@ -0,0 +1,262 @@
package main
import (
"bytes"
"crypto/sha256"
"encoding/hex"
"fmt"
"io"
"log"
"net/http"
"net/url"
"os"
"path"
"path/filepath"
"strconv"
"strings"
"sync"
"time"
)
// Thumbnailer produces a thumbnail of a source file. Implementations register
// themselves in init() by appending to thumbnailers. The first registered
// handler whose CanHandle returns true is used.
type Thumbnailer interface {
CanHandle(ext string) bool
Generate(src io.Reader, dst io.Writer, width int) error
}
// PathThumbnailer is an optional capability for thumbnailers whose source must
// be a seekable file on disk rather than a stream (e.g. ffmpeg for video). When
// a Thumbnailer also implements this, handleThumb passes the file path directly
// and skips reading the file into memory and content-hashing it.
type PathThumbnailer interface {
GenerateFromPath(srcPath string, dst io.Writer, width int) error
}
var thumbnailers []Thumbnailer
// thumbCacheDir is set from the -cache flag at startup.
var thumbCacheDir string
const thumbURLPrefix = "/_thumb"
// Cache is content-addressed: the cache path is derived from the SHA-256 of
// the source file. Renames and moves reuse the same cache entry; overwriting
// a file with new content produces a new digest and regenerates.
var (
thumbLocks = map[string]*sync.Mutex{}
thumbLocksMu sync.Mutex
digestCache = map[string]digestEntry{}
digestCacheMu sync.Mutex
)
// digestEntry remembers the digest of a source file so repeated requests do
// not re-hash the whole file. The (mtime, size) pair invalidates the cache
// when the file is overwritten in place.
type digestEntry struct {
mtime time.Time
size int64
hex string
}
func findThumbnailer(name string) Thumbnailer {
ext := strings.ToLower(filepath.Ext(name))
if ext == "" {
return nil
}
for _, t := range thumbnailers {
if t.CanHandle(ext) {
return t
}
}
return nil
}
// hasThumbnail reports whether a file name has a registered thumbnailer.
func hasThumbnail(name string) bool {
return findThumbnailer(name) != nil
}
// thumbURL builds a thumbnail URL for a wiki file. filePath must be URL-style
// (slash-separated, leading slash), as already used on page links.
func thumbURL(filePath string, width int) string {
return thumbURLPrefix + filePath + "?w=" + strconv.Itoa(width)
}
func (h *handler) handleThumb(w http.ResponseWriter, r *http.Request) {
raw := strings.TrimPrefix(r.URL.Path, thumbURLPrefix)
if raw == "" || raw == "/" {
http.NotFound(w, r)
return
}
decoded, err := url.PathUnescape(raw)
if err != nil {
http.Error(w, "bad path", http.StatusBadRequest)
return
}
h.serveThumbnail(w, r, filepath.Join(h.root, filepath.FromSlash(path.Clean(decoded))))
}
// serveThumbnail generates (or serves from cache) a thumbnail of the file at
// srcFS, sized by the ?w= query. Both entry points land here: the /_thumb
// route used by listings and the diary, and the ?w= query on a file's own URL,
// which is the form embeds use so their <img src> stays relative to the page.
func (h *handler) serveThumbnail(w http.ResponseWriter, r *http.Request, srcFS string) {
rel, err := filepath.Rel(h.root, srcFS)
if err != nil || strings.HasPrefix(rel, "..") {
http.Error(w, "Forbidden", http.StatusForbidden)
return
}
srcInfo, err := os.Stat(srcFS)
if err != nil || srcInfo.IsDir() {
http.NotFound(w, r)
return
}
t := findThumbnailer(srcFS)
if t == nil {
http.NotFound(w, r)
return
}
width := 300
if s := r.URL.Query().Get("w"); s != "" {
if n, err := strconv.Atoi(s); err == nil && n > 0 && n <= 2000 {
width = n
}
}
// Path thumbnailers (video/ffmpeg) take the source path directly and key
// the cache on path+mtime+size, so the file is never read into memory.
// Content thumbnailers (images) stay content-addressed so renames reuse
// the cache entry. Either way, generation runs through the closure below.
var (
digest string
write func(dst io.Writer) error
)
if pt, ok := t.(PathThumbnailer); ok {
digest = pathDigest(srcFS, srcInfo)
write = func(dst io.Writer) error { return pt.GenerateFromPath(srcFS, dst, width) }
} else {
d, data, err := sourceDigest(srcFS, srcInfo)
if err != nil {
log.Printf("thumb digest %s: %v", rel, err)
http.Error(w, "thumbnail failed", http.StatusInternalServerError)
return
}
digest = d
write = func(dst io.Writer) error {
var src io.Reader
if data != nil {
src = bytes.NewReader(data)
} else {
f, err := os.Open(srcFS)
if err != nil {
return err
}
defer f.Close()
src = f
}
return t.Generate(src, dst, width)
}
}
cacheFS := filepath.Join(thumbCacheDir, digest[:2], fmt.Sprintf("%s.%d.jpg", digest, width))
if _, err := os.Stat(cacheFS); err == nil {
serveThumb(w, r, cacheFS)
return
}
lock := thumbLock(cacheFS)
lock.Lock()
defer lock.Unlock()
if _, err := os.Stat(cacheFS); err == nil {
serveThumb(w, r, cacheFS)
return
}
if err := generateThumb(cacheFS, write); err != nil {
log.Printf("thumb %s: %v", rel, err)
http.Error(w, "thumbnail failed", http.StatusInternalServerError)
return
}
serveThumb(w, r, cacheFS)
}
// pathDigest keys the cache for path-based thumbnailers on the source path plus
// its mtime and size, so the (potentially large) file is never read into memory
// just to hash it. Overwriting the file changes mtime/size and busts the entry.
func pathDigest(srcFS string, info os.FileInfo) string {
sum := sha256.Sum256(fmt.Appendf(nil, "%s\x00%d\x00%d", srcFS, info.ModTime().UnixNano(), info.Size()))
return hex.EncodeToString(sum[:])
}
// sourceDigest returns the SHA-256 hex digest of a source file's content.
// On a cache hit (path + mtime + size unchanged) the returned data is nil,
// so the caller knows to open the file itself. On a miss the file is read
// once and the contents are returned for the caller to reuse.
func sourceDigest(srcFS string, info os.FileInfo) (string, []byte, error) {
digestCacheMu.Lock()
d, ok := digestCache[srcFS]
digestCacheMu.Unlock()
if ok && d.mtime.Equal(info.ModTime()) && d.size == info.Size() {
return d.hex, nil, nil
}
data, err := os.ReadFile(srcFS)
if err != nil {
return "", nil, err
}
sum := sha256.Sum256(data)
h := hex.EncodeToString(sum[:])
digestCacheMu.Lock()
digestCache[srcFS] = digestEntry{mtime: info.ModTime(), size: info.Size(), hex: h}
digestCacheMu.Unlock()
return h, data, nil
}
func serveThumb(w http.ResponseWriter, r *http.Request, cacheFS string) {
w.Header().Set("Cache-Control", "public, max-age=86400")
http.ServeFile(w, r, cacheFS)
}
// generateThumb writes a thumbnail to cacheFS atomically: write builds the
// image into a temp file in the same dir, which is renamed into place only on
// success so readers never see a partial file.
func generateThumb(cacheFS string, write func(dst io.Writer) error) error {
if err := os.MkdirAll(filepath.Dir(cacheFS), 0755); err != nil {
return err
}
tmp, err := os.CreateTemp(filepath.Dir(cacheFS), ".thumb-*")
if err != nil {
return err
}
tmpName := tmp.Name()
if err := write(tmp); err != nil {
tmp.Close()
os.Remove(tmpName)
return err
}
if err := tmp.Close(); err != nil {
os.Remove(tmpName)
return err
}
return os.Rename(tmpName, cacheFS)
}
func thumbLock(key string) *sync.Mutex {
thumbLocksMu.Lock()
defer thumbLocksMu.Unlock()
m, ok := thumbLocks[key]
if !ok {
m = &sync.Mutex{}
thumbLocks[key] = m
}
return m
}
+91
View File
@@ -0,0 +1,91 @@
package main
import (
"image"
"image/color"
_ "image/gif"
"image/jpeg"
_ "image/png"
"io"
"path/filepath"
"strings"
)
func init() {
thumbnailers = append(thumbnailers, &imageThumbnailer{})
}
// isImageFile reports whether name is a raster image we can thumbnail and thus
// embed on a page. Video files also carry thumbnails but are not embeddable, so
// this keys on the image thumbnailer's own extension set rather than
// hasThumbnail.
func isImageFile(name string) bool {
return (&imageThumbnailer{}).CanHandle(strings.ToLower(filepath.Ext(name)))
}
type imageThumbnailer struct{}
func (it *imageThumbnailer) CanHandle(ext string) bool {
switch ext {
case ".jpg", ".jpeg", ".png", ".gif":
return true
}
return false
}
func (it *imageThumbnailer) Generate(src io.Reader, dst io.Writer, width int) error {
img, _, err := image.Decode(src)
if err != nil {
return err
}
return jpeg.Encode(dst, resizeBox(img, width), &jpeg.Options{Quality: 80})
}
// resizeBox downsamples src to the requested width using a box filter.
// Aspect ratio is preserved. Upscaling is a no-op (returns src unchanged).
// Each source pixel is visited exactly once; alpha is discarded.
func resizeBox(src image.Image, width int) image.Image {
b := src.Bounds()
srcW, srcH := b.Dx(), b.Dy()
if srcW <= width {
return src
}
dstW := width
dstH := srcH * width / srcW
if dstH < 1 {
dstH = 1
}
dst := image.NewRGBA(image.Rect(0, 0, dstW, dstH))
for y := 0; y < dstH; y++ {
sy0 := y * srcH / dstH
sy1 := (y + 1) * srcH / dstH
if sy1 == sy0 {
sy1 = sy0 + 1
}
for x := 0; x < dstW; x++ {
sx0 := x * srcW / dstW
sx1 := (x + 1) * srcW / dstW
if sx1 == sx0 {
sx1 = sx0 + 1
}
var r, g, bl, n uint64
for sy := sy0; sy < sy1; sy++ {
for sx := sx0; sx < sx1; sx++ {
sr, sg, sb, _ := src.At(b.Min.X+sx, b.Min.Y+sy).RGBA()
r += uint64(sr >> 8)
g += uint64(sg >> 8)
bl += uint64(sb >> 8)
n++
}
}
dst.SetRGBA(x, y, color.RGBA{
R: uint8(r / n),
G: uint8(g / n),
B: uint8(bl / n),
A: 255,
})
}
}
return dst
}
+136
View File
@@ -0,0 +1,136 @@
package main
import (
"bytes"
"context"
"fmt"
"io"
"os/exec"
"path/filepath"
"strconv"
"strings"
"time"
)
func init() {
thumbnailers = append(thumbnailers, &videoThumbnailer{})
}
// ffmpegPath is resolved once at startup. Empty means ffmpeg is not installed,
// in which case the video thumbnailer disables itself and videos keep their
// icon. ffmpeg is a graceful soft-dependency, not a hard requirement: the
// binary still runs standalone without it.
var ffmpegPath, _ = exec.LookPath("ffmpeg")
type videoThumbnailer struct{}
func isVideoExt(ext string) bool {
switch ext {
case ".mp4", ".mkv", ".mov", ".avi", ".webm", ".m4v":
return true
}
return false
}
// isVideoFile reports whether name is a video container we generate previews
// for. Independent of ffmpeg availability — used to tag tiles with a play badge.
func isVideoFile(name string) bool {
return isVideoExt(strings.ToLower(filepath.Ext(name)))
}
func (vt *videoThumbnailer) CanHandle(ext string) bool {
return ffmpegPath != "" && isVideoExt(ext)
}
// Generate satisfies Thumbnailer but is never used: handleThumb routes video
// through the PathThumbnailer branch (GenerateFromPath), since ffmpeg needs a
// seekable file path and we must not read large videos into memory.
func (vt *videoThumbnailer) Generate(src io.Reader, dst io.Writer, width int) error {
return fmt.Errorf("video thumbnails require a file path; use GenerateFromPath")
}
// GenerateFromPath extracts a single frame and writes it to dst as JPEG. It
// prefers a frame from the middle of the clip (see seekOffsets) to avoid the
// black/fade-in frames common at the very start, falling back to earlier
// offsets. The (small) JPEG is buffered first so a failed attempt never leaves
// partial bytes in dst.
func (vt *videoThumbnailer) GenerateFromPath(srcPath string, dst io.Writer, width int) error {
for _, seek := range seekOffsets(srcPath) {
var buf bytes.Buffer
if err := runFFmpegFrame(srcPath, &buf, width, seek); err == nil && buf.Len() > 0 {
_, err = dst.Write(buf.Bytes())
return err
}
}
return fmt.Errorf("ffmpeg produced no frame for %s", srcPath)
}
// seekOffsets returns seek targets (in seconds) to try in order. When the clip
// duration is known the midpoint comes first — a representative frame that
// dodges black intros and end credits. "1" and "0" are fallbacks for when the
// duration is unknown or the midpoint seek yields nothing (very short clips, or
// a keyframe gap at the midpoint).
func seekOffsets(srcPath string) []string {
if d := videoDurationSec(srcPath); d > 2 {
return []string{strconv.FormatFloat(d/2, 'f', 2, 64), "1", "0"}
}
return []string{"1", "0"}
}
// videoDurationSec parses the clip duration from ffmpeg's own stderr, so it
// depends only on ffmpeg (ffprobe is not installed everywhere we deploy).
// `ffmpeg -i <file>` with no output exits non-zero but prints the container
// metadata, including a "Duration: HH:MM:SS.ss" line. Returns 0 when the
// duration can't be determined, so the caller falls back to a fixed offset.
func videoDurationSec(srcPath string) float64 {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
cmd := exec.CommandContext(ctx, ffmpegPath, "-i", srcPath)
var stderr bytes.Buffer
cmd.Stderr = &stderr
_ = cmd.Run() // expected to "fail": no output file specified. We want stderr.
return parseFFmpegDuration(stderr.String())
}
// parseFFmpegDuration extracts seconds from an ffmpeg log containing a line like
// " Duration: 00:01:23.45, start: 0.000000, bitrate: 1234 kb/s".
func parseFFmpegDuration(log string) float64 {
_, after, ok := strings.Cut(log, "Duration:")
if !ok {
return 0
}
field := strings.TrimSpace(after)
if c := strings.IndexByte(field, ','); c >= 0 {
field = field[:c]
}
parts := strings.Split(strings.TrimSpace(field), ":")
if len(parts) != 3 {
return 0
}
h, err1 := strconv.ParseFloat(parts[0], 64)
m, err2 := strconv.ParseFloat(parts[1], 64)
s, err3 := strconv.ParseFloat(parts[2], 64)
if err1 != nil || err2 != nil || err3 != nil {
return 0
}
return h*3600 + m*60 + s
}
func runFFmpegFrame(srcPath string, dst io.Writer, width int, seek string) error {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
cmd := exec.CommandContext(ctx, ffmpegPath,
"-ss", seek,
"-i", srcPath,
"-frames:v", "1",
// Cap width at the requested size but never upscale (matches the image
// thumbnailer); -2 keeps the height even. The comma in min() is escaped
// so ffmpeg does not read it as a filter separator.
"-vf", "scale='min(iw\\,"+strconv.Itoa(width)+")':-2",
"-f", "mjpeg",
"-q:v", "5",
"pipe:1",
)
cmd.Stdout = dst
return cmd.Run()
}
+131
View File
@@ -0,0 +1,131 @@
package main
import (
"encoding/json"
"net/http"
"os"
"path/filepath"
"sort"
"strings"
)
type treeEntry struct {
Name string `json:"name"`
Kind string `json:"kind"`
// Children is populated only along the expandTo chain (see handleTree);
// omitted otherwise so the flat picker listing keeps its original shape.
Children []treeEntry `json:"children,omitempty"`
}
type treeResponse struct {
Path string `json:"path"`
Entries []treeEntry `json:"entries"`
}
// handleTree responds with a JSON listing of the immediate children of the
// folder at fsPath. Hidden entries and `index.md` are filtered. Files are not
// descended — the client lazy-loads children on expand.
func (h *handler) handleTree(w http.ResponseWriter, r *http.Request, urlPath, fsPath string) {
info, err := os.Stat(fsPath)
if err != nil {
if os.IsNotExist(err) {
http.NotFound(w, r)
return
}
http.Error(w, "stat failed", http.StatusInternalServerError)
return
}
if !info.IsDir() {
http.Error(w, "not a folder", http.StatusBadRequest)
return
}
foldersOnly := r.URL.Query().Has("foldersOnly")
entries, err := listTreeEntries(fsPath, foldersOnly)
if err != nil {
http.Error(w, "read failed", http.StatusInternalServerError)
return
}
if expandTo := r.URL.Query().Get("expandTo"); expandTo != "" {
expandTreeChain(fsPath, entries, treePathSegments(expandTo), foldersOnly)
}
resp := treeResponse{Path: canonicalTreePath(urlPath), Entries: entries}
w.Header().Set("Content-Type", "application/json; charset=utf-8")
_ = json.NewEncoder(w).Encode(resp)
}
// canonicalTreePath returns the URL path in the form used by the picker:
// "/" for root, otherwise stripped of any trailing slash.
func canonicalTreePath(urlPath string) string {
if urlPath == "" || urlPath == "/" {
return "/"
}
return "/" + strings.Trim(urlPath, "/")
}
// listTreeEntries returns the immediate children of fsPath, filtering hidden
// entries and index.md. Folders are listed before files; both groups are
// sorted alphabetically. foldersOnly will not list any individual files at all.
func listTreeEntries(fsPath string, foldersOnly bool) ([]treeEntry, error) {
raw, err := os.ReadDir(fsPath)
if err != nil {
return nil, err
}
var folders, files []treeEntry
for _, e := range raw {
name := e.Name()
if strings.HasPrefix(name, ".") {
continue
}
if e.IsDir() {
folders = append(folders, treeEntry{Name: name, Kind: "folder"})
} else {
if foldersOnly || name == "index.md" {
continue
}
files = append(files, treeEntry{Name: name, Kind: "file"})
}
}
sort.Slice(folders, func(i, j int) bool { return folders[i].Name < folders[j].Name })
sort.Slice(files, func(i, j int) bool { return files[i].Name < files[j].Name })
return append(folders, files...), nil
}
// treePathSegments splits a wiki path into its non-empty segments.
func treePathSegments(p string) []string {
var segs []string
for _, s := range strings.Split(p, "/") {
if s != "" {
segs = append(segs, s)
}
}
return segs
}
// expandTreeChain walks segs, matching each against a folder in entries by
// name, loading that folder's children in place, and recursing. The walk stops
// at the deepest matching segment, so a stale or deleted path simply expands as
// far as it still exists. Segments only ever match real directory names from
// listTreeEntries (no "." or ".." entries), so this cannot traverse outside the
// listed tree.
func expandTreeChain(fsPath string, entries []treeEntry, segs []string, foldersOnly bool) {
if len(segs) == 0 {
return
}
for i := range entries {
if entries[i].Kind != "folder" || entries[i].Name != segs[0] {
continue
}
childFs := filepath.Join(fsPath, segs[0])
kids, err := listTreeEntries(childFs, foldersOnly)
if err != nil {
return
}
entries[i].Children = kids
expandTreeChain(childFs, entries[i].Children, segs[1:], foldersOnly)
return
}
}
+56 -99
View File
@@ -3,8 +3,6 @@ package main
import (
"bytes"
"net/url"
"os"
"path/filepath"
"regexp"
"strings"
@@ -16,19 +14,17 @@ import (
"github.com/yuin/goldmark/util"
)
// wikiLinkRe matches [[target]] and [[target|display]] anchored at the start
// of the current inline reader. Target and display forbid newlines and
// brackets; target additionally forbids the pipe separator.
var wikiLinkRe = regexp.MustCompile(`^\[\[([^\[\]\|\n]+)(?:\|([^\[\]\n]+))?\]\]`)
// wikiLinkRe matches [[query]] and [[query::display]] anchored at the start of
// the current inline reader. Query and display forbid newlines and brackets;
// the query is non-greedy so the first `::` separates query from display when
// both are present.
var wikiLinkRe = regexp.MustCompile(`^\[\[([^\[\]\n]+?)(?:::([^\[\]\n]+))?\]\]`)
// wikiLinkPattern matches wiki-link tokens anywhere in a markdown source.
// Used by the move-endpoint rewriter; not by the goldmark parser.
var wikiLinkPattern = regexp.MustCompile(`\[\[([^\[\]\n\|]+)(\|[^\[\]\n]+)?\]\]`)
// wikiLinkNode is the AST node produced by wikiLinkParser.
// wikiLinkNode is the AST node produced by wikiLinkParser. Query is already
// normalized by wikiSearchQuery.
type wikiLinkNode struct {
ast.BaseInline
Target []byte
Query []byte
Display []byte
}
@@ -38,27 +34,32 @@ func (n *wikiLinkNode) Kind() ast.NodeKind { return kindWikiLink }
func (n *wikiLinkNode) Dump(source []byte, level int) {
ast.DumpHelper(n, source, level, map[string]string{
"Target": string(n.Target),
"Query": string(n.Query),
"Display": string(n.Display),
}, nil)
}
// isValidWikiTarget rejects targets that are not absolute or that contain
// traversal / empty segments. Matches the validation used by the move endpoint.
func isValidWikiTarget(target []byte) bool {
if len(target) == 0 || target[0] != '/' {
return false
// wikiSearchQuery normalizes the raw text inside [[...]] into a search query.
// A leading `/` marks a link written under the old path-based syntax; those
// collapse to their last segment ("/Trips/Japan 2024" -> "Japan 2024") so
// existing content keeps resolving without a rewrite. Everything else is free
// text and passes through with only surrounding space trimmed.
func wikiSearchQuery(raw string) string {
q := strings.TrimSpace(raw)
if !strings.HasPrefix(q, "/") {
return q
}
trimmed := strings.Trim(string(target), "/")
if trimmed == "" {
return true // root link
trimmed := strings.Trim(q, "/")
if i := strings.LastIndex(trimmed, "/"); i >= 0 {
return strings.TrimSpace(trimmed[i+1:])
}
for _, seg := range strings.Split(trimmed, "/") {
if seg == "" || seg == "." || seg == ".." {
return false
}
}
return true
return trimmed
}
// wikiSearchHref builds the search-page URL for a query. Escaping leaves only
// URL-safe bytes, so the result needs no further HTML escaping in an href.
func wikiSearchHref(query string) string {
return "/?q=" + url.QueryEscape(query)
}
type wikiLinkParser struct{}
@@ -74,8 +75,10 @@ func (p *wikiLinkParser) Parse(parent ast.Node, block text.Reader, pc parser.Con
if m == nil {
return nil
}
target := bytes.TrimSpace(line[m[2]:m[3]])
if !isValidWikiTarget(target) {
// A query that normalizes to nothing (e.g. a legacy "[[/]]" root link) is
// not a link; leave the source text alone.
query := wikiSearchQuery(string(line[m[2]:m[3]]))
if query == "" {
return nil
}
var display []byte
@@ -84,58 +87,12 @@ func (p *wikiLinkParser) Parse(parent ast.Node, block text.Reader, pc parser.Con
}
block.Advance(m[1])
return &wikiLinkNode{
Target: append([]byte(nil), target...),
Query: []byte(query),
Display: append([]byte(nil), display...),
}
}
// normalizeWikiTarget strips a trailing slash (but leaves "/" intact) and
// returns the cleaned absolute path.
func normalizeWikiTarget(target string) string {
if target == "/" {
return "/"
}
return "/" + strings.Trim(target, "/")
}
// wikiTargetHref converts a wiki target to a URL href with each segment
// percent-encoded and a trailing slash appended.
func wikiTargetHref(target string) string {
target = normalizeWikiTarget(target)
if target == "/" {
return "/"
}
var b strings.Builder
for _, seg := range strings.Split(strings.TrimPrefix(target, "/"), "/") {
b.WriteByte('/')
b.WriteString(url.PathEscape(seg))
}
b.WriteByte('/')
return b.String()
}
// wikiTargetExists reports whether the on-disk folder backing the target
// exists under root.
func wikiTargetExists(root, target string) bool {
target = normalizeWikiTarget(target)
fsPath := filepath.Join(root, filepath.FromSlash(strings.TrimPrefix(target, "/")))
info, err := os.Stat(fsPath)
return err == nil && info.IsDir()
}
// wikiDefaultDisplay returns the last segment of a target, or "/" for the root.
func wikiDefaultDisplay(target string) string {
target = normalizeWikiTarget(target)
if target == "/" {
return "/"
}
segs := strings.Split(strings.TrimPrefix(target, "/"), "/")
return segs[len(segs)-1]
}
type wikiLinkRenderer struct {
root string
}
type wikiLinkRenderer struct{}
func (r *wikiLinkRenderer) RegisterFuncs(reg renderer.NodeRendererFuncRegisterer) {
reg.Register(kindWikiLink, r.render)
@@ -146,32 +103,32 @@ func (r *wikiLinkRenderer) render(w util.BufWriter, source []byte, node ast.Node
return ast.WalkContinue, nil
}
n := node.(*wikiLinkNode)
target := string(n.Target)
href := wikiTargetHref(target)
display := string(n.Display)
if display == "" {
display = wikiDefaultDisplay(target)
}
broken := !wikiTargetExists(r.root, target)
w.WriteString(`<a href="`)
w.WriteString(href)
w.WriteString(`"`)
if broken {
w.WriteString(` class="broken"`)
}
w.WriteString(`>`)
w.Write(util.EscapeHTML([]byte(display)))
w.WriteString(`</a>`)
writeWikiSearchLink(w, string(n.Query), string(n.Display))
return ast.WalkContinue, nil
}
type wikiLinkExt struct{ root string }
// writeWikiSearchLink renders the anchor for a [[query]] link: an href into the
// search page, labelled with the display override or the query itself. Nothing
// is resolved at render time — whether the query matches anything is the search
// page's business, so there is no broken state here.
func writeWikiSearchLink(w util.BufWriter, query, display string) {
if display == "" {
display = query
}
w.WriteString(`<a href="`)
w.WriteString(wikiSearchHref(query))
w.WriteString(`">`)
w.Write(util.EscapeHTML([]byte(display)))
w.WriteString(`</a>`)
}
type wikiLinkExt struct{}
// newWikiLinkExt returns a goldmark extension that turns [[...]] tokens into
// links resolved against root.
func newWikiLinkExt(root string) goldmark.Extender {
return &wikiLinkExt{root: root}
// links to the search page. It needs no wiki root — a query is never resolved
// against the filesystem.
func newWikiLinkExt() goldmark.Extender {
return &wikiLinkExt{}
}
func (e *wikiLinkExt) Extend(m goldmark.Markdown) {
@@ -181,6 +138,6 @@ func (e *wikiLinkExt) Extend(m goldmark.Markdown) {
util.Prioritized(&wikiLinkParser{}, 199),
))
m.Renderer().AddOptions(renderer.WithNodeRenderers(
util.Prioritized(&wikiLinkRenderer{root: e.root}, 500),
util.Prioritized(&wikiLinkRenderer{}, 500),
))
}