Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1d8dfdb1da | ||
|
|
6c268aa829 | ||
|
|
73a8b4f78f | ||
|
|
1f7cfd637a | ||
|
|
02a1482789 | ||
|
|
60b514eae7 | ||
|
|
dedeeb77a8 |
@@ -1,16 +1,7 @@
|
|||||||
.claude/
|
.claude/
|
||||||
.zed/
|
|
||||||
wiki/
|
wiki/
|
||||||
cache/
|
cache/
|
||||||
|
|
||||||
# Binaries
|
# Binaries
|
||||||
datascape
|
datascape
|
||||||
*.exe
|
*.exe
|
||||||
bin/
|
|
||||||
companion/datascape-companion-*
|
|
||||||
|
|
||||||
# Editor build tooling deps (the built bundle is committed; node_modules is not)
|
|
||||||
editor-build/node_modules/
|
|
||||||
|
|
||||||
AGENTS.md
|
|
||||||
CLAUDE.md
|
|
||||||
|
|||||||
@@ -0,0 +1,86 @@
|
|||||||
|
# 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
|
||||||
@@ -1,53 +1,9 @@
|
|||||||
NAS := luxick@192.168.3.3
|
NAS := luxick@192.168.3.3
|
||||||
|
|
||||||
COMPANION_WIN := companion/datascape-companion-windows-amd64.exe
|
.PHONY: deploy
|
||||||
COMPANION_LIN := companion/datascape-companion-linux-amd64
|
deploy:
|
||||||
COMPANION_SRCS := $(wildcard cmd/companion/*.go) $(wildcard cmd/companion/*.html) go.mod go.sum
|
GOOS=linux GOARCH=arm GOARM=7 go build -o datascape-arm .
|
||||||
|
|
||||||
# Release builds drop the symbol table, DWARF debug info and local source
|
|
||||||
# paths. Panics still print file:line stack traces; only debuggers lose out.
|
|
||||||
RELEASE_FLAGS := -trimpath -ldflags="-s -w"
|
|
||||||
|
|
||||||
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 e2e
|
|
||||||
|
|
||||||
# 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 -trimpath -ldflags="-s -w -H windowsgui" -o $@ ./cmd/companion
|
|
||||||
|
|
||||||
$(COMPANION_LIN): $(COMPANION_SRCS)
|
|
||||||
GOOS=linux GOARCH=amd64 go build $(RELEASE_FLAGS) -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 $(RELEASE_FLAGS) -o datascape-arm .
|
|
||||||
ssh $(NAS) 'kill $$(cat /share/homes/luxick/.local/bin/datascape.pid) 2>/dev/null; rm -f /share/homes/luxick/.local/bin/datascape.pid'
|
ssh $(NAS) 'kill $$(cat /share/homes/luxick/.local/bin/datascape.pid) 2>/dev/null; rm -f /share/homes/luxick/.local/bin/datascape.pid'
|
||||||
scp datascape-arm $(NAS):/share/homes/luxick/.local/bin/datascape
|
scp datascape-arm $(NAS):/share/homes/luxick/.local/bin/datascape
|
||||||
ssh $(NAS) /share/homes/luxick/.local/bin/datascape-start.sh
|
ssh $(NAS) /share/homes/luxick/.local/bin/datascape-start.sh
|
||||||
rm datascape-arm
|
rm datascape-arm
|
||||||
|
|
||||||
# Browser end-to-end tests (Playwright driving the system Chrome). Builds the
|
|
||||||
# wiki binary and runs it against a throwaway wiki in e2e/.tmp.
|
|
||||||
e2e:
|
|
||||||
cd e2e && npm install --no-audit --no-fund && npx playwright test
|
|
||||||
|
|||||||
@@ -2,154 +2,62 @@
|
|||||||
|
|
||||||
Minimal self-hosted personal wiki. Folders are pages.
|
Minimal self-hosted personal wiki. Folders are pages.
|
||||||
|
|
||||||
## Features
|
## Run
|
||||||
|
|
||||||
- **Pages**: a folder's `index.md` renders as HTML, and the folder's other files are listed below it. Going to a missing path lets you create the page there.
|
|
||||||
- **Editing**: edit the whole page or a single heading's section. Tick task checkboxes directly on the rendered page.
|
|
||||||
- **Move, rename, delete**: moving a page onto an empty folder offers to merge into it.
|
|
||||||
- **View settings**: show the file list as a list or thumbnail grid, sorted by name, date, or size. Videos get thumbnails when `ffmpeg` is installed.
|
|
||||||
- **Search**: find pages by name.
|
|
||||||
- **Wikilinks**: `[[query]]` or `[[query::label]]`. They open a search, so links still work after a page is renamed. Typing `[[` in the editor suggests page names. Embed files with ``.
|
|
||||||
- **Movie import**: create a page from OMDb metadata. Asks once for a free [OMDb API key](https://www.omdbapi.com/apikey.aspx) and keeps it in the browser.
|
|
||||||
- **Canvases**: [JSON Canvas](https://jsoncanvas.org) boards stored next to a page. See [Canvas](#canvas).
|
|
||||||
- **Special folders**: a [photo diary](#diary) and a [fitness dashboard](#fitness).
|
|
||||||
- **Calendars**: Nextcloud calendars mirrored into the wiki. They add events and weight to the diary, and a [maintenance widget](#calendars) to every page.
|
|
||||||
- **Quick-add bookmarklet**: save the current tab to a wiki page.
|
|
||||||
- **Companion app**: an optional desktop helper, downloadable from the wiki footer. It opens wiki files in local apps and folders in the file manager.
|
|
||||||
|
|
||||||
## Build & run
|
|
||||||
|
|
||||||
The wiki binary embeds the companion binaries, so build those first:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
make companion-release # once, and after companion changes
|
go run . -dir ./wiki -addr :8080
|
||||||
go build -o datascape . # local
|
go run . -dir ./wiki -addr :8080 -user me -pass secret
|
||||||
make deploy # QNAP NAS: stripped ARMv7 build, copy, restart
|
|
||||||
./datascape -dir ./wiki -addr :8080 -user me -pass secret
|
|
||||||
```
|
```
|
||||||
|
|
||||||
| Flag | Default | |
|
## Build
|
||||||
|------|---------|-|
|
|
||||||
| `-addr` | `:8080` | Listen address |
|
|
||||||
| `-dir` | `./wiki` | Wiki root |
|
|
||||||
| `-cache` | `./cache` | Thumbnail cache |
|
|
||||||
| `-user`, `-pass` | | Basic auth (omit to disable) |
|
|
||||||
| `-reindex-interval` | `30m` | Search index rebuild (`0` = off) |
|
|
||||||
| `-calendars` | `Calendars` | [Calendars](#calendars) folder, relative to `-dir` (empty = off) |
|
|
||||||
| `-tz` | system | Time zone for calendars and the diary, e.g. `Europe/Berlin` |
|
|
||||||
|
|
||||||
## `.page-settings`
|
```bash
|
||||||
|
# local
|
||||||
|
go build -o datascape .
|
||||||
|
|
||||||
Per-folder `key = value` file. The **view** button in the `Files` header sets the first three keys.
|
# QNAP NAS (linux/arm)
|
||||||
|
GOOS=linux GOARCH=arm go build -o datascape .
|
||||||
|
```
|
||||||
|
|
||||||
| Key | Values (default first) |
|
## Usage
|
||||||
|-----|------------------------|
|
|
||||||
| `view` | `list`, `thumbnail` |
|
|
||||||
| `sort` | `name`, `modified`, `size` |
|
|
||||||
| `order` | `asc`, `desc` |
|
|
||||||
| `type` | `diary`, `fitness`, `calendars` |
|
|
||||||
|
|
||||||
## Keyboard shortcuts
|
| 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 |
|
||||||
|
|
||||||
All use Alt+Shift.
|
Navigating to a URL that does not exist shows an empty page with a **[CREATE]** prompt.
|
||||||
|
|
||||||
| Where | Keys |
|
## Special Folder Types
|
||||||
|-------|------|
|
|
||||||
| Anywhere | `E` edit (on a canvas: the selected node), `N` new page, `M` move, `F` search, `H` maintenance |
|
|
||||||
| Editor | `S` save, `1`–`3` heading, `B`/`I` bold/italic, `C`/`K` code/code block, `L` link, `P` wikilink, `G` embed, `U`/`O`/`X` bullet/numbered/task list, `Q` quote, `R` rule, `T` format table, `D`/`W` date (ISO/long), `V` movie, `Y` delete line |
|
|
||||||
|
|
||||||
## Canvas
|
A folder can opt into special rendering by adding a `.page-settings` file:
|
||||||
|
|
||||||
A page can hold any number of canvases: `<name>.canvas` files in its folder, in the [JSON Canvas 1.0](https://jsoncanvas.org/spec/1.0/) format. **CANVAS** in the page actions creates one. Canvases show up in the page's file list. Opening one shows the whole canvas, with no folder tree or sidebar. `?raw` on its URL returns the file itself.
|
|
||||||
|
|
||||||
- **Notes** (text nodes) are markdown, rendered like pages: wikilinks, embeds, tables, task checkboxes. Double-click a note to edit it with the page editor, including its toolbar and shortcuts. Esc or a click outside closes the editor. A note left empty is removed.
|
|
||||||
- **Files** show images, videos and audio. A markdown file or a page (a folder) is shown rendered, and a `#Heading` subpath narrows it to that section. Anything else shows as a link. Paths are relative to the wiki root.
|
|
||||||
- **Links** show as a card. **Groups** are labelled frames: moving a group moves what is inside it.
|
|
||||||
- **Connections**: drag from a node's side handle onto another node. Dropping on empty space creates a new note there. Double-click a connection to label it.
|
|
||||||
|
|
||||||
| Input | |
|
|
||||||
|-------|-|
|
|
||||||
| Drag background / Space+drag / middle drag | Pan |
|
|
||||||
| Wheel, pinch | Zoom (Shift+wheel pans sideways) |
|
|
||||||
| Shift+drag background | Select an area |
|
|
||||||
| Double-click background | New note |
|
|
||||||
| Double-click node, Enter, Alt+Shift+E | Edit (label, URL or file for non-notes) |
|
|
||||||
| Drag node / corner handle | Move / resize, snapped to a 10 px grid (Alt: no snap) |
|
|
||||||
| Arrows | Nudge (Shift: 1 px) |
|
|
||||||
| Del, Ctrl+Z, Ctrl+Y, Ctrl+D, Ctrl+A | Delete, undo, redo, duplicate, select all |
|
|
||||||
|
|
||||||
Changes save automatically. If the file changes elsewhere while a canvas is open, saving stops and asks for a reload instead of overwriting.
|
|
||||||
|
|
||||||
## Diary
|
|
||||||
|
|
||||||
```
|
```
|
||||||
Diary/
|
type = diary
|
||||||
|
```
|
||||||
|
|
||||||
|
### Diary
|
||||||
|
|
||||||
|
Designed for a chronological photo diary. Expected structure:
|
||||||
|
|
||||||
|
```
|
||||||
|
FolderName/
|
||||||
.page-settings ← type = diary
|
.page-settings ← type = diary
|
||||||
2026/
|
YYYY/
|
||||||
index.md ← # 2026, ## 2026-05, ### 2026-05-11 sections
|
YYYY-MM-DD Desc.jpg ← photos named with date prefix
|
||||||
2026-05-11 Beach.jpg ← photos go under their date's section
|
MM/
|
||||||
|
DD/
|
||||||
|
index.md ← diary entry for that day
|
||||||
```
|
```
|
||||||
|
|
||||||
The year page shows each day's section with its photos. Months and days that are missing (up to today) appear as placeholders with an `[edit]` button. A calendar in the sidebar jumps to any date.
|
| 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 |
|
||||||
|
|
||||||
Bookmarkable shortcuts: `<diary>/today/`, `<diary>/this-month/`, `<diary>/this-year/`.
|
Days with photos but no `index.md` still appear in the month view and can be created by clicking their heading link.
|
||||||
|
|
||||||
## Fitness
|
|
||||||
|
|
||||||
With `type = fitness`, a folder shows weight charts (daily and weekly mean) below its content. The weights come from the calendars with the `weight` role (see [Calendars](#calendars)). Add `weight-goal = 85` to the folder's `.page-settings` to draw a goal line.
|
|
||||||
|
|
||||||
## Calendars
|
|
||||||
|
|
||||||
```
|
|
||||||
Calendars/
|
|
||||||
.page-settings ← type = calendars, roles, sync credentials
|
|
||||||
Personal.ics ← one file per calendar
|
|
||||||
Gewicht.ics
|
|
||||||
…
|
|
||||||
```
|
|
||||||
|
|
||||||
Pages only read these local files and never contact the calendar server. The built-in sync downloads all calendars of a CalDAV calendar home in the background. Or you can put the `.ics` files there yourself.
|
|
||||||
|
|
||||||
```
|
|
||||||
type = calendars
|
|
||||||
|
|
||||||
# Roles: file names without .ics
|
|
||||||
diary = Personal, Feiertage
|
|
||||||
weight = Gewicht
|
|
||||||
maintenance = Maintainance
|
|
||||||
|
|
||||||
# Sync (Nextcloud)
|
|
||||||
caldav-url = https://cloud.example/remote.php/dav/calendars/me/
|
|
||||||
caldav-user = me
|
|
||||||
caldav-pass = xxxxx-xxxxx-xxxxx-xxxxx-xxxxx
|
|
||||||
sync-interval = 15m
|
|
||||||
```
|
|
||||||
|
|
||||||
| Key | |
|
|
||||||
|-----|-|
|
|
||||||
| `diary` | Events shown under each diary day: all-day first, then by start time |
|
|
||||||
| `weight` | Titles like `95,6kg`, shown under the diary day's events |
|
|
||||||
| `maintenance` | Tasks in the header widget (with the number due today) and on the calendars page |
|
|
||||||
| `caldav-url` | Calendar home. Empty = no sync |
|
|
||||||
| `caldav-user`, `caldav-pass` | Use a Nextcloud app password (Settings → Security), not the account password |
|
|
||||||
| `sync-interval` | Default `15m`. `0` = only at startup and with **Sync now** |
|
|
||||||
|
|
||||||
Changes apply without a restart. Each calendar is saved as `<display name>.ics`. A download that fails or is not valid iCalendar keeps the old file. Datascape never serves dotfiles, so the password in `.page-settings` cannot be downloaded. It is still in every backup of the wiki folder.
|
|
||||||
|
|
||||||
The calendars page lists the maintenance tasks (repeat rule, last date, next date) and the sync status of each calendar.
|
|
||||||
|
|
||||||
## Quick-add bookmarklet
|
|
||||||
|
|
||||||
Replace `wiki.host` and `/Topics/Bookmarks/` with your host and target page:
|
|
||||||
|
|
||||||
```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 adds this to the target page's `index.md`:
|
|
||||||
|
|
||||||
```markdown
|
|
||||||
- [Example Page](https://example.com)
|
|
||||||
2026-05-11 14:30
|
|
||||||
optional comment
|
|
||||||
```
|
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
(function () {
|
||||||
|
document.querySelectorAll('.content h1, .content h2, .content h3, .content h4, .content h5, .content h6').forEach(function (h) {
|
||||||
|
if (!h.id) return;
|
||||||
|
var a = document.createElement('a');
|
||||||
|
a.href = '#' + h.id;
|
||||||
|
a.className = 'heading-anchor';
|
||||||
|
a.setAttribute('aria-label', 'Link to this section');
|
||||||
|
a.textContent = '#';
|
||||||
|
h.insertBefore(a, h.firstChild);
|
||||||
|
});
|
||||||
|
}());
|
||||||
@@ -1,45 +0,0 @@
|
|||||||
<h2 id="maintenance">Maintenance</h2>
|
|
||||||
{{if .Maintenance}}
|
|
||||||
<div class="cal-scroll"><table class="data-table panel cal-table">
|
|
||||||
<thead><tr><th>Task</th><th>Repeats</th><th>Last</th><th>Next</th></tr></thead>
|
|
||||||
<tbody>
|
|
||||||
{{range .Maintenance}}
|
|
||||||
<tr><td>{{.Summary}}</td><td class="muted">{{.Rule}}</td><td class="nowrap">{{.Last}}</td><td class="nowrap">{{.Next}}</td></tr>
|
|
||||||
{{end}}
|
|
||||||
</tbody>
|
|
||||||
</table></div>
|
|
||||||
{{else if .HasMaintenance}}
|
|
||||||
<p class="muted">No maintenance tasks within a year of today.</p>
|
|
||||||
{{else}}
|
|
||||||
<p class="muted">No maintenance calendar. Set <code>maintenance = <name></code> in this folder's <code>.page-settings</code>.</p>
|
|
||||||
{{end}}
|
|
||||||
|
|
||||||
<h2 id="calendars">Calendars</h2>
|
|
||||||
{{if .Calendars}}
|
|
||||||
<div class="cal-scroll"><table class="data-table panel cal-table">
|
|
||||||
<thead><tr><th>Calendar</th><th>Role</th><th>Events</th><th>Updated</th></tr></thead>
|
|
||||||
<tbody>
|
|
||||||
{{range .Calendars}}
|
|
||||||
<tr>
|
|
||||||
<td>{{.Name}}{{if .Error}}<div class="cal-error">{{.Error}}</div>{{end}}</td>
|
|
||||||
<td class="muted">{{.Roles}}</td>
|
|
||||||
<td>{{.Events}}</td>
|
|
||||||
<td class="nowrap">{{.Updated}}</td>
|
|
||||||
</tr>
|
|
||||||
{{end}}
|
|
||||||
</tbody>
|
|
||||||
</table></div>
|
|
||||||
{{else}}
|
|
||||||
<p class="muted">No <code>.ics</code> files in this folder yet.</p>
|
|
||||||
{{end}}
|
|
||||||
{{if .IsSyncDir}}
|
|
||||||
{{if .SyncEnabled}}
|
|
||||||
<form class="cal-sync row" method="post" action="?calsync">
|
|
||||||
<button type="submit" class="btn">Sync now</button>
|
|
||||||
<span class="muted">{{if .LastRun}}Last sync: {{.LastRun}}{{else}}Not synced yet{{end}}</span>
|
|
||||||
</form>
|
|
||||||
{{if .LastError}}<p class="cal-error">{{.LastError}}</p>{{end}}
|
|
||||||
{{else}}
|
|
||||||
<p class="muted">Sync is off. Set <code>caldav-url</code>, <code>caldav-user</code> and <code>caldav-pass</code> in this folder's <code>.page-settings</code>.</p>
|
|
||||||
{{end}}
|
|
||||||
{{end}}
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
<span class="dropdown maint">
|
|
||||||
<button type="button" class="btn maint-toggle{{if not .Count}} is-idle{{end}}" data-action="maint-menu" aria-haspopup="true" aria-expanded="false" title="Maintenance (H)">{{icon "maintenance"}}<span class="maint-count">{{.Count}}</span></button>
|
|
||||||
<div class="dropdown-menu align-right maint-menu">
|
|
||||||
<div class="panel-header">Today</div>
|
|
||||||
<div class="maint-list">
|
|
||||||
{{range .Today}}<span class="maint-when">{{.When}}</span><span>{{.Summary}}</span>
|
|
||||||
{{else}}<span class="muted">Nothing due</span>{{end}}
|
|
||||||
</div>
|
|
||||||
<div class="panel-header">Next 7 days</div>
|
|
||||||
<div class="maint-list">
|
|
||||||
{{range .Upcoming}}<span class="maint-when">{{.When}}</span><span>{{.Summary}}</span>
|
|
||||||
{{else}}<span class="muted">Nothing planned</span>{{end}}
|
|
||||||
</div>
|
|
||||||
{{if .PageURL}}<a class="btn btn-block maint-all" href="{{.PageURL}}">All maintenance →</a>{{end}}
|
|
||||||
</div>
|
|
||||||
</span>
|
|
||||||
@@ -1,40 +0,0 @@
|
|||||||
{{/* The canvas view (canvas.go). It fills the whole shell: no folder tree,
|
|
||||||
no sidebar, no footer. Links are not boosted, so leaving the canvas is a full
|
|
||||||
navigation and canvas.js can save on pagehide. */}}
|
|
||||||
|
|
||||||
{{define "boost"}} hx-boost="false"{{end}}
|
|
||||||
|
|
||||||
{{define "page-actions"}}<div id="page-actions" class="page-actions">{{template "headerActions" .}}</div>{{end}}
|
|
||||||
|
|
||||||
{{define "headerActions"}}
|
|
||||||
<a class="btn btn-block" href="{{.PageURL}}" title="Back to the page"><span class="btn-icon">{{icon "up"}}</span><span class="btn-label">PAGE</span></a>
|
|
||||||
<a class="btn btn-block" href="{{.RawURL}}" download="{{.Name}}" title="Download the canvas file"><span class="btn-icon">{{icon "doc"}}</span><span class="btn-label">DOWNLOAD</span></a>
|
|
||||||
{{end}}
|
|
||||||
|
|
||||||
{{define "shell"}}
|
|
||||||
<div class="canvas-app" data-version="{{.Version}}"{{if .Error}} data-readonly{{end}}>
|
|
||||||
{{if .Error}}<p class="canvas-error">This canvas cannot be edited: {{.Error}}</p>{{end}}
|
|
||||||
<div class="canvas-stage">
|
|
||||||
<div class="canvas-viewport" tabindex="0">
|
|
||||||
<div class="canvas-world">
|
|
||||||
<svg class="canvas-edges" aria-hidden="true"></svg>
|
|
||||||
<div class="canvas-edge-labels"></div>
|
|
||||||
<div class="canvas-nodes">{{range .Nodes}}{{template "canvas-node" .}}{{end}}</div>
|
|
||||||
</div>
|
|
||||||
<p class="canvas-hint"{{if .Nodes}} hidden{{end}}>Double-click to add a note. Right-click or long-press for more.</p>
|
|
||||||
</div>
|
|
||||||
<div class="canvas-statusbar muted"><span class="canvas-status" aria-live="polite"></span><span class="canvas-zoom"></span></div>
|
|
||||||
{{if not .Error}}<div class="canvas-editor-toolbar" hidden>{{template "editor-toolbar"}}</div>{{end}}
|
|
||||||
</div>
|
|
||||||
<script type="application/json" id="canvas-data">{{.Doc}}</script>
|
|
||||||
</div>
|
|
||||||
{{if not .Error}}
|
|
||||||
<script src="{{asset "editor/vendor/codemirror.bundle.js"}}"></script>
|
|
||||||
<script src="{{asset "editor/tables.js"}}"></script>
|
|
||||||
<script src="{{asset "editor/dates.js"}}"></script>
|
|
||||||
<script src="{{asset "editor/movie.js"}}"></script>
|
|
||||||
<script src="{{asset "editor/wikicomplete.js"}}"></script>
|
|
||||||
<script src="{{asset "editor/core.js"}}"></script>
|
|
||||||
{{end}}
|
|
||||||
<script src="{{asset "canvas/canvas.js"}}"></script>
|
|
||||||
{{end}}
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
{{/* One canvas node element (canvas.go canvasNodeView). Rendered for the
|
|
||||||
whole canvas on load and for a single node by POST ?render, so the editor
|
|
||||||
(canvas/canvas.js) never builds node markup itself. Geometry is in canvas
|
|
||||||
units; canvas.js keeps style and data-color in sync while editing. */}}
|
|
||||||
|
|
||||||
{{define "canvas-node"}}<div class="canvas-node canvas-node-{{.Type}}" data-id="{{.ID}}"{{with .Preset}} data-color="{{.}}"{{end}} style="left: {{.X}}px; top: {{.Y}}px; width: {{.W}}px; height: {{.H}}px;{{with .Hex}} --node-color: {{.}};{{end}}">
|
|
||||||
{{- if .Label}}<div class="canvas-node-label truncate">{{if .LabelURL}}<a href="{{.LabelURL}}">{{.Label}}</a>{{else}}{{.Label}}{{end}}</div>{{end -}}
|
|
||||||
<div class="canvas-node-body{{if .Markdown}} content{{end}}">{{.Body}}</div></div>{{end}}
|
|
||||||
|
|
||||||
{{define "canvas-media"}}{{if eq .Kind "image"}}<a class="canvas-media" href="{{.URL}}"><img src="{{.Thumb}}" alt="" loading="lazy" draggable="false"></a>
|
|
||||||
{{- else if eq .Kind "video"}}<video class="canvas-media" src="{{.URL}}" controls preload="metadata"></video>
|
|
||||||
{{- else}}<audio class="canvas-media" src="{{.URL}}" controls preload="metadata"></audio>{{end}}{{end}}
|
|
||||||
|
|
||||||
{{define "canvas-file"}}<a class="canvas-card" href="{{.URL}}"><span class="canvas-card-icon">{{.Icon}}</span><span class="truncate">{{.Name}}</span></a>{{end}}
|
|
||||||
|
|
||||||
{{define "canvas-link"}}<a class="canvas-card" href="{{.URL}}" target="_blank" rel="noopener noreferrer" title="{{.URL}}"><img class="canvas-card-icon" src="https://icons.duckduckgo.com/ip3/{{.Host}}.ico" alt="" width="16" height="16" draggable="false"><span class="canvas-card-text">{{if .Label}}<span class="truncate">{{.Label}}</span><span class="muted truncate">{{.Host}}</span>{{else}}<span class="truncate">{{.Host}}</span><span class="muted truncate">{{.URL}}</span>{{end}}</span></a>{{end}}
|
|
||||||
|
|
||||||
{{define "canvas-missing"}}<p class="canvas-missing">Missing: {{.}}</p>{{end}}
|
|
||||||
|
|
||||||
{{define "canvas-group-bg"}}<div class="canvas-group-bg canvas-group-bg-{{.Style}}" style="background-image: url('{{.URL}}')"></div>{{end}}
|
|
||||||
@@ -1,224 +0,0 @@
|
|||||||
// Detects the local datascape-companion via a /status probe and wires up
|
|
||||||
// the footer status icon, file-row click interception, and the "reveal in
|
|
||||||
// file manager" page action. All companion calls are best-effort: if the
|
|
||||||
// fetch fails the page falls back to default browser behavior.
|
|
||||||
|
|
||||||
(function () {
|
|
||||||
var COMPANION_PORT = 17680;
|
|
||||||
var COMPANION_BASE = 'http://127.0.0.1:' + COMPANION_PORT;
|
|
||||||
var STATUS_TIMEOUT_MS = 1500;
|
|
||||||
|
|
||||||
var state = { available: false, info: null };
|
|
||||||
|
|
||||||
function wikiPathFromHref(href) {
|
|
||||||
// href is the URL the wiki rendered for the listing item (e.g.
|
|
||||||
// "/photos/2024/img.jpg"). Strip leading slash and decode so the
|
|
||||||
// companion sees a relative wiki path matching its on-disk layout.
|
|
||||||
try {
|
|
||||||
var u = new URL(href, window.location.href);
|
|
||||||
if (u.origin !== window.location.origin) return null;
|
|
||||||
var p = u.pathname.replace(/^\/+/, '');
|
|
||||||
return decodeURIComponent(p);
|
|
||||||
} catch (_) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function companionGET(path, params) {
|
|
||||||
var qs = '';
|
|
||||||
if (params) {
|
|
||||||
var parts = [];
|
|
||||||
for (var k in params) {
|
|
||||||
parts.push(encodeURIComponent(k) + '=' + encodeURIComponent(params[k]));
|
|
||||||
}
|
|
||||||
qs = '?' + parts.join('&');
|
|
||||||
}
|
|
||||||
var ctrl = new AbortController();
|
|
||||||
var timer = setTimeout(function () { ctrl.abort(); }, STATUS_TIMEOUT_MS);
|
|
||||||
return fetch(COMPANION_BASE + path + qs, {
|
|
||||||
method: 'GET',
|
|
||||||
mode: 'cors',
|
|
||||||
credentials: 'omit',
|
|
||||||
signal: ctrl.signal
|
|
||||||
}).finally(function () { clearTimeout(timer); });
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderFlyout(menu) {
|
|
||||||
menu.innerHTML = '';
|
|
||||||
if (state.available) {
|
|
||||||
var info = state.info || {};
|
|
||||||
var name = info.name || 'datascape-companion';
|
|
||||||
var ver = info.version ? ' v' + info.version : '';
|
|
||||||
var head = document.createElement('div');
|
|
||||||
head.className = 'panel-header';
|
|
||||||
head.textContent = 'Companion';
|
|
||||||
menu.appendChild(head);
|
|
||||||
|
|
||||||
var label = document.createElement('div');
|
|
||||||
label.className = 'companion-line';
|
|
||||||
label.textContent = name + ver;
|
|
||||||
menu.appendChild(label);
|
|
||||||
|
|
||||||
var link = document.createElement('a');
|
|
||||||
link.className = 'btn btn-block';
|
|
||||||
link.href = COMPANION_BASE + '/config';
|
|
||||||
link.target = '_blank';
|
|
||||||
link.rel = 'noopener';
|
|
||||||
link.textContent = 'Settings';
|
|
||||||
menu.appendChild(link);
|
|
||||||
} else {
|
|
||||||
var head2 = document.createElement('div');
|
|
||||||
head2.className = 'panel-header';
|
|
||||||
head2.textContent = 'Companion not detected';
|
|
||||||
menu.appendChild(head2);
|
|
||||||
|
|
||||||
var msg = document.createElement('div');
|
|
||||||
msg.className = 'companion-line muted';
|
|
||||||
msg.textContent = 'Install the companion to open files locally.';
|
|
||||||
menu.appendChild(msg);
|
|
||||||
|
|
||||||
var win = document.createElement('a');
|
|
||||||
win.className = 'btn btn-block';
|
|
||||||
win.href = '/companion/download/windows';
|
|
||||||
win.textContent = 'Download — Windows';
|
|
||||||
menu.appendChild(win);
|
|
||||||
|
|
||||||
var lin = document.createElement('a');
|
|
||||||
lin.className = 'btn btn-block';
|
|
||||||
lin.href = '/companion/download/linux';
|
|
||||||
lin.textContent = 'Download — Linux';
|
|
||||||
menu.appendChild(lin);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateFooterIcon() {
|
|
||||||
var wrap = document.querySelector('[data-companion-status]');
|
|
||||||
if (!wrap) return;
|
|
||||||
wrap.hidden = false;
|
|
||||||
var btn = wrap.querySelector('.companion-icon');
|
|
||||||
if (state.available) {
|
|
||||||
btn.textContent = '●';
|
|
||||||
btn.classList.add('companion-on');
|
|
||||||
btn.classList.remove('companion-off');
|
|
||||||
btn.title = 'Companion detected';
|
|
||||||
} else {
|
|
||||||
btn.textContent = '○';
|
|
||||||
btn.classList.add('companion-off');
|
|
||||||
btn.classList.remove('companion-on');
|
|
||||||
btn.title = 'Companion not detected';
|
|
||||||
}
|
|
||||||
var menu = wrap.querySelector('.companion-flyout');
|
|
||||||
renderFlyout(menu);
|
|
||||||
if (typeof wireDropdown === 'function') wireDropdown(btn);
|
|
||||||
}
|
|
||||||
|
|
||||||
function wireFileLinks() {
|
|
||||||
if (!state.available) return;
|
|
||||||
document.addEventListener('click', function (e) {
|
|
||||||
if (!e.target.closest) return;
|
|
||||||
// Match 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;
|
|
||||||
// Canvases open in the wiki's own canvas view.
|
|
||||||
if (/\.canvas$/i.test(path.split(/[?#]/)[0])) 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 () {
|
|
||||||
wireFileLinks(); // delegated to document: once
|
|
||||||
applyToPage();
|
|
||||||
// Boosted navigation swaps in a new page without reloading this
|
|
||||||
// script: re-apply to its footer icon and reveal buttons.
|
|
||||||
document.addEventListener('page:swapped', applyToPage);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function applyToPage() {
|
|
||||||
updateFooterIcon();
|
|
||||||
wireRevealButton();
|
|
||||||
wireFileRevealButtons();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (document.readyState === 'loading') {
|
|
||||||
document.addEventListener('DOMContentLoaded', init);
|
|
||||||
} else {
|
|
||||||
init();
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
@@ -5,7 +5,7 @@
|
|||||||
img.src = 'https://icons.duckduckgo.com/ip3/' + hostname + '.ico';
|
img.src = 'https://icons.duckduckgo.com/ip3/' + hostname + '.ico';
|
||||||
img.width = 16;
|
img.width = 16;
|
||||||
img.height = 16;
|
img.height = 16;
|
||||||
img.style.verticalAlign = 'center';
|
img.style.verticalAlign = 'middle';
|
||||||
img.style.marginRight = '3px';
|
img.style.marginRight = '3px';
|
||||||
a.prepend(img);
|
a.prepend(img);
|
||||||
});
|
});
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
<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="{{asset "diary/calendar.js"}}"></script>
|
|
||||||
@@ -1,45 +0,0 @@
|
|||||||
(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);
|
|
||||||
})();
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
{{range .Sections}}
|
|
||||||
{{if eq .Level 1}}<h1 id="{{.ID}}">{{.Heading}}{{if .EditURL}} <a href="{{.EditURL}}" class="btn btn-small">edit</a>{{end}}</h1>
|
|
||||||
{{else if eq .Level 2}}<h2 id="{{.ID}}">{{.Heading}}{{if .EditURL}} <a href="{{.EditURL}}" class="btn btn-small">edit</a>{{end}}</h2>
|
|
||||||
{{else}}<h3 id="{{.ID}}">{{.Heading}}{{if .EditURL}} <a href="{{.EditURL}}" class="btn btn-small 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}}
|
|
||||||
{{if or .Events .Weight}}<div class="diary-day-cal muted">
|
|
||||||
{{if .Events}}<p>{{.Events}}</p>{{end}}
|
|
||||||
{{if .Weight}}<p>{{.Weight}}</p>{{end}}
|
|
||||||
</div>{{end}}
|
|
||||||
{{end}}
|
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
<div class="diary-cal">
|
||||||
|
<div class="panel-header"><a href="{{.DiaryURL}}">Chronological</a></div>
|
||||||
|
<div class="diary-cal-nav">
|
||||||
|
<a href="{{.MonthURL}}" class="diary-cal-heading">{{.MonthName}}</a>
|
||||||
|
<div class="dropdown diary-cal-drop">
|
||||||
|
<button type="button" class="btn btn-small" data-action="cal-month-drop" aria-expanded="false" title="Monat wählen">▾</button>
|
||||||
|
<div class="dropdown-menu">
|
||||||
|
{{range .AllMonths}}<a class="dropdown-item{{if .IsCurrent}} cal-current{{end}}" href="{{.URL}}">{{.Name}}</a>{{end}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<a href="{{.YearURL}}" class="diary-cal-heading">{{.DisplayYear}}</a>
|
||||||
|
<div class="dropdown diary-cal-drop">
|
||||||
|
<button type="button" class="btn btn-small" data-action="cal-year-drop" aria-expanded="false" title="Jahr wählen">▾</button>
|
||||||
|
<div class="dropdown-menu align-right">
|
||||||
|
{{range .Years}}<a class="dropdown-item{{if .IsCurrent}} cal-current{{end}}" href="{{.URL}}">{{.Num}}</a>{{end}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<table class="diary-cal-grid">
|
||||||
|
<thead>
|
||||||
|
<tr><th>Mo</th><th>Di</th><th>Mi</th><th>Do</th><th>Fr</th><th>Sa</th><th>So</th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{{range .Weeks}}<tr>{{range .}}<td class="{{if .IsCurrent}}cal-current{{else if .IsToday}}cal-today{{end}}{{if and .Num (not .HasEntry)}} cal-empty{{end}}">{{if .Num}}<a href="{{.URL}}">{{.Num}}</a>{{end}}</td>{{end}}</tr>
|
||||||
|
{{end}}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<script src="/_/diary/diary-calendar.js"></script>
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
(function () {
|
||||||
|
var cal = document.querySelector(".diary-cal");
|
||||||
|
if (!cal) return;
|
||||||
|
|
||||||
|
var toggle = document.createElement("button");
|
||||||
|
toggle.type = "button";
|
||||||
|
toggle.className = "panel-toggle";
|
||||||
|
toggle.textContent = "Kalender";
|
||||||
|
toggle.setAttribute("aria-expanded", "false");
|
||||||
|
toggle.addEventListener("click", function () {
|
||||||
|
var open = cal.classList.toggle("is-open");
|
||||||
|
toggle.setAttribute("aria-expanded", open ? "true" : "false");
|
||||||
|
});
|
||||||
|
|
||||||
|
var main = document.querySelector("main");
|
||||||
|
if (main) {
|
||||||
|
main.parentNode.insertBefore(toggle, main);
|
||||||
|
main.parentNode.insertBefore(cal, main);
|
||||||
|
}
|
||||||
|
|
||||||
|
cal.querySelectorAll(".diary-cal-drop > button").forEach(wireDropdown);
|
||||||
|
|
||||||
|
var pageHeader = document.querySelector("header");
|
||||||
|
function updateTop() {
|
||||||
|
if (!pageHeader || getComputedStyle(cal).position !== "fixed") return;
|
||||||
|
var rect = pageHeader.getBoundingClientRect();
|
||||||
|
cal.style.top = Math.max(8, rect.bottom + 8) + "px";
|
||||||
|
}
|
||||||
|
window.addEventListener("scroll", updateTop, { passive: true });
|
||||||
|
window.addEventListener("resize", updateTop);
|
||||||
|
updateTop();
|
||||||
|
})();
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
{{if .Photos}}
|
||||||
|
<div class="photo-grid">
|
||||||
|
{{range .Photos}}
|
||||||
|
<a href="{{.URL}}" target="_blank"><img src="{{.ThumbURL}}" alt="" loading="lazy"></a>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
{{range .Days}}
|
||||||
|
<h2 id="{{.ID}}">
|
||||||
|
{{if .URL}}<a href="{{.URL}}">{{.Heading}}</a>{{else}}{{.Heading}}{{end}}
|
||||||
|
{{if .EditURL}}<a href="{{.EditURL}}" class="btn btn-small">edit</a>{{end}}
|
||||||
|
</h2>
|
||||||
|
{{if .Content}}{{.Content}}{{end}}
|
||||||
|
{{if .Photos}}
|
||||||
|
<div class="photo-grid">
|
||||||
|
{{range .Photos}}
|
||||||
|
<a href="{{.URL}}" target="_blank"><img src="{{.ThumbURL}}" alt="" loading="lazy"></a>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
{{end}}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
<h2 id="months">Monate</h2>
|
||||||
|
{{range .Months}}
|
||||||
|
<h3 id="{{.ID}}">
|
||||||
|
<a href="{{.URL}}">{{.Name}}</a>
|
||||||
|
</h3>
|
||||||
|
{{if .Photos}}
|
||||||
|
<div class="photo-grid">
|
||||||
|
{{range .Photos}}
|
||||||
|
<a href="{{.URL}}" target="_blank"><img src="{{.ThumbURL}}" alt="" loading="lazy"></a>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
{{end}}
|
||||||
@@ -0,0 +1,226 @@
|
|||||||
|
(function () {
|
||||||
|
var textarea = document.getElementById('editor');
|
||||||
|
if (!textarea) return;
|
||||||
|
|
||||||
|
var form = textarea.closest('form');
|
||||||
|
|
||||||
|
// --- DOM helpers ---
|
||||||
|
|
||||||
|
function wrap(before, after, placeholder) {
|
||||||
|
var start = textarea.selectionStart;
|
||||||
|
var end = textarea.selectionEnd;
|
||||||
|
var selected = textarea.value.slice(start, end) || placeholder;
|
||||||
|
var replacement = before + selected + after;
|
||||||
|
textarea.value = textarea.value.slice(0, start) + replacement + textarea.value.slice(end);
|
||||||
|
if (selected === placeholder) {
|
||||||
|
textarea.selectionStart = start + before.length;
|
||||||
|
textarea.selectionEnd = start + before.length + placeholder.length;
|
||||||
|
} else {
|
||||||
|
textarea.selectionStart = start + replacement.length;
|
||||||
|
textarea.selectionEnd = start + replacement.length;
|
||||||
|
}
|
||||||
|
textarea.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
function linePrefix(prefix) {
|
||||||
|
var start = textarea.selectionStart;
|
||||||
|
var lineStart = textarea.value.lastIndexOf('\n', start - 1) + 1;
|
||||||
|
textarea.value = textarea.value.slice(0, lineStart) + prefix + textarea.value.slice(lineStart);
|
||||||
|
textarea.selectionStart = textarea.selectionEnd = start + prefix.length;
|
||||||
|
textarea.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
function insertAtCursor(s) {
|
||||||
|
var start = textarea.selectionStart;
|
||||||
|
var end = textarea.selectionEnd;
|
||||||
|
textarea.value = textarea.value.slice(0, start) + s + textarea.value.slice(end);
|
||||||
|
textarea.selectionStart = textarea.selectionEnd = start + s.length;
|
||||||
|
textarea.dispatchEvent(new Event('input'));
|
||||||
|
textarea.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyResult(result) {
|
||||||
|
textarea.value = result.text;
|
||||||
|
textarea.selectionStart = textarea.selectionEnd = result.cursor;
|
||||||
|
textarea.dispatchEvent(new Event('input'));
|
||||||
|
textarea.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyTableOp(fn, arg) {
|
||||||
|
var result = arg !== undefined
|
||||||
|
? fn(textarea.value, textarea.selectionStart, arg)
|
||||||
|
: fn(textarea.value, textarea.selectionStart);
|
||||||
|
if (result) applyResult(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Actions ---
|
||||||
|
|
||||||
|
var T = EditorTables;
|
||||||
|
var L = EditorLists;
|
||||||
|
var D = EditorDates;
|
||||||
|
var M = EditorMovie;
|
||||||
|
|
||||||
|
var actions = {
|
||||||
|
save: function () { form.submit(); },
|
||||||
|
bold: function () { wrap('**', '**', 'bold text'); },
|
||||||
|
italic: function () { wrap('*', '*', 'italic text'); },
|
||||||
|
h1: function () { linePrefix('# '); },
|
||||||
|
h2: function () { linePrefix('## '); },
|
||||||
|
h3: function () { linePrefix('### '); },
|
||||||
|
code: function () { wrap('`', '`', 'code'); },
|
||||||
|
codeblock: function () { wrap('```\n', '\n```', 'code'); },
|
||||||
|
quote: function () { linePrefix('> '); },
|
||||||
|
link: function () { wrap('[', '](url)', 'link text'); },
|
||||||
|
wikilink: function () {
|
||||||
|
var sel = textarea.value.slice(textarea.selectionStart, textarea.selectionEnd);
|
||||||
|
openTreePicker({
|
||||||
|
title: 'Insert link',
|
||||||
|
mode: 'any',
|
||||||
|
initialPath: '/',
|
||||||
|
confirmLabel: 'INSERT',
|
||||||
|
onSelect: function (path, kind) {
|
||||||
|
if (kind === 'folder') {
|
||||||
|
insertAtCursor(sel ? '[[' + path + '|' + sel + ']]' : '[[' + path + ']]');
|
||||||
|
} else {
|
||||||
|
var name = path.split('/').pop();
|
||||||
|
insertAtCursor('[' + (sel || name) + '](' + path + ')');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
ul: function () { linePrefix('- '); },
|
||||||
|
ol: function () { linePrefix('1. '); },
|
||||||
|
hr: function () { wrap('\n\n---\n\n', '', ''); },
|
||||||
|
fmttable: function () { applyTableOp(T.formatTableText); },
|
||||||
|
tblalignleft: function () { applyTableOp(T.setColumnAlignment, 'left'); },
|
||||||
|
tblaligncenter: function () { applyTableOp(T.setColumnAlignment, 'center'); },
|
||||||
|
tblalignright: function () { applyTableOp(T.setColumnAlignment, 'right'); },
|
||||||
|
tblinsertcol: function () { applyTableOp(T.insertColumn); },
|
||||||
|
tbldeletecol: function () { applyTableOp(T.deleteColumn); },
|
||||||
|
tblinsertrow: function () { applyTableOp(T.insertRow); },
|
||||||
|
tbldeleterow: function () { applyTableOp(T.deleteRow); },
|
||||||
|
dateiso: function () { insertAtCursor(D.isoDate()); },
|
||||||
|
datelong: function () { insertAtCursor(D.longDate()); },
|
||||||
|
movie: function () { M.run(textarea); },
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- Keyboard shortcut registration ---
|
||||||
|
|
||||||
|
var keyMap = {};
|
||||||
|
document.querySelectorAll('[data-action]').forEach(function (btn) {
|
||||||
|
btn.addEventListener('click', function () {
|
||||||
|
var action = actions[btn.dataset.action];
|
||||||
|
if (action) action();
|
||||||
|
});
|
||||||
|
if (btn.dataset.key) {
|
||||||
|
keyMap[btn.dataset.key] = actions[btn.dataset.action];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
keyMap['T'] = actions.fmttable;
|
||||||
|
keyMap['D'] = actions.dateiso;
|
||||||
|
keyMap['W'] = actions.datelong;
|
||||||
|
|
||||||
|
document.addEventListener('keydown', function (e) {
|
||||||
|
if (!e.altKey || !e.shiftKey) return;
|
||||||
|
// Shift+digit produces a layout-dependent character in e.key (e.g. "!"
|
||||||
|
// on US, "!" on DE), so fall back to e.code for digit rows.
|
||||||
|
var key = /^Digit[0-9]$/.test(e.code) ? e.code.slice(5) : e.key;
|
||||||
|
var action = keyMap[key];
|
||||||
|
if (action) {
|
||||||
|
e.preventDefault();
|
||||||
|
action();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Textarea key handling ---
|
||||||
|
|
||||||
|
textarea.addEventListener('keydown', function (e) {
|
||||||
|
if (e.key === 'Delete' && e.shiftKey) {
|
||||||
|
var result = T.deleteRow(textarea.value, textarea.selectionStart)
|
||||||
|
|| L.deleteOrderedLine(textarea.value, textarea.selectionStart);
|
||||||
|
if (!result) return;
|
||||||
|
e.preventDefault();
|
||||||
|
applyResult(result);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (e.key === 'Enter' && e.shiftKey) {
|
||||||
|
var result = T.insertRowBelow(textarea.value, textarea.selectionStart);
|
||||||
|
if (!result) return;
|
||||||
|
e.preventDefault();
|
||||||
|
applyResult(result);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (e.key !== 'Enter') return;
|
||||||
|
var result = L.handleEnterKey(textarea.value, textarea.selectionStart);
|
||||||
|
if (!result) return;
|
||||||
|
e.preventDefault();
|
||||||
|
applyResult(result);
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Dropdown helper ---
|
||||||
|
|
||||||
|
var openMenus = [];
|
||||||
|
|
||||||
|
function makeDropdown(triggerBtn, items) {
|
||||||
|
var menu = document.createElement('div');
|
||||||
|
menu.className = 'dropdown-menu';
|
||||||
|
items.forEach(function (item) {
|
||||||
|
var btn = document.createElement('button');
|
||||||
|
btn.type = 'button';
|
||||||
|
btn.className = 'btn btn-tool dropdown-item';
|
||||||
|
btn.textContent = item.label;
|
||||||
|
btn.addEventListener('mousedown', function (e) {
|
||||||
|
e.preventDefault();
|
||||||
|
actions[item.action]();
|
||||||
|
menu.classList.remove('is-open');
|
||||||
|
});
|
||||||
|
menu.appendChild(btn);
|
||||||
|
});
|
||||||
|
triggerBtn.appendChild(menu);
|
||||||
|
openMenus.push(menu);
|
||||||
|
|
||||||
|
triggerBtn.addEventListener('click', function (e) {
|
||||||
|
if (e.target !== triggerBtn) return;
|
||||||
|
var wasOpen = menu.classList.contains('is-open');
|
||||||
|
openMenus.forEach(function (m) { m.classList.remove('is-open'); });
|
||||||
|
if (!wasOpen) menu.classList.add('is-open');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('click', function (e) {
|
||||||
|
var insideAny = openMenus.some(function (m) {
|
||||||
|
return m.parentElement && m.parentElement.contains(e.target);
|
||||||
|
});
|
||||||
|
if (!insideAny) openMenus.forEach(function (m) { m.classList.remove('is-open'); });
|
||||||
|
});
|
||||||
|
document.addEventListener('keydown', function (e) {
|
||||||
|
if (e.key === 'Escape') openMenus.forEach(function (m) { m.classList.remove('is-open'); });
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Table dropdown ---
|
||||||
|
|
||||||
|
var tblDropBtn = document.querySelector('[data-action="tbldrop"]');
|
||||||
|
if (tblDropBtn) {
|
||||||
|
makeDropdown(tblDropBtn, [
|
||||||
|
{ label: 'Format table', action: 'fmttable' },
|
||||||
|
{ label: 'Align left', action: 'tblalignleft' },
|
||||||
|
{ label: 'Align center', action: 'tblaligncenter' },
|
||||||
|
{ label: 'Align right', action: 'tblalignright' },
|
||||||
|
{ label: 'Insert column', action: 'tblinsertcol' },
|
||||||
|
{ label: 'Delete column', action: 'tbldeletecol' },
|
||||||
|
{ label: 'Insert row', action: 'tblinsertrow' },
|
||||||
|
{ label: 'Delete row', action: 'tbldeleterow' },
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Date dropdown ---
|
||||||
|
|
||||||
|
var dateDropBtn = document.querySelector('[data-action="datedrop"]');
|
||||||
|
if (dateDropBtn) {
|
||||||
|
makeDropdown(dateDropBtn, [
|
||||||
|
{ label: 'YYYY-MM-DD', action: 'dateiso' },
|
||||||
|
{ label: 'DE Long', action: 'datelong' },
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
})();
|
||||||
@@ -1,434 +0,0 @@
|
|||||||
// core.js — the markdown editor: the CodeMirror setup plus the formatting
|
|
||||||
// actions behind the toolbar (editor/toolbar.html) and the Alt+Shift
|
|
||||||
// shortcuts. Shared by the page editor (main.js) and the canvas node editor
|
|
||||||
// (canvas/canvas.js).
|
|
||||||
//
|
|
||||||
// MarkdownEditor.create(mount, opts) → { view, actions, getValue, focus, destroy }
|
|
||||||
// opts.doc initial text
|
|
||||||
// opts.keymap extra CM key bindings, ahead of the defaults
|
|
||||||
// opts.onChange called after every change to the text
|
|
||||||
// MarkdownEditor.bind(root, getActions)
|
|
||||||
// wires root's [data-action] buttons, [data-key] shortcuts and toolbar
|
|
||||||
// dropdowns to whatever getActions() returns (null = no editor open).
|
|
||||||
window.MarkdownEditor = (function () {
|
|
||||||
var T = EditorTables;
|
|
||||||
var D = EditorDates;
|
|
||||||
var M = EditorMovie;
|
|
||||||
|
|
||||||
// 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');
|
|
||||||
}
|
|
||||||
|
|
||||||
// pageBase is the folder of the page being edited, slash-terminated: the
|
|
||||||
// page URL itself, or for a canvas (/Page/Board.canvas) the folder it
|
|
||||||
// lives in. Relative embed destinations resolve against it.
|
|
||||||
function pageBase() {
|
|
||||||
var p = decodeURIComponent(window.location.pathname);
|
|
||||||
return p.slice(0, p.lastIndexOf('/') + 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
function create(mount, opts) {
|
|
||||||
opts = opts || {};
|
|
||||||
var doc = opts.doc || '';
|
|
||||||
var view;
|
|
||||||
|
|
||||||
// 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 (v) {
|
|
||||||
var result = fn(v.state.doc.toString(), v.state.selection.main.head);
|
|
||||||
if (!result) return false;
|
|
||||||
dispatchFullReplace(result);
|
|
||||||
return true;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function tableNavKey(fn) {
|
|
||||||
var handler = tableKey(fn);
|
|
||||||
return function (v) {
|
|
||||||
if (completionOpen()) return false;
|
|
||||||
return handler(v);
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
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: doc,
|
|
||||||
// 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: doc.length },
|
|
||||||
extensions: [
|
|
||||||
// The caller's keys go first, ahead of even autocompletion's
|
|
||||||
// own highest-precedence keys: those swallow an Escape while a
|
|
||||||
// completion query is still pending, with no popup in sight.
|
|
||||||
CM.Prec.highest(CM.keymap.of(opts.keymap || [])),
|
|
||||||
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,
|
|
||||||
opts.onChange ? CM.EditorView.updateListener.of(function (u) {
|
|
||||||
if (u.docChanged) opts.onChange();
|
|
||||||
}) : [],
|
|
||||||
CM.Prec.highest(CM.keymap.of(tableKeymap)),
|
|
||||||
CM.keymap.of([].concat(
|
|
||||||
CM.closeBracketsKeymap,
|
|
||||||
CM.completionKeymap,
|
|
||||||
CM.markdownKeymap,
|
|
||||||
CM.defaultKeymap,
|
|
||||||
CM.historyKeymap,
|
|
||||||
[CM.indentWithTab]
|
|
||||||
)),
|
|
||||||
],
|
|
||||||
});
|
|
||||||
|
|
||||||
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();
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
// 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 = pageBase();
|
|
||||||
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 = pageBase().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
|
|
||||||
));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
var actions = {
|
|
||||||
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); },
|
|
||||||
};
|
|
||||||
|
|
||||||
return {
|
|
||||||
view: view,
|
|
||||||
actions: actions,
|
|
||||||
getValue: function () { return view.state.doc.toString(); },
|
|
||||||
focus: function () { view.focus(); },
|
|
||||||
destroy: function () { view.destroy(); },
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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';
|
|
||||||
}
|
|
||||||
|
|
||||||
function bind(root, getActions) {
|
|
||||||
function run(name) {
|
|
||||||
var actions = getActions();
|
|
||||||
var action = actions && actions[name];
|
|
||||||
if (action) action();
|
|
||||||
}
|
|
||||||
|
|
||||||
var keyMap = {};
|
|
||||||
root.querySelectorAll('[data-action]').forEach(function (btn) {
|
|
||||||
btn.addEventListener('click', function () { run(btn.dataset.action); });
|
|
||||||
if (btn.dataset.key) keyMap[btn.dataset.key] = 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 = root.classList.contains('editor-toolbar') ? root : root.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 || !getActions()) 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 name = keyMap[key];
|
|
||||||
if (name) {
|
|
||||||
e.preventDefault();
|
|
||||||
run(name);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
root.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); });
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return { create: create, bind: bind };
|
|
||||||
})();
|
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
window.EditorLists = (function () {
|
||||||
|
|
||||||
|
function detectListPrefix(lineText) {
|
||||||
|
var m;
|
||||||
|
m = lineText.match(/^(\s*)(- \[[ x]\] )/);
|
||||||
|
if (m) return { indent: m[1], prefix: m[2], type: 'task' };
|
||||||
|
m = lineText.match(/^(\s*)([-*+] )/);
|
||||||
|
if (m) return { indent: m[1], prefix: m[2], type: 'unordered' };
|
||||||
|
m = lineText.match(/^(\s*)(\d+)\. /);
|
||||||
|
if (m) return { indent: m[1], prefix: m[2] + '. ', type: 'ordered', num: parseInt(m[2], 10) };
|
||||||
|
m = lineText.match(/^(\s*)(> )/);
|
||||||
|
if (m) return { indent: m[1], prefix: m[2], type: 'blockquote' };
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function continuationPrefix(info) {
|
||||||
|
if (info.type === 'task') return info.indent + '- [ ] ';
|
||||||
|
if (info.type === 'ordered') return info.indent + (info.num + 1) + '. ';
|
||||||
|
return info.indent + info.prefix;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renumberOrderedList(text, fromLineIndex, indent, startNum) {
|
||||||
|
var lines = text.split('\n');
|
||||||
|
var re = new RegExp('^' + indent.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '(\\d+)\\. ');
|
||||||
|
var num = startNum !== undefined ? startNum : null;
|
||||||
|
for (var i = fromLineIndex; i < lines.length; i++) {
|
||||||
|
var m = lines[i].match(re);
|
||||||
|
if (!m) break;
|
||||||
|
if (num === null) num = parseInt(m[1], 10);
|
||||||
|
var newNumStr = String(num);
|
||||||
|
if (m[1] !== newNumStr) {
|
||||||
|
lines[i] = indent + newNumStr + '. ' + lines[i].slice(m[0].length);
|
||||||
|
}
|
||||||
|
num++;
|
||||||
|
}
|
||||||
|
return lines.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleEnterKey(text, cursorPos) {
|
||||||
|
var before = text.slice(0, cursorPos);
|
||||||
|
var after = text.slice(cursorPos);
|
||||||
|
var lineStart = before.lastIndexOf('\n') + 1;
|
||||||
|
var lineEnd = text.indexOf('\n', cursorPos);
|
||||||
|
if (lineEnd === -1) lineEnd = text.length;
|
||||||
|
var fullLine = text.slice(lineStart, lineEnd);
|
||||||
|
var info = detectListPrefix(fullLine);
|
||||||
|
if (!info) return null;
|
||||||
|
|
||||||
|
var contentAfterPrefix = fullLine.slice(info.indent.length + info.prefix.length);
|
||||||
|
if (contentAfterPrefix.trim() === '') {
|
||||||
|
var newText = text.slice(0, lineStart) + '\n' + after;
|
||||||
|
var newCursor = lineStart + 1;
|
||||||
|
if (info.type === 'ordered') {
|
||||||
|
var lineIndex = text.slice(0, lineStart).split('\n').length;
|
||||||
|
newText = renumberOrderedList(newText, lineIndex, info.indent);
|
||||||
|
}
|
||||||
|
return { text: newText, cursor: newCursor };
|
||||||
|
}
|
||||||
|
|
||||||
|
var cont = continuationPrefix(info);
|
||||||
|
var newText = before + '\n' + cont + after;
|
||||||
|
var newCursor = cursorPos + 1 + cont.length;
|
||||||
|
if (info.type === 'ordered') {
|
||||||
|
var insertedLineIndex = before.split('\n').length;
|
||||||
|
newText = renumberOrderedList(newText, insertedLineIndex, info.indent);
|
||||||
|
}
|
||||||
|
return { text: newText, cursor: newCursor };
|
||||||
|
}
|
||||||
|
|
||||||
|
function deleteOrderedLine(text, cursorPos) {
|
||||||
|
var lineStart = text.lastIndexOf('\n', cursorPos - 1) + 1;
|
||||||
|
var lineEnd = text.indexOf('\n', cursorPos);
|
||||||
|
if (lineEnd === -1) lineEnd = text.length;
|
||||||
|
var fullLine = text.slice(lineStart, lineEnd);
|
||||||
|
var info = detectListPrefix(fullLine);
|
||||||
|
if (!info || info.type !== 'ordered') return null;
|
||||||
|
|
||||||
|
var newText = text.slice(0, lineStart) + text.slice(lineEnd === text.length ? lineEnd : lineEnd + 1);
|
||||||
|
var newCursor = lineStart;
|
||||||
|
var fromLineIndex = text.slice(0, lineStart).split('\n').length - 1;
|
||||||
|
newText = renumberOrderedList(newText, fromLineIndex, info.indent, info.num);
|
||||||
|
return { text: newText, cursor: Math.min(newCursor, newText.length) };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { handleEnterKey: handleEnterKey, deleteOrderedLine: deleteOrderedLine };
|
||||||
|
})();
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
{{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}}
|
|
||||||
{{template "editor-toolbar"}}
|
|
||||||
<div id="editor" class="editor-cm"></div>
|
|
||||||
<textarea name="content" id="editor-content" hidden>{{.RawContent}}</textarea>
|
|
||||||
</form>
|
|
||||||
<script src="{{asset "editor/vendor/codemirror.bundle.js"}}"></script>
|
|
||||||
<script src="{{asset "editor/tables.js"}}"></script>
|
|
||||||
<script src="{{asset "editor/dates.js"}}"></script>
|
|
||||||
<script src="{{asset "editor/movie.js"}}"></script>
|
|
||||||
<script src="{{asset "editor/wikicomplete.js"}}"></script>
|
|
||||||
<script src="{{asset "editor/core.js"}}"></script>
|
|
||||||
<script src="{{asset "editor/main.js"}}"></script>
|
|
||||||
{{end}}
|
|
||||||
@@ -1,66 +0,0 @@
|
|||||||
// The page editor: one markdown editor (core.js) inside the edit form, saved
|
|
||||||
// with the header's SAVE button.
|
|
||||||
(function () {
|
|
||||||
var mount = document.getElementById('editor');
|
|
||||||
var hidden = document.getElementById('editor-content');
|
|
||||||
if (!mount || !hidden || !window.CM) return;
|
|
||||||
|
|
||||||
var form = hidden.closest('form');
|
|
||||||
var editor = MarkdownEditor.create(mount, { doc: hidden.value });
|
|
||||||
|
|
||||||
// --- 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 = editor.getValue();
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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 ---
|
|
||||||
|
|
||||||
editor.actions.save = function () { form.requestSubmit(); };
|
|
||||||
|
|
||||||
// The toolbar and the header SAVE button both name actions.
|
|
||||||
MarkdownEditor.bind(document.body, function () { return editor.actions; });
|
|
||||||
})();
|
|
||||||
@@ -1,7 +1,8 @@
|
|||||||
window.EditorMovie = (function () {
|
window.EditorMovie = (function () {
|
||||||
'use strict';
|
'use strict';
|
||||||
|
|
||||||
var STORAGE_KEY = 'omdb-api-key';
|
// OMDb API key. Shipped to the browser; acceptable for a single-user LAN tool.
|
||||||
|
var OMDB_API_KEY = 'c906744f';
|
||||||
|
|
||||||
var BEGIN = '<!-- BEGIN MOVIE -->';
|
var BEGIN = '<!-- BEGIN MOVIE -->';
|
||||||
var END = '<!-- END MOVIE -->';
|
var END = '<!-- END MOVIE -->';
|
||||||
@@ -48,26 +49,26 @@ window.EditorMovie = (function () {
|
|||||||
return out.join('\n');
|
return out.join('\n');
|
||||||
}
|
}
|
||||||
|
|
||||||
// ctx is the CM adapter from main.js: { getValue(), replace(start,end,text) }.
|
function insertOrReplace(ta, markup) {
|
||||||
function insertOrReplace(ctx, markup) {
|
var t = ta.value || '';
|
||||||
var t = ctx.getValue() || '';
|
|
||||||
var b = t.indexOf(BEGIN);
|
var b = t.indexOf(BEGIN);
|
||||||
var e = t.indexOf(END);
|
var e = t.indexOf(END);
|
||||||
if (b !== -1 && e !== -1 && e > b) {
|
if (b !== -1 && e !== -1 && e > b) {
|
||||||
ctx.replace(b, e + END.length, markup);
|
ta.value = t.slice(0, b) + markup + t.slice(e + END.length);
|
||||||
} else {
|
} else {
|
||||||
var h = t.match(/^#{1,6}\s+.+?\s*$/m);
|
var h = t.match(/^#{1,6}\s+.+?\s*$/m);
|
||||||
if (h) {
|
if (h) {
|
||||||
var idx = t.indexOf(h[0]) + h[0].length;
|
var idx = t.indexOf(h[0]) + h[0].length;
|
||||||
ctx.replace(idx, idx, '\n\n' + markup);
|
ta.value = t.slice(0, idx) + '\n\n' + markup + t.slice(idx);
|
||||||
} else {
|
} else {
|
||||||
ctx.replace(0, 0, t ? markup + '\n\n' : markup);
|
ta.value = markup + (t ? '\n\n' + t : '');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
ta.dispatchEvent(new Event('input'));
|
||||||
}
|
}
|
||||||
|
|
||||||
function fetchMovie(key, title, year) {
|
function fetchMovie(title, year) {
|
||||||
var url = 'https://www.omdbapi.com/?apikey=' + encodeURIComponent(key) +
|
var url = 'https://www.omdbapi.com/?apikey=' + encodeURIComponent(OMDB_API_KEY) +
|
||||||
'&type=movie&t=' + encodeURIComponent(title);
|
'&type=movie&t=' + encodeURIComponent(title);
|
||||||
if (year) url += '&y=' + encodeURIComponent(year);
|
if (year) url += '&y=' + encodeURIComponent(year);
|
||||||
return fetch(url).then(function (r) { return r.json(); });
|
return fetch(url).then(function (r) { return r.json(); });
|
||||||
@@ -77,54 +78,17 @@ window.EditorMovie = (function () {
|
|||||||
openModal({ title: title, body: msg, confirm: { label: 'OK' } });
|
openModal({ title: title, body: msg, confirm: { label: 'OK' } });
|
||||||
}
|
}
|
||||||
|
|
||||||
function promptForKey(rejected, onSaved) {
|
function run(textarea) {
|
||||||
var body = document.createDocumentFragment();
|
if (!OMDB_API_KEY) {
|
||||||
|
showMessage('Movie import', 'OMDb API key is not set. Edit assets/editor/movie.js.');
|
||||||
if (rejected) {
|
return;
|
||||||
var notice = document.createElement('p');
|
|
||||||
notice.textContent = 'The previously stored key was rejected by OMDb.';
|
|
||||||
body.appendChild(notice);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var info = document.createElement('p');
|
|
||||||
info.appendChild(document.createTextNode('Enter your OMDb API key. Get one at '));
|
|
||||||
var link = document.createElement('a');
|
|
||||||
link.href = 'https://www.omdbapi.com/apikey.aspx';
|
|
||||||
link.target = '_blank';
|
|
||||||
link.rel = 'noopener';
|
|
||||||
link.textContent = 'omdbapi.com/apikey.aspx';
|
|
||||||
info.appendChild(link);
|
|
||||||
info.appendChild(document.createTextNode('.'));
|
|
||||||
body.appendChild(info);
|
|
||||||
|
|
||||||
var input = document.createElement('input');
|
var input = document.createElement('input');
|
||||||
input.type = 'text';
|
input.type = 'text';
|
||||||
input.className = 'input';
|
input.className = 'modal-input';
|
||||||
input.placeholder = 'OMDb API key';
|
|
||||||
body.appendChild(input);
|
|
||||||
|
|
||||||
openModal({
|
|
||||||
title: 'OMDb API key required',
|
|
||||||
body: body,
|
|
||||||
confirm: {
|
|
||||||
label: 'SAVE',
|
|
||||||
onConfirm: function () {
|
|
||||||
var key = input.value.trim();
|
|
||||||
if (!key) return;
|
|
||||||
localStorage.setItem(STORAGE_KEY, key);
|
|
||||||
closeModal();
|
|
||||||
onSaved(key);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function importWithKey(ctx, key, initialTitle) {
|
|
||||||
var input = document.createElement('input');
|
|
||||||
input.type = 'text';
|
|
||||||
input.className = 'input';
|
|
||||||
input.placeholder = 'Title, optionally with (YYYY)';
|
input.placeholder = 'Title, optionally with (YYYY)';
|
||||||
input.value = initialTitle;
|
input.value = firstHeading(textarea.value || '');
|
||||||
|
|
||||||
openModal({
|
openModal({
|
||||||
title: 'Import movie',
|
title: 'Import movie',
|
||||||
@@ -136,22 +100,14 @@ window.EditorMovie = (function () {
|
|||||||
if (!raw) return;
|
if (!raw) return;
|
||||||
var parsed = parseTitleYear(raw);
|
var parsed = parseTitleYear(raw);
|
||||||
closeModal();
|
closeModal();
|
||||||
fetchMovie(key, parsed.title, parsed.year)
|
fetchMovie(parsed.title, parsed.year)
|
||||||
.then(function (data) {
|
.then(function (data) {
|
||||||
if (data && data.Response === 'False' &&
|
|
||||||
data.Error === 'Invalid API key!') {
|
|
||||||
localStorage.removeItem(STORAGE_KEY);
|
|
||||||
promptForKey(true, function (newKey) {
|
|
||||||
importWithKey(ctx, newKey, raw);
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!data || data.Response === 'False') {
|
if (!data || data.Response === 'False') {
|
||||||
showMessage('Not found',
|
showMessage('Not found',
|
||||||
(data && data.Error) || 'Movie not found.');
|
(data && data.Error) || 'Movie not found.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
insertOrReplace(ctx, buildBlock(data));
|
insertOrReplace(textarea, buildBlock(data));
|
||||||
})
|
})
|
||||||
.catch(function () {
|
.catch(function () {
|
||||||
showMessage('Import failed', 'OMDb lookup failed.');
|
showMessage('Import failed', 'OMDb lookup failed.');
|
||||||
@@ -161,17 +117,5 @@ window.EditorMovie = (function () {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function run(ctx) {
|
|
||||||
var initialTitle = firstHeading(ctx.getValue() || '');
|
|
||||||
var key = localStorage.getItem(STORAGE_KEY);
|
|
||||||
if (!key) {
|
|
||||||
promptForKey(false, function (newKey) {
|
|
||||||
importWithKey(ctx, newKey, initialTitle);
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
importWithKey(ctx, key, initialTitle);
|
|
||||||
}
|
|
||||||
|
|
||||||
return { run: run };
|
return { run: run };
|
||||||
})();
|
})();
|
||||||
|
|||||||
@@ -6,34 +6,22 @@ window.EditorTables = (function () {
|
|||||||
return s;
|
return s;
|
||||||
}
|
}
|
||||||
|
|
||||||
var segmenter = new Intl.Segmenter(undefined, { granularity: 'grapheme' });
|
|
||||||
|
|
||||||
// Graphemes rendered two columns wide: emoji-presentation characters, anything
|
|
||||||
// forced to emoji presentation via VS16, and East Asian wide/fullwidth ranges.
|
|
||||||
var WIDE = /\p{Emoji_Presentation}|️|[ᄀ-ᅟ⺀-〾ぁ-㏿㐀-䶿一-鿿ꀀ-가-힣豈-︰-﹏-⦆¢-₩]/u;
|
|
||||||
var ZERO = /^[\p{Mn}\p{Me}\p{Cf}]+$/u;
|
|
||||||
|
|
||||||
function displayWidth(s) {
|
|
||||||
var w = 0;
|
|
||||||
Array.from(segmenter.segment(s), function (seg) {
|
|
||||||
var g = seg.segment;
|
|
||||||
if (!ZERO.test(g)) w += WIDE.test(g) ? 2 : 1;
|
|
||||||
});
|
|
||||||
return w;
|
|
||||||
}
|
|
||||||
|
|
||||||
function padLeft(s, w) {
|
function padLeft(s, w) {
|
||||||
return repeat(' ', w - displayWidth(s)) + s;
|
while (s.length < w) s = ' ' + s;
|
||||||
|
return s;
|
||||||
}
|
}
|
||||||
|
|
||||||
function padRight(s, w) {
|
function padRight(s, w) {
|
||||||
return s + repeat(' ', w - displayWidth(s));
|
while (s.length < w) s += ' ';
|
||||||
|
return s;
|
||||||
}
|
}
|
||||||
|
|
||||||
function padCenter(s, w) {
|
function padCenter(s, w) {
|
||||||
var total = w - displayWidth(s);
|
while (s.length < w) {
|
||||||
var left = Math.floor(total / 2);
|
s = s + ' ';
|
||||||
return repeat(' ', left) + s + repeat(' ', total - left);
|
if (s.length < w) s = ' ' + s;
|
||||||
|
}
|
||||||
|
return s;
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseTableRow(line) {
|
function parseTableRow(line) {
|
||||||
@@ -113,8 +101,7 @@ window.EditorTables = (function () {
|
|||||||
var max = 3;
|
var max = 3;
|
||||||
for (var r = 0; r < rows.length; r++) {
|
for (var r = 0; r < rows.length; r++) {
|
||||||
if (r === sepIndex) continue;
|
if (r === sepIndex) continue;
|
||||||
var len = displayWidth(rows[r][c]);
|
if (rows[r][c].length > max) max = rows[r][c].length;
|
||||||
if (len > max) max = len;
|
|
||||||
}
|
}
|
||||||
widths.push(max);
|
widths.push(max);
|
||||||
}
|
}
|
||||||
@@ -275,94 +262,8 @@ window.EditorTables = (function () {
|
|||||||
return formatTableText(newLines.join('\n'), Math.min(newCursor, newLines.join('\n').length));
|
return formatTableText(newLines.join('\n'), Math.min(newCursor, newLines.join('\n').length));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Absolute cursor offset at the start of the `col`-th cell's content on
|
|
||||||
// `lineIdx` of a *formatted* table (i.e. just past the "| " / " | " that
|
|
||||||
// opens the cell). Clamps to the last cell if col overflows the row.
|
|
||||||
function cellCursor(text, lineIdx, col) {
|
|
||||||
var lines = text.split('\n');
|
|
||||||
var offset = 0;
|
|
||||||
for (var i = 0; i < lineIdx; i++) offset += lines[i].length + 1;
|
|
||||||
var line = lines[lineIdx] || '';
|
|
||||||
var pipes = [];
|
|
||||||
for (var c = 0; c < line.length; c++) if (line.charAt(c) === '|') pipes.push(c);
|
|
||||||
if (pipes.length === 0) return Math.min(offset, text.length);
|
|
||||||
var p = pipes[Math.min(col, pipes.length - 1)];
|
|
||||||
return Math.min(offset + p + 2, text.length);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Shared engine for the Enter / Tab / Shift-Tab cell navigation. Reformats
|
|
||||||
// the table and drops the cursor into the target cell, appending an empty
|
|
||||||
// row when navigation runs past the last row. Returns null when the cursor
|
|
||||||
// is not in a table (so the caller can fall back to the editor default).
|
|
||||||
function moveInTable(text, cursorPos, mode) {
|
|
||||||
var range = findTableRange(text, cursorPos);
|
|
||||||
if (!range) return null;
|
|
||||||
var colIdx = getCursorColumn(text, cursorPos);
|
|
||||||
if (colIdx === null) return null;
|
|
||||||
|
|
||||||
var sepRel = -1, colCount = 0;
|
|
||||||
for (var i = range.start; i <= range.end; i++) {
|
|
||||||
var cells = parseTableRow(range.lines[i]);
|
|
||||||
if (cells.length > colCount) colCount = cells.length;
|
|
||||||
if (sepRel === -1 && isSeparatorRow(cells)) sepRel = i - range.start;
|
|
||||||
}
|
|
||||||
if (sepRel === -1) return null;
|
|
||||||
|
|
||||||
var lastRel = range.end - range.start;
|
|
||||||
var rowRel = range.cursorLine - range.start;
|
|
||||||
var targetRow = rowRel, targetCol = colIdx;
|
|
||||||
|
|
||||||
if (mode === 'enter') {
|
|
||||||
targetRow = rowRel + 1;
|
|
||||||
if (targetRow === sepRel) targetRow++;
|
|
||||||
} else if (mode === 'tab') {
|
|
||||||
targetCol = colIdx + 1;
|
|
||||||
if (targetCol >= colCount) {
|
|
||||||
targetCol = 0;
|
|
||||||
targetRow = rowRel + 1;
|
|
||||||
if (targetRow === sepRel) targetRow++;
|
|
||||||
}
|
|
||||||
} else if (mode === 'shifttab') {
|
|
||||||
targetCol = colIdx - 1;
|
|
||||||
if (targetCol < 0) {
|
|
||||||
targetCol = colCount - 1;
|
|
||||||
targetRow = rowRel - 1;
|
|
||||||
if (targetRow === sepRel) targetRow--;
|
|
||||||
}
|
|
||||||
if (targetRow < 0) return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
var lines = range.lines.slice();
|
|
||||||
if (targetRow > lastRel) {
|
|
||||||
var emptyCells = [];
|
|
||||||
for (var c = 0; c < colCount; c++) emptyCells.push('');
|
|
||||||
var emptyLine = '| ' + emptyCells.join(' | ') + ' |';
|
|
||||||
while (lastRel < targetRow) {
|
|
||||||
lines.splice(range.start + lastRel + 1, 0, emptyLine);
|
|
||||||
lastRel++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var tableStartOffset = 0;
|
|
||||||
for (var i = 0; i < range.start; i++) tableStartOffset += lines[i].length + 1;
|
|
||||||
var formatted = formatTableText(lines.join('\n'), tableStartOffset);
|
|
||||||
if (!formatted) return null;
|
|
||||||
|
|
||||||
return {
|
|
||||||
text: formatted.text,
|
|
||||||
cursor: cellCursor(formatted.text, range.start + targetRow, targetCol),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function nextRowSameColumn(text, cursorPos) { return moveInTable(text, cursorPos, 'enter'); }
|
|
||||||
function nextCell(text, cursorPos) { return moveInTable(text, cursorPos, 'tab'); }
|
|
||||||
function prevCell(text, cursorPos) { return moveInTable(text, cursorPos, 'shifttab'); }
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
formatTableText: formatTableText,
|
formatTableText: formatTableText,
|
||||||
nextRowSameColumn: nextRowSameColumn,
|
|
||||||
nextCell: nextCell,
|
|
||||||
prevCell: prevCell,
|
|
||||||
setColumnAlignment: setColumnAlignment,
|
setColumnAlignment: setColumnAlignment,
|
||||||
insertColumn: insertColumn,
|
insertColumn: insertColumn,
|
||||||
deleteColumn: deleteColumn,
|
deleteColumn: deleteColumn,
|
||||||
|
|||||||
@@ -1,67 +0,0 @@
|
|||||||
{{/* The markdown editor's toolbar, shared by the page editor and the canvas
|
|
||||||
node editor. Buttons name an action of editor/core.js (data-action) and its
|
|
||||||
Alt+Shift shortcut (data-key). */}}
|
|
||||||
{{define "editor-toolbar"}}<div class="editor-toolbar">
|
|
||||||
<button type="button" class="btn btn-tool" data-action="undo" title="Undo">↶</button>
|
|
||||||
<button type="button" class="btn btn-tool" data-action="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)">></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>{{end}}
|
|
||||||
@@ -1,73 +0,0 @@
|
|||||||
// 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 };
|
|
||||||
})();
|
|
||||||
@@ -1,64 +0,0 @@
|
|||||||
// Fitness dashboard chart hover. The range dropdowns and "absolute"
|
|
||||||
// checkboxes are htmx (assets/fitness/main.html): they swap in only their
|
|
||||||
// chart.
|
|
||||||
// Boosted navigation re-runs this script on every visit; the listeners
|
|
||||||
// are delegated to document, so register them once.
|
|
||||||
if (!window.fitnessWired) {
|
|
||||||
window.fitnessWired = true;
|
|
||||||
|
|
||||||
// Hover tooltip: the pointer snaps to the data point nearest in time. Dots
|
|
||||||
// are sorted by x, so a binary search finds it; the tooltip sits above the
|
|
||||||
// point and is clamped to the chart panel.
|
|
||||||
function fitnessNearest(dots, x) {
|
|
||||||
var lo = 0, hi = dots.length - 1;
|
|
||||||
while (lo < hi) {
|
|
||||||
var mid = (lo + hi) >> 1;
|
|
||||||
if (dots[mid].cx.baseVal.value < x) lo = mid + 1; else hi = mid;
|
|
||||||
}
|
|
||||||
if (lo > 0 && x - dots[lo - 1].cx.baseVal.value < dots[lo].cx.baseVal.value - x) lo--;
|
|
||||||
return dots[lo];
|
|
||||||
}
|
|
||||||
|
|
||||||
function fitnessHide(svg) {
|
|
||||||
var chart = svg.closest('.fitness-chart');
|
|
||||||
svg.classList.remove('is-hover');
|
|
||||||
chart.querySelector('.fitness-tip').hidden = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
document.addEventListener('pointermove', function (e) {
|
|
||||||
var svg = e.target.closest && e.target.closest('.fitness-svg');
|
|
||||||
document.querySelectorAll('.fitness-svg').forEach(function (other) {
|
|
||||||
if (other !== svg) fitnessHide(other);
|
|
||||||
});
|
|
||||||
if (!svg) return;
|
|
||||||
var dots = svg.querySelectorAll('.chart-dot');
|
|
||||||
if (!dots.length) return;
|
|
||||||
var ctm = svg.getScreenCTM();
|
|
||||||
if (!ctm) return;
|
|
||||||
var pt = new DOMPoint(e.clientX, e.clientY).matrixTransform(ctm.inverse());
|
|
||||||
var dot = fitnessNearest(dots, pt.x);
|
|
||||||
var cx = dot.cx.baseVal.value, cy = dot.cy.baseVal.value;
|
|
||||||
|
|
||||||
var cursor = svg.querySelector('.chart-cursor');
|
|
||||||
cursor.setAttribute('x1', cx);
|
|
||||||
cursor.setAttribute('x2', cx);
|
|
||||||
var focus = svg.querySelector('.chart-focus');
|
|
||||||
focus.setAttribute('cx', cx);
|
|
||||||
focus.setAttribute('cy', cy);
|
|
||||||
svg.classList.add('is-hover');
|
|
||||||
|
|
||||||
var chart = svg.closest('.fitness-chart');
|
|
||||||
var tip = chart.querySelector('.fitness-tip');
|
|
||||||
tip.firstElementChild.textContent = dot.dataset.date;
|
|
||||||
tip.lastElementChild.textContent = dot.dataset.value;
|
|
||||||
tip.hidden = false;
|
|
||||||
var box = chart.getBoundingClientRect();
|
|
||||||
var at = new DOMPoint(cx, cy).matrixTransform(ctm);
|
|
||||||
var left = at.x - box.left - tip.offsetWidth / 2;
|
|
||||||
left = Math.max(0, Math.min(left, box.width - tip.offsetWidth));
|
|
||||||
var top = at.y - box.top - tip.offsetHeight - 10;
|
|
||||||
if (top < 0) top = at.y - box.top + 10;
|
|
||||||
tip.style.left = left + 'px';
|
|
||||||
tip.style.top = top + 'px';
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -1,52 +0,0 @@
|
|||||||
{{/* A chart's controls re-fetch only that chart (fitnessHandler.serveFragment),
|
|
||||||
sending every chart's controls so the server can update the page URL. */}}
|
|
||||||
{{define "fitnessChart"}}
|
|
||||||
<section class="fitness-chart panel" hx-get="./" hx-vals='{"chart": "{{.Param}}"}' hx-trigger="change" hx-include="closest .fitness-dash" hx-target="this" hx-swap="outerHTML" hx-sync="this:replace">
|
|
||||||
<div class="fitness-chart-header row space-between">
|
|
||||||
<span class="caption">{{.Title}}</span>
|
|
||||||
<div class="row fitness-controls">
|
|
||||||
<label class="fitness-absolute"><input type="checkbox" name="{{.Param}}-absolute" value="1"{{if .Absolute}} checked{{end}}> Absolute</label>
|
|
||||||
<select class="input fitness-range" name="{{.Param}}" aria-label="{{.Title}} time range">
|
|
||||||
{{range .Options}}<option value="{{.Value}}"{{if .Selected}} selected{{end}}>{{.Label}}</option>{{end}}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{{if .Empty}}
|
|
||||||
<p class="fitness-empty is-empty">No data in this range.</p>
|
|
||||||
{{else}}
|
|
||||||
<svg class="fitness-svg{{if .Dense}} is-dense{{end}}" 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}}
|
|
||||||
<line class="chart-grid" x1="{{.Pos}}" y1="{{$.PlotY}}" x2="{{.Pos}}" y2="{{$.PlotB}}"/>
|
|
||||||
<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" data-date="{{.Date}}" data-value="{{.Value}}"/>
|
|
||||||
{{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}}
|
|
||||||
<line class="chart-cursor" x1="0" y1="{{.PlotY}}" x2="0" y2="{{.PlotB}}"/>
|
|
||||||
<circle class="chart-focus" r="4"/>
|
|
||||||
</svg>
|
|
||||||
<div class="fitness-tip" hidden><strong></strong><span></span></div>
|
|
||||||
{{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="{{asset "fitness/fitness.js"}}"></script>
|
|
||||||
@@ -4,8 +4,6 @@
|
|||||||
switch (e.key) {
|
switch (e.key) {
|
||||||
case 'E':
|
case 'E':
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
// assign, not replace — opening the editor pushes a history
|
|
||||||
// entry so Back cancels the edit (see history-nav.js).
|
|
||||||
window.location.href = window.location.pathname + '?edit';
|
window.location.href = window.location.pathname + '?edit';
|
||||||
break;
|
break;
|
||||||
case 'N':
|
case 'N':
|
||||||
@@ -16,19 +14,6 @@
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (window.location.pathname !== '/' && typeof movePage === 'function') movePage();
|
if (window.location.pathname !== '/' && typeof movePage === 'function') movePage();
|
||||||
break;
|
break;
|
||||||
case 'H':
|
|
||||||
var maint = document.querySelector('[data-action="maint-menu"]');
|
|
||||||
if (!maint) return;
|
|
||||||
e.preventDefault();
|
|
||||||
maint.click();
|
|
||||||
break;
|
|
||||||
case 'F':
|
|
||||||
var input = document.querySelector('.search-input');
|
|
||||||
if (!input) return;
|
|
||||||
e.preventDefault();
|
|
||||||
input.focus();
|
|
||||||
input.select();
|
|
||||||
break;
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
})();
|
})();
|
||||||
@@ -62,16 +47,3 @@ document.addEventListener('keydown', function (e) {
|
|||||||
menu.classList.remove('is-open');
|
menu.classList.remove('is-open');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
document.addEventListener('DOMContentLoaded', function () {
|
|
||||||
['actions-menu', 'maint-menu'].forEach(function (action) {
|
|
||||||
var trigger = document.querySelector('[data-action="' + action + '"]');
|
|
||||||
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);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -1,37 +0,0 @@
|
|||||||
// 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);
|
|
||||||
});
|
|
||||||
}());
|
|
||||||
@@ -1,141 +0,0 @@
|
|||||||
// Site-wide htmx policy. Loaded right after htmx itself.
|
|
||||||
(function () {
|
|
||||||
// htmx wires up swapped-in content (e.g. a dialog's hx-post form) in its
|
|
||||||
// settle step, by default 20ms after the swap: a click or Enter in that
|
|
||||||
// window would submit the form natively. No settle transitions are used,
|
|
||||||
// so settle in the same tick.
|
|
||||||
htmx.config.defaultSettleDelay = 0;
|
|
||||||
|
|
||||||
function boosting() {
|
|
||||||
return document.body.getAttribute('hx-boost') === 'true';
|
|
||||||
}
|
|
||||||
|
|
||||||
// Only wiki pages are boosted: same origin, a slash-terminated path, no
|
|
||||||
// query except a search (?q=). Files, the editor (?edit), internal
|
|
||||||
// endpoints (/_…) and POST forms navigate normally.
|
|
||||||
function isPageURL(url) {
|
|
||||||
if (url.origin !== location.origin) return false;
|
|
||||||
if (!url.pathname.endsWith('/') || url.pathname.indexOf('/_') === 0) return false;
|
|
||||||
var ok = true;
|
|
||||||
url.searchParams.forEach(function (v, key) { if (key !== 'q') ok = false; });
|
|
||||||
return ok;
|
|
||||||
}
|
|
||||||
|
|
||||||
// A server-sent HX-Redirect follows a mutation (move, delete, …): load the
|
|
||||||
// target into the current history entry rather than pushing a new one, so
|
|
||||||
// Back skips the stale pre-mutation page. Boosted pages swap a page target
|
|
||||||
// in like a navigation; anything else (or a target that is not a page,
|
|
||||||
// like a new canvas) gets replaceState + reload (not location.replace: a
|
|
||||||
// target differing only by #fragment would otherwise not re-fetch).
|
|
||||||
htmx.location = {
|
|
||||||
set href(target) {
|
|
||||||
if (boosting() && isPageURL(new URL(target, location.href))) {
|
|
||||||
if (window.closeModal) closeModal();
|
|
||||||
htmx.ajax('GET', target, {
|
|
||||||
target: '.shell',
|
|
||||||
swap: 'innerHTML transition:true',
|
|
||||||
headers: { 'HX-Boosted': 'true' },
|
|
||||||
replace: target
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
window.history.replaceState(null, '', target);
|
|
||||||
window.location.reload();
|
|
||||||
},
|
|
||||||
reload: function () { window.location.reload(); }
|
|
||||||
};
|
|
||||||
|
|
||||||
// Failed requests answer with a plain-text reason; show it. A failed
|
|
||||||
// boosted navigation falls back to loading the page normally instead.
|
|
||||||
function fallBack(e) {
|
|
||||||
if (!e.detail.boosted) return false;
|
|
||||||
window.location.href = e.detail.pathInfo.finalRequestPath || e.detail.pathInfo.requestPath;
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
document.addEventListener('htmx:responseError', function (e) {
|
|
||||||
if (fallBack(e)) return;
|
|
||||||
var xhr = e.detail.xhr;
|
|
||||||
alert(xhr.responseText || ('Request failed (' + xhr.status + ')'));
|
|
||||||
});
|
|
||||||
document.addEventListener('htmx:sendError', function (e) {
|
|
||||||
if (fallBack(e)) return;
|
|
||||||
alert('Network error');
|
|
||||||
});
|
|
||||||
|
|
||||||
// --- Boosted navigation ---------------------------------------------
|
|
||||||
// Outside the editor, links and GET forms to wiki pages load the next page
|
|
||||||
// into .shell instead of replacing the document: the server answers with
|
|
||||||
// the "partial" (title, page actions out-of-band, shell content). The
|
|
||||||
// header stays put, nothing flashes in browsers without cross-document
|
|
||||||
// view transitions, and the swap runs as a same-document view transition.
|
|
||||||
// Back/Forward refetch the partial (hx-history="false" on .shell: no
|
|
||||||
// snapshots that could show a page from before a move or delete).
|
|
||||||
htmx.config.scrollIntoViewOnBoost = false;
|
|
||||||
|
|
||||||
// htmx decides per element whether it is boosted, right after this event.
|
|
||||||
document.addEventListener('htmx:beforeProcessNode', function (e) {
|
|
||||||
var elt = e.target;
|
|
||||||
if (elt.hasAttribute('hx-boost')) return;
|
|
||||||
var page = false;
|
|
||||||
if (elt.tagName === 'A') {
|
|
||||||
page = elt.hasAttribute('href') && !elt.hasAttribute('download') && isPageURL(new URL(elt.href));
|
|
||||||
} else if (elt.tagName === 'FORM') {
|
|
||||||
page = (elt.getAttribute('method') || 'get').toLowerCase() === 'get' && isPageURL(new URL(elt.action));
|
|
||||||
} else {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!page) elt.setAttribute('hx-boost', 'false');
|
|
||||||
});
|
|
||||||
|
|
||||||
function isShell(elt) {
|
|
||||||
return elt && elt.classList && elt.classList.contains('shell');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Scroll positions: the tree rail keeps its position across navigation;
|
|
||||||
// the content column (.center) starts at the top, or where it was when
|
|
||||||
// coming back via Back/Forward.
|
|
||||||
var treeTop = 0;
|
|
||||||
var restoring = false;
|
|
||||||
var centerTops = {};
|
|
||||||
function pageKey() {
|
|
||||||
return location.pathname + location.search;
|
|
||||||
}
|
|
||||||
function rememberTree() {
|
|
||||||
var tree = document.querySelector('.tree-scroll');
|
|
||||||
treeTop = tree ? tree.scrollTop : 0;
|
|
||||||
}
|
|
||||||
document.addEventListener('scroll', function (e) {
|
|
||||||
var t = e.target;
|
|
||||||
if (t.classList && t.classList.contains('center')) centerTops[pageKey()] = t.scrollTop;
|
|
||||||
}, { capture: true, passive: true });
|
|
||||||
|
|
||||||
document.addEventListener('htmx:beforeSwap', function (e) {
|
|
||||||
if (e.detail.boosted) {
|
|
||||||
e.detail.target = document.querySelector('.shell');
|
|
||||||
e.detail.swapOverride = 'innerHTML transition:true';
|
|
||||||
}
|
|
||||||
if (isShell(e.detail.target)) rememberTree();
|
|
||||||
});
|
|
||||||
document.addEventListener('htmx:historyCacheMissLoad', function () {
|
|
||||||
restoring = true;
|
|
||||||
rememberTree();
|
|
||||||
});
|
|
||||||
|
|
||||||
// After a new page is in .shell: restore scroll positions, then tell page
|
|
||||||
// scripts (companion.js, …) to apply themselves to the new content.
|
|
||||||
document.addEventListener('htmx:afterSettle', function (e) {
|
|
||||||
if (!isShell(e.target)) return;
|
|
||||||
var tree = document.querySelector('.tree-scroll');
|
|
||||||
if (tree) {
|
|
||||||
tree.scrollTop = treeTop;
|
|
||||||
var active = tree.querySelector('.tree-row.is-active');
|
|
||||||
if (active) active.scrollIntoView({ block: 'nearest' });
|
|
||||||
}
|
|
||||||
if (restoring) {
|
|
||||||
restoring = false;
|
|
||||||
var center = document.querySelector('.center');
|
|
||||||
if (center) center.scrollTop = centerTops[pageKey()] || 0;
|
|
||||||
}
|
|
||||||
document.dispatchEvent(new CustomEvent('page:swapped'));
|
|
||||||
});
|
|
||||||
})();
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="1em" height="1em"
|
|
||||||
fill="none" stroke="currentColor" stroke-width="1.5" stroke-linejoin="miter" shape-rendering="crispEdges">
|
|
||||||
<path d="M1 2h6v4H1zM9 10h6v4H9zM4 6v6h5"/>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 250 B |
@@ -1,4 +0,0 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="1em" height="1em"
|
|
||||||
fill="none" stroke="currentColor" stroke-width="1.5" stroke-linejoin="miter" stroke-linecap="square">
|
|
||||||
<path d="M2 4h12M6 4V2h4v2M4 4l1 10h6l1-10M7 7v4M9 7v4"/>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 259 B |
@@ -1,4 +0,0 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="1em" height="1em"
|
|
||||||
fill="none" stroke="currentColor" stroke-width="1.5" stroke-linejoin="miter" stroke-linecap="square">
|
|
||||||
<path d="M11 2l3 3-8 8H3v-3zM9 4l3 3"/>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 241 B |
@@ -1,4 +0,0 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="1em" height="1em"
|
|
||||||
fill="none" stroke="currentColor" stroke-width="1.5" stroke-linejoin="miter" stroke-linecap="square">
|
|
||||||
<path d="M2 3h12v11H2zM2 6h12M5 1v2M11 1v2M5 10l2 2 4-4"/>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 260 B |
@@ -1,4 +0,0 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="1em" height="1em"
|
|
||||||
fill="none" stroke="currentColor" stroke-width="1.5" stroke-linejoin="miter" stroke-linecap="square">
|
|
||||||
<path d="M2 8h8M7 5l3 3-3 3M13 2v12"/>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 240 B |
@@ -1,4 +0,0 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="1em" height="1em"
|
|
||||||
fill="none" stroke="currentColor" stroke-width="1.5" stroke-linejoin="miter" stroke-linecap="square">
|
|
||||||
<path d="M8 3v10M3 8h10"/>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 228 B |
@@ -1,4 +0,0 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="1em" height="1em"
|
|
||||||
fill="none" stroke="currentColor" stroke-width="1.5" stroke-linejoin="miter" stroke-linecap="square">
|
|
||||||
<path d="M13 8a5 5 0 1 1-1.5-3.5M12 1v4H8"/>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 246 B |
@@ -1,4 +0,0 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="1em" height="1em"
|
|
||||||
fill="none" stroke="currentColor" stroke-width="1.5" stroke-linejoin="miter" stroke-linecap="square">
|
|
||||||
<path d="M1 14V3h5l1 2h8v9zM8 12V8M6 10l2-2 2 2"/>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 252 B |
@@ -1,4 +0,0 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="1em" height="1em"
|
|
||||||
fill="none" stroke="currentColor" stroke-width="1.5" stroke-linejoin="miter" stroke-linecap="square">
|
|
||||||
<path d="M8 13V3M3 8l5-5 5 5"/>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 233 B |
@@ -1,107 +0,0 @@
|
|||||||
{{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="{{asset "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="{{asset "style.css"}}" />
|
|
||||||
<script src="{{asset "vendor/htmx.min.js"}}" defer></script>
|
|
||||||
<script src="{{asset "hx.js"}}" defer></script>
|
|
||||||
<script src="{{asset "modal.js"}}"></script>
|
|
||||||
<script src="{{asset "global-shortcuts.js"}}"></script>
|
|
||||||
<script src="{{asset "history-nav.js"}}"></script>
|
|
||||||
<script src="{{asset "scroll-sync.js"}}" defer></script>
|
|
||||||
<script src="{{asset "search-suggest.js"}}" defer></script>
|
|
||||||
<script src="{{asset "tree-picker.js"}}"></script>
|
|
||||||
<script src="{{asset "companion.js"}}" defer></script>
|
|
||||||
{{if not .EditMode}}<script src="{{asset "overlay.js"}}" defer></script>
|
|
||||||
<script src="{{asset "page/actions.js"}}"></script>
|
|
||||||
<script src="{{asset "search/actions.js"}}"></script>{{end}}
|
|
||||||
</head>
|
|
||||||
{{/* Outside the editor, links to pages are boosted: htmx swaps .shell
|
|
||||||
(plus title and page actions, see "partial") instead of loading a new
|
|
||||||
document. hx.js decides which links count as pages. A view can opt out by
|
|
||||||
overriding "boost" (the canvas does: it saves on pagehide). */}}
|
|
||||||
<body{{if not .EditMode}}{{block "boost" .}} hx-boost="true"{{end}}{{end}}>
|
|
||||||
<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 not .EditMode}}
|
|
||||||
<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 .EditMode}}{{block "headerActions" .}}{{end}}{{else}}
|
|
||||||
{{maintenanceWidget}}
|
|
||||||
<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">
|
|
||||||
{{template "page-actions" .}}
|
|
||||||
</div>
|
|
||||||
</span>
|
|
||||||
{{end}}
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
<div class="shell" hx-history-elt hx-history="false">{{template "shell" .}}</div>
|
|
||||||
{{block "extras" .}}{{end}}
|
|
||||||
<div id="modal-host"></div>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
{{end}}
|
|
||||||
|
|
||||||
{{/* The page-specific header actions; the partial swaps them out-of-band
|
|
||||||
(the attribute is inert in a full page). */}}
|
|
||||||
{{define "page-actions"}}<div id="page-actions" class="page-actions" hx-swap-oob="true">
|
|
||||||
<div class="actions-special">
|
|
||||||
<button type="button" class="btn btn-block" title="Folders" onclick="openOverlay(document.querySelector('.tree-scroll'))">FOLDERS</button>
|
|
||||||
</div>
|
|
||||||
{{template "headerActions" .}}
|
|
||||||
</div>{{end}}
|
|
||||||
|
|
||||||
{{define "shell"}}
|
|
||||||
{{if not .EditMode}}<aside class="tree-sidebar col">
|
|
||||||
<nav class="tree-scroll" aria-label="Folders">{{.Tree}}</nav>
|
|
||||||
<script>
|
|
||||||
// Full page loads: keep the rail's scroll position (before first
|
|
||||||
// paint, so it doesn't jump), then reveal the active row. Boosted
|
|
||||||
// navigation keeps it itself (hx.js); this script re-runs then and
|
|
||||||
// bails out.
|
|
||||||
(function () {
|
|
||||||
if (document.readyState !== "loading") return;
|
|
||||||
var el = document.currentScript.previousElementSibling;
|
|
||||||
try { el.scrollTop = +sessionStorage.getItem("tree-scroll") || 0; } catch (e) {}
|
|
||||||
var active = el.querySelector(".tree-row.is-active");
|
|
||||||
if (active) active.scrollIntoView({ block: "nearest" });
|
|
||||||
addEventListener("pagehide", function () {
|
|
||||||
var cur = document.querySelector(".tree-scroll");
|
|
||||||
try { sessionStorage.setItem("tree-scroll", cur ? cur.scrollTop : 0); } catch (e) {}
|
|
||||||
});
|
|
||||||
})();
|
|
||||||
</script>
|
|
||||||
</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 not .EditMode}}{{block "aside" .}}{{end}}{{end}}
|
|
||||||
{{end}}
|
|
||||||
|
|
||||||
{{/* Response to boosted navigation and history restores (hx.js swaps it into
|
|
||||||
.shell): the title, the page actions out-of-band, and the shell's content. */}}
|
|
||||||
{{define "partial"}}<title>{{.Title}}</title>
|
|
||||||
{{template "page-actions" .}}
|
|
||||||
{{template "shell" .}}{{end}}
|
|
||||||
@@ -1,44 +1,65 @@
|
|||||||
// One modal at a time, from either of two sources sharing the same chrome
|
|
||||||
// (backdrop click, Escape, drag by the header, focus restore):
|
|
||||||
// - openModal(opts) builds a modal from JS;
|
|
||||||
// - server-rendered dialogs (assets/page/dialogs.html) that htmx swaps into
|
|
||||||
// #modal-host. Their forms post themselves; a [data-modal-close] element
|
|
||||||
// closes them.
|
|
||||||
(function () {
|
(function () {
|
||||||
var current = null; // { backdrop, prevFocus, onKeydown }
|
var backdrop = null;
|
||||||
var drag = null; // { modal, startX, startY, originX, originY }
|
var modal = null;
|
||||||
|
var titleEl = null;
|
||||||
|
var bodyEl = null;
|
||||||
|
var cancelBtn = null;
|
||||||
|
var confirmBtn = null;
|
||||||
|
var footerEl = null;
|
||||||
|
var prevFocus = null;
|
||||||
|
var currentOpts = null;
|
||||||
|
var onKeydown = null;
|
||||||
|
|
||||||
|
function build() {
|
||||||
|
backdrop = document.createElement('div');
|
||||||
|
backdrop.className = 'modal-backdrop';
|
||||||
|
|
||||||
|
modal = document.createElement('div');
|
||||||
|
modal.className = 'modal';
|
||||||
|
modal.setAttribute('role', 'dialog');
|
||||||
|
modal.setAttribute('aria-modal', 'true');
|
||||||
|
|
||||||
|
var header = document.createElement('div');
|
||||||
|
header.className = 'modal-header';
|
||||||
|
titleEl = document.createElement('span');
|
||||||
|
header.appendChild(titleEl);
|
||||||
|
|
||||||
|
bodyEl = document.createElement('div');
|
||||||
|
bodyEl.className = 'modal-body';
|
||||||
|
|
||||||
|
footerEl = document.createElement('div');
|
||||||
|
footerEl.className = 'modal-footer';
|
||||||
|
|
||||||
|
cancelBtn = document.createElement('button');
|
||||||
|
cancelBtn.type = 'button';
|
||||||
|
confirmBtn = document.createElement('button');
|
||||||
|
confirmBtn.type = 'button';
|
||||||
|
|
||||||
|
modal.appendChild(header);
|
||||||
|
modal.appendChild(bodyEl);
|
||||||
|
modal.appendChild(footerEl);
|
||||||
|
backdrop.appendChild(modal);
|
||||||
|
|
||||||
// show mounts backdrop (whose child is the .modal) into parent and makes
|
|
||||||
// it the current modal. onKeydown handles keys beyond Escape.
|
|
||||||
function show(backdrop, parent, prevFocus, onKeydown) {
|
|
||||||
var modal = backdrop.querySelector('.modal');
|
|
||||||
var header = modal.querySelector('.panel-header');
|
|
||||||
function onDown(e) { startDrag(e, modal); }
|
|
||||||
header.addEventListener('mousedown', onDown);
|
|
||||||
header.addEventListener('touchstart', onDown, { passive: false });
|
|
||||||
backdrop.addEventListener('mousedown', function (e) {
|
backdrop.addEventListener('mousedown', function (e) {
|
||||||
if (e.target === backdrop) close();
|
if (e.target === backdrop) close();
|
||||||
});
|
});
|
||||||
backdrop.addEventListener('click', function (e) {
|
wireDrag(header);
|
||||||
if (e.target.closest('[data-modal-close]')) close();
|
cancelBtn.addEventListener('click', close);
|
||||||
|
confirmBtn.addEventListener('click', function () {
|
||||||
|
if (confirmBtn.disabled) return;
|
||||||
|
if (currentOpts && currentOpts.confirm && currentOpts.confirm.onConfirm) {
|
||||||
|
currentOpts.confirm.onConfirm();
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
var keydown = function (e) {
|
|
||||||
if (e.key === 'Escape') {
|
|
||||||
e.preventDefault();
|
|
||||||
close();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (onKeydown) onKeydown(e);
|
|
||||||
};
|
|
||||||
document.addEventListener('keydown', keydown);
|
|
||||||
current = { backdrop: backdrop, prevFocus: prevFocus, onKeydown: keydown };
|
|
||||||
if (backdrop.parentNode !== parent) parent.appendChild(backdrop);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Dragging switches the modal to fixed positioning so flexbox alignment
|
// wireDrag makes the modal draggable by `handle`. Dragging switches the
|
||||||
// doesn't fight us.
|
// modal to fixed positioning so flexbox alignment doesn't fight us.
|
||||||
function startDrag(e, modal) {
|
function wireDrag(handle) {
|
||||||
|
var dragging = false;
|
||||||
|
var startX, startY, originX, originY;
|
||||||
|
|
||||||
|
function onDown(e) {
|
||||||
if (e.button !== undefined && e.button !== 0) return;
|
if (e.button !== undefined && e.button !== 0) return;
|
||||||
var pt = e.touches ? e.touches[0] : e;
|
var pt = e.touches ? e.touches[0] : e;
|
||||||
var rect = modal.getBoundingClientRect();
|
var rect = modal.getBoundingClientRect();
|
||||||
@@ -46,66 +67,54 @@
|
|||||||
modal.style.position = 'fixed';
|
modal.style.position = 'fixed';
|
||||||
modal.style.left = rect.left + 'px';
|
modal.style.left = rect.left + 'px';
|
||||||
modal.style.top = rect.top + 'px';
|
modal.style.top = rect.top + 'px';
|
||||||
drag = { modal: modal, startX: pt.clientX, startY: pt.clientY, originX: rect.left, originY: rect.top };
|
dragging = true;
|
||||||
|
startX = pt.clientX;
|
||||||
|
startY = pt.clientY;
|
||||||
|
originX = rect.left;
|
||||||
|
originY = rect.top;
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
}
|
}
|
||||||
|
|
||||||
function onMove(e) {
|
function onMove(e) {
|
||||||
if (!drag) return;
|
if (!dragging) return;
|
||||||
var pt = e.touches ? e.touches[0] : e;
|
var pt = e.touches ? e.touches[0] : e;
|
||||||
var m = drag.modal;
|
var dx = pt.clientX - startX;
|
||||||
var maxX = window.innerWidth - m.offsetWidth;
|
var dy = pt.clientY - startY;
|
||||||
var maxY = window.innerHeight - m.offsetHeight;
|
var w = modal.offsetWidth;
|
||||||
m.style.left = Math.max(0, Math.min(maxX, drag.originX + pt.clientX - drag.startX)) + 'px';
|
var h = modal.offsetHeight;
|
||||||
m.style.top = Math.max(0, Math.min(maxY, drag.originY + pt.clientY - drag.startY)) + 'px';
|
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() { drag = null; }
|
function onUp() { dragging = false; }
|
||||||
|
|
||||||
|
handle.addEventListener('mousedown', onDown);
|
||||||
|
handle.addEventListener('touchstart', onDown, { passive: false });
|
||||||
document.addEventListener('mousemove', onMove);
|
document.addEventListener('mousemove', onMove);
|
||||||
document.addEventListener('touchmove', onMove, { passive: false });
|
document.addEventListener('touchmove', onMove, { passive: false });
|
||||||
document.addEventListener('mouseup', onUp);
|
document.addEventListener('mouseup', onUp);
|
||||||
document.addEventListener('touchend', onUp);
|
document.addEventListener('touchend', onUp);
|
||||||
|
|
||||||
// detach tears down the current modal and returns the element that had
|
|
||||||
// focus before it opened. keepDOM leaves the backdrop in place for a swap
|
|
||||||
// about to replace it: htmx must still find the element that issued the
|
|
||||||
// request in the document.
|
|
||||||
function detach(keepDOM) {
|
|
||||||
if (!current) return null;
|
|
||||||
var c = current;
|
|
||||||
current = null;
|
|
||||||
drag = null;
|
|
||||||
document.removeEventListener('keydown', c.onKeydown);
|
|
||||||
if (!keepDOM && c.backdrop.parentNode) c.backdrop.parentNode.removeChild(c.backdrop);
|
|
||||||
return c.prevFocus;
|
|
||||||
}
|
|
||||||
|
|
||||||
function close() {
|
|
||||||
var toRestore = detach();
|
|
||||||
if (toRestore && toRestore.focus) {
|
|
||||||
try { toRestore.focus(); } catch (e) {}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function open(opts) {
|
function open(opts) {
|
||||||
var prevFocus = current ? detach() : document.activeElement;
|
if (backdrop && backdrop.parentNode) close();
|
||||||
|
if (!backdrop) build();
|
||||||
|
|
||||||
var backdrop = document.createElement('div');
|
currentOpts = opts;
|
||||||
backdrop.className = 'modal-backdrop';
|
prevFocus = document.activeElement;
|
||||||
var modal = document.createElement('div');
|
|
||||||
modal.className = 'modal panel panel-floating';
|
modal.classList.remove('is-dragged');
|
||||||
modal.setAttribute('role', 'dialog');
|
modal.style.position = '';
|
||||||
modal.setAttribute('aria-modal', 'true');
|
modal.style.left = '';
|
||||||
|
modal.style.top = '';
|
||||||
|
|
||||||
var header = document.createElement('div');
|
|
||||||
header.className = 'panel-header';
|
|
||||||
var titleEl = document.createElement('span');
|
|
||||||
titleEl.textContent = opts.title || '';
|
titleEl.textContent = opts.title || '';
|
||||||
header.appendChild(titleEl);
|
|
||||||
|
|
||||||
var bodyEl = document.createElement('div');
|
bodyEl.textContent = '';
|
||||||
bodyEl.className = 'panel-body';
|
|
||||||
if (opts.body instanceof Node) {
|
if (opts.body instanceof Node) {
|
||||||
bodyEl.appendChild(opts.body);
|
bodyEl.appendChild(opts.body);
|
||||||
} else if (typeof opts.body === 'string') {
|
} else if (typeof opts.body === 'string') {
|
||||||
@@ -115,24 +124,14 @@
|
|||||||
var confirmOpts = opts.confirm || {};
|
var confirmOpts = opts.confirm || {};
|
||||||
var cancelOpts = opts.cancel || {};
|
var cancelOpts = opts.cancel || {};
|
||||||
|
|
||||||
var confirmBtn = document.createElement('button');
|
|
||||||
confirmBtn.type = 'button';
|
|
||||||
confirmBtn.textContent = confirmOpts.label || 'OK';
|
confirmBtn.textContent = confirmOpts.label || 'OK';
|
||||||
confirmBtn.className = 'btn' + (confirmOpts.danger ? ' danger' : '');
|
confirmBtn.className = 'btn' + (confirmOpts.danger ? ' danger' : '');
|
||||||
confirmBtn.disabled = !!confirmOpts.initiallyDisabled;
|
confirmBtn.disabled = !!confirmOpts.initiallyDisabled;
|
||||||
confirmBtn.addEventListener('click', function () {
|
|
||||||
if (confirmBtn.disabled) return;
|
|
||||||
if (confirmOpts.onConfirm) confirmOpts.onConfirm();
|
|
||||||
});
|
|
||||||
|
|
||||||
var cancelBtn = document.createElement('button');
|
|
||||||
cancelBtn.type = 'button';
|
|
||||||
cancelBtn.textContent = cancelOpts.label || 'CANCEL';
|
cancelBtn.textContent = cancelOpts.label || 'CANCEL';
|
||||||
cancelBtn.className = 'btn';
|
cancelBtn.className = 'btn';
|
||||||
cancelBtn.setAttribute('data-modal-close', '');
|
|
||||||
|
|
||||||
var footerEl = document.createElement('div');
|
footerEl.textContent = '';
|
||||||
footerEl.className = 'panel-footer';
|
|
||||||
if (opts.swapButtons) {
|
if (opts.swapButtons) {
|
||||||
footerEl.appendChild(confirmBtn);
|
footerEl.appendChild(confirmBtn);
|
||||||
footerEl.appendChild(cancelBtn);
|
footerEl.appendChild(cancelBtn);
|
||||||
@@ -141,20 +140,7 @@
|
|||||||
footerEl.appendChild(confirmBtn);
|
footerEl.appendChild(confirmBtn);
|
||||||
}
|
}
|
||||||
|
|
||||||
modal.appendChild(header);
|
document.body.appendChild(backdrop);
|
||||||
modal.appendChild(bodyEl);
|
|
||||||
modal.appendChild(footerEl);
|
|
||||||
backdrop.appendChild(modal);
|
|
||||||
|
|
||||||
var enterConfirms = confirmOpts.enterConfirms !== false;
|
|
||||||
show(backdrop, document.body, prevFocus, function (e) {
|
|
||||||
if (e.key === 'Enter' && enterConfirms) {
|
|
||||||
var tag = (e.target && e.target.tagName) || '';
|
|
||||||
if (tag === 'TEXTAREA') return;
|
|
||||||
e.preventDefault();
|
|
||||||
if (!confirmBtn.disabled) confirmBtn.click();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
setTimeout(function () {
|
setTimeout(function () {
|
||||||
if (cancelOpts.autofocus) {
|
if (cancelOpts.autofocus) {
|
||||||
@@ -170,6 +156,22 @@
|
|||||||
}
|
}
|
||||||
}, 0);
|
}, 0);
|
||||||
|
|
||||||
|
var enterConfirms = confirmOpts.enterConfirms !== false;
|
||||||
|
onKeydown = function (e) {
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
e.preventDefault();
|
||||||
|
close();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (e.key === 'Enter' && enterConfirms) {
|
||||||
|
var tag = (e.target && e.target.tagName) || '';
|
||||||
|
if (tag === 'TEXTAREA') return;
|
||||||
|
e.preventDefault();
|
||||||
|
if (!confirmBtn.disabled) confirmBtn.click();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
document.addEventListener('keydown', onKeydown);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
close: close,
|
close: close,
|
||||||
setConfirmDisabled: function (d) { confirmBtn.disabled = !!d; },
|
setConfirmDisabled: function (d) { confirmBtn.disabled = !!d; },
|
||||||
@@ -177,30 +179,20 @@
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Server-rendered dialogs. A swap into #modal-host replaces whatever modal
|
function close() {
|
||||||
// is open (e.g. the merge step replacing the move dialog); focus returns
|
if (!backdrop || !backdrop.parentNode) return;
|
||||||
// to where it was before the first one opened.
|
if (onKeydown) {
|
||||||
var hostPrevFocus = null;
|
document.removeEventListener('keydown', onKeydown);
|
||||||
// History restores swap without event details; they never target the host.
|
onKeydown = null;
|
||||||
function isHost(e) {
|
}
|
||||||
return !!(e.detail && e.detail.target && e.detail.target.id === 'modal-host');
|
backdrop.parentNode.removeChild(backdrop);
|
||||||
|
currentOpts = null;
|
||||||
|
var toRestore = prevFocus;
|
||||||
|
prevFocus = null;
|
||||||
|
if (toRestore && toRestore.focus) {
|
||||||
|
try { toRestore.focus(); } catch (e) {}
|
||||||
}
|
}
|
||||||
document.addEventListener('htmx:beforeSwap', function (e) {
|
|
||||||
if (!isHost(e)) return;
|
|
||||||
hostPrevFocus = current ? detach(true) : document.activeElement;
|
|
||||||
});
|
|
||||||
document.addEventListener('htmx:afterSwap', function (e) {
|
|
||||||
if (!isHost(e)) return;
|
|
||||||
var host = e.detail.target;
|
|
||||||
var backdrop = host.querySelector('.modal-backdrop');
|
|
||||||
if (!backdrop) return;
|
|
||||||
show(backdrop, host, hostPrevFocus);
|
|
||||||
var f = backdrop.querySelector('[autofocus]') || backdrop.querySelector('input:not([type=hidden])');
|
|
||||||
if (f) {
|
|
||||||
f.focus();
|
|
||||||
if (f.select) f.select();
|
|
||||||
}
|
}
|
||||||
});
|
|
||||||
|
|
||||||
window.openModal = open;
|
window.openModal = open;
|
||||||
window.closeModal = close;
|
window.closeModal = close;
|
||||||
|
|||||||
@@ -1,96 +0,0 @@
|
|||||||
// 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;
|
|
||||||
})();
|
|
||||||
@@ -6,7 +6,7 @@ function encodePickedPath(p) {
|
|||||||
function promptPageName(title, initial, confirmLabel, onName) {
|
function promptPageName(title, initial, confirmLabel, onName) {
|
||||||
var input = document.createElement('input');
|
var input = document.createElement('input');
|
||||||
input.type = 'text';
|
input.type = 'text';
|
||||||
input.className = 'input';
|
input.className = 'modal-input';
|
||||||
input.placeholder = 'Page name';
|
input.placeholder = 'Page name';
|
||||||
if (initial) input.value = initial;
|
if (initial) input.value = initial;
|
||||||
openModal({
|
openModal({
|
||||||
@@ -44,7 +44,10 @@ function newPage() {
|
|||||||
function movePage() {
|
function movePage() {
|
||||||
var current = decodeURIComponent(window.location.pathname).replace(/\/+$/, '');
|
var current = decodeURIComponent(window.location.pathname).replace(/\/+$/, '');
|
||||||
if (!current) return;
|
if (!current) return;
|
||||||
var parent = '/' + current.split('/').filter(Boolean).slice(0, -1).join('/');
|
var segs = current.split('/').filter(Boolean);
|
||||||
|
var currentName = segs[segs.length - 1] || '';
|
||||||
|
var parent = '/' + segs.slice(0, -1).join('/');
|
||||||
|
if (parent === '/') parent = '/';
|
||||||
|
|
||||||
openTreePicker({
|
openTreePicker({
|
||||||
title: 'Move — new parent?',
|
title: 'Move — new parent?',
|
||||||
@@ -53,14 +56,44 @@ function movePage() {
|
|||||||
preselect: parent,
|
preselect: parent,
|
||||||
hideFiles: true,
|
hideFiles: true,
|
||||||
confirmLabel: 'NEXT',
|
confirmLabel: 'NEXT',
|
||||||
|
allowRoot: false,
|
||||||
onSelect: function (newParent) {
|
onSelect: function (newParent) {
|
||||||
// The server renders the name step and handles what follows
|
promptPageName('Move — new name?', currentName, 'MOVE', function (name) {
|
||||||
// (merge confirmation, errors, redirect).
|
var dest = (newParent === '/' ? '' : newParent) + '/' + name;
|
||||||
closeModal();
|
var form = document.createElement('form');
|
||||||
htmx.ajax('GET', window.location.pathname + '?dialog=move', {
|
form.method = 'POST';
|
||||||
target: '#modal-host',
|
form.action = window.location.pathname + '?move=' +
|
||||||
values: { parent: newParent }
|
encodeURIComponent(dest);
|
||||||
|
document.body.appendChild(form);
|
||||||
|
form.submit();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function deletePage() {
|
||||||
|
var decodedPath = decodeURIComponent(window.location.pathname);
|
||||||
|
|
||||||
|
openModal({
|
||||||
|
title: 'Delete page',
|
||||||
|
body: 'Delete ' + decodedPath + ' and everything inside it?',
|
||||||
|
confirm: {
|
||||||
|
label: 'DELETE',
|
||||||
|
danger: true,
|
||||||
|
enterConfirms: false,
|
||||||
|
onConfirm: function () {
|
||||||
|
var form = document.createElement('form');
|
||||||
|
form.method = 'POST';
|
||||||
|
form.action = window.location.pathname + '?delete=1';
|
||||||
|
document.body.appendChild(form);
|
||||||
|
form.submit();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
cancel: { autofocus: true },
|
||||||
|
swapButtons: true
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('DOMContentLoaded', function () {
|
||||||
|
wireDropdown(document.querySelector('[data-action="actions-drop"]'));
|
||||||
|
});
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<title>{{.Title}}</title>
|
||||||
|
<link rel="icon" href="/_/favicon.ico" />
|
||||||
|
<link rel="preload" href="/_/fonts/IosevkaEtoile.woff2" as="font" type="font/woff2" crossorigin />
|
||||||
|
<link rel="preload" href="/_/fonts/IosevkaSlab.woff2" as="font" type="font/woff2" crossorigin />
|
||||||
|
<link rel="stylesheet" href="/_/style.css" />
|
||||||
|
<script src="/_/modal.js"></script>
|
||||||
|
<script src="/_/global-shortcuts.js"></script>
|
||||||
|
<script src="/_/tree-picker.js"></script>
|
||||||
|
<script src="/_/page-actions.js"></script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header>
|
||||||
|
<nav class="breadcrumb">
|
||||||
|
<a href="/"><svg class="logo" viewBox="0 0 26.052269 26.052269" xmlns="http://www.w3.org/2000/svg"><g fill="none" stroke="currentColor" stroke-linejoin="miter" transform="matrix(0.05463483,8.1519706e-6,-8.1519706e-6,0.05463483,-64.560546,-24.6949)"><rect x="1188.537" y="457.92056" width="461.87488" height="462.15189" stroke-width="20.2288"/><path d="m1348.9955 456.59572.046 309.36839" stroke-width="19.6849"/><path d="m1200.3996 765.80237 441.8362-.0659" stroke-width="19.6849"/><path d="m1648.2897 620.244-299.2012.0446" stroke-width="20.5676"/><path d="m1491.6148 909.24806-.021-136.93117" stroke-width="19.6849"/><rect x="1191.6504" y="461.66092" width="457.09634" height="457.09634" stroke-width="19.6761"/></g></svg></a>
|
||||||
|
{{range .Crumbs}}
|
||||||
|
<span class="sep">/</span><a href="{{.URL}}">{{.Name}}</a>
|
||||||
|
{{end}}
|
||||||
|
</nav>
|
||||||
|
{{if .EditMode}}
|
||||||
|
<a class="btn" href="{{.PostURL}}">CANCEL</a>
|
||||||
|
<button class="btn" type="submit" form="edit-form" data-action="save" data-key="S" title="Save (S)">SAVE</button>
|
||||||
|
{{else if .CanEdit}}
|
||||||
|
<div class="dropdown">
|
||||||
|
<button class="btn" data-action="actions-drop" title="Actions">ACTIONS ▾</button>
|
||||||
|
<div class="dropdown-menu align-right">
|
||||||
|
<button class="btn dropdown-item" onclick="newPage()" title="New page (N)">NEW</button>
|
||||||
|
<a class="btn dropdown-item" href="?edit" title="Edit page (E)">EDIT</a>
|
||||||
|
{{if not .IsRoot}}
|
||||||
|
<button class="btn dropdown-item" onclick="movePage()" title="Move page (M)">MOVE</button>
|
||||||
|
<button class="btn dropdown-item danger" onclick="deletePage()" title="Delete page">DELETE</button>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
</header>
|
||||||
|
<main>
|
||||||
|
{{if .EditMode}}
|
||||||
|
<form id="edit-form" class="edit-form" method="POST" action="{{.PostURL}}">
|
||||||
|
{{if ge .SectionIndex 0}}<input type="hidden" name="section" value="{{.SectionIndex}}">{{end}}
|
||||||
|
<div class="editor-toolbar">
|
||||||
|
<button type="button" class="btn btn-tool" data-action="bold" data-key="B" title="Bold (B)">**</button>
|
||||||
|
<button type="button" class="btn btn-tool" data-action="italic" data-key="I" title="Italic (I)">*</button>
|
||||||
|
<span class="toolbar-sep"></span>
|
||||||
|
<button type="button" class="btn btn-tool" data-action="h1" data-key="1" title="Heading 1 (1)">#</button>
|
||||||
|
<button type="button" class="btn btn-tool" data-action="h2" data-key="2" title="Heading 2 (2)">##</button>
|
||||||
|
<button type="button" class="btn btn-tool" data-action="h3" data-key="3" title="Heading 3 (3)">###</button>
|
||||||
|
<span class="toolbar-sep"></span>
|
||||||
|
<button type="button" class="btn btn-tool" data-action="code" data-key="C" title="Inline code (C)">`</button>
|
||||||
|
<button type="button" class="btn btn-tool" data-action="codeblock" data-key="K" title="Code block (K)">```</button>
|
||||||
|
<span class="toolbar-sep"></span>
|
||||||
|
<button type="button" class="btn btn-tool" data-action="link" data-key="L" title="Link (L)">[]</button>
|
||||||
|
<button type="button" class="btn btn-tool" data-action="wikilink" data-key="P" title="Insert wiki link (P)">[[]]</button>
|
||||||
|
<button type="button" class="btn btn-tool" data-action="quote" data-key="Q" title="Blockquote (Q)">></button>
|
||||||
|
<button type="button" class="btn btn-tool" data-action="ul" data-key="U" title="Unordered list (U)">-</button>
|
||||||
|
<button type="button" class="btn btn-tool" data-action="ol" data-key="O" title="Ordered list (O)">1.</button>
|
||||||
|
<button type="button" class="btn btn-tool" data-action="hr" data-key="R" title="Horizontal rule (R)">---</button>
|
||||||
|
<span class="toolbar-sep"></span>
|
||||||
|
<button type="button" class="btn btn-tool dropdown" data-action="tbldrop" title="Table (T)">T▾</button>
|
||||||
|
<button type="button" class="btn btn-tool dropdown" data-action="datedrop" title="Insert date (D/W)">D▾</button>
|
||||||
|
<span class="toolbar-sep"></span>
|
||||||
|
<button type="button" class="btn btn-tool" data-action="movie" data-key="V" title="Import movie (V)">MV</button>
|
||||||
|
</div>
|
||||||
|
<textarea name="content" id="editor" autofocus>{{.RawContent}}</textarea>
|
||||||
|
</form>
|
||||||
|
<script src="/_/editor/lists.js"></script>
|
||||||
|
<script src="/_/editor/tables.js"></script>
|
||||||
|
<script src="/_/editor/dates.js"></script>
|
||||||
|
<script src="/_/editor/movie.js"></script>
|
||||||
|
<script src="/_/editor.js"></script>
|
||||||
|
{{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="/_/anchors.js"></script>
|
||||||
|
<script src="/_/toc.js"></script>
|
||||||
|
{{end}}
|
||||||
|
{{if .Content}}
|
||||||
|
<script src="/_/sections.js"></script>
|
||||||
|
{{end}}
|
||||||
|
{{if .Entries}}
|
||||||
|
<div class="listing">
|
||||||
|
<div class="listing-header">Contents</div>
|
||||||
|
{{range .Entries}}
|
||||||
|
<div class="listing-item">
|
||||||
|
<span class="icon">{{.Icon}}</span>
|
||||||
|
<a href="{{.URL}}">{{.Name}}</a>
|
||||||
|
<span class="meta">{{.Meta}}</span>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
{{else if not .Content}}
|
||||||
|
{{if not .SpecialContent}}
|
||||||
|
<p class="empty">Empty folder — <a href="?edit">[CREATE]</a></p>
|
||||||
|
{{end}}
|
||||||
|
{{end}}
|
||||||
|
{{end}}
|
||||||
|
</main>
|
||||||
|
{{if .SidebarWidget}}{{.SidebarWidget}}{{end}}
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
// Runs on DOMContentLoaded for a full page load, and right away when this
|
|
||||||
// script arrives with a boosted navigation (the document is loaded then).
|
|
||||||
(function () {
|
|
||||||
function run() {
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
if (document.readyState === "loading") document.addEventListener("DOMContentLoaded", run);
|
|
||||||
else run();
|
|
||||||
})();
|
|
||||||
@@ -1,93 +0,0 @@
|
|||||||
{{/* Server-rendered dialogs, fetched with ?dialog=<name> and swapped into
|
|
||||||
#modal-host by htmx; modal.js supplies the chrome (backdrop, Escape, drag,
|
|
||||||
focus). Each dialog is a form that posts the action it offers, so the client
|
|
||||||
never builds action URLs itself. Responses to those posts either redirect
|
|
||||||
(HX-Redirect), replace the dialog with the next step (e.g. the merge
|
|
||||||
confirmation), or fail with a plain-text error. */}}
|
|
||||||
|
|
||||||
{{define "delete"}}<div class="modal-backdrop">
|
|
||||||
<form class="modal panel panel-floating" role="dialog" aria-modal="true" aria-labelledby="modal-title" hx-post="?delete=1" hx-target="#modal-host">
|
|
||||||
<div class="panel-header"><span id="modal-title">Delete page</span></div>
|
|
||||||
<div class="panel-body">Delete {{.Path}} and everything inside it?</div>
|
|
||||||
<div class="panel-footer">
|
|
||||||
<button class="btn danger" type="submit">DELETE</button>
|
|
||||||
<button class="btn" type="button" data-modal-close autofocus>CANCEL</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>{{end}}
|
|
||||||
|
|
||||||
{{define "canvas"}}<div class="modal-backdrop">
|
|
||||||
<form class="modal panel panel-floating" role="dialog" aria-modal="true" aria-labelledby="modal-title" hx-post="?canvas" hx-target="#modal-host">
|
|
||||||
<div class="panel-header"><span id="modal-title">New canvas — name?</span></div>
|
|
||||||
<div class="panel-body">
|
|
||||||
<input class="input" type="text" name="name" placeholder="Canvas name" required autofocus />
|
|
||||||
</div>
|
|
||||||
<div class="panel-footer">
|
|
||||||
<button class="btn" type="button" data-modal-close>CANCEL</button>
|
|
||||||
<button class="btn" type="submit">CREATE</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>{{end}}
|
|
||||||
|
|
||||||
{{define "move"}}<div class="modal-backdrop">
|
|
||||||
<form class="modal panel panel-floating" role="dialog" aria-modal="true" aria-labelledby="modal-title" hx-post="?move" hx-target="#modal-host">
|
|
||||||
<div class="panel-header"><span id="modal-title">Move — new name?</span></div>
|
|
||||||
<div class="panel-body">
|
|
||||||
<input type="hidden" name="parent" value="{{.Parent}}" />
|
|
||||||
<input class="input" type="text" name="name" value="{{.Name}}" placeholder="Page name" required autofocus />
|
|
||||||
</div>
|
|
||||||
<div class="panel-footer">
|
|
||||||
<button class="btn" type="button" data-modal-close>CANCEL</button>
|
|
||||||
<button class="btn" type="submit">MOVE</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>{{end}}
|
|
||||||
|
|
||||||
{{define "merge"}}<div class="modal-backdrop">
|
|
||||||
<form class="modal panel panel-floating" role="dialog" aria-modal="true" aria-labelledby="modal-title" hx-post="?move" hx-target="#modal-host">
|
|
||||||
<div class="panel-header"><span id="modal-title">Merge folders?</span></div>
|
|
||||||
<div class="panel-body">
|
|
||||||
The destination folder already exists but has no page of its own. Merge this page and its contents into it?
|
|
||||||
<input type="hidden" name="move" value="{{.Dest}}" />
|
|
||||||
<input type="hidden" name="merge" value="1" />
|
|
||||||
</div>
|
|
||||||
<div class="panel-footer">
|
|
||||||
<button class="btn" type="button" data-modal-close>CANCEL</button>
|
|
||||||
<button class="btn" type="submit" autofocus>MERGE</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>{{end}}
|
|
||||||
|
|
||||||
{{define "view"}}<div class="modal-backdrop">
|
|
||||||
<form class="modal panel panel-floating" role="dialog" aria-modal="true" aria-labelledby="modal-title" hx-post="?settings" hx-target="#modal-host">
|
|
||||||
<div class="panel-header"><span id="modal-title">View settings</span></div>
|
|
||||||
<div class="panel-body col">
|
|
||||||
<fieldset class="col gap-1">
|
|
||||||
<legend class="caption">View style</legend>
|
|
||||||
<div class="row gap-1 segmented">
|
|
||||||
<label class="btn"><input type="radio" name="view" value="list"{{if eq .View "list"}} checked{{end}} />List</label>
|
|
||||||
<label class="btn"><input type="radio" name="view" value="thumbnail"{{if eq .View "thumbnail"}} checked{{end}} />Thumbnail</label>
|
|
||||||
</div>
|
|
||||||
</fieldset>
|
|
||||||
<label class="col gap-1">
|
|
||||||
<span class="caption">Sort by</span>
|
|
||||||
<select class="input" name="sort">
|
|
||||||
<option value="name"{{if eq .Sort "name"}} selected{{end}}>Name</option>
|
|
||||||
<option value="modified"{{if eq .Sort "modified"}} selected{{end}}>Modified</option>
|
|
||||||
<option value="size"{{if eq .Sort "size"}} selected{{end}}>Size</option>
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
<fieldset class="col gap-1">
|
|
||||||
<legend class="caption">Order</legend>
|
|
||||||
<div class="row gap-1 segmented">
|
|
||||||
<label class="btn"><input type="radio" name="order" value="asc"{{if eq .Order "asc"}} checked{{end}} />Asc</label>
|
|
||||||
<label class="btn"><input type="radio" name="order" value="desc"{{if eq .Order "desc"}} checked{{end}} />Desc</label>
|
|
||||||
</div>
|
|
||||||
</fieldset>
|
|
||||||
</div>
|
|
||||||
<div class="panel-footer">
|
|
||||||
<button class="btn" type="button" data-modal-close>CANCEL</button>
|
|
||||||
<button class="btn" type="submit" autofocus>SAVE</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>{{end}}
|
|
||||||
@@ -1,57 +0,0 @@
|
|||||||
{{define "headerActions"}}{{if .CanEdit}}
|
|
||||||
<button class="btn btn-block" onclick="newPage()" title="New page (N)"><span class="btn-icon">{{icon "new"}}</span><span class="btn-label">NEW</span></button>
|
|
||||||
<button class="btn btn-block" hx-get="?dialog=canvas" hx-target="#modal-host" title="New canvas on this page"><span class="btn-icon">{{icon "canvas"}}</span><span class="btn-label">CANVAS</span></button>
|
|
||||||
<button class="btn btn-block" data-companion-reveal hidden title="Reveal in file manager"><span class="btn-icon">{{icon "reveal"}}</span><span class="btn-label">REVEAL</span></button>
|
|
||||||
{{if not .IsRoot}}
|
|
||||||
<button class="btn btn-block" onclick="movePage()" title="Move page (M)"><span class="btn-icon">{{icon "move"}}</span><span class="btn-label">MOVE</span></button>
|
|
||||||
<button class="btn btn-block danger" hx-get="?dialog=delete" hx-target="#modal-host" title="Delete page"><span class="btn-icon">{{icon "delete"}}</span><span class="btn-label">DELETE</span></button>
|
|
||||||
{{end}}
|
|
||||||
<a class="btn btn-block" href="?edit" title="Edit page (E)"><span class="btn-icon">{{icon "edit"}}</span><span class="btn-label">EDIT</span></a>
|
|
||||||
{{end}}{{end}}
|
|
||||||
|
|
||||||
{{define "content"}}
|
|
||||||
{{if .Content}}
|
|
||||||
<div class="content">{{.Content}}</div>
|
|
||||||
{{end}}
|
|
||||||
{{if .SpecialContent}}
|
|
||||||
<div class="content">{{.SpecialContent}}</div>
|
|
||||||
{{end}}
|
|
||||||
{{if not (or .Content .SpecialContent)}}
|
|
||||||
<p class="empty">{{if .Entries}}No page{{else}}Empty folder{{end}} — <a href="?edit">[CREATE]</a></p>
|
|
||||||
{{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" hx-get="?dialog=view" hx-target="#modal-host" title="View & sorting">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}}
|
|
||||||
{{end}}
|
|
||||||
{{if or .Content .SpecialContent}}
|
|
||||||
<script src="{{asset "page/content.js"}}"></script>
|
|
||||||
{{if not .SuppressTOC}}<script src="{{asset "page/toc.js"}}"></script>{{end}}
|
|
||||||
{{end}}
|
|
||||||
{{if .Content}}
|
|
||||||
<script src="{{asset "page/sections.js"}}"></script>
|
|
||||||
{{end}}
|
|
||||||
<script src="{{asset "page/contents.js"}}"></script>
|
|
||||||
{{end}}
|
|
||||||
|
|
||||||
{{define "aside"}}{{if .SidebarWidget}}<aside class="sidebar">{{.SidebarWidget}}</aside>{{end}}{{end}}
|
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
// Runs on DOMContentLoaded for a full page load, and right away when this
|
|
||||||
// script arrives with a boosted navigation (the document is loaded then).
|
|
||||||
(function () {
|
|
||||||
function run() {
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
if (document.readyState === "loading") document.addEventListener("DOMContentLoaded", run);
|
|
||||||
else run();
|
|
||||||
})();
|
|
||||||
@@ -1,93 +0,0 @@
|
|||||||
<!doctype html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8" />
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
||||||
<title>Save link</title>
|
|
||||||
<link rel="icon" href="{{asset "favicon.ico"}}" />
|
|
||||||
<link rel="stylesheet" href="{{asset "style.css"}}" />
|
|
||||||
<style>
|
|
||||||
body { padding: 0.8rem; }
|
|
||||||
.qa-form { display: flex; flex-direction: column; gap: 0.5rem; }
|
|
||||||
.qa-row { display: flex; flex-direction: column; gap: 0.1rem; }
|
|
||||||
.qa-label { font-size: 0.7rem; }
|
|
||||||
.qa-value { word-break: break-all; font-size: 0.85rem; }
|
|
||||||
.qa-comment {
|
|
||||||
width: 100%;
|
|
||||||
padding: 0.35rem;
|
|
||||||
background: var(--bg-panel);
|
|
||||||
color: var(--text);
|
|
||||||
border: 1px solid var(--bg-panel-hover);
|
|
||||||
font: inherit;
|
|
||||||
}
|
|
||||||
.qa-actions { display: flex; gap: 1rem; }
|
|
||||||
.qa-status { min-height: 1em; font-size: 0.85rem; }
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<form id="qa-form" class="qa-form"
|
|
||||||
data-to="{{.To}}" data-url="{{.URL}}" data-title="{{.Title}}">
|
|
||||||
<div class="qa-row">
|
|
||||||
<span class="qa-label muted">Save to</span>
|
|
||||||
<span class="qa-value">{{.To}}</span>
|
|
||||||
</div>
|
|
||||||
<div class="qa-row">
|
|
||||||
<span class="qa-label muted">Title</span>
|
|
||||||
<span class="qa-value">{{.Title}}</span>
|
|
||||||
</div>
|
|
||||||
<div class="qa-row">
|
|
||||||
<span class="qa-label muted">URL</span>
|
|
||||||
<span class="qa-value">{{.URL}}</span>
|
|
||||||
</div>
|
|
||||||
<div class="qa-row">
|
|
||||||
<label class="qa-label muted" for="qa-comment">Comment</label>
|
|
||||||
<input id="qa-comment" name="comment" type="text" class="qa-comment" autofocus />
|
|
||||||
</div>
|
|
||||||
<div class="qa-actions">
|
|
||||||
<button type="submit" class="btn">SAVE</button>
|
|
||||||
<button type="button" class="btn" id="qa-cancel">CANCEL</button>
|
|
||||||
</div>
|
|
||||||
<div id="qa-status" class="qa-status muted"></div>
|
|
||||||
</form>
|
|
||||||
<script>
|
|
||||||
(function () {
|
|
||||||
const form = document.getElementById("qa-form");
|
|
||||||
const status = document.getElementById("qa-status");
|
|
||||||
const comment = document.getElementById("qa-comment");
|
|
||||||
document
|
|
||||||
.getElementById("qa-cancel")
|
|
||||||
.addEventListener("click", () => window.close());
|
|
||||||
form.addEventListener("submit", async (ev) => {
|
|
||||||
ev.preventDefault();
|
|
||||||
status.classList.remove("danger");
|
|
||||||
status.classList.add("muted");
|
|
||||||
status.textContent = "Saving…";
|
|
||||||
const body = new URLSearchParams();
|
|
||||||
body.set("url", form.dataset.url);
|
|
||||||
body.set("title", form.dataset.title);
|
|
||||||
body.set("comment", comment.value);
|
|
||||||
try {
|
|
||||||
const res = await fetch(form.dataset.to + "?append", {
|
|
||||||
method: "POST",
|
|
||||||
body,
|
|
||||||
credentials: "same-origin",
|
|
||||||
});
|
|
||||||
if (!res.ok) {
|
|
||||||
const text = (await res.text()).trim();
|
|
||||||
status.classList.remove("muted");
|
|
||||||
status.classList.add("danger");
|
|
||||||
status.textContent = text || "HTTP " + res.status;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
status.textContent = "Saved ✓";
|
|
||||||
setTimeout(() => window.close(), 1000);
|
|
||||||
} catch (e) {
|
|
||||||
status.classList.remove("muted");
|
|
||||||
status.classList.add("danger");
|
|
||||||
status.textContent = (e && e.message) || "Network error";
|
|
||||||
}
|
|
||||||
});
|
|
||||||
})();
|
|
||||||
</script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@@ -1,66 +0,0 @@
|
|||||||
// 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. Boosted
|
|
||||||
// navigation replaces .center and changes the page without a reload, so
|
|
||||||
// listen on document and resolve both when writing.
|
|
||||||
var pending = false;
|
|
||||||
document.addEventListener('scroll', function (e) {
|
|
||||||
if (pending || !e.target.classList || !e.target.classList.contains('center')) return;
|
|
||||||
pending = true;
|
|
||||||
requestAnimationFrame(function () {
|
|
||||||
pending = false;
|
|
||||||
center = document.querySelector('.center');
|
|
||||||
if (!center) return;
|
|
||||||
var m = maxScroll();
|
|
||||||
try {
|
|
||||||
sessionStorage.setItem('scrollsync:' + location.pathname,
|
|
||||||
JSON.stringify({ f: m > 0 ? center.scrollTop / m : 0, t: Date.now() }));
|
|
||||||
} catch (e) {}
|
|
||||||
});
|
|
||||||
}, { capture: true, passive: true });
|
|
||||||
})();
|
|
||||||
@@ -1,150 +0,0 @@
|
|||||||
// search-suggest.js — instant typeahead dropdown.
|
|
||||||
//
|
|
||||||
// Exposes window.attachSuggestions(inputEl, opts) used by both the header
|
|
||||||
// search box and the editor's "Insert link" modal. Fetching is htmx: the input
|
|
||||||
// requests /_search as you type (debounced; a newer request replaces an
|
|
||||||
// in-flight one) and the server renders the rows (assets/search/suggest.html).
|
|
||||||
// This file owns the dropdown's open/close lifecycle and keyboard handling.
|
|
||||||
//
|
|
||||||
// opts:
|
|
||||||
// onPick({name}) — optional; called when the user selects a row.
|
|
||||||
// Without it rows are plain links and navigate.
|
|
||||||
// showFooter (bool) — show the "Show all N matches" footer row
|
|
||||||
// container (Element) — optional parent (defaults to inputEl.parentNode)
|
|
||||||
(function () {
|
|
||||||
var seq = 0;
|
|
||||||
|
|
||||||
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';
|
|
||||||
dropdown.id = 'suggest-' + (++seq);
|
|
||||||
host.appendChild(dropdown);
|
|
||||||
|
|
||||||
// htmx sends the input's value under its name.
|
|
||||||
if (!inputEl.name) inputEl.name = 'q';
|
|
||||||
inputEl.setAttribute('hx-get', '/_search' + (opts.showFooter ? '?footer' : ''));
|
|
||||||
inputEl.setAttribute('hx-trigger', 'input changed delay:100ms');
|
|
||||||
inputEl.setAttribute('hx-sync', 'this:replace');
|
|
||||||
inputEl.setAttribute('hx-target', '#' + dropdown.id);
|
|
||||||
htmx.process(inputEl);
|
|
||||||
|
|
||||||
var activeIdx = -1;
|
|
||||||
|
|
||||||
function rows() {
|
|
||||||
return dropdown.querySelectorAll('.suggest-row');
|
|
||||||
}
|
|
||||||
|
|
||||||
function isOpen() {
|
|
||||||
return dropdown.classList.contains('is-open');
|
|
||||||
}
|
|
||||||
|
|
||||||
function open() {
|
|
||||||
if (dropdown.firstElementChild) dropdown.classList.add('is-open');
|
|
||||||
}
|
|
||||||
|
|
||||||
function close() {
|
|
||||||
dropdown.classList.remove('is-open');
|
|
||||||
setActive(-1);
|
|
||||||
}
|
|
||||||
|
|
||||||
function setActive(idx) {
|
|
||||||
var rs = rows();
|
|
||||||
if (rs[activeIdx]) rs[activeIdx].classList.remove('is-active');
|
|
||||||
activeIdx = idx;
|
|
||||||
if (rs[idx]) {
|
|
||||||
rs[idx].classList.add('is-active');
|
|
||||||
try { rs[idx].scrollIntoView({ block: 'nearest' }); } catch (e) {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function moveActive(delta) {
|
|
||||||
var n = rows().length;
|
|
||||||
if (n === 0) return;
|
|
||||||
var next = activeIdx + delta;
|
|
||||||
if (next < 0) next = n - 1;
|
|
||||||
if (next >= n) next = 0;
|
|
||||||
setActive(next);
|
|
||||||
}
|
|
||||||
|
|
||||||
// pick activates a row: onPick for data rows when given, otherwise
|
|
||||||
// (and always for the footer) the row's link.
|
|
||||||
function pick(row) {
|
|
||||||
close();
|
|
||||||
if (opts.onPick && !row.classList.contains('suggest-footer')) {
|
|
||||||
opts.onPick({ name: row.dataset.name });
|
|
||||||
} else {
|
|
||||||
row.querySelector('a').click();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
dropdown.addEventListener('htmx:afterSwap', function () {
|
|
||||||
activeIdx = -1;
|
|
||||||
if (document.activeElement === inputEl && dropdown.firstElementChild) {
|
|
||||||
open();
|
|
||||||
} else {
|
|
||||||
close();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// mousedown keeps focus in the input so blur doesn't close the
|
|
||||||
// dropdown before the click lands.
|
|
||||||
dropdown.addEventListener('mousedown', function (e) { e.preventDefault(); });
|
|
||||||
dropdown.addEventListener('click', function (e) {
|
|
||||||
var row = e.target.closest('.suggest-row');
|
|
||||||
if (!row) return;
|
|
||||||
if (opts.onPick && !row.classList.contains('suggest-footer')) {
|
|
||||||
e.preventDefault();
|
|
||||||
pick(row);
|
|
||||||
} else {
|
|
||||||
close(); // the link navigates
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
inputEl.addEventListener('focus', open);
|
|
||||||
inputEl.addEventListener('blur', close);
|
|
||||||
inputEl.addEventListener('keydown', function (e) {
|
|
||||||
if (!isOpen()) return;
|
|
||||||
if (e.key === 'ArrowDown') {
|
|
||||||
e.preventDefault();
|
|
||||||
moveActive(1);
|
|
||||||
} else if (e.key === 'ArrowUp') {
|
|
||||||
e.preventDefault();
|
|
||||||
moveActive(-1);
|
|
||||||
} else if (e.key === 'Escape') {
|
|
||||||
e.preventDefault();
|
|
||||||
close();
|
|
||||||
} else if (e.key === 'Enter') {
|
|
||||||
// Without an active row: native submit (full results page).
|
|
||||||
var row = rows()[activeIdx];
|
|
||||||
if (row) {
|
|
||||||
e.preventDefault();
|
|
||||||
pick(row);
|
|
||||||
}
|
|
||||||
} else if (e.key === 'Tab') {
|
|
||||||
if (rows().length === 0) return;
|
|
||||||
e.preventDefault();
|
|
||||||
moveActive(e.shiftKey ? -1 : 1);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
close: close,
|
|
||||||
destroy: function () {
|
|
||||||
if (dropdown.parentNode) dropdown.parentNode.removeChild(dropdown);
|
|
||||||
host.classList.remove('suggest-host');
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
window.attachSuggestions = attachSuggestions;
|
|
||||||
|
|
||||||
document.addEventListener('DOMContentLoaded', function () {
|
|
||||||
attachSuggestions(document.querySelector('header .search-input'), { showFooter: true });
|
|
||||||
});
|
|
||||||
})();
|
|
||||||
@@ -1,83 +0,0 @@
|
|||||||
function rebuildIndex() {
|
|
||||||
openModal({ title: 'Rebuilding search index…', body: 'Walking the wiki tree.' });
|
|
||||||
fetch('/_reindex', { method: 'POST' })
|
|
||||||
.then(function (resp) {
|
|
||||||
if (!resp.ok) throw new Error('rebuild failed: ' + resp.status);
|
|
||||||
window.location.href = window.location.href;
|
|
||||||
})
|
|
||||||
.catch(function (err) {
|
|
||||||
closeModal();
|
|
||||||
openModal({ title: 'Rebuild failed', body: String(err), confirm: { label: 'OK' } });
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
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';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// The "create page" link on results pages; delegated, since boosted
|
|
||||||
// navigation swaps the results in without a new DOMContentLoaded.
|
|
||||||
document.addEventListener('click', function (e) {
|
|
||||||
var createLink = e.target.closest && e.target.closest('[data-create-page]');
|
|
||||||
if (!createLink) return;
|
|
||||||
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, …
|
|
||||||
document.addEventListener('DOMContentLoaded', function () {
|
|
||||||
var input = document.querySelector('.search-input');
|
|
||||||
if (input && input.value) {
|
|
||||||
input.focus();
|
|
||||||
var end = input.value.length;
|
|
||||||
try { input.setSelectionRange(end, end); } catch (e) {}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// The header (and its search box) survives boosted navigation: show the
|
|
||||||
// current page's query, as a full load would.
|
|
||||||
document.addEventListener('page:swapped', function () {
|
|
||||||
var input = document.querySelector('.search-input');
|
|
||||||
if (input && document.activeElement !== input) {
|
|
||||||
input.value = new URLSearchParams(location.search).get('q') || '';
|
|
||||||
}
|
|
||||||
});
|
|
||||||
@@ -1,49 +0,0 @@
|
|||||||
{{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 “{{.Query}}” — <button class="btn btn-small" data-create-page="{{.Query}}">create it</button></p>
|
|
||||||
{{end}}
|
|
||||||
{{if .Pages}}
|
|
||||||
<h2 class="search-section">Matching Pages <span class="muted">{{.PageTotal}}</span></h2>
|
|
||||||
{{range .Pages}}
|
|
||||||
<article class="search-card">
|
|
||||||
<a href="{{.URL}}">{{.Name}}</a>
|
|
||||||
<div class="muted">/{{.Path}}</div>
|
|
||||||
</article>
|
|
||||||
{{end}}
|
|
||||||
{{end}}
|
|
||||||
{{if .Files}}
|
|
||||||
<h2 class="search-section">Matching Files <span class="muted">{{.FileTotal}}</span></h2>
|
|
||||||
{{range .Files}}
|
|
||||||
<article class="search-card">
|
|
||||||
<div class="row">
|
|
||||||
{{fileIcon .Name}}
|
|
||||||
<a href="{{.URL}}">{{.Name}}</a>
|
|
||||||
<button class="btn btn-small" data-companion-file-reveal="{{.Path}}" hidden title="Open location in file manager">open</button>
|
|
||||||
</div>
|
|
||||||
<div class="muted">/{{.Path}} · {{.Meta}}</div>
|
|
||||||
</article>
|
|
||||||
{{end}}
|
|
||||||
{{end}}
|
|
||||||
{{else}}
|
|
||||||
<p class="empty">Enter a query above.</p>
|
|
||||||
{{end}}
|
|
||||||
{{end}}
|
|
||||||
|
|
||||||
{{define "headerActions"}}
|
|
||||||
<button class="btn btn-block" onclick="rebuildIndex()" title="Rebuild search index"><span class="btn-icon">{{icon "rebuild"}}</span><span class="btn-label">REBUILD INDEX</span></button>
|
|
||||||
{{end}}
|
|
||||||
|
|
||||||
{{define "footerExtras"}}
|
|
||||||
{{if not .IndexBuiltAt.IsZero}}<span class="muted">· Index: {{.IndexBuiltAt.Format "2006-01-02 15:04"}}</span>{{end}}
|
|
||||||
{{end}}
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
{{/* Search typeahead dropdown (/_search, htmx requests). Rows are links, so a
|
|
||||||
pick in the header is plain navigation; the editor's link dialog intercepts
|
|
||||||
the click and reads data-name. Empty output closes the dropdown. */}}
|
|
||||||
{{define "suggest"}}{{if .Show}}<table class="data-table"><tbody>
|
|
||||||
{{range .Results}}<tr class="suggest-row" data-name="{{.Name}}"><td><a class="suggest-link" href="{{.URL}}" tabindex="-1"><span class="suggest-name">{{.Highlighted}}</span><span class="suggest-path">/{{.Path}}</span></a></td></tr>
|
|
||||||
{{else}}<tr class="is-empty"><td>No matches</td></tr>
|
|
||||||
{{end}}{{if .AllURL}}<tr class="suggest-row suggest-footer"><td><a class="suggest-link" href="{{.AllURL}}" tabindex="-1">Show all {{.Total}} matches</a></td></tr>
|
|
||||||
{{end}}</tbody></table>{{end}}{{end}}
|
|
||||||
@@ -4,19 +4,12 @@
|
|||||||
var headings = content.querySelectorAll('h1, h2, h3, h4, h5, h6');
|
var headings = content.querySelectorAll('h1, h2, h3, h4, h5, h6');
|
||||||
if (!headings.length) return;
|
if (!headings.length) return;
|
||||||
|
|
||||||
|
// Section 0 is pre-heading content, editable via full-page edit.
|
||||||
|
// Sections 1..N each start at a heading; that is the index sent to the server.
|
||||||
headings.forEach(function (h, i) {
|
headings.forEach(function (h, i) {
|
||||||
if (h.querySelector('a.btn')) return;
|
|
||||||
var a = document.createElement('a');
|
var a = document.createElement('a');
|
||||||
|
a.href = '?edit§ion=' + (i + 1);
|
||||||
if (h.tagName === 'H1') {
|
a.className = 'btn btn-small';
|
||||||
a.href = '?edit';
|
|
||||||
} else if (h.tagName === 'H2') {
|
|
||||||
a.href = '?edit§ion=' + (i + 1) + '&span';
|
|
||||||
} else {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
a.className = 'btn btn-small btn-float';
|
|
||||||
a.textContent = 'edit';
|
a.textContent = 'edit';
|
||||||
h.appendChild(document.createTextNode(' '))
|
h.appendChild(document.createTextNode(' '))
|
||||||
h.appendChild(a);
|
h.appendChild(a);
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
(function () {
|
||||||
|
var content = document.querySelector("main");
|
||||||
|
if (!content) return;
|
||||||
|
|
||||||
|
var headings = content.querySelectorAll("h1, h2, h3");
|
||||||
|
if (headings.length < 2) return;
|
||||||
|
|
||||||
|
var nav = document.createElement("nav");
|
||||||
|
nav.className = "toc";
|
||||||
|
|
||||||
|
var header = document.createElement("div");
|
||||||
|
header.className = "panel-header";
|
||||||
|
header.textContent = "Contents";
|
||||||
|
nav.appendChild(header);
|
||||||
|
|
||||||
|
var list = document.createElement("ul");
|
||||||
|
headings.forEach(function (h) {
|
||||||
|
if (!h.id) return;
|
||||||
|
var li = document.createElement("li");
|
||||||
|
li.className = "toc-" + h.tagName.toLowerCase();
|
||||||
|
var a = document.createElement("a");
|
||||||
|
a.href = "#" + h.id;
|
||||||
|
var clone = h.cloneNode(true);
|
||||||
|
clone.querySelectorAll(".btn, .muted, .heading-anchor").forEach(function (el) { el.remove(); });
|
||||||
|
a.textContent = clone.textContent.trim();
|
||||||
|
li.appendChild(a);
|
||||||
|
list.appendChild(li);
|
||||||
|
});
|
||||||
|
nav.appendChild(list);
|
||||||
|
|
||||||
|
var toggle = document.createElement("button");
|
||||||
|
toggle.type = "button";
|
||||||
|
toggle.className = "panel-toggle";
|
||||||
|
toggle.textContent = "Contents";
|
||||||
|
toggle.setAttribute("aria-expanded", "false");
|
||||||
|
toggle.addEventListener("click", function () {
|
||||||
|
var open = nav.classList.toggle("is-open");
|
||||||
|
toggle.setAttribute("aria-expanded", open ? "true" : "false");
|
||||||
|
});
|
||||||
|
|
||||||
|
var main = document.querySelector("main");
|
||||||
|
if (main) {
|
||||||
|
main.parentNode.insertBefore(toggle, main);
|
||||||
|
main.parentNode.insertBefore(nav, main);
|
||||||
|
} else {
|
||||||
|
document.body.appendChild(toggle);
|
||||||
|
document.body.appendChild(nav);
|
||||||
|
}
|
||||||
|
|
||||||
|
var pageHeader = document.querySelector("header");
|
||||||
|
function updateTop() {
|
||||||
|
if (!pageHeader || getComputedStyle(nav).position !== "fixed") return;
|
||||||
|
var rect = pageHeader.getBoundingClientRect();
|
||||||
|
nav.style.top = Math.max(8, rect.bottom + 8) + "px";
|
||||||
|
}
|
||||||
|
window.addEventListener("scroll", updateTop, { passive: true });
|
||||||
|
window.addEventListener("resize", updateTop);
|
||||||
|
updateTop();
|
||||||
|
})();
|
||||||
@@ -1,29 +1,43 @@
|
|||||||
// openTreePicker shows a modal to pick a wiki path. The tree is server-rendered
|
|
||||||
// (?tree&pick=…, see tree.go and tree.html): each selectable row carries a
|
|
||||||
// radio named "pick", folders expand natively and lazy-load via htmx. This
|
|
||||||
// file only hosts the tree in a modal and reports the checked row.
|
|
||||||
//
|
|
||||||
// opts: title, confirmLabel, mode ('folder' | 'file' | 'any'), initialPath
|
|
||||||
// (expanded on open), preselect (checked on open), hideFiles, allowRoot
|
|
||||||
// (default true: offer "/" in folder modes), onSelect(path, kind).
|
|
||||||
(function () {
|
(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) {
|
function openTreePicker(opts) {
|
||||||
opts = opts || {};
|
opts = opts || {};
|
||||||
var mode = opts.mode || 'folder';
|
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 container = document.createElement('div');
|
||||||
|
|
||||||
var treeEl = document.createElement('div');
|
var treeEl = document.createElement('div');
|
||||||
treeEl.className = 'tree-picker panel';
|
treeEl.className = 'tree-picker';
|
||||||
treeEl.textContent = '…';
|
|
||||||
var selectedPathEl = document.createElement('div');
|
var selectedPathEl = document.createElement('div');
|
||||||
selectedPathEl.className = 'tree-selected-path muted';
|
selectedPathEl.className = 'tree-selected-path muted';
|
||||||
selectedPathEl.textContent = ' ';
|
selectedPathEl.textContent = '\u00a0';
|
||||||
|
|
||||||
container.appendChild(treeEl);
|
container.appendChild(treeEl);
|
||||||
container.appendChild(selectedPathEl);
|
container.appendChild(selectedPathEl);
|
||||||
|
|
||||||
function checked() {
|
var selected = null; // { path, kind, rowEl }
|
||||||
return treeEl.querySelector('input[name="pick"]:checked');
|
|
||||||
}
|
|
||||||
|
|
||||||
var handle = openModal({
|
var handle = openModal({
|
||||||
title: opts.title || 'Pick',
|
title: opts.title || 'Pick',
|
||||||
@@ -32,34 +46,237 @@
|
|||||||
label: opts.confirmLabel || 'SELECT',
|
label: opts.confirmLabel || 'SELECT',
|
||||||
initiallyDisabled: true,
|
initiallyDisabled: true,
|
||||||
onConfirm: function () {
|
onConfirm: function () {
|
||||||
var pick = checked();
|
if (!selected) return;
|
||||||
if (!pick) return;
|
|
||||||
handle.close();
|
handle.close();
|
||||||
if (opts.onSelect) opts.onSelect(pick.value, pick.dataset.kind);
|
if (opts.onSelect) opts.onSelect(selected.path, selected.kind);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
function sync() {
|
function setSelected(path, kind, rowEl) {
|
||||||
var pick = checked();
|
if (selected && selected.rowEl) selected.rowEl.classList.remove('is-selected');
|
||||||
selectedPathEl.textContent = pick ? pick.value : ' ';
|
selected = { path: path, kind: kind, rowEl: rowEl };
|
||||||
handle.setConfirmDisabled(!pick);
|
rowEl.classList.add('is-selected');
|
||||||
|
selectedPathEl.textContent = path;
|
||||||
|
handle.setConfirmDisabled(false);
|
||||||
}
|
}
|
||||||
treeEl.addEventListener('change', sync);
|
|
||||||
|
|
||||||
var url = '/?tree&pick=' + mode +
|
function isSelectable(kind) {
|
||||||
(opts.hideFiles ? '' : '&files=1') +
|
if (mode === 'any') return true;
|
||||||
(opts.allowRoot !== false ? '&root=1' : '') +
|
if (mode === 'folder') return kind === 'folder';
|
||||||
'&expandTo=' + encodeURIComponent(opts.initialPath || '/') +
|
if (mode === 'file') return kind === 'file';
|
||||||
(opts.preselect ? '&select=' + encodeURIComponent(opts.preselect) : '');
|
return false;
|
||||||
htmx.ajax('GET', url, { target: treeEl, swap: 'innerHTML' }).then(function () {
|
}
|
||||||
sync();
|
|
||||||
var pick = checked();
|
// buildRow returns { rowEl, expand(): Promise }. `expand` is a no-op
|
||||||
if (pick) {
|
// for files and idempotent for folders (resolves with the already-
|
||||||
try { pick.closest('.tree-row').scrollIntoView({ block: 'nearest' }); } catch (e) {}
|
// 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 marker = document.createElement('span');
|
||||||
|
marker.className = 'tree-marker';
|
||||||
|
marker.textContent = kind === 'folder' ? '' : '\u00b7';
|
||||||
|
row.appendChild(marker);
|
||||||
|
|
||||||
|
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();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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 rootMarker = document.createElement('span');
|
||||||
|
rootMarker.className = 'tree-marker';
|
||||||
|
rootRow.appendChild(rootMarker);
|
||||||
|
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;
|
return handle;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,18 +0,0 @@
|
|||||||
{{/* Folder tree rows (tree.go), shared by the sidebar and the tree picker.
|
|
||||||
Expandable folders are native <details>: open/close needs no script, and a
|
|
||||||
folder's children load via htmx the first time it opens, unless the server
|
|
||||||
pre-expanded them. A label inside <summary> selects its radio without
|
|
||||||
toggling the folder; the rest of the row toggles. */}}
|
|
||||||
|
|
||||||
{{define "rows"}}{{range .}}{{if .Expandable}}<details class="tree-node"{{if .Children}} open{{end}}>
|
|
||||||
<summary class="{{template "row-class" .}}"><span class="tree-chevron" aria-hidden="true"></span>{{template "label" .}}</summary>
|
|
||||||
<div class="tree-children"{{if not .Children}} hx-get="{{.ChildrenURL}}" hx-trigger="toggle once from:closest details"{{end}}>{{template "rows" .Children}}</div>
|
|
||||||
</details>
|
|
||||||
{{else}}<div class="{{template "row-class" .}}"><span class="tree-chevron is-leaf" aria-hidden="true"></span>{{template "label" .}}</div>
|
|
||||||
{{end}}{{end}}{{end}}
|
|
||||||
|
|
||||||
{{define "row-class"}}tree-row{{if .Active}} is-active{{end}}{{if and (not .Link) (not .Selectable)}} is-disabled{{end}}{{end}}
|
|
||||||
|
|
||||||
{{define "label"}}{{if .Link}}<a class="tree-name tree-{{.Kind}}" href="{{.URL}}">{{.Name}}</a>
|
|
||||||
{{- else if .Selectable}}<label class="tree-name"><input type="radio" name="pick" value="{{.Path}}" data-kind="{{.Kind}}"{{if .Checked}} checked{{end}} />{{.Name}}</label>
|
|
||||||
{{- else}}<span class="tree-name">{{.Name}}</span>{{end}}{{end}}
|
|
||||||
@@ -1,677 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"log"
|
|
||||||
"math"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"regexp"
|
|
||||||
"sort"
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
"sync"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/emersion/go-ical"
|
|
||||||
"github.com/teambition/rrule-go"
|
|
||||||
)
|
|
||||||
|
|
||||||
// calendarsDir is the absolute path of the calendars folder (-calendars).
|
|
||||||
// Empty disables the diary lines, the maintenance widget and the sync. Every
|
|
||||||
// calendar is one <Name>.ics file in this folder; the folder's .page-settings
|
|
||||||
// assigns calendars to roles and holds the CalDAV sync credentials.
|
|
||||||
var calendarsDir string
|
|
||||||
|
|
||||||
// === Settings ===
|
|
||||||
|
|
||||||
type calendarSettings struct {
|
|
||||||
// Roles: calendar names (file basenames without .ics).
|
|
||||||
Diary []string
|
|
||||||
Weight []string
|
|
||||||
Maintenance []string
|
|
||||||
|
|
||||||
CaldavURL string
|
|
||||||
CaldavUser string
|
|
||||||
CaldavPass string
|
|
||||||
SyncInterval time.Duration // 0 = startup and manual only
|
|
||||||
}
|
|
||||||
|
|
||||||
// readCalendarSettings reads the calendar keys from dir's .page-settings.
|
|
||||||
// Read on every use, so edits apply without a restart.
|
|
||||||
func readCalendarSettings(dir string) calendarSettings {
|
|
||||||
kv := readSettingsFile(dir)
|
|
||||||
s := calendarSettings{
|
|
||||||
Diary: splitCalendarList(kv["diary"]),
|
|
||||||
Weight: splitCalendarList(kv["weight"]),
|
|
||||||
Maintenance: splitCalendarList(kv["maintenance"]),
|
|
||||||
CaldavURL: kv["caldav-url"],
|
|
||||||
CaldavUser: kv["caldav-user"],
|
|
||||||
CaldavPass: kv["caldav-pass"],
|
|
||||||
SyncInterval: 15 * time.Minute,
|
|
||||||
}
|
|
||||||
if v, ok := kv["sync-interval"]; ok {
|
|
||||||
if d, err := time.ParseDuration(v); err == nil && d >= 0 {
|
|
||||||
s.SyncInterval = d
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return s
|
|
||||||
}
|
|
||||||
|
|
||||||
func splitCalendarList(v string) []string {
|
|
||||||
var out []string
|
|
||||||
for _, name := range strings.Split(v, ",") {
|
|
||||||
if name = strings.TrimSpace(name); name != "" {
|
|
||||||
out = append(out, name)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
// calendarPath returns the file of calendar name in dir, or "" when the name
|
|
||||||
// would escape the folder.
|
|
||||||
func calendarPath(dir, name string) string {
|
|
||||||
if name == "" || strings.ContainsAny(name, `/\`) || strings.HasPrefix(name, ".") {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
return filepath.Join(dir, name+".ics")
|
|
||||||
}
|
|
||||||
|
|
||||||
// === Model ===
|
|
||||||
|
|
||||||
// calEvent is one expanded occurrence.
|
|
||||||
type calEvent struct {
|
|
||||||
Calendar string
|
|
||||||
UID string
|
|
||||||
Summary string
|
|
||||||
Start time.Time // local time; all-day events start at local midnight
|
|
||||||
End time.Time // exclusive
|
|
||||||
AllDay bool
|
|
||||||
Stamp time.Time // LAST-MODIFIED, else DTSTAMP
|
|
||||||
Series *calSeries // nil for single events
|
|
||||||
}
|
|
||||||
|
|
||||||
// calSeries describes a recurring event as a whole.
|
|
||||||
type calSeries struct {
|
|
||||||
Calendar string
|
|
||||||
UID string
|
|
||||||
Summary string
|
|
||||||
Rule string // human-readable, e.g. "every 2 weeks (Sa)"
|
|
||||||
}
|
|
||||||
|
|
||||||
// calItem is a parsed VEVENT: a single event, a recurring master, or a
|
|
||||||
// RECURRENCE-ID override attached to its master.
|
|
||||||
type calItem struct {
|
|
||||||
uid string
|
|
||||||
summary string
|
|
||||||
start time.Time
|
|
||||||
end time.Time
|
|
||||||
allDay bool
|
|
||||||
days int // all-day length in days; keeps occurrences DST-safe
|
|
||||||
stamp time.Time
|
|
||||||
cancelled bool
|
|
||||||
|
|
||||||
set *rrule.Set // nil = single event
|
|
||||||
series *calSeries // set together with set
|
|
||||||
overrides map[int64]*calItem // RECURRENCE-ID (unix) → replacement
|
|
||||||
}
|
|
||||||
|
|
||||||
// calFile is one parsed calendar file.
|
|
||||||
type calFile struct {
|
|
||||||
modTime time.Time
|
|
||||||
size int64
|
|
||||||
items []*calItem
|
|
||||||
events int // VEVENT components in the file
|
|
||||||
}
|
|
||||||
|
|
||||||
// === Store ===
|
|
||||||
|
|
||||||
// calStore caches parsed calendar files by path. An entry is reused while the
|
|
||||||
// file's modtime and size are unchanged, so a request costs one stat per
|
|
||||||
// calendar. Parsed files are never mutated, so callers may keep using a
|
|
||||||
// snapshot after the lock is released.
|
|
||||||
var calStore = struct {
|
|
||||||
sync.Mutex
|
|
||||||
files map[string]*calFile
|
|
||||||
}{files: map[string]*calFile{}}
|
|
||||||
|
|
||||||
// loadCalendar returns the parsed calendar at path; nil when it does not exist
|
|
||||||
// or cannot be parsed (logged).
|
|
||||||
func loadCalendar(path string) *calFile {
|
|
||||||
info, err := os.Stat(path)
|
|
||||||
if err != nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
calStore.Lock()
|
|
||||||
defer calStore.Unlock()
|
|
||||||
if f := calStore.files[path]; f != nil && f.modTime.Equal(info.ModTime()) && f.size == info.Size() {
|
|
||||||
return f
|
|
||||||
}
|
|
||||||
data, err := os.ReadFile(path)
|
|
||||||
if err != nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
f, err := parseCalendar(filepath.Base(path), data)
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("calendar %s: %v", path, err)
|
|
||||||
delete(calStore.files, path)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
f.modTime = info.ModTime()
|
|
||||||
f.size = info.Size()
|
|
||||||
calStore.files[path] = f
|
|
||||||
return f
|
|
||||||
}
|
|
||||||
|
|
||||||
// calendarOccurrences expands every event of the named calendars in dir that
|
|
||||||
// overlaps [from, to). The result is sorted by start, then summary.
|
|
||||||
func calendarOccurrences(dir string, names []string, from, to time.Time) []calEvent {
|
|
||||||
var out []calEvent
|
|
||||||
for _, name := range names {
|
|
||||||
p := calendarPath(dir, name)
|
|
||||||
if p == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
f := loadCalendar(p)
|
|
||||||
if f == nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
for _, it := range f.items {
|
|
||||||
out = it.expand(name, from, to, out)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
sort.SliceStable(out, func(i, j int) bool {
|
|
||||||
if !out[i].Start.Equal(out[j].Start) {
|
|
||||||
return out[i].Start.Before(out[j].Start)
|
|
||||||
}
|
|
||||||
return out[i].Summary < out[j].Summary
|
|
||||||
})
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
func (it *calItem) occurrence(calendar string, start, end time.Time) calEvent {
|
|
||||||
return calEvent{
|
|
||||||
Calendar: calendar,
|
|
||||||
UID: it.uid,
|
|
||||||
Summary: it.summary,
|
|
||||||
Start: start.In(time.Local),
|
|
||||||
End: end.In(time.Local),
|
|
||||||
AllDay: it.allDay,
|
|
||||||
Stamp: it.stamp,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// overlaps reports whether [start, end) intersects [from, to). Zero-length
|
|
||||||
// events count when they start inside the window.
|
|
||||||
func overlaps(start, end, from, to time.Time) bool {
|
|
||||||
if !start.Before(to) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
return end.After(from) || !start.Before(from)
|
|
||||||
}
|
|
||||||
|
|
||||||
// expand appends the item's occurrences in [from, to) to out.
|
|
||||||
func (it *calItem) expand(calendar string, from, to time.Time, out []calEvent) []calEvent {
|
|
||||||
if it.cancelled {
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
if it.set == nil {
|
|
||||||
if overlaps(it.start, it.end, from, to) {
|
|
||||||
out = append(out, it.occurrence(calendar, it.start, it.end))
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
// Start the search early enough to catch occurrences that began before
|
|
||||||
// the window but still run into it.
|
|
||||||
after := from.Add(-it.end.Sub(it.start)).Add(-48 * time.Hour)
|
|
||||||
for _, s := range it.set.Between(after, to, true) {
|
|
||||||
if _, overridden := it.overrides[s.Unix()]; overridden {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
e := it.occurrenceEnd(s)
|
|
||||||
if overlaps(s, e, from, to) {
|
|
||||||
ev := it.occurrence(calendar, s, e)
|
|
||||||
ev.Series = it.series
|
|
||||||
out = append(out, ev)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Overrides are placed on their own dates: a moved occurrence can enter
|
|
||||||
// or leave the window independently of its original slot.
|
|
||||||
for _, o := range it.overrides {
|
|
||||||
if o.cancelled || !overlaps(o.start, o.end, from, to) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
ev := o.occurrence(calendar, o.start, o.end)
|
|
||||||
ev.Series = it.series
|
|
||||||
out = append(out, ev)
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
// occurrenceEnd returns the end of the occurrence starting at s.
|
|
||||||
func (it *calItem) occurrenceEnd(s time.Time) time.Time {
|
|
||||||
if it.allDay {
|
|
||||||
return s.AddDate(0, 0, it.days)
|
|
||||||
}
|
|
||||||
return s.Add(it.end.Sub(it.start))
|
|
||||||
}
|
|
||||||
|
|
||||||
// === Parsing ===
|
|
||||||
|
|
||||||
// calParser holds per-file parse state: the file name for log messages, the
|
|
||||||
// TZIDs already warned about, and resolved locations.
|
|
||||||
type calParser struct {
|
|
||||||
file string
|
|
||||||
zones map[string]*time.Location
|
|
||||||
warned map[string]bool
|
|
||||||
}
|
|
||||||
|
|
||||||
func parseCalendar(file string, data []byte) (*calFile, error) {
|
|
||||||
data = bytes.TrimPrefix(data, []byte{0xEF, 0xBB, 0xBF}) // UTF-8 BOM
|
|
||||||
cal, err := ical.NewDecoder(bytes.NewReader(data)).Decode()
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
p := &calParser{file: file, zones: map[string]*time.Location{}, warned: map[string]bool{}}
|
|
||||||
f := &calFile{}
|
|
||||||
masters := map[string]*calItem{}
|
|
||||||
type override struct {
|
|
||||||
item *calItem
|
|
||||||
recurID time.Time
|
|
||||||
}
|
|
||||||
var overrides []override
|
|
||||||
for _, comp := range cal.Children {
|
|
||||||
if comp.Name != ical.CompEvent {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
f.events++
|
|
||||||
it, recurID, err := p.item(comp)
|
|
||||||
if err != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if !recurID.IsZero() {
|
|
||||||
overrides = append(overrides, override{it, recurID})
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
f.items = append(f.items, it)
|
|
||||||
if it.uid != "" {
|
|
||||||
masters[it.uid] = it
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Overrides can come before their master in the file, so attach them in
|
|
||||||
// a second pass. One without a recurring master is shown as a single event.
|
|
||||||
for _, o := range overrides {
|
|
||||||
m := masters[o.item.uid]
|
|
||||||
if m == nil || m.set == nil {
|
|
||||||
f.items = append(f.items, o.item)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if o.item.summary == "" {
|
|
||||||
o.item.summary = m.summary
|
|
||||||
}
|
|
||||||
m.overrides[o.recurID.Unix()] = o.item
|
|
||||||
}
|
|
||||||
return f, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// item parses one VEVENT. recurID is non-zero for RECURRENCE-ID overrides.
|
|
||||||
func (p *calParser) item(comp *ical.Component) (it *calItem, recurID time.Time, err error) {
|
|
||||||
startProp := comp.Props.Get(ical.PropDateTimeStart)
|
|
||||||
if startProp == nil {
|
|
||||||
return nil, time.Time{}, errors.New("no DTSTART")
|
|
||||||
}
|
|
||||||
starts, allDay, err := p.times(startProp)
|
|
||||||
if err != nil || len(starts) == 0 {
|
|
||||||
return nil, time.Time{}, fmt.Errorf("DTSTART: %v", err)
|
|
||||||
}
|
|
||||||
it = &calItem{start: starts[0], allDay: allDay}
|
|
||||||
it.uid = calText(comp.Props.Get(ical.PropUID))
|
|
||||||
it.summary = strings.TrimSpace(calText(comp.Props.Get(ical.PropSummary)))
|
|
||||||
|
|
||||||
it.end = it.start
|
|
||||||
if prop := comp.Props.Get(ical.PropDateTimeEnd); prop != nil {
|
|
||||||
if ends, _, err := p.times(prop); err == nil && len(ends) > 0 {
|
|
||||||
it.end = ends[0]
|
|
||||||
}
|
|
||||||
} else if prop := comp.Props.Get(ical.PropDuration); prop != nil {
|
|
||||||
if d, err := prop.Duration(); err == nil {
|
|
||||||
if allDay {
|
|
||||||
it.end = it.start.AddDate(0, 0, int(math.Round(d.Hours()/24)))
|
|
||||||
} else {
|
|
||||||
it.end = it.start.Add(d)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else if allDay {
|
|
||||||
it.end = it.start.AddDate(0, 0, 1)
|
|
||||||
}
|
|
||||||
if it.end.Before(it.start) {
|
|
||||||
it.end = it.start
|
|
||||||
}
|
|
||||||
if allDay {
|
|
||||||
it.days = calendarDaysBetween(it.start, it.end)
|
|
||||||
if it.days < 1 {
|
|
||||||
it.days = 1
|
|
||||||
}
|
|
||||||
it.end = it.start.AddDate(0, 0, it.days)
|
|
||||||
}
|
|
||||||
|
|
||||||
if strings.EqualFold(calText(comp.Props.Get(ical.PropStatus)), "CANCELLED") {
|
|
||||||
it.cancelled = true
|
|
||||||
}
|
|
||||||
for _, name := range []string{ical.PropLastModified, ical.PropDateTimeStamp} {
|
|
||||||
if prop := comp.Props.Get(name); prop != nil {
|
|
||||||
if ts, _, err := p.times(prop); err == nil && len(ts) > 0 {
|
|
||||||
it.stamp = ts[0]
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if prop := comp.Props.Get(ical.PropRecurrenceID); prop != nil {
|
|
||||||
ids, _, err := p.times(prop)
|
|
||||||
if err != nil || len(ids) == 0 {
|
|
||||||
return nil, time.Time{}, fmt.Errorf("RECURRENCE-ID: %v", err)
|
|
||||||
}
|
|
||||||
return it, ids[0], nil
|
|
||||||
}
|
|
||||||
|
|
||||||
rule := comp.Props.Get(ical.PropRecurrenceRule)
|
|
||||||
rdates := comp.Props.Values(ical.PropRecurrenceDates)
|
|
||||||
if rule == nil && len(rdates) == 0 {
|
|
||||||
return it, time.Time{}, nil
|
|
||||||
}
|
|
||||||
set := &rrule.Set{}
|
|
||||||
ruleText := ""
|
|
||||||
if rule != nil {
|
|
||||||
opt, err := rrule.StrToROptionInLocation(rule.Value, it.start.Location())
|
|
||||||
if err != nil {
|
|
||||||
p.warn("rrule:"+rule.Value, "cannot parse RRULE %q of %q: %v", rule.Value, it.summary, err)
|
|
||||||
return it, time.Time{}, nil
|
|
||||||
}
|
|
||||||
opt.Dtstart = it.start
|
|
||||||
r, err := rrule.NewRRule(*opt)
|
|
||||||
if err != nil {
|
|
||||||
p.warn("rrule:"+rule.Value, "cannot build RRULE %q of %q: %v", rule.Value, it.summary, err)
|
|
||||||
return it, time.Time{}, nil
|
|
||||||
}
|
|
||||||
set.RRule(r)
|
|
||||||
ruleText = describeRRule(opt)
|
|
||||||
} else {
|
|
||||||
// Without an RRULE the set only holds RDATEs; DTSTART is always the
|
|
||||||
// first occurrence.
|
|
||||||
set.RDate(it.start)
|
|
||||||
ruleText = "on selected dates"
|
|
||||||
}
|
|
||||||
for i := range rdates {
|
|
||||||
if ts, _, err := p.times(&rdates[i]); err == nil {
|
|
||||||
for _, t := range ts {
|
|
||||||
set.RDate(t)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for _, prop := range comp.Props.Values(ical.PropExceptionDates) {
|
|
||||||
if ts, _, err := p.times(&prop); err == nil {
|
|
||||||
for _, t := range ts {
|
|
||||||
set.ExDate(t)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
it.set = set
|
|
||||||
it.overrides = map[int64]*calItem{}
|
|
||||||
it.series = &calSeries{
|
|
||||||
Calendar: strings.TrimSuffix(p.file, ".ics"),
|
|
||||||
UID: it.uid,
|
|
||||||
Summary: it.summary,
|
|
||||||
Rule: ruleText,
|
|
||||||
}
|
|
||||||
return it, time.Time{}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// calText unescapes a TEXT value. Unlike go-ical's Text it keeps unescaped
|
|
||||||
// commas: clients write titles like "95,6kg" without the required backslash,
|
|
||||||
// and go-ical would cut them at the comma. Unknown escapes are kept as is.
|
|
||||||
func calText(prop *ical.Prop) string {
|
|
||||||
if prop == nil {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
v := prop.Value
|
|
||||||
if !strings.Contains(v, `\`) {
|
|
||||||
return v
|
|
||||||
}
|
|
||||||
var sb strings.Builder
|
|
||||||
for i := 0; i < len(v); i++ {
|
|
||||||
c := v[i]
|
|
||||||
if c != '\\' || i+1 == len(v) {
|
|
||||||
sb.WriteByte(c)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
i++
|
|
||||||
switch n := v[i]; n {
|
|
||||||
case 'n', 'N':
|
|
||||||
sb.WriteByte('\n')
|
|
||||||
case '\\', ';', ',':
|
|
||||||
sb.WriteByte(n)
|
|
||||||
default:
|
|
||||||
sb.WriteByte('\\')
|
|
||||||
sb.WriteByte(n)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return sb.String()
|
|
||||||
}
|
|
||||||
|
|
||||||
// times parses a DATE or DATE-TIME property. Comma-separated lists (EXDATE,
|
|
||||||
// RDATE) yield several values. Dates and floating times use the local zone; an
|
|
||||||
// unknown TZID falls back to it with a warning.
|
|
||||||
func (p *calParser) times(prop *ical.Prop) ([]time.Time, bool, error) {
|
|
||||||
loc := time.Local
|
|
||||||
if tzid := prop.Params.Get(ical.PropTimezoneID); tzid != "" {
|
|
||||||
loc = p.zone(tzid)
|
|
||||||
}
|
|
||||||
var out []time.Time
|
|
||||||
allDay := false
|
|
||||||
for _, v := range strings.Split(prop.Value, ",") {
|
|
||||||
v = strings.TrimSpace(v)
|
|
||||||
var t time.Time
|
|
||||||
var err error
|
|
||||||
switch {
|
|
||||||
case len(v) == 8:
|
|
||||||
t, err = time.ParseInLocation("20060102", v, time.Local)
|
|
||||||
allDay = true
|
|
||||||
case len(v) == 16 && strings.HasSuffix(v, "Z"):
|
|
||||||
t, err = time.Parse("20060102T150405Z", v)
|
|
||||||
case len(v) == 15:
|
|
||||||
t, err = time.ParseInLocation("20060102T150405", v, loc)
|
|
||||||
default:
|
|
||||||
err = fmt.Errorf("unsupported value %q", v)
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
return nil, false, err
|
|
||||||
}
|
|
||||||
out = append(out, t)
|
|
||||||
}
|
|
||||||
return out, allDay, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *calParser) zone(tzid string) *time.Location {
|
|
||||||
if loc, ok := p.zones[tzid]; ok {
|
|
||||||
return loc
|
|
||||||
}
|
|
||||||
loc, err := time.LoadLocation(tzid)
|
|
||||||
if err != nil {
|
|
||||||
// Some clients prefix the IANA name, e.g.
|
|
||||||
// "/freeassociation.sourceforge.net/Tzfile/Europe/Berlin".
|
|
||||||
parts := strings.Split(strings.Trim(tzid, "/"), "/")
|
|
||||||
if len(parts) >= 2 {
|
|
||||||
loc, err = time.LoadLocation(strings.Join(parts[len(parts)-2:], "/"))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
p.warn("tz:"+tzid, "unknown TZID %q, using local time", tzid)
|
|
||||||
loc = time.Local
|
|
||||||
}
|
|
||||||
p.zones[tzid] = loc
|
|
||||||
return loc
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *calParser) warn(key, format string, args ...any) {
|
|
||||||
if p.warned[key] {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
p.warned[key] = true
|
|
||||||
log.Printf("calendar %s: "+format, append([]any{p.file}, args...)...)
|
|
||||||
}
|
|
||||||
|
|
||||||
// calendarDaysBetween counts calendar days from a to b (both local midnights),
|
|
||||||
// independent of DST shifts.
|
|
||||||
func calendarDaysBetween(a, b time.Time) int {
|
|
||||||
ay, am, ad := a.Date()
|
|
||||||
by, bm, bd := b.Date()
|
|
||||||
ua := time.Date(ay, am, ad, 0, 0, 0, 0, time.UTC)
|
|
||||||
ub := time.Date(by, bm, bd, 0, 0, 0, 0, time.UTC)
|
|
||||||
return int(ub.Sub(ua).Hours() / 24)
|
|
||||||
}
|
|
||||||
|
|
||||||
// === Formatting ===
|
|
||||||
|
|
||||||
var germanWeekdays = [...]string{"So", "Mo", "Di", "Mi", "Do", "Fr", "Sa"}
|
|
||||||
|
|
||||||
// calDayLabel formats a date as "Sa 27.09.".
|
|
||||||
func calDayLabel(t time.Time) string {
|
|
||||||
return fmt.Sprintf("%s %02d.%02d.", germanWeekdays[t.Weekday()], t.Day(), int(t.Month()))
|
|
||||||
}
|
|
||||||
|
|
||||||
// describeRRule turns common rules into short text ("weekly (Mo)",
|
|
||||||
// "every 2 weeks (Sa)", "monthly (day 1)"). Anything with parts it does not
|
|
||||||
// know falls back to the raw rule.
|
|
||||||
func describeRRule(opt *rrule.ROption) string {
|
|
||||||
if len(opt.Bysetpos) > 0 || len(opt.Byyearday) > 0 || len(opt.Byweekno) > 0 ||
|
|
||||||
len(opt.Byhour) > 0 || len(opt.Byminute) > 0 || len(opt.Bysecond) > 0 {
|
|
||||||
return opt.RRuleString()
|
|
||||||
}
|
|
||||||
units := map[rrule.Frequency][2]string{
|
|
||||||
rrule.DAILY: {"daily", "days"},
|
|
||||||
rrule.WEEKLY: {"weekly", "weeks"},
|
|
||||||
rrule.MONTHLY: {"monthly", "months"},
|
|
||||||
rrule.YEARLY: {"yearly", "years"},
|
|
||||||
}
|
|
||||||
u, ok := units[opt.Freq]
|
|
||||||
if !ok {
|
|
||||||
return opt.RRuleString()
|
|
||||||
}
|
|
||||||
text := u[0]
|
|
||||||
if opt.Interval > 1 {
|
|
||||||
text = fmt.Sprintf("every %d %s", opt.Interval, u[1])
|
|
||||||
}
|
|
||||||
var details []string
|
|
||||||
for _, wd := range opt.Byweekday {
|
|
||||||
name := germanWeekdays[(wd.Day()+1)%7]
|
|
||||||
if n := wd.N(); n != 0 {
|
|
||||||
name = strconv.Itoa(n) + ". " + name
|
|
||||||
if n == -1 {
|
|
||||||
name = "last " + germanWeekdays[(wd.Day()+1)%7]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
details = append(details, name)
|
|
||||||
}
|
|
||||||
for _, d := range opt.Bymonthday {
|
|
||||||
details = append(details, fmt.Sprintf("day %d", d))
|
|
||||||
}
|
|
||||||
for _, m := range opt.Bymonth {
|
|
||||||
if m >= 1 && m <= 12 {
|
|
||||||
details = append(details, germanMonths[time.Month(m)])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if len(details) > 0 {
|
|
||||||
text += " (" + strings.Join(details, ", ") + ")"
|
|
||||||
}
|
|
||||||
return text
|
|
||||||
}
|
|
||||||
|
|
||||||
// === Diary lines ===
|
|
||||||
|
|
||||||
// calDayLines is the calendar content shown under a diary day.
|
|
||||||
type calDayLines struct {
|
|
||||||
Events string // "Feiertag, 10:00 Brunch, 19:30 Kino"
|
|
||||||
Weight string // "95,6 kg"
|
|
||||||
}
|
|
||||||
|
|
||||||
var weightTitle = regexp.MustCompile(`(?i)^\s*(\d+(?:[.,]\d+)?)\s*kg\s*$`)
|
|
||||||
|
|
||||||
// calendarDayLines builds the diary lines for every day in [from, to), keyed
|
|
||||||
// by "YYYY-MM-DD". Events come from the diary role, weights from the weight
|
|
||||||
// role. Days without either are absent.
|
|
||||||
func calendarDayLines(from, to time.Time) map[string]calDayLines {
|
|
||||||
out := map[string]calDayLines{}
|
|
||||||
if calendarsDir == "" {
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
s := readCalendarSettings(calendarsDir)
|
|
||||||
|
|
||||||
type dayEvents struct{ allDay, timed []calEvent }
|
|
||||||
byDay := map[string]*dayEvents{}
|
|
||||||
for _, ev := range calendarOccurrences(calendarsDir, s.Diary, from, to) {
|
|
||||||
if ev.AllDay {
|
|
||||||
// Multi-day all-day events show on every day they cover.
|
|
||||||
for d := ev.Start; d.Before(ev.End); d = d.AddDate(0, 0, 1) {
|
|
||||||
key := d.Format("2006-01-02")
|
|
||||||
if byDay[key] == nil {
|
|
||||||
byDay[key] = &dayEvents{}
|
|
||||||
}
|
|
||||||
byDay[key].allDay = append(byDay[key].allDay, ev)
|
|
||||||
}
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
key := ev.Start.Format("2006-01-02")
|
|
||||||
if byDay[key] == nil {
|
|
||||||
byDay[key] = &dayEvents{}
|
|
||||||
}
|
|
||||||
byDay[key].timed = append(byDay[key].timed, ev)
|
|
||||||
}
|
|
||||||
for key, d := range byDay {
|
|
||||||
sort.SliceStable(d.allDay, func(i, j int) bool { return d.allDay[i].Summary < d.allDay[j].Summary })
|
|
||||||
var parts []string
|
|
||||||
for _, ev := range d.allDay {
|
|
||||||
if ev.Summary != "" {
|
|
||||||
parts = append(parts, ev.Summary)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Timed events are already sorted by start, then summary.
|
|
||||||
for _, ev := range d.timed {
|
|
||||||
parts = append(parts, strings.TrimSpace(ev.Start.Format("15:04")+" "+ev.Summary))
|
|
||||||
}
|
|
||||||
if len(parts) > 0 {
|
|
||||||
out[key] = calDayLines{Events: strings.Join(parts, ", ")}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for key, w := range calendarWeights(s, from, to) {
|
|
||||||
lines := out[key]
|
|
||||||
lines.Weight = strings.ReplaceAll(w, ".", ",") + " kg"
|
|
||||||
out[key] = lines
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
// calendarWeights reads the weight role for [from, to): one entry per day,
|
|
||||||
// keyed by "YYYY-MM-DD", holding the kg value with a '.' decimal separator.
|
|
||||||
// Events whose title is not a weight are skipped; several entries on one day:
|
|
||||||
// the last edited one wins.
|
|
||||||
func calendarWeights(s calendarSettings, from, to time.Time) map[string]string {
|
|
||||||
out := map[string]string{}
|
|
||||||
stamps := map[string]time.Time{}
|
|
||||||
for _, ev := range calendarOccurrences(calendarsDir, s.Weight, from, to) {
|
|
||||||
m := weightTitle.FindStringSubmatch(ev.Summary)
|
|
||||||
if m == nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
key := ev.Start.Format("2006-01-02")
|
|
||||||
if prev, ok := stamps[key]; ok && ev.Stamp.Before(prev) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
stamps[key] = ev.Stamp
|
|
||||||
out[key] = strings.ReplaceAll(m[1], ",", ".")
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
@@ -1,283 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"io"
|
|
||||||
"net/http"
|
|
||||||
"net/http/httptest"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
// writeCal writes a VCALENDAR with the given VEVENT bodies to dir/name.ics.
|
|
||||||
func writeCal(t *testing.T, dir, name string, events ...string) {
|
|
||||||
t.Helper()
|
|
||||||
var b strings.Builder
|
|
||||||
b.WriteString("BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:test\r\n")
|
|
||||||
for _, ev := range events {
|
|
||||||
b.WriteString("BEGIN:VEVENT\r\n")
|
|
||||||
for _, line := range strings.Split(strings.TrimSpace(ev), "\n") {
|
|
||||||
b.WriteString(strings.TrimSpace(line) + "\r\n")
|
|
||||||
}
|
|
||||||
b.WriteString("END:VEVENT\r\n")
|
|
||||||
}
|
|
||||||
b.WriteString("END:VCALENDAR\r\n")
|
|
||||||
if err := os.WriteFile(filepath.Join(dir, name+".ics"), []byte(b.String()), 0644); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func withBerlin(t *testing.T) {
|
|
||||||
t.Helper()
|
|
||||||
loc, err := time.LoadLocation("Europe/Berlin")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
prev := time.Local
|
|
||||||
time.Local = loc
|
|
||||||
t.Cleanup(func() { time.Local = prev })
|
|
||||||
}
|
|
||||||
|
|
||||||
func day(y int, m time.Month, d int) time.Time {
|
|
||||||
return time.Date(y, m, d, 0, 0, 0, 0, time.Local)
|
|
||||||
}
|
|
||||||
|
|
||||||
func summaries(evs []calEvent) []string {
|
|
||||||
var out []string
|
|
||||||
for _, ev := range evs {
|
|
||||||
out = append(out, ev.Start.Format("2006-01-02 15:04")+" "+ev.Summary)
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
func assertStrings(t *testing.T, got, want []string) {
|
|
||||||
t.Helper()
|
|
||||||
if strings.Join(got, "\n") != strings.Join(want, "\n") {
|
|
||||||
t.Fatalf("got:\n %s\nwant:\n %s", strings.Join(got, "\n "), strings.Join(want, "\n "))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCalendarSingleAndAllDay(t *testing.T) {
|
|
||||||
withBerlin(t)
|
|
||||||
dir := t.TempDir()
|
|
||||||
writeCal(t, dir, "P",
|
|
||||||
`UID:a
|
|
||||||
SUMMARY:Zahnarzt
|
|
||||||
DTSTART;TZID=Europe/Berlin:20261002T090000
|
|
||||||
DTEND;TZID=Europe/Berlin:20261002T100000`,
|
|
||||||
`UID:b
|
|
||||||
SUMMARY:Urlaub
|
|
||||||
DTSTART;VALUE=DATE:20261001
|
|
||||||
DTEND;VALUE=DATE:20261004`,
|
|
||||||
`UID:c
|
|
||||||
SUMMARY:UTC call
|
|
||||||
DTSTART:20261002T160000Z
|
|
||||||
DURATION:PT1H`,
|
|
||||||
)
|
|
||||||
got := summaries(calendarOccurrences(dir, []string{"P"}, day(2026, 10, 2), day(2026, 10, 3)))
|
|
||||||
assertStrings(t, got, []string{
|
|
||||||
"2026-10-01 00:00 Urlaub",
|
|
||||||
"2026-10-02 09:00 Zahnarzt",
|
|
||||||
"2026-10-02 18:00 UTC call",
|
|
||||||
})
|
|
||||||
// DTEND of all-day events is exclusive.
|
|
||||||
if got := calendarOccurrences(dir, []string{"P"}, day(2026, 10, 4), day(2026, 10, 5)); len(got) != 0 {
|
|
||||||
t.Fatalf("all-day event leaked past DTEND: %v", summaries(got))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCalendarRecurrence(t *testing.T) {
|
|
||||||
withBerlin(t)
|
|
||||||
dir := t.TempDir()
|
|
||||||
writeCal(t, dir, "M",
|
|
||||||
`UID:backup
|
|
||||||
SUMMARY:Backup
|
|
||||||
DTSTART;TZID=Europe/Berlin:20260907T030000
|
|
||||||
DTEND;TZID=Europe/Berlin:20260907T040000
|
|
||||||
RRULE:FREQ=WEEKLY;BYDAY=MO
|
|
||||||
EXDATE;TZID=Europe/Berlin:20260914T030000,20260921T030000`,
|
|
||||||
`UID:backup
|
|
||||||
RECURRENCE-ID;TZID=Europe/Berlin:20260928T030000
|
|
||||||
SUMMARY:Backup (moved)
|
|
||||||
DTSTART;TZID=Europe/Berlin:20260929T050000
|
|
||||||
DTEND;TZID=Europe/Berlin:20260929T060000`,
|
|
||||||
`UID:backup
|
|
||||||
RECURRENCE-ID;TZID=Europe/Berlin:20261005T030000
|
|
||||||
DTSTART;TZID=Europe/Berlin:20261005T030000
|
|
||||||
STATUS:CANCELLED`,
|
|
||||||
`UID:sheets
|
|
||||||
SUMMARY:Bettwäsche
|
|
||||||
DTSTART;VALUE=DATE:20260905
|
|
||||||
RRULE:FREQ=WEEKLY;INTERVAL=2`,
|
|
||||||
)
|
|
||||||
got := summaries(calendarOccurrences(dir, []string{"M"}, day(2026, 9, 1), day(2026, 10, 20)))
|
|
||||||
assertStrings(t, got, []string{
|
|
||||||
"2026-09-05 00:00 Bettwäsche",
|
|
||||||
"2026-09-07 03:00 Backup",
|
|
||||||
"2026-09-19 00:00 Bettwäsche",
|
|
||||||
"2026-09-29 05:00 Backup (moved)",
|
|
||||||
"2026-10-03 00:00 Bettwäsche",
|
|
||||||
"2026-10-12 03:00 Backup",
|
|
||||||
"2026-10-17 00:00 Bettwäsche",
|
|
||||||
"2026-10-19 03:00 Backup",
|
|
||||||
})
|
|
||||||
// Wall-clock time survives the DST change on 2026-10-25.
|
|
||||||
got = summaries(calendarOccurrences(dir, []string{"M"}, day(2026, 10, 26), day(2026, 10, 27)))
|
|
||||||
assertStrings(t, got, []string{"2026-10-26 03:00 Backup"})
|
|
||||||
|
|
||||||
evs := calendarOccurrences(dir, []string{"M"}, day(2026, 9, 5), day(2026, 9, 8))
|
|
||||||
if evs[0].Series == nil || evs[0].Series.Rule != "every 2 weeks" {
|
|
||||||
t.Fatalf("sheets rule = %+v", evs[0].Series)
|
|
||||||
}
|
|
||||||
if evs[1].Series == nil || evs[1].Series.Rule != "weekly (Mo)" {
|
|
||||||
t.Fatalf("backup rule = %+v", evs[1].Series)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCalendarUnknownTZIDFallsBack(t *testing.T) {
|
|
||||||
withBerlin(t)
|
|
||||||
dir := t.TempDir()
|
|
||||||
writeCal(t, dir, "P",
|
|
||||||
`UID:x
|
|
||||||
SUMMARY:Windows zone
|
|
||||||
DTSTART;TZID=W. Europe Standard Time:20261002T090000
|
|
||||||
DTEND;TZID=W. Europe Standard Time:20261002T100000`,
|
|
||||||
`UID:y
|
|
||||||
SUMMARY:Prefixed zone
|
|
||||||
DTSTART;TZID=/freeassociation.sourceforge.net/Tzfile/Europe/Berlin:20261002T110000`,
|
|
||||||
)
|
|
||||||
got := summaries(calendarOccurrences(dir, []string{"P"}, day(2026, 10, 2), day(2026, 10, 3)))
|
|
||||||
assertStrings(t, got, []string{
|
|
||||||
"2026-10-02 09:00 Windows zone",
|
|
||||||
"2026-10-02 11:00 Prefixed zone",
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCalendarDayLines(t *testing.T) {
|
|
||||||
withBerlin(t)
|
|
||||||
dir := t.TempDir()
|
|
||||||
prev := calendarsDir
|
|
||||||
calendarsDir = dir
|
|
||||||
t.Cleanup(func() { calendarsDir = prev })
|
|
||||||
|
|
||||||
if err := os.WriteFile(filepath.Join(dir, ".page-settings"),
|
|
||||||
[]byte("type = calendars\ndiary = Personal, Feiertage\nweight = Gewicht\nmaintenance = Maintainance\n"), 0644); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
writeCal(t, dir, "Personal",
|
|
||||||
`UID:1
|
|
||||||
SUMMARY:Kino
|
|
||||||
DTSTART;TZID=Europe/Berlin:20261003T193000`,
|
|
||||||
`UID:2
|
|
||||||
SUMMARY:Brunch\, Anna
|
|
||||||
DTSTART;TZID=Europe/Berlin:20261003T100000`,
|
|
||||||
)
|
|
||||||
writeCal(t, dir, "Feiertage",
|
|
||||||
`UID:3
|
|
||||||
SUMMARY:Tag der Deutschen Einheit
|
|
||||||
DTSTART;VALUE=DATE:20261003`,
|
|
||||||
)
|
|
||||||
writeCal(t, dir, "Gewicht",
|
|
||||||
`UID:4
|
|
||||||
SUMMARY:95.9 KG
|
|
||||||
DTSTART;VALUE=DATE:20261003
|
|
||||||
DTSTAMP:20261003T060000Z`,
|
|
||||||
`UID:5
|
|
||||||
SUMMARY:95,6kg
|
|
||||||
DTSTART;VALUE=DATE:20261003
|
|
||||||
DTSTAMP:20261003T070000Z`,
|
|
||||||
`UID:6
|
|
||||||
SUMMARY:not a weight
|
|
||||||
DTSTART;VALUE=DATE:20261004`,
|
|
||||||
)
|
|
||||||
writeCal(t, dir, "Maintainance",
|
|
||||||
`UID:7
|
|
||||||
SUMMARY:Backup
|
|
||||||
DTSTART;VALUE=DATE:20261003`,
|
|
||||||
)
|
|
||||||
|
|
||||||
lines := calendarDayLines(day(2026, 10, 1), day(2026, 10, 10))
|
|
||||||
want := calDayLines{
|
|
||||||
Events: "Tag der Deutschen Einheit, 10:00 Brunch, Anna, 19:30 Kino",
|
|
||||||
Weight: "95,6 kg",
|
|
||||||
}
|
|
||||||
if lines["2026-10-03"] != want {
|
|
||||||
t.Fatalf("got %+v, want %+v", lines["2026-10-03"], want)
|
|
||||||
}
|
|
||||||
if _, ok := lines["2026-10-04"]; ok {
|
|
||||||
t.Fatalf("unparsable weight produced a line: %+v", lines["2026-10-04"])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCalendarSync(t *testing.T) {
|
|
||||||
dir := t.TempDir()
|
|
||||||
prev := calendarsDir
|
|
||||||
calendarsDir = dir
|
|
||||||
t.Cleanup(func() { calendarsDir = prev })
|
|
||||||
|
|
||||||
ics := "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nBEGIN:VEVENT\r\nUID:1\r\nSUMMARY:95,6kg\r\nDTSTART;VALUE=DATE:20261003\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n"
|
|
||||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
if u, p, _ := r.BasicAuth(); u != "me" || p != "secret" {
|
|
||||||
w.WriteHeader(http.StatusUnauthorized)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
switch {
|
|
||||||
case r.Method == "PROPFIND" && r.URL.Path == "/dav/calendars/me/":
|
|
||||||
w.WriteHeader(http.StatusMultiStatus)
|
|
||||||
io.WriteString(w, `<?xml version="1.0"?>
|
|
||||||
<d:multistatus xmlns:d="DAV:" xmlns:cal="urn:ietf:params:xml:ns:caldav">
|
|
||||||
<d:response><d:href>/dav/calendars/me/</d:href><d:propstat><d:prop><d:resourcetype><d:collection/></d:resourcetype></d:prop><d:status>HTTP/1.1 200 OK</d:status></d:propstat></d:response>
|
|
||||||
<d:response><d:href>/dav/calendars/me/gewicht/</d:href><d:propstat><d:prop><d:displayname>Gewicht</d:displayname><d:resourcetype><d:collection/><cal:calendar/></d:resourcetype></d:prop><d:status>HTTP/1.1 200 OK</d:status></d:propstat></d:response>
|
|
||||||
<d:response><d:href>/dav/calendars/me/broken/</d:href><d:propstat><d:prop><d:displayname>Broken/Name</d:displayname><d:resourcetype><d:collection/><cal:calendar/></d:resourcetype></d:prop><d:status>HTTP/1.1 200 OK</d:status></d:propstat></d:response>
|
|
||||||
<d:response><d:href>/dav/calendars/me/inbox/</d:href><d:propstat><d:prop><d:resourcetype><d:collection/><cal:schedule-inbox/></d:resourcetype></d:prop><d:status>HTTP/1.1 200 OK</d:status></d:propstat></d:response>
|
|
||||||
</d:multistatus>`)
|
|
||||||
case r.Method == http.MethodGet && r.URL.Path == "/dav/calendars/me/gewicht/" && r.URL.RawQuery == "export":
|
|
||||||
io.WriteString(w, ics)
|
|
||||||
case r.Method == http.MethodGet && r.URL.Path == "/dav/calendars/me/broken/":
|
|
||||||
io.WriteString(w, "<html>login</html>")
|
|
||||||
default:
|
|
||||||
http.NotFound(w, r)
|
|
||||||
}
|
|
||||||
}))
|
|
||||||
defer srv.Close()
|
|
||||||
|
|
||||||
// An existing file must survive a broken download.
|
|
||||||
if err := os.WriteFile(filepath.Join(dir, "Broken_Name.ics"), []byte("old"), 0644); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
results, err := syncCalendars(calendarSettings{CaldavURL: srv.URL + "/dav/calendars/me", CaldavUser: "me", CaldavPass: "secret"})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if len(results) != 2 || results["Gewicht"].Error != "" || results["Broken_Name"].Error == "" {
|
|
||||||
t.Fatalf("results = %+v", results)
|
|
||||||
}
|
|
||||||
if got, _ := os.ReadFile(filepath.Join(dir, "Gewicht.ics")); string(got) != ics {
|
|
||||||
t.Fatalf("Gewicht.ics = %q", got)
|
|
||||||
}
|
|
||||||
if got, _ := os.ReadFile(filepath.Join(dir, "Broken_Name.ics")); string(got) != "old" {
|
|
||||||
t.Fatalf("broken download replaced the file: %q", got)
|
|
||||||
}
|
|
||||||
|
|
||||||
if _, err := syncCalendars(calendarSettings{CaldavURL: srv.URL + "/dav/calendars/me/", CaldavUser: "me", CaldavPass: "wrong"}); err == nil || !strings.Contains(err.Error(), "authentication") {
|
|
||||||
t.Fatalf("wrong password: err = %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestHasDotSegment(t *testing.T) {
|
|
||||||
for p, want := range map[string]bool{
|
|
||||||
"/Calendars/.page-settings": true,
|
|
||||||
"/.auth-key": true,
|
|
||||||
"/a/.hidden/b.txt": true,
|
|
||||||
"/Calendars/Personal.ics": false,
|
|
||||||
"/a/b.c/": false,
|
|
||||||
"/a/../.auth-key": true,
|
|
||||||
"/": false,
|
|
||||||
} {
|
|
||||||
if got := hasDotSegment(p); got != want {
|
|
||||||
t.Errorf("hasDotSegment(%q) = %v, want %v", p, got, want)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,116 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"html/template"
|
|
||||||
"log"
|
|
||||||
"net/http"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"sort"
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
func init() {
|
|
||||||
pageTypeHandlers = append(pageTypeHandlers, &calendarsHandler{})
|
|
||||||
}
|
|
||||||
|
|
||||||
// calendarsHandler renders folders whose .page-settings declares
|
|
||||||
// type = calendars: the maintenance table and the sync status. Markdown
|
|
||||||
// content and the listing stay visible, as in the fitness folder.
|
|
||||||
type calendarsHandler struct{}
|
|
||||||
|
|
||||||
func (c *calendarsHandler) redirect(root, fsPath, urlPath string, r *http.Request) (string, bool) {
|
|
||||||
return "", false
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *calendarsHandler) handle(root, fsPath, urlPath string, r *http.Request) *specialPage {
|
|
||||||
s := readPageSettings(fsPath)
|
|
||||||
if s == nil || s.Type != "calendars" {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return &specialPage{Content: renderCalendarsPage(fsPath), SuppressTOC: true}
|
|
||||||
}
|
|
||||||
|
|
||||||
type calFileRow struct {
|
|
||||||
Name string
|
|
||||||
Roles string
|
|
||||||
Events string
|
|
||||||
Updated string // file modification time
|
|
||||||
Error string // last sync or parse error
|
|
||||||
}
|
|
||||||
|
|
||||||
type calPageData struct {
|
|
||||||
HasMaintenance bool
|
|
||||||
Maintenance []maintSeriesRow
|
|
||||||
Calendars []calFileRow
|
|
||||||
// Sync is only shown for the configured calendars folder.
|
|
||||||
IsSyncDir bool
|
|
||||||
SyncEnabled bool
|
|
||||||
LastRun string
|
|
||||||
LastError string
|
|
||||||
}
|
|
||||||
|
|
||||||
var calPageTmpl = template.Must(template.ParseFS(assets, "assets/calendar/page.html"))
|
|
||||||
|
|
||||||
func renderCalendarsPage(dir string) template.HTML {
|
|
||||||
s := readCalendarSettings(dir)
|
|
||||||
data := calPageData{
|
|
||||||
HasMaintenance: len(s.Maintenance) > 0,
|
|
||||||
Maintenance: maintenanceSeries(dir, s.Maintenance, time.Now()),
|
|
||||||
IsSyncDir: calendarsDir != "" && dir == calendarsDir,
|
|
||||||
SyncEnabled: s.CaldavURL != "",
|
|
||||||
}
|
|
||||||
|
|
||||||
var results map[string]calSyncResult
|
|
||||||
if data.IsSyncDir {
|
|
||||||
var lastRun time.Time
|
|
||||||
lastRun, data.LastError, results = calSyncSnapshot()
|
|
||||||
if !lastRun.IsZero() {
|
|
||||||
data.LastRun = lastRun.Format("2006-01-02 15:04")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
roles := map[string][]string{}
|
|
||||||
for role, names := range map[string][]string{"diary": s.Diary, "weight": s.Weight, "maintenance": s.Maintenance} {
|
|
||||||
for _, n := range names {
|
|
||||||
roles[n] = append(roles[n], role)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
entries, _ := os.ReadDir(dir)
|
|
||||||
for _, e := range entries {
|
|
||||||
name := e.Name()
|
|
||||||
if e.IsDir() || strings.HasPrefix(name, ".") || !strings.EqualFold(filepath.Ext(name), ".ics") {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
name = strings.TrimSuffix(name, filepath.Ext(name))
|
|
||||||
sort.Strings(roles[name])
|
|
||||||
row := calFileRow{Name: name, Roles: strings.Join(roles[name], ", "), Error: results[name].Error}
|
|
||||||
if info, err := e.Info(); err == nil {
|
|
||||||
row.Updated = info.ModTime().Format("2006-01-02 15:04")
|
|
||||||
}
|
|
||||||
if f := loadCalendar(filepath.Join(dir, e.Name())); f != nil {
|
|
||||||
row.Events = strconv.Itoa(f.events)
|
|
||||||
} else if row.Error == "" {
|
|
||||||
row.Error = "cannot parse file"
|
|
||||||
}
|
|
||||||
data.Calendars = append(data.Calendars, row)
|
|
||||||
delete(results, name)
|
|
||||||
}
|
|
||||||
// Calendars whose download failed before a file ever existed.
|
|
||||||
for name, r := range results {
|
|
||||||
if r.Error != "" {
|
|
||||||
data.Calendars = append(data.Calendars, calFileRow{Name: name, Error: r.Error})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
sort.SliceStable(data.Calendars, func(i, j int) bool { return data.Calendars[i].Name < data.Calendars[j].Name })
|
|
||||||
|
|
||||||
var buf bytes.Buffer
|
|
||||||
if err := calPageTmpl.Execute(&buf, data); err != nil {
|
|
||||||
log.Printf("calendars page template: %v", err)
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
return template.HTML(buf.String())
|
|
||||||
}
|
|
||||||
@@ -1,296 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"encoding/xml"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"log"
|
|
||||||
"net/http"
|
|
||||||
"net/url"
|
|
||||||
"os"
|
|
||||||
"path"
|
|
||||||
"strings"
|
|
||||||
"sync"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
// The calendar sync mirrors every calendar of a CalDAV calendar home (e.g.
|
|
||||||
// Nextcloud's /remote.php/dav/calendars/<user>/) into calendarsDir as
|
|
||||||
// <display name>.ics. It runs in the background only: page renders read the
|
|
||||||
// local files and never touch the network. A failed run leaves the previous
|
|
||||||
// files in place.
|
|
||||||
|
|
||||||
// calSyncMaxBody bounds a single calendar download.
|
|
||||||
const calSyncMaxBody = 64 << 20
|
|
||||||
|
|
||||||
var calSyncClient = &http.Client{Timeout: 60 * time.Second}
|
|
||||||
|
|
||||||
// calSyncResult is the outcome of the last run for one calendar.
|
|
||||||
type calSyncResult struct {
|
|
||||||
Updated time.Time // last successful download
|
|
||||||
Error string
|
|
||||||
}
|
|
||||||
|
|
||||||
var calSync struct {
|
|
||||||
run sync.Mutex // serializes runs
|
|
||||||
|
|
||||||
mu sync.Mutex // guards the fields below
|
|
||||||
lastRun time.Time
|
|
||||||
lastError string // config/discovery error of the last run
|
|
||||||
results map[string]calSyncResult
|
|
||||||
}
|
|
||||||
|
|
||||||
// startCalendarSync checks the settings once a minute and syncs when the
|
|
||||||
// interval since the last run has passed. The first check happens right
|
|
||||||
// away. Settings are read every time, so enabling the sync or changing the
|
|
||||||
// interval needs no restart.
|
|
||||||
func startCalendarSync() {
|
|
||||||
go func() {
|
|
||||||
for {
|
|
||||||
s := readCalendarSettings(calendarsDir)
|
|
||||||
if s.CaldavURL != "" {
|
|
||||||
calSync.mu.Lock()
|
|
||||||
last := calSync.lastRun
|
|
||||||
calSync.mu.Unlock()
|
|
||||||
if last.IsZero() || (s.SyncInterval > 0 && time.Since(last) >= s.SyncInterval) {
|
|
||||||
runCalendarSync()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
time.Sleep(time.Minute)
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
}
|
|
||||||
|
|
||||||
// runCalendarSync performs one sync run and records the outcome.
|
|
||||||
func runCalendarSync() {
|
|
||||||
calSync.run.Lock()
|
|
||||||
defer calSync.run.Unlock()
|
|
||||||
|
|
||||||
results, err := syncCalendars(readCalendarSettings(calendarsDir))
|
|
||||||
|
|
||||||
calSync.mu.Lock()
|
|
||||||
defer calSync.mu.Unlock()
|
|
||||||
calSync.lastRun = time.Now()
|
|
||||||
calSync.lastError = ""
|
|
||||||
if err != nil {
|
|
||||||
calSync.lastError = err.Error()
|
|
||||||
log.Printf("calendar sync: %v", err)
|
|
||||||
}
|
|
||||||
if calSync.results == nil {
|
|
||||||
calSync.results = map[string]calSyncResult{}
|
|
||||||
}
|
|
||||||
for name, r := range results {
|
|
||||||
prev := calSync.results[name]
|
|
||||||
if r.Updated.IsZero() {
|
|
||||||
r.Updated = prev.Updated
|
|
||||||
}
|
|
||||||
calSync.results[name] = r
|
|
||||||
if r.Error != "" {
|
|
||||||
log.Printf("calendar sync %s: %s", name, r.Error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// calSyncSnapshot returns a copy of the sync status for display.
|
|
||||||
func calSyncSnapshot() (lastRun time.Time, lastError string, results map[string]calSyncResult) {
|
|
||||||
calSync.mu.Lock()
|
|
||||||
defer calSync.mu.Unlock()
|
|
||||||
results = make(map[string]calSyncResult, len(calSync.results))
|
|
||||||
for k, v := range calSync.results {
|
|
||||||
results[k] = v
|
|
||||||
}
|
|
||||||
return calSync.lastRun, calSync.lastError, results
|
|
||||||
}
|
|
||||||
|
|
||||||
// handleCalendarSync runs a sync now (POST ?calsync on the calendars page)
|
|
||||||
// and returns to the page.
|
|
||||||
func (h *handler) handleCalendarSync(w http.ResponseWriter, r *http.Request, urlPath, fsPath string) {
|
|
||||||
if calendarsDir == "" || fsPath != calendarsDir {
|
|
||||||
http.Error(w, "not the calendars folder", http.StatusBadRequest)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if readCalendarSettings(calendarsDir).CaldavURL == "" {
|
|
||||||
http.Error(w, "sync is not configured (caldav-url)", http.StatusBadRequest)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
runCalendarSync()
|
|
||||||
http.Redirect(w, r, urlPath, http.StatusSeeOther)
|
|
||||||
}
|
|
||||||
|
|
||||||
// syncCalendars downloads every calendar of the calendar home. The returned
|
|
||||||
// map has one entry per calendar that was attempted; err is set when nothing
|
|
||||||
// could be attempted (bad settings, discovery failed).
|
|
||||||
func syncCalendars(s calendarSettings) (map[string]calSyncResult, error) {
|
|
||||||
home, err := url.Parse(s.CaldavURL)
|
|
||||||
if err != nil || (home.Scheme != "http" && home.Scheme != "https") {
|
|
||||||
return nil, fmt.Errorf("invalid caldav-url %q", s.CaldavURL)
|
|
||||||
}
|
|
||||||
if !strings.HasSuffix(home.Path, "/") {
|
|
||||||
home.Path += "/"
|
|
||||||
}
|
|
||||||
cals, err := davDiscoverCalendars(home, s)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
results := map[string]calSyncResult{}
|
|
||||||
used := map[string]bool{}
|
|
||||||
for _, c := range cals {
|
|
||||||
name := calendarFileName(c.name, c.href)
|
|
||||||
if used[strings.ToLower(name)] {
|
|
||||||
name += " (" + path.Base(strings.TrimSuffix(c.href, "/")) + ")"
|
|
||||||
}
|
|
||||||
used[strings.ToLower(name)] = true
|
|
||||||
|
|
||||||
if err := syncOneCalendar(home, c.href, name, s); err != nil {
|
|
||||||
results[name] = calSyncResult{Error: err.Error()}
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
results[name] = calSyncResult{Updated: time.Now()}
|
|
||||||
}
|
|
||||||
return results, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// syncOneCalendar downloads one calendar via Nextcloud's ?export and writes
|
|
||||||
// it when the content changed. Invalid downloads (HTML error pages, broken
|
|
||||||
// iCalendar) never replace the existing file.
|
|
||||||
func syncOneCalendar(home *url.URL, href, name string, s calendarSettings) error {
|
|
||||||
u, err := home.Parse(href)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
u.RawQuery = "export"
|
|
||||||
req, err := http.NewRequest(http.MethodGet, u.String(), nil)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
req.SetBasicAuth(s.CaldavUser, s.CaldavPass)
|
|
||||||
req.Header.Set("Accept", "text/calendar")
|
|
||||||
resp, err := calSyncClient.Do(req)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
if resp.StatusCode != http.StatusOK {
|
|
||||||
return fmt.Errorf("download: %s", resp.Status)
|
|
||||||
}
|
|
||||||
data, err := io.ReadAll(io.LimitReader(resp.Body, calSyncMaxBody+1))
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if len(data) > calSyncMaxBody {
|
|
||||||
return errors.New("download: calendar larger than 64 MB")
|
|
||||||
}
|
|
||||||
if _, err := parseCalendar(name+".ics", data); err != nil {
|
|
||||||
return fmt.Errorf("invalid calendar data: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
p := calendarPath(calendarsDir, name)
|
|
||||||
if p == "" {
|
|
||||||
return fmt.Errorf("unusable file name %q", name)
|
|
||||||
}
|
|
||||||
if old, err := os.ReadFile(p); err == nil && bytes.Equal(old, data) {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
if err := os.MkdirAll(calendarsDir, 0755); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return writeFileAtomic(p, data, 0644)
|
|
||||||
}
|
|
||||||
|
|
||||||
// calendarFileName turns a display name into a safe file basename. Falls back
|
|
||||||
// to the last segment of the calendar's href.
|
|
||||||
func calendarFileName(display, href string) string {
|
|
||||||
clean := strings.Map(func(r rune) rune {
|
|
||||||
if r < 0x20 || strings.ContainsRune(`/\:*?"<>|`, r) {
|
|
||||||
return '_'
|
|
||||||
}
|
|
||||||
return r
|
|
||||||
}, display)
|
|
||||||
clean = strings.TrimLeft(strings.TrimSpace(clean), ".")
|
|
||||||
if clean == "" {
|
|
||||||
clean = strings.TrimLeft(path.Base(strings.TrimSuffix(href, "/")), ".")
|
|
||||||
}
|
|
||||||
if clean == "" {
|
|
||||||
clean = "calendar"
|
|
||||||
}
|
|
||||||
return clean
|
|
||||||
}
|
|
||||||
|
|
||||||
// === Discovery ===
|
|
||||||
|
|
||||||
type davCalendar struct {
|
|
||||||
href string
|
|
||||||
name string
|
|
||||||
}
|
|
||||||
|
|
||||||
const davPropfindBody = `<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<d:propfind xmlns:d="DAV:"><d:prop><d:displayname/><d:resourcetype/></d:prop></d:propfind>`
|
|
||||||
|
|
||||||
type davMultistatus struct {
|
|
||||||
Responses []struct {
|
|
||||||
Href string `xml:"DAV: href"`
|
|
||||||
Propstats []struct {
|
|
||||||
Status string `xml:"DAV: status"`
|
|
||||||
Prop struct {
|
|
||||||
DisplayName string `xml:"DAV: displayname"`
|
|
||||||
ResourceType struct {
|
|
||||||
Types []struct {
|
|
||||||
XMLName xml.Name
|
|
||||||
} `xml:",any"`
|
|
||||||
} `xml:"DAV: resourcetype"`
|
|
||||||
} `xml:"DAV: prop"`
|
|
||||||
} `xml:"DAV: propstat"`
|
|
||||||
} `xml:"DAV: response"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// davDiscoverCalendars lists the calendar collections directly below home
|
|
||||||
// with one PROPFIND (Depth 1).
|
|
||||||
func davDiscoverCalendars(home *url.URL, s calendarSettings) ([]davCalendar, error) {
|
|
||||||
req, err := http.NewRequest("PROPFIND", home.String(), strings.NewReader(davPropfindBody))
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
req.SetBasicAuth(s.CaldavUser, s.CaldavPass)
|
|
||||||
req.Header.Set("Depth", "1")
|
|
||||||
req.Header.Set("Content-Type", "application/xml; charset=utf-8")
|
|
||||||
resp, err := calSyncClient.Do(req)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("discovery: %v", err)
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
if resp.StatusCode == http.StatusUnauthorized {
|
|
||||||
return nil, errors.New("discovery: authentication failed (check caldav-user / caldav-pass)")
|
|
||||||
}
|
|
||||||
if resp.StatusCode != http.StatusMultiStatus {
|
|
||||||
return nil, fmt.Errorf("discovery: %s", resp.Status)
|
|
||||||
}
|
|
||||||
var ms davMultistatus
|
|
||||||
if err := xml.NewDecoder(io.LimitReader(resp.Body, calSyncMaxBody)).Decode(&ms); err != nil {
|
|
||||||
return nil, fmt.Errorf("discovery: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
var out []davCalendar
|
|
||||||
for _, r := range ms.Responses {
|
|
||||||
for _, ps := range r.Propstats {
|
|
||||||
if !strings.Contains(ps.Status, " 200 ") {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
isCalendar := false
|
|
||||||
for _, t := range ps.Prop.ResourceType.Types {
|
|
||||||
if t.XMLName.Space == "urn:ietf:params:xml:ns:caldav" && t.XMLName.Local == "calendar" {
|
|
||||||
isCalendar = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if isCalendar {
|
|
||||||
out = append(out, davCalendar{href: r.Href, name: strings.TrimSpace(ps.Prop.DisplayName)})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if len(out) == 0 {
|
|
||||||
return nil, errors.New("discovery: no calendars found at caldav-url")
|
|
||||||
}
|
|
||||||
return out, nil
|
|
||||||
}
|
|
||||||
@@ -1,586 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"crypto/sha256"
|
|
||||||
"encoding/hex"
|
|
||||||
"encoding/json"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"html/template"
|
|
||||||
"io"
|
|
||||||
"log"
|
|
||||||
"math"
|
|
||||||
"net/http"
|
|
||||||
"net/url"
|
|
||||||
"os"
|
|
||||||
"path"
|
|
||||||
"path/filepath"
|
|
||||||
"regexp"
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
"sync"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Canvases are JSON Canvas 1.0 files (https://jsoncanvas.org) stored as
|
|
||||||
// <name>.canvas inside a page's folder, so a page can hold any number of them.
|
|
||||||
//
|
|
||||||
// GET /Page/Board.canvas the canvas view (canvas/main.html)
|
|
||||||
// GET /Page/Board.canvas?raw the file itself
|
|
||||||
// POST /Page/Board.canvas?save the whole document (autosave, canvas.js)
|
|
||||||
// POST /Page/Board.canvas?render one node → its rendered element
|
|
||||||
// POST /Page/?canvas create a canvas (dialog "canvas")
|
|
||||||
//
|
|
||||||
// The canvas URL sits next to the page, so relative links and embeds in text
|
|
||||||
// nodes resolve against the page folder exactly like they do in index.md.
|
|
||||||
//
|
|
||||||
// The server only reads the fields it renders. Saves write the client's JSON
|
|
||||||
// back as sent (reindented), so fields this code does not know survive.
|
|
||||||
|
|
||||||
const canvasExt = ".canvas"
|
|
||||||
|
|
||||||
var canvasTmpl = template.Must(template.New("canvas").Funcs(tmplFuncs).ParseFS(assets,
|
|
||||||
"assets/layout.html", "assets/editor/toolbar.html", "assets/canvas/main.html", "assets/canvas/node.html"))
|
|
||||||
|
|
||||||
func isCanvasFile(name string) bool {
|
|
||||||
return strings.EqualFold(path.Ext(name), canvasExt)
|
|
||||||
}
|
|
||||||
|
|
||||||
type canvasNode struct {
|
|
||||||
ID string `json:"id"`
|
|
||||||
Type string `json:"type"`
|
|
||||||
X float64 `json:"x"`
|
|
||||||
Y float64 `json:"y"`
|
|
||||||
Width float64 `json:"width"`
|
|
||||||
Height float64 `json:"height"`
|
|
||||||
Color string `json:"color,omitempty"`
|
|
||||||
Text string `json:"text,omitempty"`
|
|
||||||
File string `json:"file,omitempty"`
|
|
||||||
Subpath string `json:"subpath,omitempty"`
|
|
||||||
URL string `json:"url,omitempty"`
|
|
||||||
Label string `json:"label,omitempty"`
|
|
||||||
Background string `json:"background,omitempty"`
|
|
||||||
BackgroundStyle string `json:"backgroundStyle,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type canvasEdge struct {
|
|
||||||
ID string `json:"id"`
|
|
||||||
FromNode string `json:"fromNode"`
|
|
||||||
FromSide string `json:"fromSide,omitempty"`
|
|
||||||
FromEnd string `json:"fromEnd,omitempty"`
|
|
||||||
ToNode string `json:"toNode"`
|
|
||||||
ToSide string `json:"toSide,omitempty"`
|
|
||||||
ToEnd string `json:"toEnd,omitempty"`
|
|
||||||
Color string `json:"color,omitempty"`
|
|
||||||
Label string `json:"label,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type canvasDoc struct {
|
|
||||||
Nodes []canvasNode `json:"nodes"`
|
|
||||||
Edges []canvasEdge `json:"edges"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// emptyCanvas is what a new canvas file holds, and what an empty file reads as.
|
|
||||||
var emptyCanvas = []byte("{\n\t\"nodes\": [],\n\t\"edges\": []\n}\n")
|
|
||||||
|
|
||||||
// parseCanvas decodes and validates a canvas document. An empty file is an
|
|
||||||
// empty canvas.
|
|
||||||
func parseCanvas(raw []byte) (canvasDoc, error) {
|
|
||||||
var doc canvasDoc
|
|
||||||
if len(bytes.TrimSpace(raw)) == 0 {
|
|
||||||
return doc, nil
|
|
||||||
}
|
|
||||||
// The top level must be an object; Unmarshal into a struct would accept
|
|
||||||
// `null` silently.
|
|
||||||
var top map[string]json.RawMessage
|
|
||||||
if err := json.Unmarshal(raw, &top); err != nil {
|
|
||||||
return doc, fmt.Errorf("not a JSON Canvas object: %v", err)
|
|
||||||
}
|
|
||||||
if err := json.Unmarshal(raw, &doc); err != nil {
|
|
||||||
return doc, fmt.Errorf("invalid canvas: %v", err)
|
|
||||||
}
|
|
||||||
return doc, validateCanvas(doc)
|
|
||||||
}
|
|
||||||
|
|
||||||
var canvasHexColorRe = regexp.MustCompile(`^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6})$`)
|
|
||||||
|
|
||||||
// canvasColor splits a canvas color into a preset ("1"–"6") or a hex value.
|
|
||||||
// Anything else is no color.
|
|
||||||
func canvasColor(c string) (preset string, hex template.CSS) {
|
|
||||||
if len(c) == 1 && c[0] >= '1' && c[0] <= '6' {
|
|
||||||
return c, ""
|
|
||||||
}
|
|
||||||
if canvasHexColorRe.MatchString(c) {
|
|
||||||
return "", template.CSS(c)
|
|
||||||
}
|
|
||||||
return "", ""
|
|
||||||
}
|
|
||||||
|
|
||||||
func validCanvasColor(c string) bool {
|
|
||||||
p, h := canvasColor(c)
|
|
||||||
return c == "" || p != "" || h != ""
|
|
||||||
}
|
|
||||||
|
|
||||||
func validCanvasSide(s string) bool {
|
|
||||||
switch s {
|
|
||||||
case "", "top", "right", "bottom", "left":
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
func validCanvasEnd(s string) bool {
|
|
||||||
return s == "" || s == "none" || s == "arrow"
|
|
||||||
}
|
|
||||||
|
|
||||||
// validateCanvas checks what the renderer and editor rely on: unique ids,
|
|
||||||
// known node types with their required field, finite geometry, and edges
|
|
||||||
// between existing nodes.
|
|
||||||
func validateCanvas(doc canvasDoc) error {
|
|
||||||
ids := map[string]bool{}
|
|
||||||
for i, n := range doc.Nodes {
|
|
||||||
if n.ID == "" {
|
|
||||||
return fmt.Errorf("node %d has no id", i)
|
|
||||||
}
|
|
||||||
if ids[n.ID] {
|
|
||||||
return fmt.Errorf("duplicate id %q", n.ID)
|
|
||||||
}
|
|
||||||
ids[n.ID] = true
|
|
||||||
for _, v := range []float64{n.X, n.Y, n.Width, n.Height} {
|
|
||||||
if math.IsNaN(v) || math.IsInf(v, 0) {
|
|
||||||
return fmt.Errorf("node %q has invalid geometry", n.ID)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
switch n.Type {
|
|
||||||
case "text", "group":
|
|
||||||
case "file":
|
|
||||||
if n.File == "" {
|
|
||||||
return fmt.Errorf("file node %q has no file", n.ID)
|
|
||||||
}
|
|
||||||
case "link":
|
|
||||||
if n.URL == "" {
|
|
||||||
return fmt.Errorf("link node %q has no url", n.ID)
|
|
||||||
}
|
|
||||||
default:
|
|
||||||
return fmt.Errorf("node %q has unknown type %q", n.ID, n.Type)
|
|
||||||
}
|
|
||||||
if !validCanvasColor(n.Color) {
|
|
||||||
return fmt.Errorf("node %q has invalid color %q", n.ID, n.Color)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
edgeIDs := map[string]bool{}
|
|
||||||
for i, e := range doc.Edges {
|
|
||||||
if e.ID == "" {
|
|
||||||
return fmt.Errorf("edge %d has no id", i)
|
|
||||||
}
|
|
||||||
if edgeIDs[e.ID] {
|
|
||||||
return fmt.Errorf("duplicate edge id %q", e.ID)
|
|
||||||
}
|
|
||||||
edgeIDs[e.ID] = true
|
|
||||||
if !ids[e.FromNode] || !ids[e.ToNode] {
|
|
||||||
return fmt.Errorf("edge %q connects a missing node", e.ID)
|
|
||||||
}
|
|
||||||
if !validCanvasSide(e.FromSide) || !validCanvasSide(e.ToSide) {
|
|
||||||
return fmt.Errorf("edge %q has an invalid side", e.ID)
|
|
||||||
}
|
|
||||||
if !validCanvasEnd(e.FromEnd) || !validCanvasEnd(e.ToEnd) {
|
|
||||||
return fmt.Errorf("edge %q has an invalid end", e.ID)
|
|
||||||
}
|
|
||||||
if !validCanvasColor(e.Color) {
|
|
||||||
return fmt.Errorf("edge %q has invalid color %q", e.ID, e.Color)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// canvasVersion identifies one exact file content. Saves carry the version
|
|
||||||
// they were based on; a mismatch means someone else changed the file.
|
|
||||||
func canvasVersion(raw []byte) string {
|
|
||||||
sum := sha256.Sum256(raw)
|
|
||||||
return hex.EncodeToString(sum[:8])
|
|
||||||
}
|
|
||||||
|
|
||||||
// canvasMu serializes the version check and write of canvas saves.
|
|
||||||
var canvasMu sync.Mutex
|
|
||||||
|
|
||||||
// === Rendering ===
|
|
||||||
|
|
||||||
// canvasNodeView is one node element (canvas/node.html). Geometry is in
|
|
||||||
// canvas units; the client positions the world, not the nodes.
|
|
||||||
type canvasNodeView struct {
|
|
||||||
ID, Type string
|
|
||||||
X, Y, W, H int
|
|
||||||
Preset string
|
|
||||||
Hex template.CSS
|
|
||||||
Label string
|
|
||||||
LabelURL string
|
|
||||||
Markdown bool // body is rendered markdown (.content styling)
|
|
||||||
Body template.HTML
|
|
||||||
}
|
|
||||||
|
|
||||||
// canvasBody* feed the node body templates in canvas/node.html.
|
|
||||||
type canvasMediaBody struct {
|
|
||||||
URL, Thumb, Kind string
|
|
||||||
}
|
|
||||||
|
|
||||||
type canvasCardBody struct {
|
|
||||||
Icon template.HTML
|
|
||||||
Name, URL string
|
|
||||||
Host string
|
|
||||||
Label string // a link's display text, shown instead of its URL
|
|
||||||
}
|
|
||||||
|
|
||||||
type canvasGroupBody struct {
|
|
||||||
URL string
|
|
||||||
Style string
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *handler) renderCanvasNode(n canvasNode) canvasNodeView {
|
|
||||||
v := canvasNodeView{
|
|
||||||
ID: n.ID,
|
|
||||||
Type: n.Type,
|
|
||||||
X: int(math.Round(n.X)),
|
|
||||||
Y: int(math.Round(n.Y)),
|
|
||||||
W: int(math.Round(n.Width)),
|
|
||||||
H: int(math.Round(n.Height)),
|
|
||||||
}
|
|
||||||
v.Preset, v.Hex = canvasColor(n.Color)
|
|
||||||
switch n.Type {
|
|
||||||
case "text":
|
|
||||||
v.Markdown = true
|
|
||||||
v.Body = template.HTML(canvasTaskCheckboxes(convertMarkdown([]byte(n.Text), "")))
|
|
||||||
case "file":
|
|
||||||
h.renderCanvasFile(&v, n)
|
|
||||||
case "link":
|
|
||||||
host := n.URL
|
|
||||||
if u, err := url.Parse(n.URL); err == nil && u.Host != "" {
|
|
||||||
host = u.Host
|
|
||||||
}
|
|
||||||
v.Body = execNodeTemplate("canvas-link", canvasCardBody{Name: n.URL, URL: n.URL, Host: host, Label: strings.TrimSpace(n.Label)})
|
|
||||||
case "group":
|
|
||||||
v.Label = n.Label
|
|
||||||
if n.Background != "" {
|
|
||||||
if rel, _, ok := h.canvasFilePath(n.Background); ok {
|
|
||||||
style := n.BackgroundStyle
|
|
||||||
if style != "ratio" && style != "repeat" {
|
|
||||||
style = "cover"
|
|
||||||
}
|
|
||||||
v.Body = execNodeTemplate("canvas-group-bg", canvasGroupBody{URL: fileURL(rel), Style: style})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return v
|
|
||||||
}
|
|
||||||
|
|
||||||
// canvasFilePath resolves a file node's path. Paths are relative to the wiki
|
|
||||||
// root (the spec's "vault"); a leading slash is accepted. Dot segments are
|
|
||||||
// refused like everywhere else.
|
|
||||||
func (h *handler) canvasFilePath(p string) (rel, fsPath string, ok bool) {
|
|
||||||
rel = strings.Trim(path.Clean("/"+strings.ReplaceAll(p, "\\", "/")), "/")
|
|
||||||
if hasDotSegment(rel) {
|
|
||||||
return "", "", false
|
|
||||||
}
|
|
||||||
return rel, filepath.Join(h.root, filepath.FromSlash(rel)), true
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *handler) renderCanvasFile(v *canvasNodeView, n canvasNode) {
|
|
||||||
rel, fsPath, ok := h.canvasFilePath(n.File)
|
|
||||||
v.Label = path.Base("/" + rel)
|
|
||||||
info, err := os.Stat(fsPath)
|
|
||||||
if !ok || err != nil {
|
|
||||||
v.Body = execNodeTemplate("canvas-missing", n.File)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if info.IsDir() {
|
|
||||||
// A folder is a page: show its index.md.
|
|
||||||
v.LabelURL = pageURL(rel)
|
|
||||||
raw, _ := os.ReadFile(filepath.Join(fsPath, "index.md"))
|
|
||||||
if heading := extractFirstHeading(raw); heading != "" {
|
|
||||||
v.Label = heading
|
|
||||||
}
|
|
||||||
canvasMarkdownBody(v, raw, n.Subpath, dirBase(rel))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
u := fileURL(rel)
|
|
||||||
v.LabelURL = u
|
|
||||||
name := info.Name()
|
|
||||||
ext := strings.ToLower(path.Ext(name))
|
|
||||||
switch {
|
|
||||||
case ext == ".md":
|
|
||||||
raw, _ := os.ReadFile(fsPath)
|
|
||||||
canvasMarkdownBody(v, raw, n.Subpath, dirBase(path.Dir("/"+rel)))
|
|
||||||
case isImageFile(name) || ext == ".svg" || ext == ".webp":
|
|
||||||
thumb := u
|
|
||||||
if hasThumbnail(name) {
|
|
||||||
thumb = u + "?w=800"
|
|
||||||
}
|
|
||||||
v.Body = execNodeTemplate("canvas-media", canvasMediaBody{URL: u, Thumb: thumb, Kind: "image"})
|
|
||||||
case isVideoFile(name):
|
|
||||||
v.Body = execNodeTemplate("canvas-media", canvasMediaBody{URL: u, Kind: "video"})
|
|
||||||
case ext == ".mp3" || ext == ".flac" || ext == ".ogg" || ext == ".wav":
|
|
||||||
v.Body = execNodeTemplate("canvas-media", canvasMediaBody{URL: u, Kind: "audio"})
|
|
||||||
default:
|
|
||||||
v.Body = execNodeTemplate("canvas-file", canvasCardBody{Icon: fileIcon(name), Name: name, URL: u})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// dirBase is the base URL path for markdown that lives in the folder rel
|
|
||||||
// (wiki-relative, "" or "/" for the root).
|
|
||||||
func dirBase(rel string) string {
|
|
||||||
rel = strings.Trim(rel, "/")
|
|
||||||
if rel == "" {
|
|
||||||
return "/"
|
|
||||||
}
|
|
||||||
return "/" + rel + "/"
|
|
||||||
}
|
|
||||||
|
|
||||||
// canvasMarkdownBody renders a markdown file node: the whole file, or the
|
|
||||||
// section its subpath names. A subpath that names no heading says so on the
|
|
||||||
// card rather than showing the whole file (as jsoncanvas-desktop does).
|
|
||||||
func canvasMarkdownBody(v *canvasNodeView, raw []byte, subpath, base string) {
|
|
||||||
part, ok := canvasSubpath(raw, subpath)
|
|
||||||
if !ok {
|
|
||||||
v.Body = execNodeTemplate("canvas-missing", subpath)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
v.Markdown = true
|
|
||||||
v.Body = template.HTML(convertMarkdown(part, base))
|
|
||||||
}
|
|
||||||
|
|
||||||
// canvasSubpath narrows markdown to a file node's "#Heading" subpath: that
|
|
||||||
// heading's section including its subsections, matched case-insensitively
|
|
||||||
// on the heading text. Without a subpath it is the whole file; ok is false
|
|
||||||
// for a subpath that names no heading, which includes block references
|
|
||||||
// ("#^id").
|
|
||||||
func canvasSubpath(raw []byte, subpath string) (part []byte, ok bool) {
|
|
||||||
want := strings.TrimSpace(strings.TrimPrefix(subpath, "#"))
|
|
||||||
if want == "" {
|
|
||||||
return raw, true
|
|
||||||
}
|
|
||||||
sections := splitSections(raw)
|
|
||||||
for i, s := range sections {
|
|
||||||
if _, text := sectionHeading(s); i > 0 && strings.EqualFold(text, want) {
|
|
||||||
return joinSections(sections[i:secionSpanEnd(sections, i)]), true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil, false
|
|
||||||
}
|
|
||||||
|
|
||||||
// canvasTaskCheckboxes makes a text node's task checkboxes clickable. The
|
|
||||||
// canvas editor (canvas.js) flips the Nth task line of the node's text and
|
|
||||||
// saves the whole canvas, so they carry only their index.
|
|
||||||
func canvasTaskCheckboxes(in []byte) []byte {
|
|
||||||
idx := 0
|
|
||||||
return taskCheckboxRe.ReplaceAllFunc(in, func(match []byte) []byte {
|
|
||||||
out := `<input type="checkbox" class="task-checkbox" data-task="` + strconv.Itoa(idx) + `"`
|
|
||||||
if bytes.Contains(match, []byte("checked")) {
|
|
||||||
out += ` checked=""`
|
|
||||||
}
|
|
||||||
idx++
|
|
||||||
return []byte(out + ">")
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func execNodeTemplate(name string, data any) template.HTML {
|
|
||||||
var buf bytes.Buffer
|
|
||||||
if err := canvasTmpl.ExecuteTemplate(&buf, name, data); err != nil {
|
|
||||||
log.Printf("canvas template %s: %v", name, err)
|
|
||||||
}
|
|
||||||
return template.HTML(buf.String())
|
|
||||||
}
|
|
||||||
|
|
||||||
// === Handlers ===
|
|
||||||
|
|
||||||
type canvasPageData struct {
|
|
||||||
Title string
|
|
||||||
EditMode bool
|
|
||||||
CanEdit bool
|
|
||||||
IsRoot bool
|
|
||||||
PageURL string // the page the canvas belongs to
|
|
||||||
RawURL string
|
|
||||||
Name string
|
|
||||||
Nodes []canvasNodeView
|
|
||||||
Doc json.RawMessage
|
|
||||||
Version string
|
|
||||||
Error string
|
|
||||||
renderTimer
|
|
||||||
}
|
|
||||||
|
|
||||||
// serveCanvas handles every request to an existing .canvas file.
|
|
||||||
func (h *handler) serveCanvas(w http.ResponseWriter, r *http.Request, urlPath, fsPath string) {
|
|
||||||
q := r.URL.Query()
|
|
||||||
switch {
|
|
||||||
case r.Method == http.MethodGet && q.Has("raw"):
|
|
||||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
|
||||||
http.ServeFile(w, r, fsPath)
|
|
||||||
case r.Method == http.MethodGet:
|
|
||||||
h.serveCanvasPage(w, r, urlPath, fsPath)
|
|
||||||
case r.Method == http.MethodPost && q.Has("save"):
|
|
||||||
h.handleCanvasSave(w, r, fsPath)
|
|
||||||
case r.Method == http.MethodPost && q.Has("render"):
|
|
||||||
h.handleCanvasRender(w, r)
|
|
||||||
default:
|
|
||||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *handler) serveCanvasPage(w http.ResponseWriter, r *http.Request, urlPath, fsPath string) {
|
|
||||||
raw, err := os.ReadFile(fsPath)
|
|
||||||
if err != nil {
|
|
||||||
http.Error(w, "read failed: "+err.Error(), http.StatusInternalServerError)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
name := path.Base(urlPath)
|
|
||||||
data := canvasPageData{
|
|
||||||
Title: strings.TrimSuffix(name, path.Ext(name)),
|
|
||||||
CanEdit: true,
|
|
||||||
PageURL: pageURL(path.Dir(urlPath)),
|
|
||||||
RawURL: fileURL(strings.TrimPrefix(urlPath, "/")) + "?raw",
|
|
||||||
Name: name,
|
|
||||||
Version: canvasVersion(raw),
|
|
||||||
Doc: json.RawMessage(emptyCanvas),
|
|
||||||
}
|
|
||||||
doc, err := parseCanvas(raw)
|
|
||||||
if err != nil {
|
|
||||||
data.Error = err.Error()
|
|
||||||
} else {
|
|
||||||
if len(bytes.TrimSpace(raw)) > 0 {
|
|
||||||
data.Doc = json.RawMessage(raw)
|
|
||||||
}
|
|
||||||
for _, n := range doc.Nodes {
|
|
||||||
data.Nodes = append(data.Nodes, h.renderCanvasNode(n))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
data.renderTimer = renderTimer{requestStart(r)}
|
|
||||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
||||||
if err := canvasTmpl.ExecuteTemplate(w, "layout", data); err != nil {
|
|
||||||
log.Printf("canvas template error: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// maxCanvasBytes caps a saved canvas; text nodes hold markdown, not files.
|
|
||||||
const maxCanvasBytes = 10 << 20
|
|
||||||
|
|
||||||
// handleCanvasSave replaces the canvas with the posted document. The client
|
|
||||||
// sends the version it last loaded or saved (X-Canvas-Version); when the file
|
|
||||||
// has changed since, the save is refused with 409 so nothing is overwritten.
|
|
||||||
func (h *handler) handleCanvasSave(w http.ResponseWriter, r *http.Request, fsPath string) {
|
|
||||||
body, err := io.ReadAll(http.MaxBytesReader(w, r.Body, maxCanvasBytes))
|
|
||||||
if err != nil {
|
|
||||||
http.Error(w, "canvas too large", http.StatusRequestEntityTooLarge)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if len(bytes.TrimSpace(body)) == 0 {
|
|
||||||
http.Error(w, "refusing to save an empty canvas body", http.StatusBadRequest)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if _, err := parseCanvas(body); err != nil {
|
|
||||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
var out bytes.Buffer
|
|
||||||
if err := json.Indent(&out, body, "", "\t"); err != nil {
|
|
||||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
out.WriteByte('\n')
|
|
||||||
|
|
||||||
canvasMu.Lock()
|
|
||||||
defer canvasMu.Unlock()
|
|
||||||
current, err := os.ReadFile(fsPath)
|
|
||||||
if err != nil {
|
|
||||||
if errors.Is(err, os.ErrNotExist) {
|
|
||||||
http.Error(w, "this canvas no longer exists", http.StatusNotFound)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
http.Error(w, "read failed: "+err.Error(), http.StatusInternalServerError)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if r.Header.Get("X-Canvas-Version") != canvasVersion(current) {
|
|
||||||
http.Error(w, "the canvas changed since it was opened — reload to see the current version", http.StatusConflict)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if err := writeFileAtomic(fsPath, out.Bytes(), 0644); err != nil {
|
|
||||||
http.Error(w, "write failed: "+err.Error(), http.StatusInternalServerError)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
w.Header().Set("X-Canvas-Version", canvasVersion(out.Bytes()))
|
|
||||||
w.WriteHeader(http.StatusNoContent)
|
|
||||||
}
|
|
||||||
|
|
||||||
// handleCanvasRender renders one posted node as its element, so the editor
|
|
||||||
// shows nodes exactly as the server renders them on load.
|
|
||||||
func (h *handler) handleCanvasRender(w http.ResponseWriter, r *http.Request) {
|
|
||||||
var n canvasNode
|
|
||||||
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, maxCanvasBytes)).Decode(&n); err != nil {
|
|
||||||
http.Error(w, "bad node: "+err.Error(), http.StatusBadRequest)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
||||||
if err := canvasTmpl.ExecuteTemplate(w, "canvas-node", h.renderCanvasNode(n)); err != nil {
|
|
||||||
log.Printf("canvas template error: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// canvasFileName validates a new canvas's name from the create dialog and
|
|
||||||
// returns the file name, with the extension added when missing.
|
|
||||||
func canvasFileName(raw string) (string, error) {
|
|
||||||
name := strings.TrimSpace(raw)
|
|
||||||
if isCanvasFile(name) {
|
|
||||||
name = strings.TrimSpace(name[:len(name)-len(canvasExt)])
|
|
||||||
}
|
|
||||||
if name == "" {
|
|
||||||
return "", fmt.Errorf("name is empty")
|
|
||||||
}
|
|
||||||
if strings.HasPrefix(name, ".") || strings.ContainsAny(name, "/\\\x00") {
|
|
||||||
return "", fmt.Errorf("name must not start with a dot or contain slashes")
|
|
||||||
}
|
|
||||||
return name + canvasExt, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// handleCanvasCreate creates an empty canvas in the page's folder (creating
|
|
||||||
// the folder for a page that does not exist yet) and opens it.
|
|
||||||
func (h *handler) handleCanvasCreate(w http.ResponseWriter, r *http.Request, urlPath, fsPath string) {
|
|
||||||
if err := r.ParseForm(); err != nil {
|
|
||||||
http.Error(w, "bad request", http.StatusBadRequest)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
name, err := canvasFileName(r.FormValue("name"))
|
|
||||||
if err != nil {
|
|
||||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
_, 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
|
|
||||||
}
|
|
||||||
f, err := os.OpenFile(filepath.Join(fsPath, name), os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0644)
|
|
||||||
if err != nil {
|
|
||||||
if errors.Is(err, os.ErrExist) {
|
|
||||||
http.Error(w, name+" already exists", http.StatusConflict)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
http.Error(w, "create failed: "+err.Error(), http.StatusInternalServerError)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
_, werr := f.Write(emptyCanvas)
|
|
||||||
if cerr := f.Close(); werr == nil {
|
|
||||||
werr = cerr
|
|
||||||
}
|
|
||||||
if werr != nil {
|
|
||||||
http.Error(w, "write failed: "+werr.Error(), http.StatusInternalServerError)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if newlyCreated {
|
|
||||||
if rel, err := filepath.Rel(h.root, fsPath); err == nil {
|
|
||||||
folderIndexAdd(filepath.ToSlash(rel))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
rel := strings.Trim(urlPath, "/")
|
|
||||||
if rel != "" {
|
|
||||||
rel += "/"
|
|
||||||
}
|
|
||||||
redirectAfter(w, r, fileURL(rel+name))
|
|
||||||
}
|
|
||||||
@@ -1,286 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/json"
|
|
||||||
"net/http"
|
|
||||||
"net/http/httptest"
|
|
||||||
"net/url"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
)
|
|
||||||
|
|
||||||
func writeTestFile(t *testing.T, path, content string) {
|
|
||||||
t.Helper()
|
|
||||||
if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if err := os.WriteFile(path, []byte(content), 0644); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func canvasRequest(h *handler, method, target, body string, header map[string]string) *httptest.ResponseRecorder {
|
|
||||||
r := httptest.NewRequest(method, target, strings.NewReader(body))
|
|
||||||
for k, v := range header {
|
|
||||||
r.Header.Set(k, v)
|
|
||||||
}
|
|
||||||
w := httptest.NewRecorder()
|
|
||||||
h.ServeHTTP(w, r)
|
|
||||||
return w
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestParseCanvas(t *testing.T) {
|
|
||||||
ok := []string{
|
|
||||||
``,
|
|
||||||
`{}`,
|
|
||||||
`{"nodes":[{"id":"a","type":"text","x":0,"y":0,"width":10,"height":10,"text":"hi","color":"3"},
|
|
||||||
{"id":"b","type":"group","x":0,"y":0,"width":10,"height":10,"color":"#ff8800"}],
|
|
||||||
"edges":[{"id":"e","fromNode":"a","toNode":"b","fromSide":"left","toEnd":"none"}]}`,
|
|
||||||
}
|
|
||||||
for _, src := range ok {
|
|
||||||
if _, err := parseCanvas([]byte(src)); err != nil {
|
|
||||||
t.Errorf("parseCanvas(%s): %v", src, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
bad := map[string]string{
|
|
||||||
"not an object": `[]`,
|
|
||||||
"syntax": `{"nodes":[`,
|
|
||||||
"no id": `{"nodes":[{"type":"text","x":0,"y":0,"width":1,"height":1}]}`,
|
|
||||||
"duplicate id": `{"nodes":[{"id":"a","type":"text"},{"id":"a","type":"text"}]}`,
|
|
||||||
"unknown type": `{"nodes":[{"id":"a","type":"blob"}]}`,
|
|
||||||
"file w/o path": `{"nodes":[{"id":"a","type":"file"}]}`,
|
|
||||||
"link w/o url": `{"nodes":[{"id":"a","type":"link"}]}`,
|
|
||||||
"bad color": `{"nodes":[{"id":"a","type":"text","color":"red;x"}]}`,
|
|
||||||
"string x": `{"nodes":[{"id":"a","type":"text","x":"1"}]}`,
|
|
||||||
"dangling edge": `{"nodes":[{"id":"a","type":"text"}],"edges":[{"id":"e","fromNode":"a","toNode":"zz"}]}`,
|
|
||||||
"bad side": `{"nodes":[{"id":"a","type":"text"}],"edges":[{"id":"e","fromNode":"a","toNode":"a","toSide":"up"}]}`,
|
|
||||||
"bad end": `{"nodes":[{"id":"a","type":"text"}],"edges":[{"id":"e","fromNode":"a","toNode":"a","toEnd":"dot"}]}`,
|
|
||||||
}
|
|
||||||
for name, src := range bad {
|
|
||||||
if _, err := parseCanvas([]byte(src)); err == nil {
|
|
||||||
t.Errorf("%s: parseCanvas accepted %s", name, src)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCanvasSave(t *testing.T) {
|
|
||||||
initMarkdown()
|
|
||||||
root := t.TempDir()
|
|
||||||
file := filepath.Join(root, "Page", "Board.canvas")
|
|
||||||
writeTestFile(t, file, string(emptyCanvas))
|
|
||||||
h := &handler{root: root}
|
|
||||||
|
|
||||||
save := func(body, version string) *httptest.ResponseRecorder {
|
|
||||||
return canvasRequest(h, "POST", "/Page/Board.canvas?save", body, map[string]string{"X-Canvas-Version": version})
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fields the server does not know survive the round trip.
|
|
||||||
doc := `{"nodes":[{"id":"a","type":"text","x":1,"y":2,"width":3,"height":4,"text":"hi","custom":{"k":1}}],"edges":[],"extra":true}`
|
|
||||||
w := save(doc, canvasVersion(emptyCanvas))
|
|
||||||
if w.Code != http.StatusNoContent {
|
|
||||||
t.Fatalf("save: %d %s", w.Code, w.Body)
|
|
||||||
}
|
|
||||||
saved, _ := os.ReadFile(file)
|
|
||||||
var got map[string]any
|
|
||||||
if err := json.Unmarshal(saved, &got); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if got["extra"] != true || !strings.Contains(string(saved), `"custom"`) {
|
|
||||||
t.Errorf("unknown fields lost:\n%s", saved)
|
|
||||||
}
|
|
||||||
if v := w.Header().Get("X-Canvas-Version"); v != canvasVersion(saved) {
|
|
||||||
t.Errorf("returned version %q does not match the file", v)
|
|
||||||
}
|
|
||||||
|
|
||||||
// A save based on an older version is refused and changes nothing.
|
|
||||||
if w := save(`{"nodes":[],"edges":[]}`, canvasVersion(emptyCanvas)); w.Code != http.StatusConflict {
|
|
||||||
t.Errorf("stale save: got %d, want 409", w.Code)
|
|
||||||
}
|
|
||||||
if now, _ := os.ReadFile(file); string(now) != string(saved) {
|
|
||||||
t.Error("stale save changed the file")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Invalid documents are refused.
|
|
||||||
if w := save(`{"nodes":[{"id":"a","type":"nope"}]}`, canvasVersion(saved)); w.Code != http.StatusBadRequest {
|
|
||||||
t.Errorf("invalid save: got %d, want 400", w.Code)
|
|
||||||
}
|
|
||||||
if w := save(``, canvasVersion(saved)); w.Code != http.StatusBadRequest {
|
|
||||||
t.Errorf("empty save: got %d, want 400", w.Code)
|
|
||||||
}
|
|
||||||
|
|
||||||
// A canvas deleted while open is not recreated as a page.
|
|
||||||
os.Remove(file)
|
|
||||||
if w := save(doc, canvasVersion(saved)); w.Code != http.StatusNotFound {
|
|
||||||
t.Errorf("save to deleted canvas: got %d, want 404", w.Code)
|
|
||||||
}
|
|
||||||
if _, err := os.Stat(filepath.Join(root, "Page", "Board.canvas")); !os.IsNotExist(err) {
|
|
||||||
t.Error("save to deleted canvas created something")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCanvasPage(t *testing.T) {
|
|
||||||
initMarkdown()
|
|
||||||
root := t.TempDir()
|
|
||||||
writeTestFile(t, filepath.Join(root, "Page", "Board.canvas"),
|
|
||||||
`{"nodes":[{"id":"n1","type":"text","x":10,"y":20,"width":200,"height":100,"text":"# Hello\n\n- [ ] task","color":"2"}],"edges":[]}`)
|
|
||||||
writeTestFile(t, filepath.Join(root, "Page", "Broken.canvas"), `{"nodes":[`)
|
|
||||||
h := &handler{root: root}
|
|
||||||
|
|
||||||
w := canvasRequest(h, "GET", "/Page/Board.canvas", "", nil)
|
|
||||||
body := w.Body.String()
|
|
||||||
for _, want := range []string{
|
|
||||||
`class="canvas-app"`,
|
|
||||||
`data-id="n1"`,
|
|
||||||
`data-color="2"`,
|
|
||||||
`left: 10px; top: 20px; width: 200px; height: 100px;`,
|
|
||||||
`<h1 id="hello">Hello</h1>`,
|
|
||||||
`class="task-checkbox" data-task="0"`,
|
|
||||||
`id="canvas-data"`,
|
|
||||||
} {
|
|
||||||
if !strings.Contains(body, want) {
|
|
||||||
t.Errorf("canvas page lacks %q", want)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for _, unwanted := range []string{`tree-sidebar`, `hx-boost="true"`, `<footer`, `hx-post="?toggle`} {
|
|
||||||
if strings.Contains(body, unwanted) {
|
|
||||||
t.Errorf("canvas page contains %q", unwanted)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if w := canvasRequest(h, "GET", "/Page/Board.canvas?raw", "", nil); !strings.Contains(w.Body.String(), `"n1"`) ||
|
|
||||||
!strings.HasPrefix(w.Header().Get("Content-Type"), "application/json") {
|
|
||||||
t.Errorf("?raw: %s %s", w.Header().Get("Content-Type"), w.Body)
|
|
||||||
}
|
|
||||||
|
|
||||||
w = canvasRequest(h, "GET", "/Page/Broken.canvas", "", nil)
|
|
||||||
if body := w.Body.String(); !strings.Contains(body, "data-readonly") || !strings.Contains(body, "cannot be edited") {
|
|
||||||
t.Errorf("broken canvas is not read-only:\n%s", body)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCanvasRenderNodes(t *testing.T) {
|
|
||||||
initMarkdown()
|
|
||||||
root := t.TempDir()
|
|
||||||
writeTestFile(t, filepath.Join(root, "Other Page", "index.md"), "# Other\n\n\n\n## Part\n\nPart text\n\n## Rest\n\nRest text\n")
|
|
||||||
writeTestFile(t, filepath.Join(root, "Other Page", "notes.md"), "[rel](sub/x.md) [abs](/y/) [web](https://e.x)\n")
|
|
||||||
writeTestFile(t, filepath.Join(root, "Other Page", "pic one.jpg"), "not really")
|
|
||||||
writeTestFile(t, filepath.Join(root, "Other Page", "data.zip"), "zip")
|
|
||||||
writeTestFile(t, filepath.Join(root, ".secret", "x.md"), "secret")
|
|
||||||
writeTestFile(t, filepath.Join(root, "Board.canvas"), "")
|
|
||||||
h := &handler{root: root}
|
|
||||||
|
|
||||||
render := func(node string) string {
|
|
||||||
w := canvasRequest(h, "POST", "/Board.canvas?render", node, nil)
|
|
||||||
if w.Code != 200 {
|
|
||||||
t.Fatalf("render %s: %d %s", node, w.Code, w.Body)
|
|
||||||
}
|
|
||||||
return w.Body.String()
|
|
||||||
}
|
|
||||||
|
|
||||||
page := render(`{"id":"a","type":"file","file":"Other Page","x":0,"y":0,"width":1,"height":1}`)
|
|
||||||
if !strings.Contains(page, `href="/Other%20Page/"`) || !strings.Contains(page, ">Other<") ||
|
|
||||||
!strings.Contains(page, `src="/Other%20Page/pic%20one.jpg?w=300"`) {
|
|
||||||
t.Errorf("page node:\n%s", page)
|
|
||||||
}
|
|
||||||
part := render(`{"id":"a","type":"file","file":"/Other Page/index.md","subpath":"#Part","x":0,"y":0,"width":1,"height":1}`)
|
|
||||||
if !strings.Contains(part, "Part text") || strings.Contains(part, "Rest text") || strings.Contains(part, "shot") {
|
|
||||||
t.Errorf("subpath node:\n%s", part)
|
|
||||||
}
|
|
||||||
notes := render(`{"id":"a","type":"file","file":"Other Page/notes.md","x":0,"y":0,"width":1,"height":1}`)
|
|
||||||
for _, want := range []string{`href="/Other%20Page/sub/x.md"`, `href="/y/"`, `href="https://e.x"`} {
|
|
||||||
if !strings.Contains(notes, want) {
|
|
||||||
t.Errorf("markdown file node lacks %s:\n%s", want, notes)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if img := render(`{"id":"a","type":"file","file":"Other Page/pic one.jpg","x":0,"y":0,"width":1,"height":1}`); !strings.Contains(img, `<img src="/Other%20Page/pic%20one.jpg?w=800"`) {
|
|
||||||
t.Errorf("image node:\n%s", img)
|
|
||||||
}
|
|
||||||
if zip := render(`{"id":"a","type":"file","file":"Other Page/data.zip","x":0,"y":0,"width":1,"height":1}`); !strings.Contains(zip, `class="canvas-card" href="/Other%20Page/data.zip"`) {
|
|
||||||
t.Errorf("file card node:\n%s", zip)
|
|
||||||
}
|
|
||||||
for _, p := range []string{"nope.md", ".secret/x.md", "../../etc/passwd"} {
|
|
||||||
if out := render(`{"id":"a","type":"file","file":"` + p + `","x":0,"y":0,"width":1,"height":1}`); !strings.Contains(out, "canvas-missing") || strings.Contains(out, "secret") && p != ".secret/x.md" {
|
|
||||||
t.Errorf("file %q not reported missing:\n%s", p, out)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
link := render(`{"id":"a","type":"link","url":"javascript:alert(1)","x":0,"y":0,"width":1,"height":1}`)
|
|
||||||
if strings.Contains(link, `href="javascript:`) {
|
|
||||||
t.Errorf("unsafe link rendered:\n%s", link)
|
|
||||||
}
|
|
||||||
named := render(`{"id":"a","type":"link","url":"https://jsoncanvas.org/spec/1.0/","label":"The spec","x":0,"y":0,"width":1,"height":1}`)
|
|
||||||
if !strings.Contains(named, `<span class="truncate">The spec</span><span class="muted truncate">jsoncanvas.org</span>`) || !strings.Contains(named, `title="https://jsoncanvas.org/spec/1.0/"`) {
|
|
||||||
t.Errorf("link with display text:\n%s", named)
|
|
||||||
}
|
|
||||||
group := render(`{"id":"a","type":"group","label":"<b>G</b>","color":"#abc","x":0,"y":0,"width":1,"height":1}`)
|
|
||||||
if !strings.Contains(group, "<b>G</b>") || !strings.Contains(group, "--node-color: #abc") {
|
|
||||||
t.Errorf("group node:\n%s", group)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCanvasCreate(t *testing.T) {
|
|
||||||
root := t.TempDir()
|
|
||||||
h := &handler{root: root}
|
|
||||||
create := func(name string) *httptest.ResponseRecorder {
|
|
||||||
form := url.Values{"name": {name}}.Encode()
|
|
||||||
return canvasRequest(h, "POST", "/New%20Page/?canvas", form, map[string]string{
|
|
||||||
"Content-Type": "application/x-www-form-urlencoded",
|
|
||||||
"HX-Request": "true",
|
|
||||||
})
|
|
||||||
}
|
|
||||||
w := create("My Board")
|
|
||||||
if w.Code != http.StatusNoContent || w.Header().Get("HX-Redirect") != "/New%20Page/My%20Board.canvas" {
|
|
||||||
t.Fatalf("create: %d %q %s", w.Code, w.Header().Get("HX-Redirect"), w.Body)
|
|
||||||
}
|
|
||||||
raw, err := os.ReadFile(filepath.Join(root, "New Page", "My Board.canvas"))
|
|
||||||
if err != nil || string(raw) != string(emptyCanvas) {
|
|
||||||
t.Fatalf("created file: %v %q", err, raw)
|
|
||||||
}
|
|
||||||
if w := create("My Board.canvas"); w.Code != http.StatusConflict {
|
|
||||||
t.Errorf("duplicate: got %d, want 409", w.Code)
|
|
||||||
}
|
|
||||||
for _, bad := range []string{"", " ", ".hidden", "a/b", `a\b`} {
|
|
||||||
if w := create(bad); w.Code != http.StatusBadRequest {
|
|
||||||
t.Errorf("name %q: got %d, want 400", bad, w.Code)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCanvasListedWithIcon(t *testing.T) {
|
|
||||||
if fileIcon("Board.canvas") != iconCanvas {
|
|
||||||
t.Error("canvas files do not get the canvas icon")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCanvasSubpath(t *testing.T) {
|
|
||||||
doc := []byte("# LAN Party 2026\nIntro.\n## Teilnehmer\n| Nr. | Name |\n| --- | --- |\n| 1. | Marci |\n")
|
|
||||||
cases := []struct {
|
|
||||||
subpath, want string
|
|
||||||
ok bool
|
|
||||||
}{
|
|
||||||
{"", string(doc), true},
|
|
||||||
// The last section of the file runs to its end.
|
|
||||||
{"#teilnehmer", "## Teilnehmer\n| Nr. | Name |\n| --- | --- |\n| 1. | Marci |\n", true},
|
|
||||||
// A heading takes the sections nested under it along.
|
|
||||||
{"#LAN Party 2026", string(doc), true},
|
|
||||||
{"#Gäste", "", false},
|
|
||||||
{"#^block", "", false},
|
|
||||||
}
|
|
||||||
for _, c := range cases {
|
|
||||||
got, ok := canvasSubpath(doc, c.subpath)
|
|
||||||
if ok != c.ok || string(got) != c.want {
|
|
||||||
t.Errorf("canvasSubpath(%q) = %q, %v; want %q, %v", c.subpath, got, ok, c.want, c.ok)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSectionSpanEndAtLastSection(t *testing.T) {
|
|
||||||
sections := splitSections([]byte("# A\n## B\ntext\n"))
|
|
||||||
if end := secionSpanEnd(sections, 2); end != 3 {
|
|
||||||
t.Errorf("span of the last section ends at %d, want 3", end)
|
|
||||||
}
|
|
||||||
if end := secionSpanEnd(sections, 1); end != 3 {
|
|
||||||
t.Errorf("span of the top section ends at %d, want 3", end)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,98 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"os/exec"
|
|
||||||
"runtime"
|
|
||||||
"strings"
|
|
||||||
)
|
|
||||||
|
|
||||||
// commandDefaults holds the per-OS open-command templates used when the user
|
|
||||||
// hasn't overridden them in the config.
|
|
||||||
type commandDefaults struct {
|
|
||||||
OpenFile string
|
|
||||||
OpenFolder string
|
|
||||||
}
|
|
||||||
|
|
||||||
func defaultCommands() commandDefaults {
|
|
||||||
if runtime.GOOS == "windows" {
|
|
||||||
return commandDefaults{
|
|
||||||
OpenFile: `cmd /c start "" "{path}"`,
|
|
||||||
OpenFolder: `explorer.exe "{path}"`,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return commandDefaults{
|
|
||||||
OpenFile: `xdg-open "{path}"`,
|
|
||||||
OpenFolder: `xdg-open "{path}"`,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// resolveOpenCommand returns the user-configured command if non-blank, else
|
|
||||||
// the platform default.
|
|
||||||
func resolveOpenCommand(configured, fallback string) string {
|
|
||||||
if strings.TrimSpace(configured) != "" {
|
|
||||||
return configured
|
|
||||||
}
|
|
||||||
return fallback
|
|
||||||
}
|
|
||||||
|
|
||||||
// runOpenCommand tokenizes template, substitutes {path} with the resolved
|
|
||||||
// path (appending it if the placeholder is missing), and starts the command.
|
|
||||||
func runOpenCommand(template, path string) error {
|
|
||||||
tokens, err := tokenizeCommand(template)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("parse command: %w", err)
|
|
||||||
}
|
|
||||||
if len(tokens) == 0 {
|
|
||||||
return errors.New("command is empty")
|
|
||||||
}
|
|
||||||
sawPath := false
|
|
||||||
for i, t := range tokens {
|
|
||||||
if strings.Contains(t, "{path}") {
|
|
||||||
tokens[i] = strings.ReplaceAll(t, "{path}", path)
|
|
||||||
sawPath = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if !sawPath {
|
|
||||||
tokens = append(tokens, path)
|
|
||||||
}
|
|
||||||
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
|
|
||||||
}
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
//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) {}
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
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,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,94 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/json"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"runtime"
|
|
||||||
)
|
|
||||||
|
|
||||||
const defaultPort = 17680
|
|
||||||
|
|
||||||
type config struct {
|
|
||||||
WikiRoot string `json:"wikiRoot"`
|
|
||||||
AllowedOrigins []string `json:"allowedOrigins"`
|
|
||||||
Port int `json:"port,omitempty"`
|
|
||||||
OpenFileCommand string `json:"openFileCommand,omitempty"`
|
|
||||||
OpenFolderCommand string `json:"openFolderCommand,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// configPath returns the platform-conventional config path.
|
|
||||||
//
|
|
||||||
// Windows: %APPDATA%\datascape\companion.json
|
|
||||||
// Linux: $XDG_CONFIG_HOME/datascape/companion.json
|
|
||||||
// (fallback ~/.config/datascape/companion.json)
|
|
||||||
func configPath() (string, error) {
|
|
||||||
if runtime.GOOS == "windows" {
|
|
||||||
appData := os.Getenv("APPDATA")
|
|
||||||
if appData == "" {
|
|
||||||
return "", errors.New("APPDATA not set")
|
|
||||||
}
|
|
||||||
return filepath.Join(appData, "datascape", "companion.json"), nil
|
|
||||||
}
|
|
||||||
if x := os.Getenv("XDG_CONFIG_HOME"); x != "" {
|
|
||||||
return filepath.Join(x, "datascape", "companion.json"), nil
|
|
||||||
}
|
|
||||||
home, err := os.UserHomeDir()
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
return filepath.Join(home, ".config", "datascape", "companion.json"), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// loadOrInitConfig reads the on-disk config, creating a default file if none
|
|
||||||
// exists. Returns the resolved config (with port defaulted) and the path.
|
|
||||||
func loadOrInitConfig() (*config, string, error) {
|
|
||||||
p, err := configPath()
|
|
||||||
if err != nil {
|
|
||||||
return nil, "", err
|
|
||||||
}
|
|
||||||
data, err := os.ReadFile(p)
|
|
||||||
if errors.Is(err, os.ErrNotExist) {
|
|
||||||
cfg := &config{AllowedOrigins: []string{}, Port: defaultPort}
|
|
||||||
if err := writeConfigFile(p, cfg); err != nil {
|
|
||||||
return nil, p, err
|
|
||||||
}
|
|
||||||
return cfg, p, nil
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
return nil, p, err
|
|
||||||
}
|
|
||||||
var cfg config
|
|
||||||
if err := json.Unmarshal(data, &cfg); err != nil {
|
|
||||||
return nil, p, fmt.Errorf("parse config: %w", err)
|
|
||||||
}
|
|
||||||
if cfg.Port == 0 {
|
|
||||||
cfg.Port = defaultPort
|
|
||||||
}
|
|
||||||
if cfg.AllowedOrigins == nil {
|
|
||||||
cfg.AllowedOrigins = []string{}
|
|
||||||
}
|
|
||||||
return &cfg, p, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// writeConfigFile atomically writes cfg to p (write-temp + rename).
|
|
||||||
func writeConfigFile(p string, cfg *config) error {
|
|
||||||
if err := os.MkdirAll(filepath.Dir(p), 0755); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
out := *cfg
|
|
||||||
if out.AllowedOrigins == nil {
|
|
||||||
out.AllowedOrigins = []string{}
|
|
||||||
}
|
|
||||||
data, err := json.MarshalIndent(out, "", " ")
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
tmp := p + ".tmp"
|
|
||||||
if err := os.WriteFile(tmp, data, 0644); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return os.Rename(tmp, p)
|
|
||||||
}
|
|
||||||
@@ -1,153 +0,0 @@
|
|||||||
<!doctype html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
||||||
<title>datascape companion — settings</title>
|
|
||||||
<style>
|
|
||||||
:root {
|
|
||||||
--bg: #2e2e2e;
|
|
||||||
--bg-panel: #434343;
|
|
||||||
--text: #e6e6e6;
|
|
||||||
--text-muted: #cfcfcf;
|
|
||||||
--primary: #87458a;
|
|
||||||
--primary-hover: #d64d95;
|
|
||||||
--secondary: #c48401;
|
|
||||||
--link: #01b6c4;
|
|
||||||
}
|
|
||||||
body {
|
|
||||||
background: var(--bg);
|
|
||||||
color: var(--text);
|
|
||||||
font: 1rem ui-monospace, monospace;
|
|
||||||
margin: 0;
|
|
||||||
padding: 2rem 1rem;
|
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
main { width: 100%; max-width: 40rem; }
|
|
||||||
h1 { font-size: 1.2rem; margin: 0 0 0.25rem; }
|
|
||||||
.muted { color: var(--text-muted); font-size: 0.85rem; }
|
|
||||||
.header { border-bottom: 1px dashed var(--secondary); padding-bottom: 0.75rem; margin-bottom: 1rem; }
|
|
||||||
label { display: block; margin: 1rem 0 0.25rem; font-size: 0.9rem; }
|
|
||||||
input[type=text], textarea {
|
|
||||||
width: 100%;
|
|
||||||
background: var(--bg-panel);
|
|
||||||
color: var(--text);
|
|
||||||
border: 1px solid var(--secondary);
|
|
||||||
font: inherit;
|
|
||||||
padding: 0.5rem;
|
|
||||||
}
|
|
||||||
textarea { min-height: 6rem; resize: vertical; }
|
|
||||||
.help { color: var(--text-muted); font-size: 0.8rem; margin-top: 0.25rem; }
|
|
||||||
button {
|
|
||||||
background: var(--primary);
|
|
||||||
color: var(--text);
|
|
||||||
border: none;
|
|
||||||
padding: 0.6rem 1.2rem;
|
|
||||||
font: inherit;
|
|
||||||
cursor: pointer;
|
|
||||||
margin-top: 1.25rem;
|
|
||||||
}
|
|
||||||
button:hover { background: var(--primary-hover); }
|
|
||||||
.btn-small {
|
|
||||||
padding: 0.4rem 0.75rem;
|
|
||||||
font-size: 0.8rem;
|
|
||||||
margin-top: 0;
|
|
||||||
}
|
|
||||||
.input-row {
|
|
||||||
display: flex;
|
|
||||||
gap: 0.5rem;
|
|
||||||
align-items: stretch;
|
|
||||||
}
|
|
||||||
.input-row input { flex: 1; }
|
|
||||||
.notice {
|
|
||||||
background: var(--bg-panel);
|
|
||||||
border-left: 3px solid var(--secondary);
|
|
||||||
padding: 0.5rem 0.75rem;
|
|
||||||
margin-bottom: 1rem;
|
|
||||||
}
|
|
||||||
.meta { margin-top: 2rem; font-size: 0.8rem; color: var(--text-muted); border-top: 1px dashed var(--secondary); padding-top: 0.75rem; }
|
|
||||||
.meta div { margin: 0.2rem 0; }
|
|
||||||
code { color: var(--link); word-break: break-all; }
|
|
||||||
.log {
|
|
||||||
margin-top: 2rem;
|
|
||||||
border-top: 1px dashed var(--secondary);
|
|
||||||
padding-top: 0.75rem;
|
|
||||||
}
|
|
||||||
.log h2 { font-size: 0.95rem; margin: 0 0 0.5rem; }
|
|
||||||
.log pre {
|
|
||||||
background: var(--bg-panel);
|
|
||||||
border: 1px solid var(--secondary);
|
|
||||||
margin: 0;
|
|
||||||
padding: 0.5rem 0.75rem;
|
|
||||||
max-height: 20rem;
|
|
||||||
overflow: auto;
|
|
||||||
font-size: 0.8rem;
|
|
||||||
white-space: pre-wrap;
|
|
||||||
word-break: break-all;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<main>
|
|
||||||
<div class="header">
|
|
||||||
<h1>datascape-companion</h1>
|
|
||||||
<div class="muted">version {{.Version}} · port {{.Port}}</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{{if .Notice}}<div class="notice">{{.Notice}}</div>{{end}}
|
|
||||||
|
|
||||||
<form method="POST" action="/config">
|
|
||||||
<label for="wikiRoot">Wiki content mount path</label>
|
|
||||||
<input id="wikiRoot" name="wikiRoot" type="text" value="{{.WikiRoot}}" placeholder="Z:\wiki or /mnt/wiki">
|
|
||||||
<div class="help">Local filesystem path where the wiki's content tree is mounted.</div>
|
|
||||||
|
|
||||||
<label for="allowedOrigins">Allowed wiki origins</label>
|
|
||||||
<textarea id="allowedOrigins" name="allowedOrigins" placeholder="https://wiki.example.lan http://192.168.1.10:8080">{{.AllowedOrigins}}</textarea>
|
|
||||||
<div class="help">One origin per line (scheme + host + optional port, no trailing slash). Only browser tabs from these origins can ask the companion to open files.</div>
|
|
||||||
|
|
||||||
<label for="openFileCommand">Open-file command</label>
|
|
||||||
<div class="input-row">
|
|
||||||
<input id="openFileCommand" name="openFileCommand" type="text" value="{{.OpenFileCommand}}" placeholder="{{.DefaultOpenFileCommand}}">
|
|
||||||
<button type="button" class="btn-small" data-reset="openFileCommand" data-default="{{.DefaultOpenFileCommand}}">RESET</button>
|
|
||||||
</div>
|
|
||||||
<div class="help">Run when the wiki asks to open a file. Use <code>{{`{path}`}}</code> for the resolved file path. Leave blank to use the default. Default: <code>{{.DefaultOpenFileCommand}}</code></div>
|
|
||||||
|
|
||||||
<label for="openFolderCommand">Open-folder command</label>
|
|
||||||
<div class="input-row">
|
|
||||||
<input id="openFolderCommand" name="openFolderCommand" type="text" value="{{.OpenFolderCommand}}" placeholder="{{.DefaultOpenFolderCommand}}">
|
|
||||||
<button type="button" class="btn-small" data-reset="openFolderCommand" data-default="{{.DefaultOpenFolderCommand}}">RESET</button>
|
|
||||||
</div>
|
|
||||||
<div class="help">Run when the wiki asks to reveal a folder. Default: <code>{{.DefaultOpenFolderCommand}}</code></div>
|
|
||||||
|
|
||||||
<button type="submit">SAVE</button>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
document.querySelectorAll('button[data-reset]').forEach(function (btn) {
|
|
||||||
btn.addEventListener('click', function () {
|
|
||||||
var input = document.getElementById(btn.dataset.reset);
|
|
||||||
if (input) input.value = btn.dataset.default;
|
|
||||||
});
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div class="meta">
|
|
||||||
<div>Config file: <code>{{.ConfigPath}}</code></div>
|
|
||||||
<div>Log file: <code>{{.LogPath}}</code></div>
|
|
||||||
<div>Port is set in the config file only; restart after editing.</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="log">
|
|
||||||
<h2>Log (last 50 lines)</h2>
|
|
||||||
{{if .LogError}}
|
|
||||||
<div class="muted">Could not read log: {{.LogError}}</div>
|
|
||||||
{{else if .LogTail}}
|
|
||||||
<pre>{{.LogTail}}</pre>
|
|
||||||
{{else}}
|
|
||||||
<div class="muted">Log is empty.</div>
|
|
||||||
{{end}}
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"io/fs"
|
|
||||||
"os"
|
|
||||||
)
|
|
||||||
|
|
||||||
func statPath(p string) (fs.FileInfo, error) {
|
|
||||||
return os.Stat(p)
|
|
||||||
}
|
|
||||||
@@ -1,65 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"io"
|
|
||||||
"log"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
|
||||||
)
|
|
||||||
|
|
||||||
// setupFileLogging tees the standard logger to a file alongside the config.
|
|
||||||
// Returns the log file path. Stderr is kept as a secondary sink so dev runs
|
|
||||||
// (linux, console) still print, while windowsgui builds rely on the file.
|
|
||||||
func setupFileLogging(cfgDir string) (string, error) {
|
|
||||||
if err := os.MkdirAll(cfgDir, 0755); err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
p := filepath.Join(cfgDir, "companion.log")
|
|
||||||
f, err := os.OpenFile(p, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
log.SetOutput(io.MultiWriter(f, os.Stderr))
|
|
||||||
return p, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// tailLog returns the last n lines of the log file. Reads only the trailing
|
|
||||||
// chunk of the file so the cost is bounded regardless of log size.
|
|
||||||
func tailLog(p string, n int) ([]string, error) {
|
|
||||||
const tailBytes = 32 * 1024
|
|
||||||
f, err := os.Open(p)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer f.Close()
|
|
||||||
info, err := f.Stat()
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
size := info.Size()
|
|
||||||
var off int64
|
|
||||||
if size > tailBytes {
|
|
||||||
off = size - tailBytes
|
|
||||||
}
|
|
||||||
buf := make([]byte, size-off)
|
|
||||||
if _, err := f.ReadAt(buf, off); err != nil && err != io.EOF {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
s := string(buf)
|
|
||||||
// Drop the (likely partial) first line when we didn't start at 0.
|
|
||||||
if off > 0 {
|
|
||||||
if i := strings.IndexByte(s, '\n'); i >= 0 {
|
|
||||||
s = s[i+1:]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
s = strings.TrimRight(s, "\n")
|
|
||||||
if s == "" {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
lines := strings.Split(s, "\n")
|
|
||||||
if len(lines) > n {
|
|
||||||
lines = lines[len(lines)-n:]
|
|
||||||
}
|
|
||||||
return lines, nil
|
|
||||||
}
|
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"flag"
|
|
||||||
"log"
|
|
||||||
"path/filepath"
|
|
||||||
)
|
|
||||||
|
|
||||||
const version = "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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,297 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"embed"
|
|
||||||
"encoding/json"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"html/template"
|
|
||||||
"net/http"
|
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
|
||||||
"sync"
|
|
||||||
)
|
|
||||||
|
|
||||||
//go:embed config.html
|
|
||||||
var templates embed.FS
|
|
||||||
|
|
||||||
var configTmpl = template.Must(template.ParseFS(templates, "config.html"))
|
|
||||||
|
|
||||||
type server struct {
|
|
||||||
mu sync.Mutex
|
|
||||||
cfg *config
|
|
||||||
cfgPath string
|
|
||||||
logPath string
|
|
||||||
port int
|
|
||||||
}
|
|
||||||
|
|
||||||
func newServer(cfg *config, cfgPath, logPath string) *server {
|
|
||||||
return &server{cfg: cfg, cfgPath: cfgPath, logPath: logPath, port: cfg.Port}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *server) snapshot() config {
|
|
||||||
s.mu.Lock()
|
|
||||||
defer s.mu.Unlock()
|
|
||||||
c := *s.cfg
|
|
||||||
c.AllowedOrigins = append([]string(nil), s.cfg.AllowedOrigins...)
|
|
||||||
return c
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *server) run() error {
|
|
||||||
mux := http.NewServeMux()
|
|
||||||
mux.HandleFunc("/status", s.handleStatus)
|
|
||||||
mux.HandleFunc("/open-file", s.handleOpenFile)
|
|
||||||
mux.HandleFunc("/open-folder", s.handleOpenFolder)
|
|
||||||
mux.HandleFunc("/config", s.handleConfig)
|
|
||||||
|
|
||||||
addr := fmt.Sprintf("127.0.0.1:%d", s.port)
|
|
||||||
return http.ListenAndServe(addr, mux)
|
|
||||||
}
|
|
||||||
|
|
||||||
// methodAllowed enforces a single allowed method, sending 405 otherwise.
|
|
||||||
func methodAllowed(w http.ResponseWriter, r *http.Request, method string) bool {
|
|
||||||
if r.Method == method {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
w.Header().Set("Allow", method)
|
|
||||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
// requireAllowedOrigin checks the Origin header against the allowlist.
|
|
||||||
// Sets the matching CORS header on success. Returns false (and writes a
|
|
||||||
// 403) when no match is found.
|
|
||||||
func (s *server) requireAllowedOrigin(w http.ResponseWriter, r *http.Request) bool {
|
|
||||||
origin := r.Header.Get("Origin")
|
|
||||||
if origin == "" {
|
|
||||||
http.Error(w, "origin required", http.StatusForbidden)
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
cfg := s.snapshot()
|
|
||||||
for _, allowed := range cfg.AllowedOrigins {
|
|
||||||
if origin == allowed {
|
|
||||||
w.Header().Set("Access-Control-Allow-Origin", origin)
|
|
||||||
w.Header().Set("Vary", "Origin")
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
http.Error(w, "origin not allowed", http.StatusForbidden)
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *server) handleStatus(w http.ResponseWriter, r *http.Request) {
|
|
||||||
if !methodAllowed(w, r, http.MethodGet) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if !s.requireAllowedOrigin(w, r) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
|
||||||
_ = json.NewEncoder(w).Encode(map[string]string{
|
|
||||||
"name": "datascape-companion",
|
|
||||||
"version": version,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *server) handleOpenFile(w http.ResponseWriter, r *http.Request) {
|
|
||||||
s.handleOpen(w, r, false)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *server) handleOpenFolder(w http.ResponseWriter, r *http.Request) {
|
|
||||||
s.handleOpen(w, r, true)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *server) handleOpen(w http.ResponseWriter, r *http.Request, isFolder bool) {
|
|
||||||
if !methodAllowed(w, r, http.MethodGet) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if !s.requireAllowedOrigin(w, r) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
wikiPath := r.URL.Query().Get("path")
|
|
||||||
cfg := s.snapshot()
|
|
||||||
if cfg.WikiRoot == "" {
|
|
||||||
writeJSONError(w, http.StatusBadRequest, "wikiRoot is not configured")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
resolved, err := resolveWikiPath(cfg.WikiRoot, wikiPath)
|
|
||||||
if err != nil {
|
|
||||||
writeJSONError(w, http.StatusBadRequest, err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
info, err := statPath(resolved)
|
|
||||||
if err != nil {
|
|
||||||
writeJSONError(w, http.StatusNotFound, "path not found")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if isFolder && !info.IsDir() {
|
|
||||||
// Reveal: if the user asked to open a file's folder, walk up.
|
|
||||||
resolved = filepath.Dir(resolved)
|
|
||||||
}
|
|
||||||
if !isFolder && info.IsDir() {
|
|
||||||
writeJSONError(w, http.StatusBadRequest, "path is a directory")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
defs := defaultCommands()
|
|
||||||
var template string
|
|
||||||
if isFolder {
|
|
||||||
template = resolveOpenCommand(cfg.OpenFolderCommand, defs.OpenFolder)
|
|
||||||
} else {
|
|
||||||
template = resolveOpenCommand(cfg.OpenFileCommand, defs.OpenFile)
|
|
||||||
}
|
|
||||||
if err := runOpenCommand(template, resolved); err != nil {
|
|
||||||
writeJSONError(w, http.StatusInternalServerError, err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
w.WriteHeader(http.StatusNoContent)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *server) handleConfig(w http.ResponseWriter, r *http.Request) {
|
|
||||||
switch r.Method {
|
|
||||||
case http.MethodGet:
|
|
||||||
s.renderConfig(w, "")
|
|
||||||
case http.MethodPost:
|
|
||||||
s.saveConfig(w, r)
|
|
||||||
default:
|
|
||||||
w.Header().Set("Allow", "GET, POST")
|
|
||||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
type configPageData struct {
|
|
||||||
WikiRoot string
|
|
||||||
AllowedOrigins string
|
|
||||||
Port int
|
|
||||||
ConfigPath string
|
|
||||||
LogPath string
|
|
||||||
LogTail string
|
|
||||||
LogError string
|
|
||||||
Version string
|
|
||||||
Notice string
|
|
||||||
OpenFileCommand string
|
|
||||||
OpenFolderCommand string
|
|
||||||
DefaultOpenFileCommand string
|
|
||||||
DefaultOpenFolderCommand string
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *server) renderConfig(w http.ResponseWriter, notice string) {
|
|
||||||
cfg := s.snapshot()
|
|
||||||
defs := defaultCommands()
|
|
||||||
data := configPageData{
|
|
||||||
WikiRoot: cfg.WikiRoot,
|
|
||||||
AllowedOrigins: strings.Join(cfg.AllowedOrigins, "\n"),
|
|
||||||
Port: cfg.Port,
|
|
||||||
ConfigPath: s.cfgPath,
|
|
||||||
LogPath: s.logPath,
|
|
||||||
Version: version,
|
|
||||||
Notice: notice,
|
|
||||||
OpenFileCommand: cfg.OpenFileCommand,
|
|
||||||
OpenFolderCommand: cfg.OpenFolderCommand,
|
|
||||||
DefaultOpenFileCommand: defs.OpenFile,
|
|
||||||
DefaultOpenFolderCommand: defs.OpenFolder,
|
|
||||||
}
|
|
||||||
if s.logPath != "" {
|
|
||||||
lines, err := tailLog(s.logPath, 50)
|
|
||||||
if err != nil {
|
|
||||||
data.LogError = err.Error()
|
|
||||||
} else {
|
|
||||||
data.LogTail = strings.Join(lines, "\n")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
||||||
if err := configTmpl.Execute(w, data); err != nil {
|
|
||||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *server) saveConfig(w http.ResponseWriter, r *http.Request) {
|
|
||||||
expected := fmt.Sprintf("http://127.0.0.1:%d", s.port)
|
|
||||||
if r.Header.Get("Origin") != expected {
|
|
||||||
http.Error(w, "origin mismatch", http.StatusForbidden)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if err := r.ParseForm(); err != nil {
|
|
||||||
http.Error(w, "bad form", http.StatusBadRequest)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
wikiRoot := strings.TrimSpace(r.FormValue("wikiRoot"))
|
|
||||||
originsRaw := r.FormValue("allowedOrigins")
|
|
||||||
var origins []string
|
|
||||||
for _, line := range strings.Split(originsRaw, "\n") {
|
|
||||||
line = strings.TrimSpace(line)
|
|
||||||
line = strings.TrimRight(line, "/")
|
|
||||||
if line == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
origins = append(origins, line)
|
|
||||||
}
|
|
||||||
if origins == nil {
|
|
||||||
origins = []string{}
|
|
||||||
}
|
|
||||||
|
|
||||||
openFileCmd := strings.TrimSpace(r.FormValue("openFileCommand"))
|
|
||||||
openFolderCmd := strings.TrimSpace(r.FormValue("openFolderCommand"))
|
|
||||||
defs := defaultCommands()
|
|
||||||
// Persist as blank when the user submits the default verbatim, so the
|
|
||||||
// config file stays clean and future default changes propagate.
|
|
||||||
if openFileCmd == defs.OpenFile {
|
|
||||||
openFileCmd = ""
|
|
||||||
}
|
|
||||||
if openFolderCmd == defs.OpenFolder {
|
|
||||||
openFolderCmd = ""
|
|
||||||
}
|
|
||||||
|
|
||||||
s.mu.Lock()
|
|
||||||
newCfg := *s.cfg
|
|
||||||
newCfg.WikiRoot = wikiRoot
|
|
||||||
newCfg.AllowedOrigins = origins
|
|
||||||
newCfg.OpenFileCommand = openFileCmd
|
|
||||||
newCfg.OpenFolderCommand = openFolderCmd
|
|
||||||
if err := writeConfigFile(s.cfgPath, &newCfg); err != nil {
|
|
||||||
s.mu.Unlock()
|
|
||||||
http.Error(w, "save failed: "+err.Error(), http.StatusInternalServerError)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
s.cfg = &newCfg
|
|
||||||
s.mu.Unlock()
|
|
||||||
|
|
||||||
s.renderConfig(w, "Saved.")
|
|
||||||
}
|
|
||||||
|
|
||||||
func writeJSONError(w http.ResponseWriter, code int, msg string) {
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
|
||||||
w.WriteHeader(code)
|
|
||||||
_ = json.NewEncoder(w).Encode(map[string]string{"error": msg})
|
|
||||||
}
|
|
||||||
|
|
||||||
// resolveWikiPath joins wikiRoot with a wiki-relative path after rejecting
|
|
||||||
// absolute paths, traversal segments, and null bytes. The cleaned result
|
|
||||||
// must remain inside wikiRoot.
|
|
||||||
func resolveWikiPath(wikiRoot, wikiPath string) (string, error) {
|
|
||||||
if strings.ContainsRune(wikiPath, 0) {
|
|
||||||
return "", errors.New("invalid path")
|
|
||||||
}
|
|
||||||
// Reject absolute paths from either family before any cleaning so we
|
|
||||||
// don't depend on filepath.IsAbs's per-OS behavior.
|
|
||||||
if strings.HasPrefix(wikiPath, "/") || strings.HasPrefix(wikiPath, `\`) ||
|
|
||||||
(len(wikiPath) >= 2 && wikiPath[1] == ':') {
|
|
||||||
return "", errors.New("absolute path not allowed")
|
|
||||||
}
|
|
||||||
clean := filepath.Clean(filepath.FromSlash(wikiPath))
|
|
||||||
if clean == ".." || strings.HasPrefix(clean, ".."+string(filepath.Separator)) {
|
|
||||||
return "", errors.New("path escapes wikiRoot")
|
|
||||||
}
|
|
||||||
rootAbs, err := filepath.Abs(wikiRoot)
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
full := filepath.Join(rootAbs, clean)
|
|
||||||
rel, err := filepath.Rel(rootAbs, full)
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
if rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) {
|
|
||||||
return "", errors.New("path escapes wikiRoot")
|
|
||||||
}
|
|
||||||
return full, nil
|
|
||||||
}
|
|
||||||
@@ -1,53 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"embed"
|
|
||||||
"io"
|
|
||||||
"net/http"
|
|
||||||
"strconv"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Cross-compiled companion binaries served as downloads from the wiki footer
|
|
||||||
// flyout when no local companion is detected. The Makefile produces these
|
|
||||||
// before invoking `go build .`; missing files will fail the build at compile
|
|
||||||
// time via the embed directive below.
|
|
||||||
//
|
|
||||||
//go:embed companion/datascape-companion-windows-amd64.exe
|
|
||||||
//go:embed companion/datascape-companion-linux-amd64
|
|
||||||
var companionBinaries embed.FS
|
|
||||||
|
|
||||||
func init() {
|
|
||||||
http.HandleFunc("/companion/download/windows", serveCompanionBinary(
|
|
||||||
"companion/datascape-companion-windows-amd64.exe",
|
|
||||||
"datascape-companion.exe",
|
|
||||||
))
|
|
||||||
http.HandleFunc("/companion/download/linux", serveCompanionBinary(
|
|
||||||
"companion/datascape-companion-linux-amd64",
|
|
||||||
"datascape-companion",
|
|
||||||
))
|
|
||||||
}
|
|
||||||
|
|
||||||
func serveCompanionBinary(embedPath, downloadName string) http.HandlerFunc {
|
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
if r.Method != http.MethodGet {
|
|
||||||
w.Header().Set("Allow", http.MethodGet)
|
|
||||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
f, err := companionBinaries.Open(embedPath)
|
|
||||||
if err != nil {
|
|
||||||
http.Error(w, "companion binary not available", http.StatusNotFound)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
defer f.Close()
|
|
||||||
info, err := f.Stat()
|
|
||||||
if err != nil {
|
|
||||||
http.Error(w, "companion binary not available", http.StatusInternalServerError)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
w.Header().Set("Content-Type", "application/octet-stream")
|
|
||||||
w.Header().Set("Content-Disposition", `attachment; filename="`+downloadName+`"`)
|
|
||||||
w.Header().Set("Content-Length", strconv.FormatInt(info.Size(), 10))
|
|
||||||
_, _ = io.Copy(w, f)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,86 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"html/template"
|
|
||||||
"log"
|
|
||||||
"net/http"
|
|
||||||
"path"
|
|
||||||
"strings"
|
|
||||||
)
|
|
||||||
|
|
||||||
var dialogTmpl = template.Must(template.New("dialogs").ParseFS(assets, "assets/page/dialogs.html"))
|
|
||||||
|
|
||||||
// isHX reports whether r was issued by htmx.
|
|
||||||
func isHX(r *http.Request) bool {
|
|
||||||
return r.Header.Get("HX-Request") == "true"
|
|
||||||
}
|
|
||||||
|
|
||||||
// layoutTemplate names the template that renders a layout page for r: the
|
|
||||||
// whole document, or for boosted navigation and htmx history restores only
|
|
||||||
// the "partial" that hx.js swaps into the open page. The editor is never
|
|
||||||
// boosted.
|
|
||||||
func layoutTemplate(w http.ResponseWriter, r *http.Request, editMode bool) string {
|
|
||||||
w.Header().Set("Vary", "HX-Boosted, HX-History-Restore-Request")
|
|
||||||
if !editMode && (r.Header.Get("HX-Boosted") == "true" || r.Header.Get("HX-History-Restore-Request") == "true") {
|
|
||||||
return "partial"
|
|
||||||
}
|
|
||||||
return "layout"
|
|
||||||
}
|
|
||||||
|
|
||||||
// redirectAfter sends the client to target once a mutation succeeded. htmx
|
|
||||||
// requests get HX-Redirect (assets/hx.js turns it into a history replace, so
|
|
||||||
// Back skips the pre-mutation page); plain form posts get a 303.
|
|
||||||
func redirectAfter(w http.ResponseWriter, r *http.Request, target string) {
|
|
||||||
if isHX(r) {
|
|
||||||
w.Header().Set("HX-Redirect", target)
|
|
||||||
w.WriteHeader(http.StatusNoContent)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
http.Redirect(w, r, target, http.StatusSeeOther)
|
|
||||||
}
|
|
||||||
|
|
||||||
// pageURL returns the escaped, slash-terminated browser URL of the page at
|
|
||||||
// the wiki path p ("/" or "/a/b", trailing slash optional).
|
|
||||||
func pageURL(p string) string {
|
|
||||||
p = strings.Trim(p, "/")
|
|
||||||
if p == "" {
|
|
||||||
return "/"
|
|
||||||
}
|
|
||||||
return fileURL(p) + "/"
|
|
||||||
}
|
|
||||||
|
|
||||||
// renderDialog writes the named dialog from dialogs.html.
|
|
||||||
func renderDialog(w http.ResponseWriter, name string, data any) {
|
|
||||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
||||||
if err := dialogTmpl.ExecuteTemplate(w, name, data); err != nil {
|
|
||||||
log.Printf("dialog template error: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// serveDialog handles GET ?dialog=<name> for the page at urlPath.
|
|
||||||
func (h *handler) serveDialog(w http.ResponseWriter, r *http.Request, urlPath, fsPath string) {
|
|
||||||
page := normalizeMovePath(urlPath)
|
|
||||||
name := r.URL.Query().Get("dialog")
|
|
||||||
if page == "/" && (name == "delete" || name == "move") {
|
|
||||||
http.Error(w, "not available for the wiki root", http.StatusBadRequest)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
switch name {
|
|
||||||
case "view":
|
|
||||||
view, sortKey, order := readPageSettings(fsPath).viewSettings()
|
|
||||||
renderDialog(w, "view", struct{ View, Sort, Order string }{view, sortKey, order})
|
|
||||||
case "canvas":
|
|
||||||
renderDialog(w, "canvas", nil)
|
|
||||||
case "delete":
|
|
||||||
renderDialog(w, "delete", struct{ Path string }{urlPath})
|
|
||||||
case "move":
|
|
||||||
// parent comes from the folder picker; default to the current one.
|
|
||||||
parent := r.URL.Query().Get("parent")
|
|
||||||
if parent == "" {
|
|
||||||
parent = path.Dir(page)
|
|
||||||
}
|
|
||||||
renderDialog(w, "move", struct{ Parent, Name string }{parent, path.Base(page)})
|
|
||||||
default:
|
|
||||||
http.NotFound(w, r)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -5,7 +5,6 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"html/template"
|
"html/template"
|
||||||
"log"
|
"log"
|
||||||
"net/http"
|
|
||||||
"net/url"
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
"path"
|
"path"
|
||||||
@@ -22,164 +21,25 @@ func init() {
|
|||||||
|
|
||||||
type diaryHandler struct{}
|
type diaryHandler struct{}
|
||||||
|
|
||||||
// redirect handles diary-specific redirect cases. The year page is the only
|
func (d *diaryHandler) handle(root, fsPath, urlPath string) *specialPage {
|
||||||
// real diary page; month and day URLs are aliases that collapse to a year
|
|
||||||
// page anchor (or to the year-file editor when ?edit is set).
|
|
||||||
//
|
|
||||||
// 1. `today` / `this-month` / `this-year` shortcuts resolve directly to a
|
|
||||||
// year+anchor target (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§ion=N when ?edit is set).
|
|
||||||
// 3. A virtual day URL (/diary/<root>/YYYY/MM/DD/) redirects to
|
|
||||||
// /diary/<root>/YYYY/#YYYY-MM-DD (or to the section / insert_before
|
|
||||||
// editor flow when ?edit is set).
|
|
||||||
//
|
|
||||||
// Returns ok=false when the request is not a diary-handled redirect.
|
|
||||||
func (d *diaryHandler) redirect(root, fsPath, urlPath string, r *http.Request) (string, bool) {
|
|
||||||
if target, ok := d.dateShortcutRedirect(root, fsPath, urlPath); ok {
|
|
||||||
return target, true
|
|
||||||
}
|
|
||||||
if r.Method != http.MethodGet {
|
|
||||||
return "", false
|
|
||||||
}
|
|
||||||
_, edit := r.URL.Query()["edit"]
|
|
||||||
return d.virtualURLRedirect(root, fsPath, urlPath, edit)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (d *diaryHandler) dateShortcutRedirect(root, fsPath, urlPath string) (string, bool) {
|
|
||||||
base := path.Base(strings.TrimSuffix(urlPath, "/"))
|
|
||||||
switch base {
|
|
||||||
case "today", "this-month", "this-year":
|
|
||||||
default:
|
|
||||||
return "", false
|
|
||||||
}
|
|
||||||
|
|
||||||
parentFS := filepath.Dir(fsPath)
|
|
||||||
parentURLPath := parentURL(urlPath)
|
|
||||||
_, _, 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§ion=%d", yearURL, idx), true
|
|
||||||
}
|
|
||||||
return yearURL + "?edit", true
|
|
||||||
}
|
|
||||||
target := fmt.Sprintf("%s-%s-%s", year, month, day)
|
|
||||||
if idx, found := findSectionIndex(sections, target); found {
|
|
||||||
return fmt.Sprintf("%s?edit§ion=%d", yearURL, idx), true
|
|
||||||
}
|
|
||||||
insertIdx := computeInsertIndex(sections, target)
|
|
||||||
return fmt.Sprintf("%s?edit&insert_before=%d&heading=%s",
|
|
||||||
yearURL, insertIdx, url.QueryEscape(target)), true
|
|
||||||
}
|
|
||||||
|
|
||||||
// parseDiaryURLParts extracts year/month/day from fsPath based on depth.
|
|
||||||
// depth=1 returns year only; depth=2 returns year+month; depth=3 returns all.
|
|
||||||
func parseDiaryURLParts(fsPath string, depth int) (year, month, day string, ok bool) {
|
|
||||||
parts := []string{}
|
|
||||||
cur := fsPath
|
|
||||||
for i := 0; i < depth; i++ {
|
|
||||||
parts = append([]string{filepath.Base(cur)}, parts...)
|
|
||||||
cur = filepath.Dir(cur)
|
|
||||||
}
|
|
||||||
switch depth {
|
|
||||||
case 1:
|
|
||||||
return parts[0], "", "", true
|
|
||||||
case 2:
|
|
||||||
return parts[0], parts[1], "", true
|
|
||||||
case 3:
|
|
||||||
return parts[0], parts[1], parts[2], true
|
|
||||||
}
|
|
||||||
return "", "", "", false
|
|
||||||
}
|
|
||||||
|
|
||||||
func (d *diaryHandler) handle(root, fsPath, urlPath string, _ *http.Request) *specialPage {
|
|
||||||
depth, diaryRootFS, diaryRootURL, ok := findDiaryContext(root, fsPath, urlPath)
|
depth, diaryRootFS, diaryRootURL, ok := findDiaryContext(root, fsPath, urlPath)
|
||||||
if !ok {
|
if !ok {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
widget := computeCalendarWidget(diaryRootFS, diaryRootURL, fsPath, depth)
|
widget := computeCalendarWidget(diaryRootFS, diaryRootURL, fsPath, depth)
|
||||||
if depth == 0 {
|
if depth == 0 {
|
||||||
return &specialPage{Widget: widget, SuppressTOC: true}
|
return &specialPage{Widget: widget}
|
||||||
}
|
}
|
||||||
year, _, _, ok := parseDiaryURLParts(fsPath, depth)
|
var content template.HTML
|
||||||
if !ok {
|
switch depth {
|
||||||
return &specialPage{Widget: widget, SuppressTOC: true}
|
case 1:
|
||||||
|
content = renderDiaryYear(fsPath, urlPath)
|
||||||
|
case 2:
|
||||||
|
content = renderDiaryMonth(fsPath, urlPath)
|
||||||
|
case 3:
|
||||||
|
content = renderDiaryDay(fsPath, urlPath)
|
||||||
}
|
}
|
||||||
if depth == 1 {
|
return &specialPage{Content: content, SuppressListing: true, Widget: widget}
|
||||||
yearFS := filepath.Join(diaryRootFS, year)
|
|
||||||
yearURL := path.Join(diaryRootURL, year) + "/"
|
|
||||||
content := renderDiaryYear(yearFS, yearURL)
|
|
||||||
return &specialPage{
|
|
||||||
Content: content,
|
|
||||||
SuppressContent: true,
|
|
||||||
SuppressListing: true,
|
|
||||||
SuppressTOC: true,
|
|
||||||
Widget: widget,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// depth 2/3 only reach here when a real folder exists at the path
|
|
||||||
// (unfinished migration). The virtual URL would have been redirected
|
|
||||||
// in `redirect()` otherwise. Render the folder normally; just add the
|
|
||||||
// calendar widget.
|
|
||||||
return &specialPage{Widget: widget, SuppressTOC: true}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// findDiaryContext walks up from fsPath toward root looking for a
|
// findDiaryContext walks up from fsPath toward root looking for a
|
||||||
@@ -207,102 +67,6 @@ func findDiaryContext(root, fsPath, urlPath string) (depth int, diaryRootFS, dia
|
|||||||
return 0, "", "", false
|
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 {
|
type calDay struct {
|
||||||
Num int
|
Num int
|
||||||
URL string
|
URL string
|
||||||
@@ -317,29 +81,32 @@ type calYear struct {
|
|||||||
IsCurrent bool
|
IsCurrent bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// calMonthGrid carries everything the template needs to render one month's
|
type calMonth struct {
|
||||||
// grid plus the 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
|
Num int
|
||||||
Name string
|
Name string
|
||||||
AnchorURL string // "#YYYY-MM" on a year page, full URL otherwise
|
URL string
|
||||||
Weeks [][]calDay
|
IsCurrent bool
|
||||||
}
|
}
|
||||||
|
|
||||||
type calendarData struct {
|
type calendarData struct {
|
||||||
DisplayYear int
|
DisplayYear int
|
||||||
DisplayMonth int
|
DisplayMonth int
|
||||||
|
MonthName string
|
||||||
DiaryURL string
|
DiaryURL string
|
||||||
Months []calMonthGrid
|
YearURL string
|
||||||
|
MonthURL string
|
||||||
|
PrevMonURL string
|
||||||
|
NextMonURL string
|
||||||
|
Weeks [][]calDay
|
||||||
Years []calYear
|
Years []calYear
|
||||||
|
AllMonths []calMonth
|
||||||
}
|
}
|
||||||
|
|
||||||
var diaryCalTmpl = template.Must(template.New("calendar.html").Funcs(assetFuncs).ParseFS(assets, "assets/diary/calendar.html"))
|
var diaryCalTmpl = template.Must(template.ParseFS(assets, "assets/diary/diary-calendar.html"))
|
||||||
|
|
||||||
func computeCalendarWidget(diaryRootFS, diaryRootURL, fsPath string, depth int) template.HTML {
|
func computeCalendarWidget(diaryRootFS, diaryRootURL, fsPath string, depth int) template.HTML {
|
||||||
today := time.Now()
|
today := time.Now()
|
||||||
var displayYear, displayMonth, currentDay, currentMonth int
|
var displayYear, displayMonth, currentDay int
|
||||||
|
|
||||||
switch depth {
|
switch depth {
|
||||||
case 0:
|
case 0:
|
||||||
@@ -384,55 +151,73 @@ func computeCalendarWidget(diaryRootFS, diaryRootURL, fsPath string, depth int)
|
|||||||
displayYear = y
|
displayYear = y
|
||||||
displayMonth = m
|
displayMonth = m
|
||||||
currentDay = d
|
currentDay = d
|
||||||
currentMonth = m
|
|
||||||
default:
|
default:
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
yearFS := filepath.Join(diaryRootFS, fmt.Sprintf("%d", displayYear))
|
// Which days in the display month have diary subfolders?
|
||||||
hasDayEntryByMonth := daysWithEntriesByMonth(yearFS, displayYear)
|
monthFSPath := filepath.Join(diaryRootFS,
|
||||||
|
fmt.Sprintf("%d", displayYear),
|
||||||
|
fmt.Sprintf("%02d", displayMonth))
|
||||||
|
dayEntries, _ := os.ReadDir(monthFSPath)
|
||||||
|
hasDayEntry := map[int]bool{}
|
||||||
|
for _, e := range dayEntries {
|
||||||
|
if !e.IsDir() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
d, err := strconv.Atoi(e.Name())
|
||||||
|
if err != nil || d < 1 || d > 31 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
hasDayEntry[d] = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build calendar grid with Monday as first column.
|
||||||
|
firstDay := time.Date(displayYear, time.Month(displayMonth), 1, 0, 0, 0, 0, time.UTC)
|
||||||
|
startOffset := int(firstDay.Weekday()+6) % 7
|
||||||
|
daysInMonth := time.Date(displayYear, time.Month(displayMonth)+1, 0, 0, 0, 0, 0, time.UTC).Day()
|
||||||
|
|
||||||
|
monthURLBase := path.Join(diaryRootURL,
|
||||||
|
fmt.Sprintf("%d", displayYear),
|
||||||
|
fmt.Sprintf("%02d", displayMonth)) + "/"
|
||||||
|
|
||||||
|
var weeks [][]calDay
|
||||||
|
week := make([]calDay, 7)
|
||||||
|
col := startOffset
|
||||||
|
for d := 1; d <= daysInMonth; d++ {
|
||||||
|
dayURL := path.Join(monthURLBase, fmt.Sprintf("%02d", d)) + "/"
|
||||||
|
cell := calDay{Num: d, HasEntry: hasDayEntry[d]}
|
||||||
|
if cell.HasEntry {
|
||||||
|
cell.URL = dayURL
|
||||||
|
} else {
|
||||||
|
cell.URL = dayURL + "?edit"
|
||||||
|
}
|
||||||
|
cell.IsCurrent = d == currentDay
|
||||||
|
cell.IsToday = d == today.Day() &&
|
||||||
|
time.Month(displayMonth) == today.Month() &&
|
||||||
|
displayYear == today.Year()
|
||||||
|
week[col] = cell
|
||||||
|
col++
|
||||||
|
if col == 7 {
|
||||||
|
weeks = append(weeks, week)
|
||||||
|
week = make([]calDay, 7)
|
||||||
|
col = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if col > 0 {
|
||||||
|
weeks = append(weeks, week)
|
||||||
|
}
|
||||||
|
|
||||||
|
prev := time.Date(displayYear, time.Month(displayMonth)-1, 1, 0, 0, 0, 0, time.UTC)
|
||||||
|
next := time.Date(displayYear, time.Month(displayMonth)+1, 1, 0, 0, 0, 0, time.UTC)
|
||||||
|
prevMonURL := path.Join(diaryRootURL,
|
||||||
|
fmt.Sprintf("%d", prev.Year()),
|
||||||
|
fmt.Sprintf("%02d", int(prev.Month()))) + "/"
|
||||||
|
nextMonURL := path.Join(diaryRootURL,
|
||||||
|
fmt.Sprintf("%d", next.Year()),
|
||||||
|
fmt.Sprintf("%02d", int(next.Month()))) + "/"
|
||||||
yearURL := path.Join(diaryRootURL, fmt.Sprintf("%d", displayYear)) + "/"
|
yearURL := path.Join(diaryRootURL, fmt.Sprintf("%d", displayYear)) + "/"
|
||||||
|
|
||||||
// On a year page, in-year month/day links collapse to anchors so the
|
|
||||||
// browser scrolls within the current page instead of navigating away.
|
|
||||||
// On the diary root (depth=0), all links remain full URLs.
|
|
||||||
pageYear := 0
|
|
||||||
if depth >= 1 {
|
|
||||||
pageYear = displayYear
|
|
||||||
}
|
|
||||||
|
|
||||||
monthAnchor := func(year, month int) string {
|
|
||||||
if pageYear == year {
|
|
||||||
return fmt.Sprintf("#%d-%02d", year, month)
|
|
||||||
}
|
|
||||||
return path.Join(diaryRootURL,
|
|
||||||
fmt.Sprintf("%d", year),
|
|
||||||
fmt.Sprintf("%02d", month)) + "/"
|
|
||||||
}
|
|
||||||
dayAnchor := func(year, month, day int) string {
|
|
||||||
if pageYear == year {
|
|
||||||
return fmt.Sprintf("#%d-%02d-%02d", year, month, day)
|
|
||||||
}
|
|
||||||
return path.Join(diaryRootURL,
|
|
||||||
fmt.Sprintf("%d", year),
|
|
||||||
fmt.Sprintf("%02d", month),
|
|
||||||
fmt.Sprintf("%02d", day)) + "/"
|
|
||||||
}
|
|
||||||
|
|
||||||
months := make([]calMonthGrid, 12)
|
|
||||||
for m := 1; m <= 12; m++ {
|
|
||||||
var cd int
|
|
||||||
if m == currentMonth {
|
|
||||||
cd = currentDay
|
|
||||||
}
|
|
||||||
months[m-1] = calMonthGrid{
|
|
||||||
Num: m,
|
|
||||||
Name: germanMonths[time.Month(m)],
|
|
||||||
AnchorURL: monthAnchor(displayYear, m),
|
|
||||||
Weeks: buildMonthGrid(displayYear, m, today, cd, hasDayEntryByMonth[m], dayAnchor),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Collect all year subdirectories in diary root (descending).
|
// Collect all year subdirectories in diary root (descending).
|
||||||
yearEntries, _ := os.ReadDir(diaryRootFS)
|
yearEntries, _ := os.ReadDir(diaryRootFS)
|
||||||
var years []calYear
|
var years []calYear
|
||||||
@@ -461,12 +246,31 @@ func computeCalendarWidget(diaryRootFS, diaryRootURL, fsPath string, depth int)
|
|||||||
}
|
}
|
||||||
sort.Slice(years, func(i, j int) bool { return years[i].Num > years[j].Num })
|
sort.Slice(years, func(i, j int) bool { return years[i].Num > years[j].Num })
|
||||||
|
|
||||||
|
// All 12 months, ascending, linked to current display year.
|
||||||
|
allMonths := make([]calMonth, 12)
|
||||||
|
for m := 1; m <= 12; m++ {
|
||||||
|
allMonths[m-1] = calMonth{
|
||||||
|
Num: m,
|
||||||
|
Name: germanMonths[time.Month(m)],
|
||||||
|
URL: path.Join(diaryRootURL,
|
||||||
|
fmt.Sprintf("%d", displayYear),
|
||||||
|
fmt.Sprintf("%02d", m)) + "/",
|
||||||
|
IsCurrent: m == displayMonth,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
data := calendarData{
|
data := calendarData{
|
||||||
DisplayYear: displayYear,
|
DisplayYear: displayYear,
|
||||||
DisplayMonth: displayMonth,
|
DisplayMonth: displayMonth,
|
||||||
|
MonthName: germanMonths[time.Month(displayMonth)],
|
||||||
DiaryURL: diaryRootURL,
|
DiaryURL: diaryRootURL,
|
||||||
Months: months,
|
YearURL: yearURL,
|
||||||
|
MonthURL: monthURLBase,
|
||||||
|
PrevMonURL: prevMonURL,
|
||||||
|
NextMonURL: nextMonURL,
|
||||||
|
Weeks: weeks,
|
||||||
Years: years,
|
Years: years,
|
||||||
|
AllMonths: allMonths,
|
||||||
}
|
}
|
||||||
|
|
||||||
var buf bytes.Buffer
|
var buf bytes.Buffer
|
||||||
@@ -477,44 +281,6 @@ func computeCalendarWidget(diaryRootFS, diaryRootURL, fsPath string, depth int)
|
|||||||
return template.HTML(buf.String())
|
return template.HTML(buf.String())
|
||||||
}
|
}
|
||||||
|
|
||||||
// buildMonthGrid renders one month's day cells as a Monday-first week grid.
|
|
||||||
// hasDayEntry maps day-of-month → has a diary entry. dayAnchor produces the
|
|
||||||
// in-page anchor (or full URL when crossing pages); empty days link to the
|
|
||||||
// same anchor — every day exists on the year page as either a real or
|
|
||||||
// virtual section, so navigation is enough. Page creation happens via the
|
|
||||||
// [edit] button on the heading itself.
|
|
||||||
func buildMonthGrid(year, month int, today time.Time, currentDay int, hasDayEntry map[int]bool, dayAnchor func(int, int, int) string) [][]calDay {
|
|
||||||
firstDay := time.Date(year, time.Month(month), 1, 0, 0, 0, 0, time.UTC)
|
|
||||||
startOffset := int(firstDay.Weekday()+6) % 7
|
|
||||||
daysInMonth := time.Date(year, time.Month(month)+1, 0, 0, 0, 0, 0, time.UTC).Day()
|
|
||||||
|
|
||||||
var weeks [][]calDay
|
|
||||||
week := make([]calDay, 7)
|
|
||||||
col := startOffset
|
|
||||||
for d := 1; d <= daysInMonth; d++ {
|
|
||||||
cell := calDay{
|
|
||||||
Num: d,
|
|
||||||
HasEntry: hasDayEntry[d],
|
|
||||||
URL: dayAnchor(year, month, d),
|
|
||||||
}
|
|
||||||
cell.IsCurrent = currentDay > 0 && d == currentDay
|
|
||||||
cell.IsToday = d == today.Day() &&
|
|
||||||
time.Month(month) == today.Month() &&
|
|
||||||
year == today.Year()
|
|
||||||
week[col] = cell
|
|
||||||
col++
|
|
||||||
if col == 7 {
|
|
||||||
weeks = append(weeks, week)
|
|
||||||
week = make([]calDay, 7)
|
|
||||||
col = 0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if col > 0 {
|
|
||||||
weeks = append(weeks, week)
|
|
||||||
}
|
|
||||||
return weeks
|
|
||||||
}
|
|
||||||
|
|
||||||
// diaryPhoto is a photo file whose name starts with a YYYY-MM-DD date prefix.
|
// diaryPhoto is a photo file whose name starts with a YYYY-MM-DD date prefix.
|
||||||
type diaryPhoto struct {
|
type diaryPhoto struct {
|
||||||
Date time.Time
|
Date time.Time
|
||||||
@@ -523,22 +289,41 @@ type diaryPhoto struct {
|
|||||||
ThumbURL string
|
ThumbURL string
|
||||||
}
|
}
|
||||||
|
|
||||||
// diarySection is one rendered section of the diary content (year, month, or
|
type diaryMonthSummary struct {
|
||||||
// day). Edit URLs point back into the year file's section editor so per-day
|
ID string
|
||||||
// editing works from any slice page.
|
Name string
|
||||||
type diarySection struct {
|
URL string
|
||||||
Level int // 1, 2, or 3
|
|
||||||
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
|
Photos []diaryPhoto
|
||||||
Events string // day's calendar events, comma-separated (level 3 only)
|
|
||||||
Weight string // day's weight from the weight calendar (level 3 only)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type diaryContentData struct {
|
type diaryDaySection struct {
|
||||||
Sections []diarySection
|
ID string
|
||||||
|
Heading string
|
||||||
|
URL string
|
||||||
|
EditURL string
|
||||||
|
Content template.HTML
|
||||||
|
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",
|
||||||
}
|
}
|
||||||
|
|
||||||
var germanMonths = map[time.Month]string{
|
var germanMonths = map[time.Month]string{
|
||||||
@@ -556,6 +341,15 @@ var germanMonths = map[time.Month]string{
|
|||||||
time.December: "Dezember",
|
time.December: "Dezember",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func formatGermanDate(t time.Time) string {
|
||||||
|
return fmt.Sprintf("%s, %d. %s %d",
|
||||||
|
germanWeekdays[t.Weekday()],
|
||||||
|
t.Day(),
|
||||||
|
germanMonths[t.Month()],
|
||||||
|
t.Year(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
var photoExts = map[string]bool{
|
var photoExts = map[string]bool{
|
||||||
".jpg": true, ".jpeg": true, ".png": true, ".gif": true, ".webp": true,
|
".jpg": true, ".jpeg": true, ".png": true, ".gif": true, ".webp": true,
|
||||||
}
|
}
|
||||||
@@ -598,219 +392,196 @@ func yearPhotos(yearFsPath, yearURLPath string) []diaryPhoto {
|
|||||||
return photos
|
return photos
|
||||||
}
|
}
|
||||||
|
|
||||||
var diaryContentTmpl = template.Must(template.ParseFS(assets, "assets/diary/content.html"))
|
// renderDiaryYear renders month sections with photo counts for a year folder.
|
||||||
|
func renderDiaryYear(fsPath, urlPath string) template.HTML {
|
||||||
// sectionBody strips the first heading line and returns the rendered body.
|
year, err := strconv.Atoi(filepath.Base(fsPath))
|
||||||
// Used so the diary template can emit the heading explicitly (with edit URL)
|
|
||||||
// while still rendering the section body via goldmark.
|
|
||||||
func sectionBody(section []byte) template.HTML {
|
|
||||||
body := stripFirstHeading(section)
|
|
||||||
if len(bytes.TrimSpace(body)) == 0 {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
return renderMarkdown(body)
|
|
||||||
}
|
|
||||||
|
|
||||||
// renderDiaryYear renders the year page: every section from the year file
|
|
||||||
// (with photos attached to `### YYYY-MM-DD` headings) plus virtual entries
|
|
||||||
// for every month/day slot the file doesn't yet contain.
|
|
||||||
func renderDiaryYear(yearFS, yearURL string) template.HTML {
|
|
||||||
year, err := strconv.Atoi(filepath.Base(yearFS))
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
raw, _ := os.ReadFile(filepath.Join(yearFS, "index.md"))
|
|
||||||
sections := splitSections(raw)
|
|
||||||
photos := yearPhotos(yearFS, yearURL)
|
|
||||||
|
|
||||||
out := buildFileSections(sections, photos, yearURL)
|
photos := yearPhotos(fsPath, urlPath)
|
||||||
out = appendVirtualEntries(out, sections, photos, year, yearURL)
|
|
||||||
attachCalendarLines(out, year)
|
|
||||||
return renderDiaryContent(out)
|
|
||||||
}
|
|
||||||
|
|
||||||
// buildFileSections converts the year file's sections (skipping the
|
// Collect month numbers from both subdirectories and photo filenames so
|
||||||
// pre-heading section 0) into rendered diarySection entries. Photos are
|
// years that contain only photos (no diary entries) still list months.
|
||||||
// attached to level-3 headings whose text parses as `YYYY-MM-DD`.
|
monthSet := map[int]bool{}
|
||||||
func buildFileSections(sections [][]byte, photos []diaryPhoto, yearURL string) []diarySection {
|
monthDirs := map[int]string{}
|
||||||
var out []diarySection
|
entries, _ := os.ReadDir(fsPath)
|
||||||
for i := 1; i < len(sections); i++ {
|
for _, e := range entries {
|
||||||
level, text := sectionHeading(sections[i])
|
if !e.IsDir() {
|
||||||
if level == 0 {
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
sec := diarySection{
|
n, err := strconv.Atoi(e.Name())
|
||||||
Level: level,
|
if err != nil || n < 1 || n > 12 {
|
||||||
ID: text,
|
continue
|
||||||
Heading: text,
|
|
||||||
EditURL: fmt.Sprintf("%s?edit§ion=%d", yearURL, i),
|
|
||||||
Body: sectionBody(sections[i]),
|
|
||||||
}
|
}
|
||||||
if level == 3 {
|
monthSet[n] = true
|
||||||
if y, m, d, ok := parseISODate(text); ok {
|
monthDirs[n] = e.Name()
|
||||||
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 {
|
for _, p := range photos {
|
||||||
if p.Date.Year() != year {
|
if p.Date.Year() == year {
|
||||||
continue
|
monthSet[int(p.Date.Month())] = true
|
||||||
}
|
}
|
||||||
photoByDay[p.Date.Format("2006-01-02")] = append(photoByDay[p.Date.Format("2006-01-02")], p)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
lastDay := time.Date(year, time.December, 31, 0, 0, 0, 0, time.UTC)
|
monthNums := make([]int, 0, len(monthSet))
|
||||||
if year == today.Year() {
|
for m := range monthSet {
|
||||||
lastDay = time.Date(year, today.Month(), today.Day(), 0, 0, 0, 0, time.UTC)
|
monthNums = append(monthNums, m)
|
||||||
}
|
}
|
||||||
|
sort.Ints(monthNums)
|
||||||
|
|
||||||
var virtual []diarySection
|
var months []diaryMonthSummary
|
||||||
for d := time.Date(year, time.January, 1, 0, 0, 0, 0, time.UTC); !d.After(lastDay); d = d.AddDate(0, 0, 1) {
|
for _, monthNum := range monthNums {
|
||||||
if d.Day() == 1 {
|
var monthPhotos []diaryPhoto
|
||||||
monthID := d.Format("2006-01")
|
for _, p := range photos {
|
||||||
if !coveredMonth[monthID] {
|
if p.Date.Year() == year && int(p.Date.Month()) == monthNum {
|
||||||
idx := computeInsertIndex(sections, monthID)
|
monthPhotos = append(monthPhotos, p)
|
||||||
virtual = append(virtual, diarySection{
|
}
|
||||||
Level: 2,
|
}
|
||||||
ID: monthID,
|
monthDate := time.Date(year, time.Month(monthNum), 1, 0, 0, 0, 0, time.UTC)
|
||||||
Heading: monthID,
|
dirName, ok := monthDirs[monthNum]
|
||||||
EditURL: fmt.Sprintf("%s?edit&insert_before=%d&heading=%s&level=%s",
|
if !ok {
|
||||||
yearURL, idx, url.QueryEscape(monthID), url.QueryEscape("##")),
|
dirName = fmt.Sprintf("%02d", monthNum)
|
||||||
|
}
|
||||||
|
months = append(months, diaryMonthSummary{
|
||||||
|
ID: monthDate.Format("2006-01"),
|
||||||
|
Name: fmt.Sprintf("%s %d", germanMonths[monthDate.Month()], year),
|
||||||
|
URL: path.Join(urlPath, dirName) + "/",
|
||||||
|
Photos: monthPhotos,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
// attachCalendarLines fills the events and weight lines of every day section
|
|
||||||
// (real or virtual) from the calendars' diary and weight roles.
|
|
||||||
func attachCalendarLines(sections []diarySection, year int) {
|
|
||||||
lines := calendarDayLines(
|
|
||||||
time.Date(year, time.January, 1, 0, 0, 0, 0, time.Local),
|
|
||||||
time.Date(year+1, time.January, 1, 0, 0, 0, 0, time.Local))
|
|
||||||
if len(lines) == 0 {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
for i := range sections {
|
|
||||||
s := §ions[i]
|
|
||||||
if s.Level != 3 {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if _, _, _, ok := parseISODate(s.ID); !ok {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
l := lines[s.ID[:10]]
|
|
||||||
s.Events, s.Weight = l.Events, l.Weight
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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
|
var buf bytes.Buffer
|
||||||
if err := diaryContentTmpl.Execute(&buf, diaryContentData{Sections: sections}); err != nil {
|
if err := diaryYearTmpl.Execute(&buf, diaryYearData{Months: months, Year: year}); err != nil {
|
||||||
log.Printf("diary content template: %v", err)
|
log.Printf("diary year template: %v", err)
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return template.HTML(buf.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
// renderDiaryMonth renders a section per day, each with its markdown content
|
||||||
|
// and photos sourced from the parent year folder.
|
||||||
|
func renderDiaryMonth(fsPath, urlPath string) template.HTML {
|
||||||
|
yearFsPath := filepath.Dir(fsPath)
|
||||||
|
yearURLPath := parentURL(urlPath)
|
||||||
|
|
||||||
|
year, err := strconv.Atoi(filepath.Base(yearFsPath))
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
monthNum, err := strconv.Atoi(filepath.Base(fsPath))
|
||||||
|
if err != nil || monthNum < 1 || monthNum > 12 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
allPhotos := yearPhotos(yearFsPath, yearURLPath)
|
||||||
|
var monthPhotos []diaryPhoto
|
||||||
|
for _, p := range allPhotos {
|
||||||
|
if p.Date.Year() == year && int(p.Date.Month()) == monthNum {
|
||||||
|
monthPhotos = append(monthPhotos, p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Collect day numbers from subdirectories and from photo filenames.
|
||||||
|
daySet := map[int]bool{}
|
||||||
|
dayDirs := map[int]string{} // day number → actual directory name
|
||||||
|
entries, _ := os.ReadDir(fsPath)
|
||||||
|
for _, e := range entries {
|
||||||
|
if !e.IsDir() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
d, err := strconv.Atoi(e.Name())
|
||||||
|
if err != nil || d < 1 || d > 31 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
daySet[d] = true
|
||||||
|
dayDirs[d] = e.Name()
|
||||||
|
}
|
||||||
|
for _, p := range monthPhotos {
|
||||||
|
daySet[p.Date.Day()] = true
|
||||||
|
}
|
||||||
|
|
||||||
|
days := make([]int, 0, len(daySet))
|
||||||
|
for d := range daySet {
|
||||||
|
days = append(days, d)
|
||||||
|
}
|
||||||
|
sort.Ints(days)
|
||||||
|
|
||||||
|
var sections []diaryDaySection
|
||||||
|
for _, dayNum := range days {
|
||||||
|
date := time.Date(year, time.Month(monthNum), dayNum, 0, 0, 0, 0, time.UTC)
|
||||||
|
|
||||||
|
heading := date.Format("2006-01-02")
|
||||||
|
dayURL := path.Join(urlPath, fmt.Sprintf("%02d", dayNum)) + "/"
|
||||||
|
var content template.HTML
|
||||||
|
if dirName, ok := dayDirs[dayNum]; ok {
|
||||||
|
dayURL = path.Join(urlPath, dirName) + "/"
|
||||||
|
dayFsPath := filepath.Join(fsPath, dirName)
|
||||||
|
if raw, err := os.ReadFile(filepath.Join(dayFsPath, "index.md")); err == nil && len(raw) > 0 {
|
||||||
|
raw = stripFirstHeading(raw)
|
||||||
|
content = renderMarkdown(raw)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var photos []diaryPhoto
|
||||||
|
for _, p := range monthPhotos {
|
||||||
|
if p.Date.Day() == dayNum {
|
||||||
|
photos = append(photos, p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sections = append(sections, diaryDaySection{
|
||||||
|
ID: date.Format("2006-01-02"),
|
||||||
|
Heading: heading,
|
||||||
|
URL: dayURL,
|
||||||
|
EditURL: dayURL + "?edit",
|
||||||
|
Content: content,
|
||||||
|
Photos: photos,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
var buf bytes.Buffer
|
||||||
|
if err := diaryMonthTmpl.Execute(&buf, diaryMonthData{Days: sections}); err != nil {
|
||||||
|
log.Printf("diary month template: %v", err)
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return template.HTML(buf.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
// renderDiaryDay renders the photo grid for a single day, sourcing photos
|
||||||
|
// from the grandparent year folder.
|
||||||
|
func renderDiaryDay(fsPath, urlPath string) template.HTML {
|
||||||
|
monthFsPath := filepath.Dir(fsPath)
|
||||||
|
yearFsPath := filepath.Dir(monthFsPath)
|
||||||
|
yearURLPath := parentURL(parentURL(urlPath))
|
||||||
|
|
||||||
|
year, err := strconv.Atoi(filepath.Base(yearFsPath))
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
monthNum, err := strconv.Atoi(filepath.Base(monthFsPath))
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
dayNum, err := strconv.Atoi(filepath.Base(fsPath))
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
allPhotos := yearPhotos(yearFsPath, yearURLPath)
|
||||||
|
var photos []diaryPhoto
|
||||||
|
for _, p := range allPhotos {
|
||||||
|
if p.Date.Year() == year && int(p.Date.Month()) == monthNum && p.Date.Day() == dayNum {
|
||||||
|
photos = append(photos, p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(photos) == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
var buf bytes.Buffer
|
||||||
|
if err := diaryDayTmpl.Execute(&buf, diaryDayData{Photos: photos}); err != nil {
|
||||||
|
log.Printf("diary day template: %v", err)
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
return template.HTML(buf.String())
|
return template.HTML(buf.String())
|
||||||
|
|||||||
@@ -1,4 +0,0 @@
|
|||||||
node_modules/
|
|
||||||
.tmp/
|
|
||||||
test-results/
|
|
||||||
playwright-report/
|
|
||||||
@@ -1,58 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "datascape-e2e",
|
|
||||||
"lockfileVersion": 3,
|
|
||||||
"requires": true,
|
|
||||||
"packages": {
|
|
||||||
"": {
|
|
||||||
"name": "datascape-e2e",
|
|
||||||
"devDependencies": {
|
|
||||||
"@playwright/test": "^1"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@playwright/test": {
|
|
||||||
"version": "1.63.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.63.0.tgz",
|
|
||||||
"integrity": "sha512-oxMK4vllB9RK5NQ2l1pq1IfOf2AvnEuj/vYGDj0H2nMtmtZpKtCwt/l00GEO6xjGfpBNAvjovvYdCm50dRQkpQ==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "Apache-2.0",
|
|
||||||
"dependencies": {
|
|
||||||
"playwright": "1.63.0"
|
|
||||||
},
|
|
||||||
"bin": {
|
|
||||||
"playwright": "cli.js"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=20"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/playwright": {
|
|
||||||
"version": "1.63.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.63.0.tgz",
|
|
||||||
"integrity": "sha512-+7ziBLidS4NaNCdt57SUDT+wYmmd5fmiQejUic/kb+YsYSCPyOOE9sebzMjNmQrsnNpDJqd4WHvV/8lfKfUDUg==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "Apache-2.0",
|
|
||||||
"dependencies": {
|
|
||||||
"playwright-core": "1.63.0"
|
|
||||||
},
|
|
||||||
"bin": {
|
|
||||||
"playwright": "cli.js"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=20"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/playwright-core": {
|
|
||||||
"version": "1.63.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.63.0.tgz",
|
|
||||||
"integrity": "sha512-rYCsBF/M5HjUch52bbtVONEFjv6Xu8sm8h72dNlR5bzIE1fvC/bxgspzkjSfU+MweEMmPM8KJebG6nnyxo5mCg==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "Apache-2.0",
|
|
||||||
"bin": {
|
|
||||||
"playwright-core": "cli.js"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=20"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "datascape-e2e",
|
|
||||||
"private": true,
|
|
||||||
"type": "module",
|
|
||||||
"scripts": {
|
|
||||||
"test": "playwright test"
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
|
||||||
"@playwright/test": "^1"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
import { defineConfig, devices } from "@playwright/test";
|
|
||||||
|
|
||||||
const port = process.env.E2E_PORT || "8765";
|
|
||||||
|
|
||||||
export default defineConfig({
|
|
||||||
testDir: "tests",
|
|
||||||
// Tests share one server and wiki but each works in its own sandbox folder,
|
|
||||||
// so they can run in parallel.
|
|
||||||
workers: 4,
|
|
||||||
fullyParallel: true,
|
|
||||||
reporter: "list",
|
|
||||||
use: {
|
|
||||||
baseURL: `http://127.0.0.1:${port}`,
|
|
||||||
trace: "retain-on-failure",
|
|
||||||
screenshot: "only-on-failure",
|
|
||||||
},
|
|
||||||
projects: [
|
|
||||||
// The system Chrome; no browser download needed.
|
|
||||||
{ name: "chrome", use: { channel: "chrome" } },
|
|
||||||
// Playwright's Firefox build: npx playwright install firefox
|
|
||||||
{ name: "firefox", use: { ...devices["Desktop Firefox"] } },
|
|
||||||
],
|
|
||||||
webServer: {
|
|
||||||
command: "node server.mjs",
|
|
||||||
url: `http://127.0.0.1:${port}/`,
|
|
||||||
timeout: 120_000,
|
|
||||||
reuseExistingServer: false,
|
|
||||||
stdout: "pipe",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
// Builds datascape, seeds a throwaway wiki and runs the server in the
|
|
||||||
// foreground. Playwright's webServer starts this and kills it after the run.
|
|
||||||
import { execFileSync, spawn } from "node:child_process";
|
|
||||||
import { mkdirSync, rmSync, writeFileSync } from "node:fs";
|
|
||||||
import { dirname, join } from "node:path";
|
|
||||||
import { fileURLToPath } from "node:url";
|
|
||||||
|
|
||||||
const here = dirname(fileURLToPath(import.meta.url));
|
|
||||||
const tmp = join(here, ".tmp");
|
|
||||||
const wiki = join(tmp, "wiki");
|
|
||||||
const bin = join(tmp, process.platform === "win32" ? "datascape.exe" : "datascape");
|
|
||||||
const port = process.env.E2E_PORT || "8765";
|
|
||||||
|
|
||||||
rmSync(tmp, { recursive: true, force: true });
|
|
||||||
mkdirSync(wiki, { recursive: true });
|
|
||||||
|
|
||||||
execFileSync("go", ["build", "-o", bin, "."], { cwd: join(here, ".."), stdio: "inherit" });
|
|
||||||
|
|
||||||
// Tests create their own pages (see sandbox in tests/pages.spec.js); the seed
|
|
||||||
// is only the home page.
|
|
||||||
writeFileSync(join(wiki, "index.md"), "# Home\n\nWelcome.\n");
|
|
||||||
|
|
||||||
const srv = spawn(bin, ["-dir", wiki, "-cache", join(tmp, "cache"), "-addr", "127.0.0.1:" + port, "-calendars", ""], {
|
|
||||||
stdio: "inherit",
|
|
||||||
});
|
|
||||||
srv.on("exit", (code) => process.exit(code ?? 0));
|
|
||||||
for (const sig of ["SIGINT", "SIGTERM"]) process.on(sig, () => srv.kill());
|
|
||||||
@@ -1,155 +0,0 @@
|
|||||||
// Boosted navigation: outside the editor, page links swap the next page into
|
|
||||||
// the open document instead of loading a new one. A marker on window survives
|
|
||||||
// a boosted navigation and disappears with a full page load.
|
|
||||||
import { test, expect } from "@playwright/test";
|
|
||||||
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
||||||
import { dirname, join } from "node:path";
|
|
||||||
import { fileURLToPath } from "node:url";
|
|
||||||
|
|
||||||
const wiki = join(dirname(fileURLToPath(import.meta.url)), "..", ".tmp", "wiki");
|
|
||||||
|
|
||||||
test.beforeEach(({ page }) => {
|
|
||||||
page.on("pageerror", (err) => {
|
|
||||||
// Chrome aborts the cross-document view transition (style.css) when the
|
|
||||||
// next document can't take part, e.g. a plain-text file, and reports it
|
|
||||||
// as an unhandled rejection that no page script can catch.
|
|
||||||
if (err.message.includes("ViewTransition opt-in disabled")) return;
|
|
||||||
throw err;
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
function randomWord(n) {
|
|
||||||
return Array.from({ length: n }, () => String.fromCharCode(97 + Math.floor(Math.random() * 26))).join("");
|
|
||||||
}
|
|
||||||
|
|
||||||
function sandbox(pages) {
|
|
||||||
const root = "t" + randomWord(7);
|
|
||||||
for (const [rel, body] of Object.entries(pages)) {
|
|
||||||
const dir = join(wiki, root, rel);
|
|
||||||
mkdirSync(dir, { recursive: true });
|
|
||||||
if (body !== null) writeFileSync(join(dir, "index.md"), body);
|
|
||||||
}
|
|
||||||
return root;
|
|
||||||
}
|
|
||||||
|
|
||||||
const sidebar = (page) => page.locator("aside.tree-sidebar");
|
|
||||||
const link = (scope, name) => scope.getByRole("link", { name, exact: true });
|
|
||||||
const mark = (page) => page.evaluate(() => (window.__marker = 1));
|
|
||||||
const marked = (page) => page.evaluate(() => window.__marker === 1);
|
|
||||||
const long = (title) => `# ${title}\n\n` + Array.from({ length: 40 }, (_, i) => `## Part ${i}\n\ntext\n`).join("\n");
|
|
||||||
|
|
||||||
test("sidebar navigation swaps the page in without a reload", async ({ page }) => {
|
|
||||||
const root = sandbox({
|
|
||||||
"": "# Parent\n",
|
|
||||||
Child: "# Child\n\n## First\n\none\n\n## Second\n\n- [ ] boosted task\n",
|
|
||||||
});
|
|
||||||
await page.goto(`/${root}/`);
|
|
||||||
await mark(page);
|
|
||||||
await link(sidebar(page), "Child").click();
|
|
||||||
await expect(page).toHaveURL(new RegExp(`/${root}/Child/$`));
|
|
||||||
await expect(page.locator("main h1")).toContainText("Child");
|
|
||||||
await expect(page).toHaveTitle("Child");
|
|
||||||
expect(await marked(page)).toBe(true);
|
|
||||||
await expect(sidebar(page).locator(".tree-row.is-active")).toHaveText("Child");
|
|
||||||
// Page scripts ran for the new content: TOC and section edit links.
|
|
||||||
await expect(page.locator("nav.toc")).toContainText("Second");
|
|
||||||
await expect(page.locator("main h2", { hasText: "First" }).getByRole("link", { name: "[edit]" })).toBeVisible();
|
|
||||||
// htmx attributes in the new content work: the task toggles.
|
|
||||||
await page.locator("main input.task-checkbox").check();
|
|
||||||
await expect.poll(() => readFileSync(join(wiki, root, "Child", "index.md"), "utf8")).toContain("- [x] boosted task");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("Back and Forward restore pages and scroll without a reload", async ({ page }) => {
|
|
||||||
const root = sandbox({ "": long("Parent"), Child: long("Child") });
|
|
||||||
await page.goto(`/${root}/`);
|
|
||||||
await mark(page);
|
|
||||||
const center = page.locator(".center");
|
|
||||||
await center.evaluate((el) => (el.scrollTop = 600));
|
|
||||||
await link(sidebar(page), "Child").click();
|
|
||||||
await expect(page.locator("main h1")).toContainText("Child");
|
|
||||||
// A new page starts at the top.
|
|
||||||
expect(await center.evaluate((el) => el.scrollTop)).toBe(0);
|
|
||||||
await page.goBack();
|
|
||||||
await expect(page).toHaveURL(new RegExp(`/${root}/$`));
|
|
||||||
await expect(page.locator("main h1")).toContainText("Parent");
|
|
||||||
await expect(page).toHaveTitle("Parent");
|
|
||||||
await expect.poll(() => center.evaluate((el) => el.scrollTop)).toBeGreaterThan(500);
|
|
||||||
await page.goForward();
|
|
||||||
await expect(page.locator("main h1")).toContainText("Child");
|
|
||||||
expect(await marked(page)).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("Back to the first, fully loaded page works", async ({ page }) => {
|
|
||||||
const root = sandbox({ "": "# Parent\n", Child: "# Child\n" });
|
|
||||||
await page.goto(`/${root}/Child/`);
|
|
||||||
await mark(page);
|
|
||||||
await link(sidebar(page), root).click();
|
|
||||||
await expect(page.locator("main h1")).toContainText("Parent");
|
|
||||||
await page.goBack();
|
|
||||||
await expect(page).toHaveURL(new RegExp(`/${root}/Child/$`));
|
|
||||||
await expect(page.locator("main h1")).toContainText("Child");
|
|
||||||
expect(await marked(page)).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("files and the editor load as full pages", async ({ page }) => {
|
|
||||||
const root = sandbox({ "": "# Parent\n" });
|
|
||||||
writeFileSync(join(wiki, root, "note.txt"), "plain file");
|
|
||||||
await page.goto(`/${root}/`);
|
|
||||||
await mark(page);
|
|
||||||
await page.locator("main").getByRole("link", { name: "note.txt" }).click();
|
|
||||||
await expect(page).toHaveURL(new RegExp(`/${root}/note.txt$`));
|
|
||||||
await expect(page.locator("body")).toContainText("plain file");
|
|
||||||
expect(await marked(page)).toBe(false);
|
|
||||||
|
|
||||||
await page.goto(`/${root}/`);
|
|
||||||
await mark(page);
|
|
||||||
await page.locator("header").getByTitle("Edit page (E)").click();
|
|
||||||
await expect(page).toHaveURL(/\?edit/);
|
|
||||||
await expect(page.locator(".cm-content")).toBeVisible();
|
|
||||||
expect(await marked(page)).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("header actions follow the page", async ({ page }) => {
|
|
||||||
const root = sandbox({ "": "# Parent\n" });
|
|
||||||
await page.goto("/");
|
|
||||||
await mark(page);
|
|
||||||
const move = page.locator("header").getByTitle("Move page (M)");
|
|
||||||
await expect(move).toHaveCount(0);
|
|
||||||
await link(sidebar(page), root).click();
|
|
||||||
await expect(page.locator("main h1")).toContainText("Parent");
|
|
||||||
await expect(move).toBeVisible();
|
|
||||||
await page.goBack();
|
|
||||||
await expect(page.locator("main h1")).toContainText("Home");
|
|
||||||
await expect(move).toHaveCount(0);
|
|
||||||
expect(await marked(page)).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("search results and the search box follow boosted navigation", async ({ page }) => {
|
|
||||||
const root = sandbox({ "": "# Parent\n", Child: "# Child\n" });
|
|
||||||
expect((await page.request.post("/_reindex")).status()).toBe(204);
|
|
||||||
await page.goto(`/${root}/`);
|
|
||||||
await mark(page);
|
|
||||||
const input = page.locator("header .search-input");
|
|
||||||
await input.fill(root);
|
|
||||||
await input.press("Enter");
|
|
||||||
await expect(page).toHaveURL(new RegExp(`/\?q=${root}$`));
|
|
||||||
await expect(page).toHaveTitle(`Search: ${root}`);
|
|
||||||
await expect(page.locator("main .search-card").first()).toBeVisible();
|
|
||||||
await link(sidebar(page), root).click();
|
|
||||||
await expect(page.locator("main h1")).toContainText("Parent");
|
|
||||||
await expect(input).toHaveValue("");
|
|
||||||
expect(await marked(page)).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("a mutation swaps in its result without a reload", async ({ page }) => {
|
|
||||||
const root = sandbox({ "": "# Parent\n", Gone: "# Gone\n" });
|
|
||||||
await page.goto(`/${root}/Gone/`);
|
|
||||||
await mark(page);
|
|
||||||
await page.locator("header").getByTitle("Delete page").click();
|
|
||||||
await page.locator(".modal").getByRole("button", { name: "DELETE" }).click();
|
|
||||||
await expect(page).toHaveURL(new RegExp(`/${root}/$`));
|
|
||||||
await expect(page.locator("main h1")).toContainText("Parent");
|
|
||||||
await expect(page.locator(".modal")).toHaveCount(0);
|
|
||||||
await expect(link(sidebar(page), "Gone")).toHaveCount(0);
|
|
||||||
expect(await marked(page)).toBe(true);
|
|
||||||
});
|
|
||||||
@@ -1,151 +0,0 @@
|
|||||||
// End-to-end coverage of the canvas view: creating a canvas from a page,
|
|
||||||
// notes, connections, moving, deleting and the autosave behind all of them.
|
|
||||||
// Like pages.spec.js, every test works in its own fresh top-level folder.
|
|
||||||
import { test, expect } from "@playwright/test";
|
|
||||||
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
||||||
import { dirname, join } from "node:path";
|
|
||||||
import { fileURLToPath } from "node:url";
|
|
||||||
|
|
||||||
const wiki = join(dirname(fileURLToPath(import.meta.url)), "..", ".tmp", "wiki");
|
|
||||||
|
|
||||||
test.beforeEach(({ page }) => {
|
|
||||||
page.on("pageerror", (err) => {
|
|
||||||
throw err;
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
function randomWord(n) {
|
|
||||||
return Array.from({ length: n }, () => String.fromCharCode(97 + Math.floor(Math.random() * 26))).join("");
|
|
||||||
}
|
|
||||||
|
|
||||||
// sandbox creates a fresh page, optionally holding Board.canvas with doc.
|
|
||||||
function sandbox(doc) {
|
|
||||||
const root = "c" + randomWord(7);
|
|
||||||
mkdirSync(join(wiki, root), { recursive: true });
|
|
||||||
writeFileSync(join(wiki, root, "index.md"), "# Canvas host\n");
|
|
||||||
if (doc) writeFileSync(join(wiki, root, "Board.canvas"), JSON.stringify(doc));
|
|
||||||
return root;
|
|
||||||
}
|
|
||||||
|
|
||||||
const readCanvas = (root, name = "Board.canvas") => JSON.parse(readFileSync(join(wiki, root, name), "utf8"));
|
|
||||||
const node = (page, id) => page.locator(`.canvas-node[data-id="${id}"]`);
|
|
||||||
|
|
||||||
function note(id, x, y, text) {
|
|
||||||
return { id, type: "text", x, y, width: 200, height: 80, text };
|
|
||||||
}
|
|
||||||
|
|
||||||
async function pageAction(page, title) {
|
|
||||||
const toggle = page.getByRole("button", { name: "ACTIONS" });
|
|
||||||
if (await toggle.isVisible()) await toggle.click();
|
|
||||||
await page.locator("header").getByTitle(title).click();
|
|
||||||
}
|
|
||||||
|
|
||||||
test("creates a canvas from a page and writes a note", async ({ page }) => {
|
|
||||||
const root = sandbox();
|
|
||||||
await page.goto(`/${root}/`);
|
|
||||||
await pageAction(page, "New canvas on this page");
|
|
||||||
await page.locator(".modal").getByPlaceholder("Canvas name").fill("Ideas");
|
|
||||||
await page.locator(".modal").getByRole("button", { name: "CREATE" }).click();
|
|
||||||
await expect(page).toHaveURL(new RegExp(`/${root}/Ideas\\.canvas$`));
|
|
||||||
// The canvas takes the whole shell: no folder tree, no footer.
|
|
||||||
await expect(page.locator("aside.tree-sidebar")).toHaveCount(0);
|
|
||||||
await expect(page.locator("footer")).toHaveCount(0);
|
|
||||||
|
|
||||||
await page.locator(".canvas-viewport").dblclick({ position: { x: 300, y: 200 } });
|
|
||||||
await expect(page.locator(".canvas-node-editor .cm-content")).toBeFocused();
|
|
||||||
await page.keyboard.type("Hello **world**");
|
|
||||||
await page.keyboard.press("Escape");
|
|
||||||
await expect(page.locator(".canvas-node-body strong")).toHaveText("world");
|
|
||||||
await expect.poll(() => readCanvas(root, "Ideas.canvas").nodes.map((n) => n.text)).toEqual(["Hello **world**"]);
|
|
||||||
await expect(page.locator(".canvas-status")).toHaveText("Saved");
|
|
||||||
|
|
||||||
await page.reload();
|
|
||||||
await expect(page.locator(".canvas-node-body strong")).toHaveText("world");
|
|
||||||
|
|
||||||
// The page lists its canvas.
|
|
||||||
await page.goto(`/${root}/`);
|
|
||||||
await expect(page.locator("main").getByRole("link", { name: "Ideas.canvas" })).toBeVisible();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("an empty new note disappears", async ({ page }) => {
|
|
||||||
const root = sandbox({ nodes: [], edges: [] });
|
|
||||||
await page.goto(`/${root}/Board.canvas`);
|
|
||||||
await page.locator(".canvas-viewport").dblclick({ position: { x: 300, y: 200 } });
|
|
||||||
await expect(page.locator(".canvas-node")).toHaveCount(1);
|
|
||||||
await page.keyboard.press("Escape");
|
|
||||||
await expect(page.locator(".canvas-node")).toHaveCount(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("edits a note with the page editor's toolbar", async ({ page }) => {
|
|
||||||
const root = sandbox({ nodes: [note("a", 0, 0, "plain")], edges: [] });
|
|
||||||
await page.goto(`/${root}/Board.canvas`);
|
|
||||||
await node(page, "a").dblclick();
|
|
||||||
await expect(page.locator(".canvas-node-toolbar")).toBeVisible();
|
|
||||||
await page.keyboard.press("Control+End");
|
|
||||||
await page.keyboard.type(" ");
|
|
||||||
await page.locator(".canvas-node-toolbar").getByTitle("Bold (B)").click();
|
|
||||||
await page.keyboard.type("strong");
|
|
||||||
// Clicking the background closes the editor.
|
|
||||||
await page.locator(".canvas-viewport").click({ position: { x: 5, y: 300 } });
|
|
||||||
await expect(page.locator(".canvas-node-toolbar")).toBeHidden();
|
|
||||||
await expect(node(page, "a").locator("strong")).toHaveText("strong");
|
|
||||||
await expect.poll(() => readCanvas(root).nodes[0].text).toBe("plain **strong**");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("connects two nodes by dragging a handle", async ({ page }) => {
|
|
||||||
const root = sandbox({ nodes: [note("a", 0, 0, "A"), note("b", 400, 0, "B")], edges: [] });
|
|
||||||
await page.goto(`/${root}/Board.canvas`);
|
|
||||||
await node(page, "a").click();
|
|
||||||
const handle = node(page, "a").locator('.canvas-handle[data-side="right"]');
|
|
||||||
const from = await handle.boundingBox();
|
|
||||||
const to = await node(page, "b").boundingBox();
|
|
||||||
await page.mouse.move(from.x + from.width / 2, from.y + from.height / 2);
|
|
||||||
await page.mouse.down();
|
|
||||||
await page.mouse.move(to.x + 20, to.y + to.height / 2, { steps: 5 });
|
|
||||||
await page.mouse.up();
|
|
||||||
await expect(page.locator(".canvas-edge")).toHaveCount(1);
|
|
||||||
await expect
|
|
||||||
.poll(() => readCanvas(root).edges.map((e) => [e.fromNode, e.fromSide, e.toNode, e.toSide]))
|
|
||||||
.toEqual([["a", "right", "b", "left"]]);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("moves, undoes and deletes nodes", async ({ page }) => {
|
|
||||||
const root = sandbox({
|
|
||||||
nodes: [note("a", 0, 0, "A"), note("b", 400, 0, "B")],
|
|
||||||
edges: [{ id: "e", fromNode: "a", toNode: "b" }],
|
|
||||||
});
|
|
||||||
await page.goto(`/${root}/Board.canvas`);
|
|
||||||
const box = await node(page, "a").boundingBox();
|
|
||||||
await page.mouse.move(box.x + 50, box.y + 40);
|
|
||||||
await page.mouse.down();
|
|
||||||
await page.mouse.move(box.x + 50, box.y + 240, { steps: 5 });
|
|
||||||
await page.mouse.up();
|
|
||||||
await expect.poll(() => readCanvas(root).nodes[0].y).toBeGreaterThan(100);
|
|
||||||
|
|
||||||
await page.keyboard.press("Control+z");
|
|
||||||
await expect.poll(() => readCanvas(root).nodes[0].y).toBe(0);
|
|
||||||
|
|
||||||
// Deleting a node removes its connections too.
|
|
||||||
await node(page, "a").click();
|
|
||||||
await page.keyboard.press("Delete");
|
|
||||||
await expect(node(page, "a")).toHaveCount(0);
|
|
||||||
await expect.poll(() => readCanvas(root)).toMatchObject({ nodes: [{ id: "b" }], edges: [] });
|
|
||||||
});
|
|
||||||
|
|
||||||
test("a task checkbox in a note persists", async ({ page }) => {
|
|
||||||
const root = sandbox({ nodes: [note("a", 0, 0, "- [ ] one\n- [ ] two")], edges: [] });
|
|
||||||
await page.goto(`/${root}/Board.canvas`);
|
|
||||||
await node(page, "a").locator("input.task-checkbox").nth(1).check();
|
|
||||||
await expect.poll(() => readCanvas(root).nodes[0].text).toBe("- [ ] one\n- [x] two");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("a change made elsewhere stops the autosave", async ({ page }) => {
|
|
||||||
const root = sandbox({ nodes: [note("a", 0, 0, "A")], edges: [] });
|
|
||||||
await page.goto(`/${root}/Board.canvas`);
|
|
||||||
const other = { nodes: [note("z", 0, 0, "from elsewhere")], edges: [] };
|
|
||||||
writeFileSync(join(wiki, root, "Board.canvas"), JSON.stringify(other));
|
|
||||||
await node(page, "a").click();
|
|
||||||
await page.keyboard.press("ArrowDown");
|
|
||||||
await expect(page.locator(".canvas-status")).toContainText("Changed elsewhere");
|
|
||||||
expect(readCanvas(root)).toEqual(other);
|
|
||||||
});
|
|
||||||
@@ -1,392 +0,0 @@
|
|||||||
// End-to-end coverage of the page UI. Tests drive the app through visible
|
|
||||||
// labels, not implementation details, so they survive changes to the
|
|
||||||
// client-side mechanics.
|
|
||||||
//
|
|
||||||
// Every test builds the pages it needs under its own fresh top-level folder
|
|
||||||
// (sandbox), so tests don't depend on each other's leftovers and can run alone
|
|
||||||
// or in any order. Names are matched exactly: other tests' folders share the
|
|
||||||
// sidebar and the search index.
|
|
||||||
import { test, expect } from "@playwright/test";
|
|
||||||
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
||||||
import { dirname, join } from "node:path";
|
|
||||||
import { fileURLToPath } from "node:url";
|
|
||||||
|
|
||||||
const wiki = join(dirname(fileURLToPath(import.meta.url)), "..", ".tmp", "wiki");
|
|
||||||
|
|
||||||
// Fail any test that logs a JS error.
|
|
||||||
test.beforeEach(({ page }) => {
|
|
||||||
page.on("pageerror", (err) => {
|
|
||||||
throw err;
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// randomWord returns n random lowercase letters.
|
|
||||||
function randomWord(n) {
|
|
||||||
return Array.from({ length: n }, () => String.fromCharCode(97 + Math.floor(Math.random() * 26))).join("");
|
|
||||||
}
|
|
||||||
|
|
||||||
// sandbox creates a fresh top-level folder holding pages, keyed by path
|
|
||||||
// relative to it ("" is the folder's own page); a null body makes an empty
|
|
||||||
// folder. Returns the folder name.
|
|
||||||
function sandbox(pages) {
|
|
||||||
const root = "t" + randomWord(7);
|
|
||||||
for (const [rel, body] of Object.entries(pages)) {
|
|
||||||
const dir = join(wiki, root, rel);
|
|
||||||
mkdirSync(dir, { recursive: true });
|
|
||||||
if (body !== null) writeFileSync(join(dir, "index.md"), body);
|
|
||||||
}
|
|
||||||
return root;
|
|
||||||
}
|
|
||||||
|
|
||||||
const readPage = (root, rel = "") => readFileSync(join(wiki, root, rel, "index.md"), "utf8");
|
|
||||||
const exactly = (name) => new RegExp(`^${name}$`);
|
|
||||||
const sidebar = (page) => page.locator("aside.tree-sidebar");
|
|
||||||
const modal = (page) => page.locator(".modal");
|
|
||||||
const treeRow = (scope, name) => scope.locator(".tree-row", { hasText: exactly(name) });
|
|
||||||
const link = (scope, name) => scope.getByRole("link", { name, exact: true });
|
|
||||||
|
|
||||||
// Page actions sit in a dropdown on narrow screens and inline (icon-only) on
|
|
||||||
// wide ones; address them by title and open the dropdown only when present.
|
|
||||||
async function pageAction(page, title) {
|
|
||||||
const toggle = page.getByRole("button", { name: "ACTIONS" });
|
|
||||||
if (await toggle.isVisible()) await toggle.click();
|
|
||||||
await page.locator("header").getByTitle(title).click();
|
|
||||||
}
|
|
||||||
|
|
||||||
// movePage runs the move flow from the current page: keep the preselected
|
|
||||||
// parent, then enter name.
|
|
||||||
async function movePage(page, name) {
|
|
||||||
await pageAction(page, "Move page (M)");
|
|
||||||
await modal(page).getByRole("button", { name: "NEXT" }).click();
|
|
||||||
await modal(page).getByPlaceholder("Page name").fill(name);
|
|
||||||
await modal(page).getByRole("button", { name: "MOVE" }).click();
|
|
||||||
}
|
|
||||||
|
|
||||||
test("renders a page and navigates via the sidebar", async ({ page }) => {
|
|
||||||
const root = sandbox({ "": "# Parent\n", Child: "# Child\n" });
|
|
||||||
await page.goto("/");
|
|
||||||
await expect(page.locator("main h1")).toContainText("Home");
|
|
||||||
await link(sidebar(page), root).click();
|
|
||||||
await expect(page).toHaveURL(new RegExp(`/${root}/$`));
|
|
||||||
await expect(page.locator("main h1")).toContainText("Parent");
|
|
||||||
// The sidebar expands the active folder's children.
|
|
||||||
await expect(link(sidebar(page), "Child")).toBeVisible();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("task checkbox toggle persists", async ({ page }) => {
|
|
||||||
const root = sandbox({ "": "# Tasks\n\n- [ ] first task\n- [x] second task\n" });
|
|
||||||
await page.goto(`/${root}/`);
|
|
||||||
const box = page.locator("main input.task-checkbox").first();
|
|
||||||
await expect(box).not.toBeChecked();
|
|
||||||
await box.check();
|
|
||||||
await expect(box).toBeEnabled();
|
|
||||||
await expect.poll(() => readPage(root)).toContain("- [x] first task");
|
|
||||||
await page.reload();
|
|
||||||
await expect(page.locator("main input.task-checkbox").first()).toBeChecked();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("unchecking a task persists", async ({ page }) => {
|
|
||||||
const root = sandbox({ "": "# Tasks\n\n- [ ] first task\n- [x] second task\n" });
|
|
||||||
await page.goto(`/${root}/`);
|
|
||||||
const box = page.locator("main input.task-checkbox").nth(1);
|
|
||||||
await expect(box).toBeChecked();
|
|
||||||
await box.uncheck();
|
|
||||||
await expect(box).toBeEnabled();
|
|
||||||
await expect.poll(() => readPage(root)).toContain("- [ ] second task");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("a failed task save reverts the checkbox", async ({ page }) => {
|
|
||||||
const root = sandbox({ "": "# Tasks\n\n- [x] done task\n" });
|
|
||||||
page.on("dialog", (d) => d.dismiss());
|
|
||||||
await page.route("**/?toggle=*", (route) => route.fulfill({ status: 500, body: "boom" }));
|
|
||||||
await page.goto(`/${root}/`);
|
|
||||||
const box = page.locator("main input.task-checkbox").first();
|
|
||||||
await box.click();
|
|
||||||
await expect(box).toBeEnabled();
|
|
||||||
await expect(box).toBeChecked();
|
|
||||||
expect(readPage(root)).toContain("- [x] done task");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("view settings switch the listing to thumbnails", async ({ page }) => {
|
|
||||||
const root = sandbox({ "": "# Listing\n", Child: "# Child\n" });
|
|
||||||
await page.goto(`/${root}/`);
|
|
||||||
await expect(page.locator("main table.data-table")).toBeVisible();
|
|
||||||
await page.getByTitle("View & sorting").click();
|
|
||||||
await modal(page).getByText("Thumbnail").click();
|
|
||||||
await modal(page).getByLabel("Sort by").selectOption("size");
|
|
||||||
await modal(page).getByText("Desc").click();
|
|
||||||
await modal(page).getByRole("button", { name: "SAVE" }).click();
|
|
||||||
await expect(page.locator("main .thumb-grid")).toBeVisible();
|
|
||||||
await expect(page).toHaveURL(new RegExp(`/${root}/$`));
|
|
||||||
// Reopening shows the saved state.
|
|
||||||
await page.getByTitle("View & sorting").click();
|
|
||||||
await expect(modal(page).getByLabel("Thumbnail")).toBeChecked();
|
|
||||||
await expect(modal(page).getByLabel("Sort by")).toHaveValue("size");
|
|
||||||
await expect(modal(page).getByLabel("Desc")).toBeChecked();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("editor saves a page", async ({ page }) => {
|
|
||||||
const root = sandbox({ "": "# Editable\n" });
|
|
||||||
await page.goto(`/${root}/`);
|
|
||||||
await pageAction(page, "Edit page (E)");
|
|
||||||
await expect(page).toHaveURL(/\?edit/);
|
|
||||||
await page.locator(".cm-content").click();
|
|
||||||
await page.keyboard.press("Control+End");
|
|
||||||
await page.keyboard.type("\nAdded by e2e.\n");
|
|
||||||
await page.getByRole("button", { name: "SAVE" }).click();
|
|
||||||
await expect(page).toHaveURL(new RegExp(`/${root}/$`));
|
|
||||||
await expect(page.locator("main")).toContainText("Added by e2e.");
|
|
||||||
expect(readPage(root)).toContain("Added by e2e.");
|
|
||||||
// Save replaces the editor's history entry: Back leaves the page entirely.
|
|
||||||
await page.goBack();
|
|
||||||
await expect(page).not.toHaveURL(/\?edit/);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("move renames a page", async ({ page }) => {
|
|
||||||
const root = sandbox({ Beta: "# Beta\n" });
|
|
||||||
await page.goto(`/${root}/Beta/`);
|
|
||||||
await movePage(page, "Gamma");
|
|
||||||
await expect(page).toHaveURL(new RegExp(`/${root}/Gamma/$`));
|
|
||||||
await expect(page.locator("main h1")).toContainText("Beta");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("move onto an empty folder offers a merge", async ({ page }) => {
|
|
||||||
const root = sandbox({ Alpha: "# Alpha\n", Empty: null });
|
|
||||||
await page.goto(`/${root}/Alpha/`);
|
|
||||||
await movePage(page, "Empty");
|
|
||||||
await expect(modal(page)).toContainText("Merge folders?");
|
|
||||||
await modal(page).getByRole("button", { name: "MERGE" }).click();
|
|
||||||
await expect(page).toHaveURL(new RegExp(`/${root}/Empty/$`));
|
|
||||||
await expect(page.locator("main h1")).toContainText("Alpha");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("a failed move shows the server's reason and keeps the dialog", async ({ page }) => {
|
|
||||||
const root = sandbox({ Mover: "# Mover\n", Taken: "# Taken\n" });
|
|
||||||
await page.goto(`/${root}/Mover/`);
|
|
||||||
// Dismiss the alert from the handler: an open alert blocks the click.
|
|
||||||
const messages = [];
|
|
||||||
page.on("dialog", (d) => {
|
|
||||||
messages.push(d.message());
|
|
||||||
d.dismiss();
|
|
||||||
});
|
|
||||||
await movePage(page, "Taken");
|
|
||||||
await expect.poll(() => messages).toEqual([expect.stringContaining("destination already exists")]);
|
|
||||||
await expect(modal(page)).toBeVisible();
|
|
||||||
await expect(page).toHaveURL(new RegExp(`/${root}/Mover/$`));
|
|
||||||
});
|
|
||||||
|
|
||||||
test("delete removes a page and returns to the parent", async ({ page }) => {
|
|
||||||
const root = sandbox({ "": "# Parent\n", Notes: "# Notes\n" });
|
|
||||||
await page.goto(`/${root}/Notes/`);
|
|
||||||
await pageAction(page, "Delete page");
|
|
||||||
await expect(modal(page)).toContainText(`Delete /${root}/Notes/`);
|
|
||||||
await modal(page).getByRole("button", { name: "DELETE" }).click();
|
|
||||||
await expect(page).toHaveURL(new RegExp(`/${root}/$`));
|
|
||||||
await expect(link(sidebar(page), "Notes")).toHaveCount(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("delete replaces the history entry, so Back skips the deleted page", async ({ page }) => {
|
|
||||||
const root = sandbox({ "": "# Parent\n", Gone: "# Gone\n" });
|
|
||||||
await page.goto(`/${root}/`);
|
|
||||||
await page.goto(`/${root}/Gone/`);
|
|
||||||
await pageAction(page, "Delete page");
|
|
||||||
await modal(page).getByRole("button", { name: "DELETE" }).click();
|
|
||||||
await expect(page).toHaveURL(new RegExp(`/${root}/$`));
|
|
||||||
await page.goBack();
|
|
||||||
await expect(page).toHaveURL(new RegExp(`/${root}/$`));
|
|
||||||
});
|
|
||||||
|
|
||||||
test("a server dialog works from the first moment it is shown", async ({ page }) => {
|
|
||||||
// htmx wires up swapped-in forms only when it settles. Submit in the very
|
|
||||||
// next task after the dialog is inserted: if the form isn't wired yet, the
|
|
||||||
// browser submits it natively (a GET) and the page is not deleted.
|
|
||||||
const root = sandbox({ "": "# Parent\n", Gone: "# Gone\n" });
|
|
||||||
await page.goto(`/${root}/Gone/`);
|
|
||||||
await page.evaluate(() => {
|
|
||||||
document.addEventListener("htmx:afterSwap", (e) => {
|
|
||||||
if (e.detail.target.id !== "modal-host") return;
|
|
||||||
const submit = e.detail.target.querySelector('button[type="submit"]');
|
|
||||||
setTimeout(() => submit.click(), 0);
|
|
||||||
}, { once: true });
|
|
||||||
});
|
|
||||||
await pageAction(page, "Delete page");
|
|
||||||
await expect(page).toHaveURL(new RegExp(`/${root}/$`));
|
|
||||||
});
|
|
||||||
|
|
||||||
test("server dialogs close with Escape and CANCEL", async ({ page }) => {
|
|
||||||
const root = sandbox({ "": "# Keep me\n" });
|
|
||||||
await page.goto(`/${root}/`);
|
|
||||||
await pageAction(page, "Delete page");
|
|
||||||
await expect(modal(page)).toBeVisible();
|
|
||||||
// CANCEL is focused, so Enter must not delete.
|
|
||||||
await expect(modal(page).getByRole("button", { name: "CANCEL" })).toBeFocused();
|
|
||||||
await page.keyboard.press("Escape");
|
|
||||||
await expect(modal(page)).toHaveCount(0);
|
|
||||||
await pageAction(page, "Delete page");
|
|
||||||
await modal(page).getByRole("button", { name: "CANCEL" }).click();
|
|
||||||
await expect(modal(page)).toHaveCount(0);
|
|
||||||
await expect(page).toHaveURL(new RegExp(`/${root}/$`));
|
|
||||||
expect(readPage(root)).toContain("Keep me");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("sidebar tree is in the page itself, no fetch on load", async ({ page }) => {
|
|
||||||
const root = sandbox({ "": "# Here\n" });
|
|
||||||
const treeRequests = [];
|
|
||||||
page.on("request", (r) => {
|
|
||||||
if (r.url().includes("?tree")) treeRequests.push(r.url());
|
|
||||||
});
|
|
||||||
const res = await page.request.get(`/${root}/`);
|
|
||||||
expect(await res.text()).toContain(`class="tree-name tree-folder" href="/${root}/"`);
|
|
||||||
await page.goto(`/${root}/`);
|
|
||||||
await expect(sidebar(page).locator(".tree-row.is-active")).toHaveText(root);
|
|
||||||
expect(treeRequests).toEqual([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("sidebar folders expand lazily and collapse", async ({ page }) => {
|
|
||||||
const root = sandbox({ "One/Deep": "# Deep\n", Two: "# Two\n" });
|
|
||||||
await page.goto("/");
|
|
||||||
const chevron = treeRow(sidebar(page), root).locator(".tree-chevron");
|
|
||||||
const glyph = () => chevron.evaluate((el) => getComputedStyle(el, "::before").content);
|
|
||||||
await expect(link(sidebar(page), "One")).toHaveCount(0);
|
|
||||||
expect(await glyph()).toBe('"▸"');
|
|
||||||
await chevron.click();
|
|
||||||
await expect(link(sidebar(page), "One")).toBeVisible();
|
|
||||||
expect(await glyph()).toBe('"▾"');
|
|
||||||
// One has a subfolder, Two has none: only One gets a chevron.
|
|
||||||
await expect(sidebar(page).locator("summary.tree-row", { hasText: exactly("One") })).toHaveCount(1);
|
|
||||||
await expect(treeRow(sidebar(page), "Two").locator(".tree-chevron")).toHaveClass(/is-leaf/);
|
|
||||||
await chevron.click();
|
|
||||||
await expect(link(sidebar(page), "One")).toBeHidden();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("tree picker selects inside a lazily expanded folder", async ({ page }) => {
|
|
||||||
const root = sandbox({ Mover: "# Mover\n", "Tree/One/Deep": "# Deep\n" });
|
|
||||||
await page.goto(`/${root}/Mover/`);
|
|
||||||
await pageAction(page, "Move page (M)");
|
|
||||||
// The picker opens with the current parent expanded; Tree is still closed.
|
|
||||||
const picker = modal(page).locator(".tree-picker");
|
|
||||||
await treeRow(picker, "Tree").locator(".tree-chevron").click();
|
|
||||||
await treeRow(picker, "One").locator(".tree-chevron").click();
|
|
||||||
await picker.getByText("Deep", { exact: true }).click();
|
|
||||||
await expect(modal(page).locator(".tree-selected-path")).toHaveText(`/${root}/Tree/One/Deep`);
|
|
||||||
await modal(page).getByRole("button", { name: "NEXT" }).click();
|
|
||||||
await modal(page).getByRole("button", { name: "MOVE" }).click();
|
|
||||||
await expect(page).toHaveURL(new RegExp(`/${root}/Tree/One/Deep/Mover/$`));
|
|
||||||
});
|
|
||||||
|
|
||||||
test("tree picker can pick the root", async ({ page }) => {
|
|
||||||
const root = sandbox({ Two: "# Two\n" });
|
|
||||||
await page.goto(`/${root}/Two/`);
|
|
||||||
await pageAction(page, "Move page (M)");
|
|
||||||
// The current parent is preselected and its chain expanded.
|
|
||||||
await expect(modal(page).locator(".tree-selected-path")).toHaveText(`/${root}`);
|
|
||||||
await modal(page).locator(".tree-picker").getByText("/", { exact: true }).click();
|
|
||||||
await modal(page).getByRole("button", { name: "NEXT" }).click();
|
|
||||||
await modal(page).getByPlaceholder("Page name").fill(`${root}-top`);
|
|
||||||
await modal(page).getByRole("button", { name: "MOVE" }).click();
|
|
||||||
await expect(page).toHaveURL(new RegExp(`127\\.0\\.0\\.1:\\d+/${root}-top/$`));
|
|
||||||
});
|
|
||||||
|
|
||||||
test("editor embed picker selects a file", async ({ page }) => {
|
|
||||||
const root = sandbox({ "": "# Embeds\n" });
|
|
||||||
writeFileSync(join(wiki, root, "photo.jpg"), "x");
|
|
||||||
await page.goto(`/${root}/?edit`);
|
|
||||||
await page.locator(".cm-content").click();
|
|
||||||
await page.keyboard.press("Alt+Shift+G");
|
|
||||||
const picker = modal(page).locator(".tree-picker");
|
|
||||||
// Folder rows aren't selectable in file mode; the file is.
|
|
||||||
await picker.getByText("photo.jpg").click();
|
|
||||||
await expect(modal(page).locator(".tree-selected-path")).toHaveText(`/${root}/photo.jpg`);
|
|
||||||
await modal(page).getByRole("button", { name: "NEXT" }).click();
|
|
||||||
await modal(page).getByRole("button", { name: "INSERT" }).click();
|
|
||||||
await expect(page.locator(".cm-content")).toContainText("photo.jpg");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("mobile: FOLDERS opens the tree in an overlay", async ({ page }) => {
|
|
||||||
const root = sandbox({ "": "# Mobile\n" });
|
|
||||||
await page.setViewportSize({ width: 400, height: 800 });
|
|
||||||
await page.goto(`/${root}/`);
|
|
||||||
await expect(sidebar(page)).toBeHidden();
|
|
||||||
await page.getByRole("button", { name: "ACTIONS" }).click();
|
|
||||||
await page.getByTitle("Folders").click();
|
|
||||||
const overlay = page.locator(".overlay");
|
|
||||||
await expect(link(overlay, root)).toBeVisible();
|
|
||||||
await link(overlay, root).click();
|
|
||||||
await expect(overlay).toHaveCount(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
// seedSearch creates pages whose names share a random word (so no other
|
|
||||||
// test's pages match it) and reindexes. Returns the sandbox, the word and its
|
|
||||||
// capitalized form used in the names.
|
|
||||||
async function seedSearch(page) {
|
|
||||||
const word = randomWord(8);
|
|
||||||
const Word = word[0].toUpperCase() + word.slice(1);
|
|
||||||
const pages = { "": "# Search\n" };
|
|
||||||
for (const n of ["Zebra Crossing", "Zebra Finch", "1", "2", "3", "4", "5", "6"]) {
|
|
||||||
pages[`${Word} ${n}`] = `# ${Word} ${n}\n`;
|
|
||||||
}
|
|
||||||
const root = sandbox(pages);
|
|
||||||
expect((await page.request.post("/_reindex")).status()).toBe(204);
|
|
||||||
return { root, word, Word };
|
|
||||||
}
|
|
||||||
const suggest = (page) => page.locator("header .suggest-dropdown");
|
|
||||||
|
|
||||||
test("header typeahead: highlight, keyboard pick", async ({ page }) => {
|
|
||||||
const { root, word, Word } = await seedSearch(page);
|
|
||||||
await page.goto("/");
|
|
||||||
const input = page.locator("header .search-input");
|
|
||||||
await input.fill(`${word} finch`);
|
|
||||||
const first = suggest(page).locator(".suggest-row").first();
|
|
||||||
await expect(first.locator(".suggest-name")).toHaveText(`${Word} Zebra Finch`);
|
|
||||||
await expect(first.locator("strong")).toHaveText([Word, "Finch"]);
|
|
||||||
await input.press("ArrowDown");
|
|
||||||
await expect(suggest(page).locator(".suggest-row.is-active")).toContainText(`${Word} Zebra Finch`);
|
|
||||||
await input.press("Enter");
|
|
||||||
await expect(page).toHaveURL(new RegExp(`/${root}/${Word}%20Zebra%20Finch/$`));
|
|
||||||
});
|
|
||||||
|
|
||||||
test("header typeahead: click, footer, escape, short query", async ({ page }) => {
|
|
||||||
const { root, word, Word } = await seedSearch(page);
|
|
||||||
await page.goto("/");
|
|
||||||
const input = page.locator("header .search-input");
|
|
||||||
await input.fill(word[0]);
|
|
||||||
await page.waitForTimeout(300);
|
|
||||||
await expect(suggest(page)).toBeHidden();
|
|
||||||
await input.fill(word);
|
|
||||||
const { total } = await (await page.request.get(`/_search?q=${word}`)).json();
|
|
||||||
expect(total).toBeGreaterThan(5);
|
|
||||||
await expect(suggest(page).locator(".suggest-footer")).toHaveText(`Show all ${total} matches`);
|
|
||||||
await expect(suggest(page).locator(".suggest-row:not(.suggest-footer)")).toHaveCount(5);
|
|
||||||
await input.press("Escape");
|
|
||||||
await expect(suggest(page)).toBeHidden();
|
|
||||||
await input.fill(`${word} crossing`);
|
|
||||||
await suggest(page).locator(".suggest-name", { hasText: `${Word} Zebra Crossing` }).click();
|
|
||||||
await expect(page).toHaveURL(new RegExp(`/${root}/${Word}%20Zebra%20Crossing/$`));
|
|
||||||
await page.goto("/");
|
|
||||||
await page.locator("header .search-input").fill(word);
|
|
||||||
await suggest(page).locator(".suggest-footer").click();
|
|
||||||
await expect(page).toHaveURL(new RegExp(`/\\?q=${word}$`));
|
|
||||||
});
|
|
||||||
|
|
||||||
test("editor link dialog fills the query from a suggestion", async ({ page }) => {
|
|
||||||
const { root, word, Word } = await seedSearch(page);
|
|
||||||
await page.goto(`/${root}/?edit`);
|
|
||||||
await page.locator(".cm-content").click();
|
|
||||||
await page.keyboard.press("Alt+Shift+P");
|
|
||||||
const query = modal(page).getByPlaceholder("Search query");
|
|
||||||
await query.fill(`${word} crossing`);
|
|
||||||
await modal(page).locator(".suggest-row", { hasText: `${Word} Zebra Crossing` }).click();
|
|
||||||
await expect(query).toHaveValue(`${Word} Zebra Crossing`);
|
|
||||||
await expect(modal(page).getByPlaceholder("Display text (optional)")).toBeFocused();
|
|
||||||
await modal(page).getByRole("button", { name: "INSERT" }).click();
|
|
||||||
await expect(page.locator(".cm-content")).toContainText(`[[${Word} Zebra Crossing]]`);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("editor [[ autocomplete lists matching pages", async ({ page }) => {
|
|
||||||
const { root, word, Word } = await seedSearch(page);
|
|
||||||
await page.goto(`/${root}/?edit`);
|
|
||||||
await page.locator(".cm-content").click();
|
|
||||||
await page.keyboard.press("Control+End");
|
|
||||||
await page.keyboard.type(`\n[[${word} finc`);
|
|
||||||
await expect(page.locator(".cm-tooltip-autocomplete")).toContainText(`${Word} Zebra Finch`);
|
|
||||||
});
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
// Builds the vendored CodeMirror 6 bundle consumed by the page editor.
|
|
||||||
//
|
|
||||||
// Output is an IIFE that assigns the CM primitives the editor scripts need
|
|
||||||
// onto window.CM (see entry.js). The editor JS is loaded as plain global
|
|
||||||
// scripts, not ES modules, so there is no runtime module loader.
|
|
||||||
//
|
|
||||||
// Run via `make editor` (or `npm run build` here) after changing CM versions.
|
|
||||||
// The committed artifact at assets/editor/vendor/codemirror.bundle.js is the
|
|
||||||
// only thing `go build` ever sees.
|
|
||||||
import * as esbuild from "esbuild";
|
|
||||||
|
|
||||||
await esbuild.build({
|
|
||||||
entryPoints: ["entry.js"],
|
|
||||||
bundle: true,
|
|
||||||
format: "iife",
|
|
||||||
minify: true,
|
|
||||||
target: ["es2018"],
|
|
||||||
legalComments: "none",
|
|
||||||
outfile: "../assets/editor/vendor/codemirror.bundle.js",
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log("built assets/editor/vendor/codemirror.bundle.js");
|
|
||||||
@@ -1,107 +0,0 @@
|
|||||||
// CodeMirror 6 bundle entry point.
|
|
||||||
//
|
|
||||||
// Imports only the CM packages the editor needs (keep the bundle small for the
|
|
||||||
// mobile/VPN path) and exposes them on window.CM for the global-style editor
|
|
||||||
// scripts. The editor theme and markdown highlight palette draw from the app's
|
|
||||||
// :root CSS variables so there are no hardcoded colors/spacing here.
|
|
||||||
import { EditorState, EditorSelection, Compartment, Prec } from "@codemirror/state";
|
|
||||||
import { EditorView, keymap, drawSelection } from "@codemirror/view";
|
|
||||||
import { history, historyKeymap, defaultKeymap, indentWithTab, undo, redo, deleteLine } from "@codemirror/commands";
|
|
||||||
import { markdown, markdownLanguage, markdownKeymap } from "@codemirror/lang-markdown";
|
|
||||||
import { syntaxHighlighting, HighlightStyle, indentOnInput } from "@codemirror/language";
|
|
||||||
import {
|
|
||||||
autocompletion,
|
|
||||||
closeBrackets,
|
|
||||||
closeBracketsKeymap,
|
|
||||||
completionKeymap,
|
|
||||||
startCompletion,
|
|
||||||
} from "@codemirror/autocomplete";
|
|
||||||
import { tags } from "@lezer/highlight";
|
|
||||||
|
|
||||||
// Editor chrome. Colors/spacing/fonts come from :root variables; var() works
|
|
||||||
// here because HighlightStyle/theme emit real CSS rules.
|
|
||||||
const theme = EditorView.theme(
|
|
||||||
{
|
|
||||||
"&": {
|
|
||||||
backgroundColor: "var(--bg)",
|
|
||||||
color: "var(--text)",
|
|
||||||
fontSize: "0.9rem",
|
|
||||||
border: "var(--border)",
|
|
||||||
borderTop: "none",
|
|
||||||
},
|
|
||||||
"&.cm-focused": { outline: "none" },
|
|
||||||
".cm-scroller": {
|
|
||||||
fontFamily: '"Iosevka Slab", monospace',
|
|
||||||
lineHeight: "1.6",
|
|
||||||
minHeight: "60vh",
|
|
||||||
},
|
|
||||||
".cm-content": {
|
|
||||||
padding: "var(--space-4)",
|
|
||||||
caretColor: "var(--text)",
|
|
||||||
},
|
|
||||||
".cm-cursor, .cm-dropCursor": { borderLeftColor: "var(--text)" },
|
|
||||||
"&.cm-focused .cm-selectionBackground, .cm-selectionBackground, .cm-content ::selection":
|
|
||||||
{ backgroundColor: "var(--bg-panel-hover)" },
|
|
||||||
".cm-activeLine": { backgroundColor: "transparent" },
|
|
||||||
".cm-tooltip": {
|
|
||||||
backgroundColor: "var(--bg-panel)",
|
|
||||||
border: "var(--border-dashed)",
|
|
||||||
color: "var(--text)",
|
|
||||||
},
|
|
||||||
".cm-tooltip.cm-tooltip-autocomplete > ul": {
|
|
||||||
fontFamily: '"Iosevka Slab", monospace',
|
|
||||||
fontSize: "var(--font-sm)",
|
|
||||||
},
|
|
||||||
".cm-tooltip-autocomplete ul li[aria-selected]": {
|
|
||||||
backgroundColor: "var(--bg-panel-hover)",
|
|
||||||
color: "var(--text)",
|
|
||||||
},
|
|
||||||
".cm-completionDetail": {
|
|
||||||
color: "var(--text-muted)",
|
|
||||||
fontStyle: "normal",
|
|
||||||
marginLeft: "var(--space-3)",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{ dark: true }
|
|
||||||
);
|
|
||||||
|
|
||||||
const highlightStyle = HighlightStyle.define([
|
|
||||||
{ tag: [tags.heading1, tags.heading2, tags.heading3, tags.heading4, tags.heading5, tags.heading6], color: "var(--secondary)", fontWeight: "bold" },
|
|
||||||
{ tag: tags.strong, fontWeight: "bold", color: "var(--text)" },
|
|
||||||
{ tag: tags.emphasis, fontStyle: "italic" },
|
|
||||||
{ tag: tags.strikethrough, textDecoration: "line-through" },
|
|
||||||
{ tag: [tags.link, tags.url], color: "var(--link)" },
|
|
||||||
{ tag: tags.monospace, color: "var(--primary-hover)" },
|
|
||||||
{ tag: tags.quote, color: "var(--text-muted)", fontStyle: "italic" },
|
|
||||||
{ tag: [tags.list, tags.contentSeparator], color: "var(--secondary)" },
|
|
||||||
{ tag: [tags.processingInstruction, tags.meta], color: "var(--text-muted)" },
|
|
||||||
]);
|
|
||||||
|
|
||||||
window.CM = {
|
|
||||||
EditorState,
|
|
||||||
EditorSelection,
|
|
||||||
Compartment,
|
|
||||||
Prec,
|
|
||||||
EditorView,
|
|
||||||
keymap,
|
|
||||||
drawSelection,
|
|
||||||
history,
|
|
||||||
historyKeymap,
|
|
||||||
defaultKeymap,
|
|
||||||
indentWithTab,
|
|
||||||
undo,
|
|
||||||
redo,
|
|
||||||
deleteLine,
|
|
||||||
markdown,
|
|
||||||
markdownLanguage,
|
|
||||||
markdownKeymap,
|
|
||||||
syntaxHighlighting,
|
|
||||||
indentOnInput,
|
|
||||||
autocompletion,
|
|
||||||
closeBrackets,
|
|
||||||
closeBracketsKeymap,
|
|
||||||
completionKeymap,
|
|
||||||
startCompletion,
|
|
||||||
theme,
|
|
||||||
highlightStyle,
|
|
||||||
};
|
|
||||||
@@ -1,730 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "datascape-editor-build",
|
|
||||||
"version": "1.0.0",
|
|
||||||
"lockfileVersion": 3,
|
|
||||||
"requires": true,
|
|
||||||
"packages": {
|
|
||||||
"": {
|
|
||||||
"name": "datascape-editor-build",
|
|
||||||
"version": "1.0.0",
|
|
||||||
"devDependencies": {
|
|
||||||
"@codemirror/autocomplete": "^6.18.0",
|
|
||||||
"@codemirror/commands": "^6.7.0",
|
|
||||||
"@codemirror/lang-markdown": "^6.3.0",
|
|
||||||
"@codemirror/language": "^6.10.0",
|
|
||||||
"@codemirror/state": "^6.4.0",
|
|
||||||
"@codemirror/view": "^6.34.0",
|
|
||||||
"@lezer/highlight": "^1.2.0",
|
|
||||||
"esbuild": "^0.24.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@codemirror/autocomplete": {
|
|
||||||
"version": "6.20.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/@codemirror/autocomplete/-/autocomplete-6.20.3.tgz",
|
|
||||||
"integrity": "sha512-tlosUqb+3BbxCxZdu4tKeRghPFC+QM7q4X5YhKV2eCmPG+1r2F3f4AaSz5sCrFqUtX4Jh20VFTKecl16MgiV9g==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@codemirror/language": "^6.0.0",
|
|
||||||
"@codemirror/state": "^6.0.0",
|
|
||||||
"@codemirror/view": "^6.17.0",
|
|
||||||
"@lezer/common": "^1.0.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@codemirror/commands": {
|
|
||||||
"version": "6.10.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/@codemirror/commands/-/commands-6.10.3.tgz",
|
|
||||||
"integrity": "sha512-JFRiqhKu+bvSkDLI+rUhJwSxQxYb759W5GBezE8Uc8mHLqC9aV/9aTC7yJSqCtB3F00pylrLCwnyS91Ap5ej4Q==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@codemirror/language": "^6.0.0",
|
|
||||||
"@codemirror/state": "^6.6.0",
|
|
||||||
"@codemirror/view": "^6.27.0",
|
|
||||||
"@lezer/common": "^1.1.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@codemirror/lang-css": {
|
|
||||||
"version": "6.3.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/@codemirror/lang-css/-/lang-css-6.3.1.tgz",
|
|
||||||
"integrity": "sha512-kr5fwBGiGtmz6l0LSJIbno9QrifNMUusivHbnA1H6Dmqy4HZFte3UAICix1VuKo0lMPKQr2rqB+0BkKi/S3Ejg==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@codemirror/autocomplete": "^6.0.0",
|
|
||||||
"@codemirror/language": "^6.0.0",
|
|
||||||
"@codemirror/state": "^6.0.0",
|
|
||||||
"@lezer/common": "^1.0.2",
|
|
||||||
"@lezer/css": "^1.1.7"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@codemirror/lang-html": {
|
|
||||||
"version": "6.4.11",
|
|
||||||
"resolved": "https://registry.npmjs.org/@codemirror/lang-html/-/lang-html-6.4.11.tgz",
|
|
||||||
"integrity": "sha512-9NsXp7Nwp891pQchI7gPdTwBuSuT3K65NGTHWHNJ55HjYcHLllr0rbIZNdOzas9ztc1EUVBlHou85FFZS4BNnw==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@codemirror/autocomplete": "^6.0.0",
|
|
||||||
"@codemirror/lang-css": "^6.0.0",
|
|
||||||
"@codemirror/lang-javascript": "^6.0.0",
|
|
||||||
"@codemirror/language": "^6.4.0",
|
|
||||||
"@codemirror/state": "^6.0.0",
|
|
||||||
"@codemirror/view": "^6.17.0",
|
|
||||||
"@lezer/common": "^1.0.0",
|
|
||||||
"@lezer/css": "^1.1.0",
|
|
||||||
"@lezer/html": "^1.3.12"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@codemirror/lang-javascript": {
|
|
||||||
"version": "6.2.5",
|
|
||||||
"resolved": "https://registry.npmjs.org/@codemirror/lang-javascript/-/lang-javascript-6.2.5.tgz",
|
|
||||||
"integrity": "sha512-zD4e5mS+50htS7F+TYjBPsiIFGanfVqg4HyUz6WNFikgOPf2BgKlx+TQedI1w6n/IqRBVBbBWmGFdLB/7uxO4A==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@codemirror/autocomplete": "^6.0.0",
|
|
||||||
"@codemirror/language": "^6.6.0",
|
|
||||||
"@codemirror/lint": "^6.0.0",
|
|
||||||
"@codemirror/state": "^6.0.0",
|
|
||||||
"@codemirror/view": "^6.17.0",
|
|
||||||
"@lezer/common": "^1.0.0",
|
|
||||||
"@lezer/javascript": "^1.0.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@codemirror/lang-markdown": {
|
|
||||||
"version": "6.5.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/@codemirror/lang-markdown/-/lang-markdown-6.5.0.tgz",
|
|
||||||
"integrity": "sha512-0K40bZ35jpHya6FriukbgaleaqzBLZfOh7HuzqbMxBXkbYMJDxfF39c23xOgxFezR+3G+tR2/Mup+Xk865OMvw==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@codemirror/autocomplete": "^6.7.1",
|
|
||||||
"@codemirror/lang-html": "^6.0.0",
|
|
||||||
"@codemirror/language": "^6.3.0",
|
|
||||||
"@codemirror/state": "^6.0.0",
|
|
||||||
"@codemirror/view": "^6.0.0",
|
|
||||||
"@lezer/common": "^1.2.1",
|
|
||||||
"@lezer/markdown": "^1.0.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@codemirror/language": {
|
|
||||||
"version": "6.12.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/@codemirror/language/-/language-6.12.3.tgz",
|
|
||||||
"integrity": "sha512-QwCZW6Tt1siP37Jet9Tb02Zs81TQt6qQrZR2H+eGMcFsL1zMrk2/b9CLC7/9ieP1fjIUMgviLWMmgiHoJrj+ZA==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@codemirror/state": "^6.0.0",
|
|
||||||
"@codemirror/view": "^6.23.0",
|
|
||||||
"@lezer/common": "^1.5.0",
|
|
||||||
"@lezer/highlight": "^1.0.0",
|
|
||||||
"@lezer/lr": "^1.0.0",
|
|
||||||
"style-mod": "^4.0.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@codemirror/lint": {
|
|
||||||
"version": "6.9.6",
|
|
||||||
"resolved": "https://registry.npmjs.org/@codemirror/lint/-/lint-6.9.6.tgz",
|
|
||||||
"integrity": "sha512-6Kp7r6XfCi/D/5sdXieMfg9pJU1bUEx96WITuLU6ESaKizCz0QHFMjY/TaFSbigDdEAIgi93itLBIUETP4oK+A==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@codemirror/state": "^6.0.0",
|
|
||||||
"@codemirror/view": "^6.42.0",
|
|
||||||
"crelt": "^1.0.5"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@codemirror/state": {
|
|
||||||
"version": "6.6.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.6.0.tgz",
|
|
||||||
"integrity": "sha512-4nbvra5R5EtiCzr9BTHiTLc+MLXK2QGiAVYMyi8PkQd3SR+6ixar/Q/01Fa21TBIDOZXgeWV4WppsQolSreAPQ==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@marijn/find-cluster-break": "^1.0.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@codemirror/view": {
|
|
||||||
"version": "6.43.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.43.0.tgz",
|
|
||||||
"integrity": "sha512-V7ZCLQO3Jus9hzh2jVCCPW3mO4IBMr43O37PqSUYautJSnnJF41YlgLw21x0fLJTYvJ+Vkm6Gp+qKGH9pltgXA==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@codemirror/state": "^6.6.0",
|
|
||||||
"crelt": "^1.0.6",
|
|
||||||
"style-mod": "^4.1.0",
|
|
||||||
"w3c-keyname": "^2.2.4"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@esbuild/aix-ppc64": {
|
|
||||||
"version": "0.24.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.24.2.tgz",
|
|
||||||
"integrity": "sha512-thpVCb/rhxE/BnMLQ7GReQLLN8q9qbHmI55F4489/ByVg2aQaQ6kbcLb6FHkocZzQhxc4gx0sCk0tJkKBFzDhA==",
|
|
||||||
"cpu": [
|
|
||||||
"ppc64"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"aix"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@esbuild/android-arm": {
|
|
||||||
"version": "0.24.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.24.2.tgz",
|
|
||||||
"integrity": "sha512-tmwl4hJkCfNHwFB3nBa8z1Uy3ypZpxqxfTQOcHX+xRByyYgunVbZ9MzUUfb0RxaHIMnbHagwAxuTL+tnNM+1/Q==",
|
|
||||||
"cpu": [
|
|
||||||
"arm"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"android"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@esbuild/android-arm64": {
|
|
||||||
"version": "0.24.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.24.2.tgz",
|
|
||||||
"integrity": "sha512-cNLgeqCqV8WxfcTIOeL4OAtSmL8JjcN6m09XIgro1Wi7cF4t/THaWEa7eL5CMoMBdjoHOTh/vwTO/o2TRXIyzg==",
|
|
||||||
"cpu": [
|
|
||||||
"arm64"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"android"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@esbuild/android-x64": {
|
|
||||||
"version": "0.24.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.24.2.tgz",
|
|
||||||
"integrity": "sha512-B6Q0YQDqMx9D7rvIcsXfmJfvUYLoP722bgfBlO5cGvNVb5V/+Y7nhBE3mHV9OpxBf4eAS2S68KZztiPaWq4XYw==",
|
|
||||||
"cpu": [
|
|
||||||
"x64"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"android"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@esbuild/darwin-arm64": {
|
|
||||||
"version": "0.24.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.24.2.tgz",
|
|
||||||
"integrity": "sha512-kj3AnYWc+CekmZnS5IPu9D+HWtUI49hbnyqk0FLEJDbzCIQt7hg7ucF1SQAilhtYpIujfaHr6O0UHlzzSPdOeA==",
|
|
||||||
"cpu": [
|
|
||||||
"arm64"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"darwin"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@esbuild/darwin-x64": {
|
|
||||||
"version": "0.24.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.24.2.tgz",
|
|
||||||
"integrity": "sha512-WeSrmwwHaPkNR5H3yYfowhZcbriGqooyu3zI/3GGpF8AyUdsrrP0X6KumITGA9WOyiJavnGZUwPGvxvwfWPHIA==",
|
|
||||||
"cpu": [
|
|
||||||
"x64"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"darwin"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@esbuild/freebsd-arm64": {
|
|
||||||
"version": "0.24.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.24.2.tgz",
|
|
||||||
"integrity": "sha512-UN8HXjtJ0k/Mj6a9+5u6+2eZ2ERD7Edt1Q9IZiB5UZAIdPnVKDoG7mdTVGhHJIeEml60JteamR3qhsr1r8gXvg==",
|
|
||||||
"cpu": [
|
|
||||||
"arm64"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"freebsd"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@esbuild/freebsd-x64": {
|
|
||||||
"version": "0.24.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.24.2.tgz",
|
|
||||||
"integrity": "sha512-TvW7wE/89PYW+IevEJXZ5sF6gJRDY/14hyIGFXdIucxCsbRmLUcjseQu1SyTko+2idmCw94TgyaEZi9HUSOe3Q==",
|
|
||||||
"cpu": [
|
|
||||||
"x64"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"freebsd"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@esbuild/linux-arm": {
|
|
||||||
"version": "0.24.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.24.2.tgz",
|
|
||||||
"integrity": "sha512-n0WRM/gWIdU29J57hJyUdIsk0WarGd6To0s+Y+LwvlC55wt+GT/OgkwoXCXvIue1i1sSNWblHEig00GBWiJgfA==",
|
|
||||||
"cpu": [
|
|
||||||
"arm"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"linux"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@esbuild/linux-arm64": {
|
|
||||||
"version": "0.24.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.24.2.tgz",
|
|
||||||
"integrity": "sha512-7HnAD6074BW43YvvUmE/35Id9/NB7BeX5EoNkK9obndmZBUk8xmJJeU7DwmUeN7tkysslb2eSl6CTrYz6oEMQg==",
|
|
||||||
"cpu": [
|
|
||||||
"arm64"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"linux"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@esbuild/linux-ia32": {
|
|
||||||
"version": "0.24.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.24.2.tgz",
|
|
||||||
"integrity": "sha512-sfv0tGPQhcZOgTKO3oBE9xpHuUqguHvSo4jl+wjnKwFpapx+vUDcawbwPNuBIAYdRAvIDBfZVvXprIj3HA+Ugw==",
|
|
||||||
"cpu": [
|
|
||||||
"ia32"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"linux"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@esbuild/linux-loong64": {
|
|
||||||
"version": "0.24.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.24.2.tgz",
|
|
||||||
"integrity": "sha512-CN9AZr8kEndGooS35ntToZLTQLHEjtVB5n7dl8ZcTZMonJ7CCfStrYhrzF97eAecqVbVJ7APOEe18RPI4KLhwQ==",
|
|
||||||
"cpu": [
|
|
||||||
"loong64"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"linux"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@esbuild/linux-mips64el": {
|
|
||||||
"version": "0.24.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.24.2.tgz",
|
|
||||||
"integrity": "sha512-iMkk7qr/wl3exJATwkISxI7kTcmHKE+BlymIAbHO8xanq/TjHaaVThFF6ipWzPHryoFsesNQJPE/3wFJw4+huw==",
|
|
||||||
"cpu": [
|
|
||||||
"mips64el"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"linux"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@esbuild/linux-ppc64": {
|
|
||||||
"version": "0.24.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.24.2.tgz",
|
|
||||||
"integrity": "sha512-shsVrgCZ57Vr2L8mm39kO5PPIb+843FStGt7sGGoqiiWYconSxwTiuswC1VJZLCjNiMLAMh34jg4VSEQb+iEbw==",
|
|
||||||
"cpu": [
|
|
||||||
"ppc64"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"linux"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@esbuild/linux-riscv64": {
|
|
||||||
"version": "0.24.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.24.2.tgz",
|
|
||||||
"integrity": "sha512-4eSFWnU9Hhd68fW16GD0TINewo1L6dRrB+oLNNbYyMUAeOD2yCK5KXGK1GH4qD/kT+bTEXjsyTCiJGHPZ3eM9Q==",
|
|
||||||
"cpu": [
|
|
||||||
"riscv64"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"linux"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@esbuild/linux-s390x": {
|
|
||||||
"version": "0.24.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.24.2.tgz",
|
|
||||||
"integrity": "sha512-S0Bh0A53b0YHL2XEXC20bHLuGMOhFDO6GN4b3YjRLK//Ep3ql3erpNcPlEFed93hsQAjAQDNsvcK+hV90FubSw==",
|
|
||||||
"cpu": [
|
|
||||||
"s390x"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"linux"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@esbuild/linux-x64": {
|
|
||||||
"version": "0.24.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.24.2.tgz",
|
|
||||||
"integrity": "sha512-8Qi4nQcCTbLnK9WoMjdC9NiTG6/E38RNICU6sUNqK0QFxCYgoARqVqxdFmWkdonVsvGqWhmm7MO0jyTqLqwj0Q==",
|
|
||||||
"cpu": [
|
|
||||||
"x64"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"linux"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@esbuild/netbsd-arm64": {
|
|
||||||
"version": "0.24.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.24.2.tgz",
|
|
||||||
"integrity": "sha512-wuLK/VztRRpMt9zyHSazyCVdCXlpHkKm34WUyinD2lzK07FAHTq0KQvZZlXikNWkDGoT6x3TD51jKQ7gMVpopw==",
|
|
||||||
"cpu": [
|
|
||||||
"arm64"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"netbsd"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@esbuild/netbsd-x64": {
|
|
||||||
"version": "0.24.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.24.2.tgz",
|
|
||||||
"integrity": "sha512-VefFaQUc4FMmJuAxmIHgUmfNiLXY438XrL4GDNV1Y1H/RW3qow68xTwjZKfj/+Plp9NANmzbH5R40Meudu8mmw==",
|
|
||||||
"cpu": [
|
|
||||||
"x64"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"netbsd"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@esbuild/openbsd-arm64": {
|
|
||||||
"version": "0.24.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.24.2.tgz",
|
|
||||||
"integrity": "sha512-YQbi46SBct6iKnszhSvdluqDmxCJA+Pu280Av9WICNwQmMxV7nLRHZfjQzwbPs3jeWnuAhE9Jy0NrnJ12Oz+0A==",
|
|
||||||
"cpu": [
|
|
||||||
"arm64"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"openbsd"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@esbuild/openbsd-x64": {
|
|
||||||
"version": "0.24.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.24.2.tgz",
|
|
||||||
"integrity": "sha512-+iDS6zpNM6EnJyWv0bMGLWSWeXGN/HTaF/LXHXHwejGsVi+ooqDfMCCTerNFxEkM3wYVcExkeGXNqshc9iMaOA==",
|
|
||||||
"cpu": [
|
|
||||||
"x64"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"openbsd"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@esbuild/sunos-x64": {
|
|
||||||
"version": "0.24.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.24.2.tgz",
|
|
||||||
"integrity": "sha512-hTdsW27jcktEvpwNHJU4ZwWFGkz2zRJUz8pvddmXPtXDzVKTTINmlmga3ZzwcuMpUvLw7JkLy9QLKyGpD2Yxig==",
|
|
||||||
"cpu": [
|
|
||||||
"x64"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"sunos"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@esbuild/win32-arm64": {
|
|
||||||
"version": "0.24.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.24.2.tgz",
|
|
||||||
"integrity": "sha512-LihEQ2BBKVFLOC9ZItT9iFprsE9tqjDjnbulhHoFxYQtQfai7qfluVODIYxt1PgdoyQkz23+01rzwNwYfutxUQ==",
|
|
||||||
"cpu": [
|
|
||||||
"arm64"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"win32"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@esbuild/win32-ia32": {
|
|
||||||
"version": "0.24.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.24.2.tgz",
|
|
||||||
"integrity": "sha512-q+iGUwfs8tncmFC9pcnD5IvRHAzmbwQ3GPS5/ceCyHdjXubwQWI12MKWSNSMYLJMq23/IUCvJMS76PDqXe1fxA==",
|
|
||||||
"cpu": [
|
|
||||||
"ia32"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"win32"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@esbuild/win32-x64": {
|
|
||||||
"version": "0.24.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.24.2.tgz",
|
|
||||||
"integrity": "sha512-7VTgWzgMGvup6aSqDPLiW5zHaxYJGTO4OokMjIlrCtf+VpEL+cXKtCvg723iguPYI5oaUNdS+/V7OU2gvXVWEg==",
|
|
||||||
"cpu": [
|
|
||||||
"x64"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"win32"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@lezer/common": {
|
|
||||||
"version": "1.5.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/@lezer/common/-/common-1.5.2.tgz",
|
|
||||||
"integrity": "sha512-sxQE460fPZyU3sdc8lafxiPwJHBzZRy/udNFynGQky1SePYBdhkBl1kOagA9uT3pxR8K09bOrmTUqA9wb/PjSQ==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/@lezer/css": {
|
|
||||||
"version": "1.3.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/@lezer/css/-/css-1.3.3.tgz",
|
|
||||||
"integrity": "sha512-RzBo8r+/6QJeow7aPHIpGVIH59xTcJXp399820gZoMo9noQDRVpJLheIBUicYwKcsbOYoBRoLZlf2720dG/4Tg==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@lezer/common": "^1.2.0",
|
|
||||||
"@lezer/highlight": "^1.0.0",
|
|
||||||
"@lezer/lr": "^1.3.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@lezer/highlight": {
|
|
||||||
"version": "1.2.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/@lezer/highlight/-/highlight-1.2.3.tgz",
|
|
||||||
"integrity": "sha512-qXdH7UqTvGfdVBINrgKhDsVTJTxactNNxLk7+UMwZhU13lMHaOBlJe9Vqp907ya56Y3+ed2tlqzys7jDkTmW0g==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@lezer/common": "^1.3.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@lezer/html": {
|
|
||||||
"version": "1.3.13",
|
|
||||||
"resolved": "https://registry.npmjs.org/@lezer/html/-/html-1.3.13.tgz",
|
|
||||||
"integrity": "sha512-oI7n6NJml729m7pjm9lvLvmXbdoMoi2f+1pwSDJkl9d68zGr7a9Btz8NdHTGQZtW2DA25ybeuv/SyDb9D5tseg==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@lezer/common": "^1.2.0",
|
|
||||||
"@lezer/highlight": "^1.0.0",
|
|
||||||
"@lezer/lr": "^1.0.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@lezer/javascript": {
|
|
||||||
"version": "1.5.4",
|
|
||||||
"resolved": "https://registry.npmjs.org/@lezer/javascript/-/javascript-1.5.4.tgz",
|
|
||||||
"integrity": "sha512-vvYx3MhWqeZtGPwDStM2dwgljd5smolYD2lR2UyFcHfxbBQebqx8yjmFmxtJ/E6nN6u1D9srOiVWm3Rb4tmcUA==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@lezer/common": "^1.2.0",
|
|
||||||
"@lezer/highlight": "^1.1.3",
|
|
||||||
"@lezer/lr": "^1.3.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@lezer/lr": {
|
|
||||||
"version": "1.4.10",
|
|
||||||
"resolved": "https://registry.npmjs.org/@lezer/lr/-/lr-1.4.10.tgz",
|
|
||||||
"integrity": "sha512-rnCpTIBafOx4mRp43xOxDJbFipJm/c0cia/V5TiGlhmMa+wsSdoGmUN3w5Bqrks/09Q/D4tNAmWaT8p6NRi77A==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@lezer/common": "^1.0.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@lezer/markdown": {
|
|
||||||
"version": "1.6.4",
|
|
||||||
"resolved": "https://registry.npmjs.org/@lezer/markdown/-/markdown-1.6.4.tgz",
|
|
||||||
"integrity": "sha512-N0SxazMj4k65DBfaf1azqtMZd6u7MqluP84/NZnB/io8Td9aleFmAhz9hcbvSfsxT5tdYlJ5qgv5aMJGY4zEtA==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@lezer/common": "^1.5.0",
|
|
||||||
"@lezer/highlight": "^1.0.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@marijn/find-cluster-break": {
|
|
||||||
"version": "1.0.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/@marijn/find-cluster-break/-/find-cluster-break-1.0.2.tgz",
|
|
||||||
"integrity": "sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/crelt": {
|
|
||||||
"version": "1.0.6",
|
|
||||||
"resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.6.tgz",
|
|
||||||
"integrity": "sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/esbuild": {
|
|
||||||
"version": "0.24.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.24.2.tgz",
|
|
||||||
"integrity": "sha512-+9egpBW8I3CD5XPe0n6BfT5fxLzxrlDzqydF3aviG+9ni1lDC/OvMHcxqEFV0+LANZG5R1bFMWfUrjVsdwxJvA==",
|
|
||||||
"dev": true,
|
|
||||||
"hasInstallScript": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"bin": {
|
|
||||||
"esbuild": "bin/esbuild"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
},
|
|
||||||
"optionalDependencies": {
|
|
||||||
"@esbuild/aix-ppc64": "0.24.2",
|
|
||||||
"@esbuild/android-arm": "0.24.2",
|
|
||||||
"@esbuild/android-arm64": "0.24.2",
|
|
||||||
"@esbuild/android-x64": "0.24.2",
|
|
||||||
"@esbuild/darwin-arm64": "0.24.2",
|
|
||||||
"@esbuild/darwin-x64": "0.24.2",
|
|
||||||
"@esbuild/freebsd-arm64": "0.24.2",
|
|
||||||
"@esbuild/freebsd-x64": "0.24.2",
|
|
||||||
"@esbuild/linux-arm": "0.24.2",
|
|
||||||
"@esbuild/linux-arm64": "0.24.2",
|
|
||||||
"@esbuild/linux-ia32": "0.24.2",
|
|
||||||
"@esbuild/linux-loong64": "0.24.2",
|
|
||||||
"@esbuild/linux-mips64el": "0.24.2",
|
|
||||||
"@esbuild/linux-ppc64": "0.24.2",
|
|
||||||
"@esbuild/linux-riscv64": "0.24.2",
|
|
||||||
"@esbuild/linux-s390x": "0.24.2",
|
|
||||||
"@esbuild/linux-x64": "0.24.2",
|
|
||||||
"@esbuild/netbsd-arm64": "0.24.2",
|
|
||||||
"@esbuild/netbsd-x64": "0.24.2",
|
|
||||||
"@esbuild/openbsd-arm64": "0.24.2",
|
|
||||||
"@esbuild/openbsd-x64": "0.24.2",
|
|
||||||
"@esbuild/sunos-x64": "0.24.2",
|
|
||||||
"@esbuild/win32-arm64": "0.24.2",
|
|
||||||
"@esbuild/win32-ia32": "0.24.2",
|
|
||||||
"@esbuild/win32-x64": "0.24.2"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/style-mod": {
|
|
||||||
"version": "4.1.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/style-mod/-/style-mod-4.1.3.tgz",
|
|
||||||
"integrity": "sha512-i/n8VsZydrugj3Iuzll8+x/00GH2vnYsk1eomD8QiRrSAeW6ItbCQDtfXCeJHd0iwiNagqjQkvpvREEPtW3IoQ==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/w3c-keyname": {
|
|
||||||
"version": "2.2.8",
|
|
||||||
"resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz",
|
|
||||||
"integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "datascape-editor-build",
|
|
||||||
"private": true,
|
|
||||||
"version": "1.0.0",
|
|
||||||
"description": "One-time build tooling for the vendored CodeMirror 6 editor bundle. Dev-only: `go build` never runs Node, it only consumes the committed bundle artifact.",
|
|
||||||
"type": "module",
|
|
||||||
"scripts": {
|
|
||||||
"build": "node build.mjs"
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
|
||||||
"@codemirror/autocomplete": "^6.18.0",
|
|
||||||
"@codemirror/commands": "^6.7.0",
|
|
||||||
"@codemirror/lang-markdown": "^6.3.0",
|
|
||||||
"@codemirror/language": "^6.10.0",
|
|
||||||
"@codemirror/state": "^6.4.0",
|
|
||||||
"@codemirror/view": "^6.34.0",
|
|
||||||
"@lezer/highlight": "^1.2.0",
|
|
||||||
"esbuild": "^0.24.0"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,186 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"path"
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/yuin/goldmark"
|
|
||||||
"github.com/yuin/goldmark/ast"
|
|
||||||
"github.com/yuin/goldmark/parser"
|
|
||||||
"github.com/yuin/goldmark/renderer"
|
|
||||||
"github.com/yuin/goldmark/text"
|
|
||||||
"github.com/yuin/goldmark/util"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Embeds are ordinary Markdown images: .
|
|
||||||
//
|
|
||||||
// Destinations are relative to the page they sit on. Pages always render at a
|
|
||||||
// URL ending in "/" (ServeHTTP redirects the slashless form), so the browser
|
|
||||||
// resolves the destination and nothing here builds a URL or touches the
|
|
||||||
// filesystem. Goldmark's own image parser produces the nodes; only rendering
|
|
||||||
// is overridden. The title slot carries the float alignment.
|
|
||||||
const (
|
|
||||||
alignLeft = "left"
|
|
||||||
alignRight = "right"
|
|
||||||
alignCenter = "center"
|
|
||||||
|
|
||||||
// embedThumbW is the width embeds request through the ?w= form of the
|
|
||||||
// file's own URL, which keeps the <img src> relative.
|
|
||||||
embedThumbW = 300
|
|
||||||
)
|
|
||||||
|
|
||||||
// embedAlign maps an image title to its float class. A title that is not an
|
|
||||||
// alignment keyword is ignored and the embed floats right.
|
|
||||||
func embedAlign(title []byte) string {
|
|
||||||
switch strings.TrimSpace(string(title)) {
|
|
||||||
case alignLeft:
|
|
||||||
return alignLeft
|
|
||||||
case alignCenter:
|
|
||||||
return alignCenter
|
|
||||||
}
|
|
||||||
return alignRight
|
|
||||||
}
|
|
||||||
|
|
||||||
// embedIsImage reports whether a destination names an image, i.e. whether it
|
|
||||||
// renders as a <figure> rather than degrading to a link. Extension-only — no
|
|
||||||
// stat, so a destination gone stale simply 404s when clicked.
|
|
||||||
func embedIsImage(dest []byte) bool {
|
|
||||||
return isImageFile(path.Base(string(dest)))
|
|
||||||
}
|
|
||||||
|
|
||||||
type embedRenderer struct{}
|
|
||||||
|
|
||||||
func (r *embedRenderer) RegisterFuncs(reg renderer.NodeRendererFuncRegisterer) {
|
|
||||||
reg.Register(ast.KindImage, r.render)
|
|
||||||
}
|
|
||||||
|
|
||||||
// render writes a <figure> holding a thumbnail that links to the full file,
|
|
||||||
// with the image's inline children as <figcaption>. Children are rendered by
|
|
||||||
// goldmark — WalkContinue, not WalkSkipChildren — so a caption may carry
|
|
||||||
// emphasis or a link. A non-image destination renders as a plain anchor
|
|
||||||
// labelled with the caption, or with the file name when there is none.
|
|
||||||
func (r *embedRenderer) render(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
|
|
||||||
n := node.(*ast.Image)
|
|
||||||
name := path.Base(string(n.Destination))
|
|
||||||
dest := util.EscapeHTML(util.URLEscape(n.Destination, true))
|
|
||||||
captioned := n.FirstChild() != nil
|
|
||||||
|
|
||||||
if !embedIsImage(n.Destination) {
|
|
||||||
if entering {
|
|
||||||
w.WriteString(`<a href="`)
|
|
||||||
w.Write(dest)
|
|
||||||
w.WriteString(`">`)
|
|
||||||
if !captioned {
|
|
||||||
w.Write(util.EscapeHTML([]byte(name)))
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
w.WriteString(`</a>`)
|
|
||||||
}
|
|
||||||
return ast.WalkContinue, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
if entering {
|
|
||||||
w.WriteString(`<figure class="embed embed-`)
|
|
||||||
w.WriteString(embedAlign(n.Title))
|
|
||||||
w.WriteString(`"><a class="embed-link" href="`)
|
|
||||||
w.Write(dest)
|
|
||||||
w.WriteString(`"><img src="`)
|
|
||||||
w.Write(dest)
|
|
||||||
w.WriteString(`?w=`)
|
|
||||||
w.WriteString(strconv.Itoa(embedThumbW))
|
|
||||||
w.WriteString(`" alt="`)
|
|
||||||
w.Write(util.EscapeHTML([]byte(name)))
|
|
||||||
w.WriteString(`" loading="lazy"></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),
|
|
||||||
))
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/yuin/goldmark"
|
||||||
|
"github.com/yuin/goldmark/ast"
|
||||||
|
"github.com/yuin/goldmark/parser"
|
||||||
|
"github.com/yuin/goldmark/text"
|
||||||
|
"github.com/yuin/goldmark/util"
|
||||||
|
)
|
||||||
|
|
||||||
|
type extLinksTransformer struct{}
|
||||||
|
|
||||||
|
func (extLinksTransformer) Transform(doc *ast.Document, reader text.Reader, pc parser.Context) {
|
||||||
|
ast.Walk(doc, func(n ast.Node, entering bool) (ast.WalkStatus, error) {
|
||||||
|
if !entering {
|
||||||
|
return ast.WalkContinue, nil
|
||||||
|
}
|
||||||
|
link, ok := n.(*ast.Link)
|
||||||
|
if !ok {
|
||||||
|
return ast.WalkContinue, nil
|
||||||
|
}
|
||||||
|
if isExternalURL(string(link.Destination)) {
|
||||||
|
link.SetAttribute([]byte("target"), []byte("_blank"))
|
||||||
|
link.SetAttribute([]byte("rel"), []byte("noopener noreferrer"))
|
||||||
|
}
|
||||||
|
return ast.WalkContinue, nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func isExternalURL(dest string) bool {
|
||||||
|
if strings.HasPrefix(dest, "//") {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
i := strings.Index(dest, ":")
|
||||||
|
if i <= 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for _, c := range dest[:i] {
|
||||||
|
if !(c >= 'a' && c <= 'z') && !(c >= 'A' && c <= 'Z') &&
|
||||||
|
!(c >= '0' && c <= '9') && c != '+' && c != '-' && c != '.' {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
type extLinksExt struct{}
|
||||||
|
|
||||||
|
func newExtLinksExt() goldmark.Extender { return &extLinksExt{} }
|
||||||
|
|
||||||
|
func (e *extLinksExt) Extend(m goldmark.Markdown) {
|
||||||
|
m.Parser().AddOptions(parser.WithASTTransformers(
|
||||||
|
util.Prioritized(extLinksTransformer{}, 999),
|
||||||
|
))
|
||||||
|
}
|
||||||
@@ -1,563 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"fmt"
|
|
||||||
"html/template"
|
|
||||||
"log"
|
|
||||||
"math"
|
|
||||||
"net/http"
|
|
||||||
"net/url"
|
|
||||||
"sort"
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
func init() {
|
|
||||||
pageTypeHandlers = append(pageTypeHandlers, &fitnessHandler{})
|
|
||||||
}
|
|
||||||
|
|
||||||
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 folder can still hold notes.
|
|
||||||
func (f *fitnessHandler) handle(root, fsPath, urlPath string, r *http.Request) *specialPage {
|
|
||||||
s := readPageSettings(fsPath)
|
|
||||||
if s == nil || s.Type != "fitness" {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
weightView, weeklyView := fitnessViews(r.URL.Query())
|
|
||||||
return &specialPage{
|
|
||||||
Content: renderFitnessDashboard(fsPath, weightView, weeklyView),
|
|
||||||
SuppressTOC: true,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// serveFragment answers a chart's controls (htmx GET ?chart=<param> with
|
|
||||||
// every chart's view state) with just that chart, and points the address bar
|
|
||||||
// at the canonical dashboard URL so the view stays bookmarkable.
|
|
||||||
func (f *fitnessHandler) serveFragment(w http.ResponseWriter, r *http.Request, root, fsPath, urlPath string) bool {
|
|
||||||
q := r.URL.Query()
|
|
||||||
param := q.Get("chart")
|
|
||||||
if param == "" {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
if s := readPageSettings(fsPath); s == nil || s.Type != "fitness" {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
weightView, weeklyView := fitnessViews(q)
|
|
||||||
dash := buildFitnessDash(fsPath, weightView, weeklyView, time.Now())
|
|
||||||
for _, c := range dash.Charts {
|
|
||||||
if c.Param != param {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
||||||
w.Header().Set("HX-Replace-Url", fitnessURL(urlPath, weightView, weeklyView))
|
|
||||||
if err := fitnessTmpl.ExecuteTemplate(w, "fitnessChart", c); err != nil {
|
|
||||||
log.Printf("fitness template: %v", err)
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
http.Error(w, "Unknown chart: "+param, http.StatusNotFound)
|
|
||||||
return 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
|
|
||||||
}
|
|
||||||
|
|
||||||
// fitnessView is one chart's view state from the query string: ?<param>=<range>
|
|
||||||
// and ?<param>-absolute=1 to start the y axis at 0.
|
|
||||||
type fitnessView struct {
|
|
||||||
Range string
|
|
||||||
Absolute bool
|
|
||||||
}
|
|
||||||
|
|
||||||
func chartViewFrom(q url.Values, param, fallback string) fitnessView {
|
|
||||||
return fitnessView{
|
|
||||||
Range: validFitnessRange(q.Get(param), fallback),
|
|
||||||
Absolute: q.Get(param+"-absolute") == "1",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Default ranges of the two charts.
|
|
||||||
const (
|
|
||||||
weightDefaultRange = "3m"
|
|
||||||
weeklyDefaultRange = "1y"
|
|
||||||
)
|
|
||||||
|
|
||||||
func fitnessViews(q url.Values) (weight, weekly fitnessView) {
|
|
||||||
return chartViewFrom(q, "weight", weightDefaultRange), chartViewFrom(q, "weekly", weeklyDefaultRange)
|
|
||||||
}
|
|
||||||
|
|
||||||
// fitnessURL is the dashboard URL for the given views; defaults are left out.
|
|
||||||
func fitnessURL(urlPath string, weight, weekly fitnessView) string {
|
|
||||||
q := url.Values{}
|
|
||||||
add := func(param, fallback string, v fitnessView) {
|
|
||||||
if v.Range != fallback {
|
|
||||||
q.Set(param, v.Range)
|
|
||||||
}
|
|
||||||
if v.Absolute {
|
|
||||||
q.Set(param+"-absolute", "1")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
add("weight", weightDefaultRange, weight)
|
|
||||||
add("weekly", weeklyDefaultRange, weekly)
|
|
||||||
target := pageURL(urlPath)
|
|
||||||
if len(q) > 0 {
|
|
||||||
target += "?" + q.Encode()
|
|
||||||
}
|
|
||||||
return target
|
|
||||||
}
|
|
||||||
|
|
||||||
func rangeMonths(v string) int {
|
|
||||||
for _, r := range fitnessRanges {
|
|
||||||
if r.Value == v {
|
|
||||||
return r.Months
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
|
|
||||||
// === Series extraction ===
|
|
||||||
|
|
||||||
type weightPoint struct {
|
|
||||||
date time.Time
|
|
||||||
value float64
|
|
||||||
days int // >0: weekly mean over this many measured days
|
|
||||||
}
|
|
||||||
|
|
||||||
// fitnessWeights reads the whole weight calendar up to today: one point per
|
|
||||||
// day, sorted by date. Dates are UTC midnights to match the chart's today.
|
|
||||||
func fitnessWeights(today time.Time) []weightPoint {
|
|
||||||
byDay := calendarWeights(readCalendarSettings(calendarsDir), time.Time{}, today.AddDate(0, 0, 1))
|
|
||||||
weights := make([]weightPoint, 0, len(byDay))
|
|
||||||
for key, w := range byDay {
|
|
||||||
d, err := time.Parse("2006-01-02", key)
|
|
||||||
v, err2 := strconv.ParseFloat(w, 64)
|
|
||||||
if err != nil || err2 != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
// weightGoal reads the optional weight-goal key from the fitness folder's
|
|
||||||
// .page-settings.
|
|
||||||
func weightGoal(fsPath string) (float64, bool) {
|
|
||||||
v, ok := readSettingsFile(fsPath)["weight-goal"]
|
|
||||||
if !ok {
|
|
||||||
return 0, false
|
|
||||||
}
|
|
||||||
g, err := strconv.ParseFloat(strings.ReplaceAll(strings.TrimSpace(v), ",", "."), 64)
|
|
||||||
return g, err == nil
|
|
||||||
}
|
|
||||||
|
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
// fitnessDotVM is one data point; Date and Value feed the hover tooltip.
|
|
||||||
type fitnessDotVM struct {
|
|
||||||
X, Y, Date, Value string
|
|
||||||
}
|
|
||||||
|
|
||||||
type fitnessGoalVM struct {
|
|
||||||
Y, LabelY, Label string
|
|
||||||
}
|
|
||||||
|
|
||||||
type fitnessChartVM struct {
|
|
||||||
Title string
|
|
||||||
Param string
|
|
||||||
Options []fitnessOptVM
|
|
||||||
Absolute bool
|
|
||||||
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
|
|
||||||
Dense bool // too many points to draw markers
|
|
||||||
}
|
|
||||||
|
|
||||||
type fitnessDashVM struct {
|
|
||||||
Notice string
|
|
||||||
Charts []fitnessChartVM
|
|
||||||
}
|
|
||||||
|
|
||||||
func newChartVM(title, param string, view fitnessView) fitnessChartVM {
|
|
||||||
opts := make([]fitnessOptVM, len(fitnessRanges))
|
|
||||||
for i, r := range fitnessRanges {
|
|
||||||
opts[i] = fitnessOptVM{r.Value, r.Label, r.Value == view.Range}
|
|
||||||
}
|
|
||||||
return fitnessChartVM{
|
|
||||||
Title: title, Param: param, Options: opts, Absolute: view.Absolute,
|
|
||||||
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
|
|
||||||
// (5 kg guides on weight charts, 20 kg when the axis starts at 0).
|
|
||||||
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, view fitnessView, param, title, unit string, today time.Time) fitnessChartVM {
|
|
||||||
vm := newChartVM(title, param, view)
|
|
||||||
|
|
||||||
points := all
|
|
||||||
var x0, x1 time.Time
|
|
||||||
if m := rangeMonths(view.Range); 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}
|
|
||||||
yStep := 5.0
|
|
||||||
if view.Absolute {
|
|
||||||
sc.y0 = 0
|
|
||||||
sc.y1 = hi + hi*0.05
|
|
||||||
yStep = 20
|
|
||||||
}
|
|
||||||
|
|
||||||
vm.YTicks = yTickVMs(sc, yStep)
|
|
||||||
vm.XTicks = timeXTicks(sc)
|
|
||||||
|
|
||||||
// One continuous polyline through every point in range — days without a
|
|
||||||
// measurement do not break the line. Every point also gets a marker that
|
|
||||||
// feeds the hover tooltip; on dense ranges the markers are hidden and the
|
|
||||||
// bare line stays legible. A single point in range renders as a dot.
|
|
||||||
dot := func(p weightPoint) fitnessDotVM {
|
|
||||||
d := fitnessDotVM{
|
|
||||||
X: svgNum(sc.x(p.date)), Y: svgNum(sc.y(p.value)),
|
|
||||||
Date: p.date.Format("Mon 2006-01-02"),
|
|
||||||
Value: svgNum(p.value) + " " + unit,
|
|
||||||
}
|
|
||||||
if p.days > 0 {
|
|
||||||
d.Date = "Week of " + p.date.Format("2006-01-02")
|
|
||||||
d.Value += fmt.Sprintf(" · %d days", p.days)
|
|
||||||
}
|
|
||||||
return d
|
|
||||||
}
|
|
||||||
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())
|
|
||||||
}
|
|
||||||
for _, p := range points {
|
|
||||||
vm.Dots = append(vm.Dots, dot(p))
|
|
||||||
}
|
|
||||||
vm.Dense = len(points) > 100
|
|
||||||
|
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
// xTickStep is a calendar-aligned spacing for date labels.
|
|
||||||
type xTickStep struct {
|
|
||||||
days, months int
|
|
||||||
label func(t time.Time, first bool) string
|
|
||||||
}
|
|
||||||
|
|
||||||
var xTickSteps = []xTickStep{
|
|
||||||
{days: 1, label: dayLabel},
|
|
||||||
{days: 7, label: dayLabel},
|
|
||||||
{days: 14, label: dayLabel},
|
|
||||||
{months: 1, label: monthLabel},
|
|
||||||
{months: 3, label: monthLabel},
|
|
||||||
{months: 6, label: monthLabel},
|
|
||||||
{months: 12, label: yearLabel},
|
|
||||||
{months: 24, label: yearLabel},
|
|
||||||
{months: 60, label: yearLabel},
|
|
||||||
}
|
|
||||||
|
|
||||||
func dayLabel(t time.Time, _ bool) string { return t.Format("2 Jan") }
|
|
||||||
|
|
||||||
// monthLabel names the year only where it changes, so a year-long axis
|
|
||||||
// reads "Oct 2025, Jan 2026, Apr, Jul".
|
|
||||||
func monthLabel(t time.Time, first bool) string {
|
|
||||||
if first || t.Month() == time.January {
|
|
||||||
return t.Format("Jan 2006")
|
|
||||||
}
|
|
||||||
return t.Format("Jan")
|
|
||||||
}
|
|
||||||
|
|
||||||
func yearLabel(t time.Time, _ bool) string { return t.Format("2006") }
|
|
||||||
|
|
||||||
// maxXTicks bounds the label count so labels never collide on the axis.
|
|
||||||
const maxXTicks = 5
|
|
||||||
|
|
||||||
// timeXTicks labels the time axis at calendar boundaries — days, Mondays,
|
|
||||||
// month starts or new years, whichever is the finest step that stays within
|
|
||||||
// maxXTicks. Labels near the plot edges are anchored inward so they stay
|
|
||||||
// inside the viewBox.
|
|
||||||
func timeXTicks(sc chartScale) []fitnessTickVM {
|
|
||||||
var ticks []time.Time
|
|
||||||
var step xTickStep
|
|
||||||
for _, step = range xTickSteps {
|
|
||||||
ticks = alignedTicks(sc.x0, sc.x1, step)
|
|
||||||
if len(ticks) <= maxXTicks {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
out := make([]fitnessTickVM, 0, len(ticks))
|
|
||||||
for i, t := range ticks {
|
|
||||||
x := sc.x(t)
|
|
||||||
anchor := "middle"
|
|
||||||
switch {
|
|
||||||
case x-chartLeft < 24:
|
|
||||||
anchor = "start"
|
|
||||||
case chartW-chartRight-x < 24:
|
|
||||||
anchor = "end"
|
|
||||||
}
|
|
||||||
out = append(out, fitnessTickVM{Pos: svgNum(x), Label: step.label(t, i == 0), Anchor: anchor})
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
// alignedTicks lists every boundary of step within [from, to]. Week steps
|
|
||||||
// start on Mondays; month and year steps on multiples of the step (Jan/Apr/
|
|
||||||
// Jul/Oct for quarters, even years for two-year steps).
|
|
||||||
func alignedTicks(from, to time.Time, step xTickStep) []time.Time {
|
|
||||||
var t time.Time
|
|
||||||
if step.days > 0 {
|
|
||||||
t = time.Date(from.Year(), from.Month(), from.Day(), 0, 0, 0, 0, time.UTC)
|
|
||||||
if step.days > 1 {
|
|
||||||
t = mondayOf(t)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
months := from.Year()*12 + int(from.Month()) - 1
|
|
||||||
months -= months % step.months
|
|
||||||
t = time.Date(months/12, time.Month(months%12+1), 1, 0, 0, 0, 0, time.UTC)
|
|
||||||
}
|
|
||||||
var out []time.Time
|
|
||||||
for ; !t.After(to); t = t.AddDate(0, step.months, step.days) {
|
|
||||||
if !t.Before(from) {
|
|
||||||
out = append(out, t)
|
|
||||||
}
|
|
||||||
if len(out) > maxXTicks {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
// === Rendering ===
|
|
||||||
|
|
||||||
var fitnessTmpl = template.Must(template.New("main.html").Funcs(assetFuncs).ParseFS(assets, "assets/fitness/main.html"))
|
|
||||||
|
|
||||||
func renderFitnessDashboard(fsPath string, weightView, weeklyView fitnessView) template.HTML {
|
|
||||||
data := buildFitnessDash(fsPath, weightView, weeklyView, 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 the weight calendar per request (the calendar store
|
|
||||||
// caches parsed files). A missing calendar setup becomes an inline notice;
|
|
||||||
// the page itself always renders.
|
|
||||||
func buildFitnessDash(fsPath string, weightView, weeklyView fitnessView, now time.Time) fitnessDashVM {
|
|
||||||
if calendarsDir == "" || len(readCalendarSettings(calendarsDir).Weight) == 0 {
|
|
||||||
return fitnessDashVM{Notice: "No weight calendar configured — set weight = <calendar> in the calendars folder's .page-settings."}
|
|
||||||
}
|
|
||||||
|
|
||||||
today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.UTC)
|
|
||||||
weights := fitnessWeights(today)
|
|
||||||
wGoal, hasWGoal := weightGoal(fsPath)
|
|
||||||
const unit = "kg"
|
|
||||||
|
|
||||||
return fitnessDashVM{Charts: []fitnessChartVM{
|
|
||||||
buildWeightChart(weights, wGoal, hasWGoal, weightView,
|
|
||||||
"weight", "Weight ("+unit+")", unit, today),
|
|
||||||
buildWeightChart(weeklyMeanWeights(weights), wGoal, hasWGoal, weeklyView,
|
|
||||||
"weekly", "Weekly average weight ("+unit+")", unit, today),
|
|
||||||
}}
|
|
||||||
}
|
|
||||||
@@ -1,130 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"net/http/httptest"
|
|
||||||
"os"
|
|
||||||
"strings"
|
|
||||||
"path/filepath"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestFitnessWeights(t *testing.T) {
|
|
||||||
withBerlin(t)
|
|
||||||
dir := t.TempDir()
|
|
||||||
prev := calendarsDir
|
|
||||||
calendarsDir = dir
|
|
||||||
t.Cleanup(func() { calendarsDir = prev })
|
|
||||||
|
|
||||||
if err := os.WriteFile(filepath.Join(dir, ".page-settings"),
|
|
||||||
[]byte("type = calendars\nweight = Gewicht\n"), 0644); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
writeCal(t, dir, "Gewicht",
|
|
||||||
`UID:1
|
|
||||||
SUMMARY:96 kg
|
|
||||||
DTSTART;VALUE=DATE:20261001`,
|
|
||||||
`UID:2
|
|
||||||
SUMMARY:95,6kg
|
|
||||||
DTSTART;VALUE=DATE:20261003`,
|
|
||||||
`UID:3
|
|
||||||
SUMMARY:not a weight
|
|
||||||
DTSTART;VALUE=DATE:20261004`,
|
|
||||||
`UID:4
|
|
||||||
SUMMARY:90 kg
|
|
||||||
DTSTART;VALUE=DATE:20261010`,
|
|
||||||
)
|
|
||||||
|
|
||||||
today := time.Date(2026, 10, 5, 0, 0, 0, 0, time.UTC)
|
|
||||||
got := fitnessWeights(today)
|
|
||||||
want := []weightPoint{
|
|
||||||
{date: time.Date(2026, 10, 1, 0, 0, 0, 0, time.UTC), value: 96},
|
|
||||||
{date: time.Date(2026, 10, 3, 0, 0, 0, 0, time.UTC), value: 95.6},
|
|
||||||
}
|
|
||||||
if len(got) != len(want) {
|
|
||||||
t.Fatalf("got %+v, want %+v", got, want)
|
|
||||||
}
|
|
||||||
for i := range want {
|
|
||||||
if !got[i].date.Equal(want[i].date) || got[i].value != want[i].value {
|
|
||||||
t.Fatalf("point %d: got %+v, want %+v", i, got[i], want[i])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestTimeXTicks(t *testing.T) {
|
|
||||||
d := func(y int, m time.Month, day int) time.Time { return time.Date(y, m, day, 0, 0, 0, 0, time.UTC) }
|
|
||||||
for _, c := range []struct {
|
|
||||||
from, to time.Time
|
|
||||||
want string
|
|
||||||
}{
|
|
||||||
{d(2022, 11, 28), d(2026, 9, 21), "2023 2024 2025 2026"},
|
|
||||||
{d(2025, 9, 27), d(2026, 9, 27), "Oct 2025|Jan 2026|Apr|Jul"},
|
|
||||||
{d(2026, 6, 27), d(2026, 9, 27), "Jul 2026|Aug|Sep"},
|
|
||||||
{d(2026, 8, 27), d(2026, 9, 27), "31 Aug|7 Sep|14 Sep|21 Sep"},
|
|
||||||
} {
|
|
||||||
var got []string
|
|
||||||
for _, tk := range timeXTicks(chartScale{x0: c.from, x1: c.to}) {
|
|
||||||
got = append(got, tk.Label)
|
|
||||||
}
|
|
||||||
sep := " "
|
|
||||||
if strings.Contains(c.want, "|") {
|
|
||||||
sep = "|"
|
|
||||||
}
|
|
||||||
if g := strings.Join(got, sep); g != c.want {
|
|
||||||
t.Errorf("%s..%s: got %q, want %q", c.from.Format("2006-01-02"), c.to.Format("2006-01-02"), g, c.want)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestFitnessChartFragment(t *testing.T) {
|
|
||||||
withBerlin(t)
|
|
||||||
root := t.TempDir()
|
|
||||||
cals := filepath.Join(root, "Calendars")
|
|
||||||
fit := filepath.Join(root, "My Fitness")
|
|
||||||
for _, d := range []string{cals, fit} {
|
|
||||||
if err := os.MkdirAll(d, 0755); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
prev := calendarsDir
|
|
||||||
calendarsDir = cals
|
|
||||||
t.Cleanup(func() { calendarsDir = prev })
|
|
||||||
if err := os.WriteFile(filepath.Join(cals, ".page-settings"), []byte("type = calendars\nweight = Gewicht\n"), 0644); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if err := os.WriteFile(filepath.Join(fit, ".page-settings"), []byte("type = fitness\n"), 0644); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
writeCal(t, cals, "Gewicht", `UID:1
|
|
||||||
SUMMARY:96 kg
|
|
||||||
DTSTART;VALUE=DATE:20200101`)
|
|
||||||
|
|
||||||
h := &handler{root: root}
|
|
||||||
get := func(query string, hx bool) *httptest.ResponseRecorder {
|
|
||||||
r := httptest.NewRequest("GET", "/My%20Fitness/?"+query, nil)
|
|
||||||
if hx {
|
|
||||||
r.Header.Set("HX-Request", "true")
|
|
||||||
}
|
|
||||||
w := httptest.NewRecorder()
|
|
||||||
h.serveDir(w, r, "/My Fitness", fit)
|
|
||||||
return w
|
|
||||||
}
|
|
||||||
|
|
||||||
w := get("chart=weekly&weight=3m&weekly=all&weekly-absolute=1", true)
|
|
||||||
body := w.Body.String()
|
|
||||||
if w.Code != 200 || strings.Contains(body, "<html") || strings.Count(body, `class="fitness-chart panel"`) != 1 ||
|
|
||||||
!strings.Contains(body, `name="weekly"`) || !strings.Contains(body, `value="all" selected`) {
|
|
||||||
t.Errorf("weekly fragment: %d\n%s", w.Code, body)
|
|
||||||
}
|
|
||||||
if got, want := w.Header().Get("HX-Replace-Url"), "/My%20Fitness/?weekly=all&weekly-absolute=1"; got != want {
|
|
||||||
t.Errorf("HX-Replace-Url = %q, want %q", got, want)
|
|
||||||
}
|
|
||||||
|
|
||||||
if w := get("chart=bogus", true); w.Code != 404 {
|
|
||||||
t.Errorf("unknown chart: got %d, want 404", w.Code)
|
|
||||||
}
|
|
||||||
// Without htmx the parameter is ignored and the full page renders.
|
|
||||||
if body := get("chart=weekly", false).Body.String(); strings.Count(body, `class="fitness-chart panel"`) != 2 {
|
|
||||||
t.Errorf("non-htmx request did not render the dashboard:\n%s", body)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -2,8 +2,4 @@ module datascape
|
|||||||
|
|
||||||
go 1.26.1
|
go 1.26.1
|
||||||
|
|
||||||
require (
|
require github.com/yuin/goldmark v1.8.2
|
||||||
github.com/emersion/go-ical v0.0.0-20250609112844-439c63cef608
|
|
||||||
github.com/teambition/rrule-go v1.8.2
|
|
||||||
github.com/yuin/goldmark v1.8.2
|
|
||||||
)
|
|
||||||
|
|||||||