Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
aa90a04f91 | ||
|
|
fa90b3baee | ||
|
|
20f0ae5111 | ||
|
|
14fadf096a | ||
|
|
a55a1c8ed4 | ||
|
|
4365fa2567 | ||
|
|
353df29ee6 | ||
|
|
3a3fb3b51f | ||
|
|
465aed47b3 | ||
|
|
9fadfda70d | ||
|
|
b5a8d3d003 | ||
|
|
3170d1c6d9 | ||
|
|
65ce1b414e | ||
|
|
3070471c38 | ||
|
|
189635d103 | ||
|
|
4116a0c332 | ||
|
|
c84f233bbb | ||
|
|
ef98177e58 | ||
|
|
ebfd382706 |
@@ -4,20 +4,24 @@ COMPANION_WIN := companion/datascape-companion-windows-amd64.exe
|
||||
COMPANION_LIN := companion/datascape-companion-linux-amd64
|
||||
COMPANION_SRCS := $(wildcard cmd/companion/*.go) $(wildcard cmd/companion/*.html) go.mod go.sum
|
||||
|
||||
# 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
|
||||
.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 -ldflags="-H windowsgui" -o $@ ./cmd/companion
|
||||
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 -o $@ ./cmd/companion
|
||||
GOOS=linux GOARCH=amd64 go build $(RELEASE_FLAGS) -o $@ ./cmd/companion
|
||||
|
||||
companion-windows: $(COMPANION_WIN)
|
||||
companion-linux: $(COMPANION_LIN)
|
||||
@@ -37,8 +41,13 @@ $(EDITOR_BUNDLE): $(EDITOR_SRCS)
|
||||
cd editor-build && npm ci && npm run build
|
||||
|
||||
deploy: companion-release
|
||||
GOOS=linux GOARCH=arm GOARM=7 go build -o datascape-arm .
|
||||
GOOS=linux GOARCH=arm GOARM=7 go build $(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'
|
||||
scp datascape-arm $(NAS):/share/homes/luxick/.local/bin/datascape
|
||||
ssh $(NAS) /share/homes/luxick/.local/bin/datascape-start.sh
|
||||
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
|
||||
|
||||
@@ -4,117 +4,126 @@ Minimal self-hosted personal wiki. Folders are pages.
|
||||
|
||||
## Features
|
||||
|
||||
- **Pages** every folder is a page. Place an `index.md` inside a folder and it renders as HTML. Drop any other files (PDFs, images, etc.) alongside it and they appear in the listing below the content. Navigating to a path that does not exist shows a **[CREATE]** prompt.
|
||||
- **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.
|
||||
- **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.
|
||||
|
||||
- **View settings** per folder, display the file listing as a list or thumbnail grid and pick the sort key/order, via the **view** button in the `Files` header. See the [View Settings](#view-settings) section.
|
||||
## Build & run
|
||||
|
||||
- **Search** search across all page names (folder names) in the wiki, accessible from the navigation bar.
|
||||
|
||||
- **Wikilinks** link between pages with `[[search query]]` syntax, or `[[query::display text]]` to override the label. The link opens the search page for that query, so it keeps resolving after a page is renamed or moved. Files embed with standard Markdown image syntax — `` — relative to the page they sit on.
|
||||
|
||||
- **Movie import** import movie entries via the OMDb API. Fetches title, year, runtime, genre, director, cast, plot, and poster, and pre-fills a new page with that metadata.
|
||||
|
||||
- **Special folder types** folders can opt into custom rendering (e.g. a photo diary with calendar navigation). See the [Special Folder Types](#special-folder-types) section for details.
|
||||
|
||||
- **Quick-add bookmarklet** save the current browser tab to a predetermined wiki page (e.g. `/Topics/Bookmarks/`) with one click. See the [Quick-Add Bookmarklet](#quick-add-bookmarklet) section.
|
||||
|
||||
## Build
|
||||
The wiki binary embeds the companion binaries, so build those first:
|
||||
|
||||
```bash
|
||||
# local
|
||||
go build -o datascape .
|
||||
|
||||
# QNAP NAS (linux/arm)
|
||||
GOOS=linux GOARCH=arm go build -o datascape .
|
||||
make companion-release # once, and after companion changes
|
||||
go build -o datascape . # local
|
||||
make deploy # QNAP NAS: stripped ARMv7 build, copy, restart
|
||||
./datascape -dir ./wiki -addr :8080 -user me -pass secret
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
```bash
|
||||
go run . -dir ./wiki -addr :8080
|
||||
go run . -dir ./wiki -addr :8080 -user me -pass secret
|
||||
```
|
||||
|
||||
| Flag | Default | Description |
|
||||
|------|---------|-------------|
|
||||
| Flag | Default | |
|
||||
|------|---------|-|
|
||||
| `-addr` | `:8080` | Listen address |
|
||||
| `-dir` | `./wiki` | Wiki root directory |
|
||||
| `-cache` | `./cache` | Thumbnail cache directory |
|
||||
| `-user` | _(none)_ | Basic auth username — omit to disable auth |
|
||||
| `-pass` | _(none)_ | Basic auth password |
|
||||
| `-reindex-interval` | `30m` | Periodic search index rebuild interval (`0` disables) |
|
||||
| `-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` |
|
||||
|
||||
## View Settings
|
||||
## `.page-settings`
|
||||
|
||||
The **view** button in a folder's `Files` header sets how its listing renders,
|
||||
persisting three keys to `.page-settings`:
|
||||
Per-folder `key = value` file. The **view** button in the `Files` header sets the first three keys.
|
||||
|
||||
| Key | Values (default first) |
|
||||
|------|------------------------|
|
||||
|-----|------------------------|
|
||||
| `view` | `list`, `thumbnail` |
|
||||
| `sort` | `name`, `modified`, `size` (folders always sort by name, grouped first) |
|
||||
| `sort` | `name`, `modified`, `size` |
|
||||
| `order` | `asc`, `desc` |
|
||||
| `type` | `diary`, `fitness`, `calendars` |
|
||||
|
||||
## Special Folder Types
|
||||
## Keyboard shortcuts
|
||||
|
||||
A folder can opt into special rendering by adding a `.page-settings` file. The
|
||||
same file also holds the [View Settings](#view-settings) keys; only the `type`
|
||||
key selects a special renderer:
|
||||
All use Alt+Shift.
|
||||
|
||||
| Where | Keys |
|
||||
|-------|------|
|
||||
| Anywhere | `E` edit, `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 |
|
||||
|
||||
## Diary
|
||||
|
||||
```
|
||||
type = diary
|
||||
Diary/
|
||||
.page-settings ← type = diary
|
||||
2026/
|
||||
index.md ← # 2026, ## 2026-05, ### 2026-05-11 sections
|
||||
2026-05-11 Beach.jpg ← photos go under their date's section
|
||||
```
|
||||
|
||||
### Diary
|
||||
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.
|
||||
|
||||
Designed for a chronological photo diary. The whole year lives in a single
|
||||
file as ISO-headed sections; photos are loose JPEGs named with a date prefix.
|
||||
Bookmarkable shortcuts: `<diary>/today/`, `<diary>/this-month/`, `<diary>/this-year/`.
|
||||
|
||||
## 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
|
||||
|
||||
```
|
||||
FolderName/
|
||||
.page-settings ← type = diary
|
||||
YYYY/
|
||||
index.md ← `# YYYY` + `## YYYY-MM` + `### YYYY-MM-DD` sections
|
||||
YYYY-MM-DD Desc.jpg ← photos named with the date they belong to
|
||||
Calendars/
|
||||
.page-settings ← type = calendars, roles, sync credentials
|
||||
Personal.ics ← one file per calendar
|
||||
Gewicht.ics
|
||||
…
|
||||
```
|
||||
|
||||
The year page (`YYYY/`) renders every section in the file with photos
|
||||
attached to each `### YYYY-MM-DD` heading. Months and days the file doesn't
|
||||
yet contain are rendered as **virtual** headings with an `[edit]` button that
|
||||
splices a new section into the year file at the right chronological position;
|
||||
virtual day headings still carry photos for that date. Past years render
|
||||
every month/day slot; the current year stops at today; future years skip
|
||||
virtual entries entirely. The file may contain non-date headings (e.g.
|
||||
`## Events` → `### Festival` between `# YYYY` and `## YYYY-01`); these keep
|
||||
their document position.
|
||||
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.
|
||||
|
||||
A sidebar calendar widget shows one month grid at a time; the month-name
|
||||
button opens a dropdown of all twelve months, and a separate year dropdown
|
||||
jumps between years. Day cells link to the matching anchor on the year page
|
||||
regardless of whether the date has a real section yet.
|
||||
```
|
||||
type = calendars
|
||||
|
||||
#### Persistent date links
|
||||
# Roles: file names without .ics
|
||||
diary = Personal, Feiertage
|
||||
weight = Gewicht
|
||||
maintenance = Maintainance
|
||||
|
||||
Each diary root exposes three stable paths intended for browser bookmarks.
|
||||
They resolve against the year page rather than separate per-day URLs:
|
||||
# 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
|
||||
```
|
||||
|
||||
| Path | Redirects to |
|
||||
|------|-------------|
|
||||
| `<diary>/today/` | `<diary>/YYYY/#YYYY-MM-DD` (or the year file's insert-section editor when today's section doesn't exist yet) |
|
||||
| `<diary>/this-month/` | `<diary>/YYYY/#YYYY-MM` |
|
||||
| `<diary>/this-year/` | `<diary>/YYYY/` |
|
||||
| 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** |
|
||||
|
||||
Legacy `YYYY/MM/` and `YYYY/MM/DD/` URLs (no longer the canonical form) redirect to the matching anchor on the year page.
|
||||
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.
|
||||
|
||||
## Quick-Add Bookmarklet
|
||||
The calendars page lists the maintenance tasks (repeat rule, last date, next date) and the sync status of each calendar.
|
||||
|
||||
Replace `wiki.host` with your wiki host and `/Topics/Bookmarks/` with the destination page (one bookmarklet per target):
|
||||
## 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 appends an entry of the following form to the destination page's `index.md`:
|
||||
Each save adds this to the target page's `index.md`:
|
||||
|
||||
```markdown
|
||||
- [Example Page](https://example.com)
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
<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}}
|
||||
@@ -0,0 +1,16 @@
|
||||
<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>
|
||||
@@ -200,13 +200,20 @@
|
||||
// leaves the status icon and reveal buttons invisible on mobile.
|
||||
if (isMobileDevice()) return;
|
||||
probeStatus().then(function () {
|
||||
updateFooterIcon();
|
||||
wireFileLinks();
|
||||
wireRevealButton();
|
||||
wireFileRevealButtons();
|
||||
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 {
|
||||
|
||||
@@ -24,4 +24,4 @@
|
||||
</table>
|
||||
{{end}}
|
||||
</div>
|
||||
<script src="/_/diary/calendar.js"></script>
|
||||
<script src="{{asset "diary/calendar.js"}}"></script>
|
||||
|
||||
@@ -10,4 +10,8 @@
|
||||
{{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}}
|
||||
|
||||
@@ -78,10 +78,10 @@
|
||||
<div id="editor" class="editor-cm"></div>
|
||||
<textarea name="content" id="editor-content" hidden>{{.RawContent}}</textarea>
|
||||
</form>
|
||||
<script src="/_/editor/vendor/codemirror.bundle.js?v={{editorBundleVersion}}"></script>
|
||||
<script src="/_/editor/tables.js"></script>
|
||||
<script src="/_/editor/dates.js"></script>
|
||||
<script src="/_/editor/movie.js"></script>
|
||||
<script src="/_/editor/wikicomplete.js"></script>
|
||||
<script src="/_/editor/main.js"></script>
|
||||
<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/main.js"}}"></script>
|
||||
{{end}}
|
||||
|
||||
@@ -6,22 +6,34 @@ window.EditorTables = (function () {
|
||||
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) {
|
||||
while (s.length < w) s = ' ' + s;
|
||||
return s;
|
||||
return repeat(' ', w - displayWidth(s)) + s;
|
||||
}
|
||||
|
||||
function padRight(s, w) {
|
||||
while (s.length < w) s += ' ';
|
||||
return s;
|
||||
return s + repeat(' ', w - displayWidth(s));
|
||||
}
|
||||
|
||||
function padCenter(s, w) {
|
||||
while (s.length < w) {
|
||||
s = s + ' ';
|
||||
if (s.length < w) s = ' ' + s;
|
||||
}
|
||||
return s;
|
||||
var total = w - displayWidth(s);
|
||||
var left = Math.floor(total / 2);
|
||||
return repeat(' ', left) + s + repeat(' ', total - left);
|
||||
}
|
||||
|
||||
function parseTableRow(line) {
|
||||
@@ -101,7 +113,8 @@ window.EditorTables = (function () {
|
||||
var max = 3;
|
||||
for (var r = 0; r < rows.length; r++) {
|
||||
if (r === sepIndex) continue;
|
||||
if (rows[r][c].length > max) max = rows[r][c].length;
|
||||
var len = displayWidth(rows[r][c]);
|
||||
if (len > max) max = len;
|
||||
}
|
||||
widths.push(max);
|
||||
}
|
||||
|
||||
@@ -1,10 +1,80 @@
|
||||
// Fitness dashboard range dropdowns: changing one reloads the page with that
|
||||
// chart's query parameter updated. Plain GET navigation — each range is a
|
||||
// distinct, bookmarkable view, so no postReplace/history handling is needed.
|
||||
// Fitness dashboard range dropdowns and "absolute" checkboxes: changing one
|
||||
// reloads the page with that chart's query parameter updated. Plain GET
|
||||
// navigation — each view is distinct and bookmarkable, so no
|
||||
// postReplace/history handling is needed.
|
||||
// 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;
|
||||
|
||||
document.addEventListener('change', function (e) {
|
||||
var sel = e.target.closest('[data-fitness-range]');
|
||||
if (!sel) return;
|
||||
var url = new URL(window.location.href);
|
||||
url.searchParams.set(sel.dataset.fitnessRange, sel.value);
|
||||
var sel = e.target.closest('[data-fitness-range]');
|
||||
var abs = e.target.closest('[data-fitness-absolute]');
|
||||
if (sel) {
|
||||
url.searchParams.set(sel.dataset.fitnessRange, sel.value);
|
||||
} else if (abs) {
|
||||
if (abs.checked) url.searchParams.set(abs.dataset.fitnessAbsolute, '1');
|
||||
else url.searchParams.delete(abs.dataset.fitnessAbsolute);
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
window.location.href = url.toString();
|
||||
});
|
||||
|
||||
// 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';
|
||||
});
|
||||
}
|
||||
|
||||
@@ -2,19 +2,23 @@
|
||||
<section class="fitness-chart panel">
|
||||
<div class="fitness-chart-header row space-between">
|
||||
<span class="caption">{{.Title}}</span>
|
||||
<select class="input fitness-range" data-fitness-range="{{.Param}}" aria-label="{{.Title}} time range">
|
||||
{{range .Options}}<option value="{{.Value}}"{{if .Selected}} selected{{end}}>{{.Label}}</option>{{end}}
|
||||
</select>
|
||||
<div class="row fitness-controls">
|
||||
<label class="fitness-absolute"><input type="checkbox" data-fitness-absolute="{{.Param}}-absolute"{{if .Absolute}} checked{{end}}> Absolute</label>
|
||||
<select class="input fitness-range" data-fitness-range="{{.Param}}" aria-label="{{.Title}} time range">
|
||||
{{range .Options}}<option value="{{.Value}}"{{if .Selected}} selected{{end}}>{{.Label}}</option>{{end}}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
{{if .Empty}}
|
||||
<p class="fitness-empty is-empty">No data in this range.</p>
|
||||
{{else}}
|
||||
<svg class="fitness-svg" viewBox="0 0 {{.ViewW}} {{.ViewH}}" role="img" aria-label="{{.Title}}">
|
||||
<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}}"/>
|
||||
@@ -23,13 +27,16 @@
|
||||
<polyline class="chart-line" points="{{.}}"/>
|
||||
{{end}}
|
||||
{{range .Dots}}
|
||||
<circle class="chart-dot" cx="{{.X}}" cy="{{.Y}}" r="2.5"><title>{{.Title}}</title></circle>
|
||||
<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}}
|
||||
@@ -40,4 +47,4 @@
|
||||
{{range .Charts}}{{template "fitnessChart" .}}{{end}}
|
||||
{{end}}
|
||||
</div>
|
||||
<script src="/_/fitness/fitness.js"></script>
|
||||
<script src="{{asset "fitness/fitness.js"}}"></script>
|
||||
|
||||
@@ -16,6 +16,12 @@
|
||||
e.preventDefault();
|
||||
if (window.location.pathname !== '/' && typeof movePage === 'function') movePage();
|
||||
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;
|
||||
@@ -58,12 +64,14 @@ document.addEventListener('keydown', function (e) {
|
||||
});
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
var trigger = document.querySelector('[data-action="actions-menu"]');
|
||||
if (!trigger) return;
|
||||
wireDropdown(trigger);
|
||||
var menu = trigger.parentElement.querySelector('.dropdown-menu');
|
||||
if (!menu) return;
|
||||
trigger.addEventListener('click', function () {
|
||||
trigger.setAttribute('aria-expanded', menu.classList.contains('is-open') ? true : false);
|
||||
['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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
// 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';
|
||||
}
|
||||
|
||||
// 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 it in like a
|
||||
// navigation; elsewhere replaceState + reload (not location.replace: a
|
||||
// target differing only by #fragment would otherwise not re-fetch).
|
||||
htmx.location = {
|
||||
set href(target) {
|
||||
if (boosting()) {
|
||||
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;
|
||||
|
||||
// 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;
|
||||
}
|
||||
|
||||
// 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'));
|
||||
});
|
||||
})();
|
||||
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="1em" height="1em"
|
||||
fill="none" stroke="currentColor" stroke-width="1.5" stroke-linejoin="miter" stroke-linecap="square">
|
||||
<path d="M2 4h12M6 4V2h4v2M4 4l1 10h6l1-10M7 7v4M9 7v4"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 259 B |
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="1em" height="1em"
|
||||
fill="none" stroke="currentColor" stroke-width="1.5" stroke-linejoin="miter" stroke-linecap="square">
|
||||
<path d="M11 2l3 3-8 8H3v-3zM9 4l3 3"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 241 B |
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="1em" height="1em"
|
||||
fill="none" stroke="currentColor" stroke-width="1.5" stroke-linejoin="miter" stroke-linecap="square">
|
||||
<path d="M2 3h12v11H2zM2 6h12M5 1v2M11 1v2M5 10l2 2 4-4"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 260 B |
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="1em" height="1em"
|
||||
fill="none" stroke="currentColor" stroke-width="1.5" stroke-linejoin="miter" stroke-linecap="square">
|
||||
<path d="M2 8h8M7 5l3 3-3 3M13 2v12"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 240 B |
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="1em" height="1em"
|
||||
fill="none" stroke="currentColor" stroke-width="1.5" stroke-linejoin="miter" stroke-linecap="square">
|
||||
<path d="M8 3v10M3 8h10"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 228 B |
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="1em" height="1em"
|
||||
fill="none" stroke="currentColor" stroke-width="1.5" stroke-linejoin="miter" stroke-linecap="square">
|
||||
<path d="M13 8a5 5 0 1 1-1.5-3.5M12 1v4H8"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 246 B |
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="1em" height="1em"
|
||||
fill="none" stroke="currentColor" stroke-width="1.5" stroke-linejoin="miter" stroke-linecap="square">
|
||||
<path d="M1 14V3h5l1 2h8v9zM8 12V8M6 10l2-2 2 2"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 252 B |
@@ -4,22 +4,27 @@
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, interactive-widget=resizes-content" />
|
||||
<title>{{.Title}}</title>
|
||||
<link rel="icon" href="/_/favicon.ico" />
|
||||
<link rel="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="/_/style.css" />
|
||||
<script src="/_/modal.js"></script>
|
||||
<script src="/_/global-shortcuts.js"></script>
|
||||
<script src="/_/history-nav.js"></script>
|
||||
<script src="/_/scroll-sync.js" defer></script>
|
||||
<script src="/_/search-suggest.js" defer></script>
|
||||
<script src="/_/tree-picker.js"></script>
|
||||
<script src="/_/companion.js" defer></script>
|
||||
{{if not .EditMode}}<script src="/_/overlay.js" defer></script>
|
||||
<script src="/_/tree-sidebar.js" defer></script>{{end}}
|
||||
{{block "headScripts" .}}{{end}}
|
||||
<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>
|
||||
<body>
|
||||
{{/* 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. */}}
|
||||
<body{{if not .EditMode}} hx-boost="true"{{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>
|
||||
@@ -31,36 +36,71 @@
|
||||
{{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">
|
||||
<div class="actions-special"></div>
|
||||
{{template "headerActions" . }}
|
||||
{{template "page-actions" .}}
|
||||
</div>
|
||||
</span>
|
||||
{{end}}
|
||||
</div>
|
||||
</header>
|
||||
<div class="shell">
|
||||
{{if not .EditMode}}<aside class="tree-sidebar col">
|
||||
<div class="tree-scroll"></div>
|
||||
</aside>{{end}}
|
||||
<div class="center">
|
||||
<main>
|
||||
{{block "content" .}}{{end}}
|
||||
</main>
|
||||
<footer>
|
||||
<span class="muted">Request: {{.RenderMS}} ms</span>
|
||||
{{block "footerExtras" .}}{{end}}
|
||||
<span class="dropdown companion-status" data-companion-status hidden>
|
||||
<button type="button" class="btn btn-small companion-icon" data-action="companion-toggle" title="Companion status" aria-label="Companion status">○</button>
|
||||
<div class="dropdown-menu align-right open-up companion-flyout"></div>
|
||||
</span>
|
||||
</footer>
|
||||
</div>
|
||||
{{if not .EditMode}}{{block "aside" .}}{{end}}{{end}}
|
||||
</div>
|
||||
<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,120 +1,111 @@
|
||||
// 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 () {
|
||||
var backdrop = null;
|
||||
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;
|
||||
var current = null; // { backdrop, prevFocus, onKeydown }
|
||||
var drag = null; // { modal, startX, startY, originX, originY }
|
||||
|
||||
function build() {
|
||||
backdrop = document.createElement('div');
|
||||
// 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) {
|
||||
if (e.target === backdrop) close();
|
||||
});
|
||||
backdrop.addEventListener('click', function (e) {
|
||||
if (e.target.closest('[data-modal-close]')) close();
|
||||
});
|
||||
|
||||
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
|
||||
// doesn't fight us.
|
||||
function startDrag(e, modal) {
|
||||
if (e.button !== undefined && e.button !== 0) return;
|
||||
var pt = e.touches ? e.touches[0] : e;
|
||||
var rect = modal.getBoundingClientRect();
|
||||
modal.classList.add('is-dragged');
|
||||
modal.style.position = 'fixed';
|
||||
modal.style.left = rect.left + 'px';
|
||||
modal.style.top = rect.top + 'px';
|
||||
drag = { modal: modal, startX: pt.clientX, startY: pt.clientY, originX: rect.left, originY: rect.top };
|
||||
e.preventDefault();
|
||||
}
|
||||
|
||||
function onMove(e) {
|
||||
if (!drag) return;
|
||||
var pt = e.touches ? e.touches[0] : e;
|
||||
var m = drag.modal;
|
||||
var maxX = window.innerWidth - m.offsetWidth;
|
||||
var maxY = window.innerHeight - m.offsetHeight;
|
||||
m.style.left = Math.max(0, Math.min(maxX, drag.originX + pt.clientX - drag.startX)) + 'px';
|
||||
m.style.top = Math.max(0, Math.min(maxY, drag.originY + pt.clientY - drag.startY)) + 'px';
|
||||
}
|
||||
|
||||
function onUp() { drag = null; }
|
||||
|
||||
document.addEventListener('mousemove', onMove);
|
||||
document.addEventListener('touchmove', onMove, { passive: false });
|
||||
document.addEventListener('mouseup', 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) {
|
||||
var prevFocus = current ? detach() : document.activeElement;
|
||||
|
||||
var backdrop = document.createElement('div');
|
||||
backdrop.className = 'modal-backdrop';
|
||||
|
||||
modal = document.createElement('div');
|
||||
var modal = document.createElement('div');
|
||||
modal.className = 'modal panel panel-floating';
|
||||
modal.setAttribute('role', 'dialog');
|
||||
modal.setAttribute('aria-modal', 'true');
|
||||
|
||||
var header = document.createElement('div');
|
||||
header.className = 'panel-header';
|
||||
titleEl = document.createElement('span');
|
||||
var titleEl = document.createElement('span');
|
||||
titleEl.textContent = opts.title || '';
|
||||
header.appendChild(titleEl);
|
||||
|
||||
bodyEl = document.createElement('div');
|
||||
var bodyEl = document.createElement('div');
|
||||
bodyEl.className = 'panel-body';
|
||||
|
||||
footerEl = document.createElement('div');
|
||||
footerEl.className = 'panel-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);
|
||||
|
||||
backdrop.addEventListener('mousedown', function (e) {
|
||||
if (e.target === backdrop) close();
|
||||
});
|
||||
wireDrag(header);
|
||||
cancelBtn.addEventListener('click', close);
|
||||
confirmBtn.addEventListener('click', function () {
|
||||
if (confirmBtn.disabled) return;
|
||||
if (currentOpts && currentOpts.confirm && currentOpts.confirm.onConfirm) {
|
||||
currentOpts.confirm.onConfirm();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// wireDrag makes the modal draggable by `handle`. Dragging switches the
|
||||
// modal to fixed positioning so flexbox alignment doesn't fight us.
|
||||
function wireDrag(handle) {
|
||||
var dragging = false;
|
||||
var startX, startY, originX, originY;
|
||||
|
||||
function onDown(e) {
|
||||
if (e.button !== undefined && e.button !== 0) return;
|
||||
var pt = e.touches ? e.touches[0] : e;
|
||||
var rect = modal.getBoundingClientRect();
|
||||
modal.classList.add('is-dragged');
|
||||
modal.style.position = 'fixed';
|
||||
modal.style.left = rect.left + 'px';
|
||||
modal.style.top = rect.top + 'px';
|
||||
dragging = true;
|
||||
startX = pt.clientX;
|
||||
startY = pt.clientY;
|
||||
originX = rect.left;
|
||||
originY = rect.top;
|
||||
e.preventDefault();
|
||||
}
|
||||
|
||||
function onMove(e) {
|
||||
if (!dragging) return;
|
||||
var pt = e.touches ? e.touches[0] : e;
|
||||
var dx = pt.clientX - startX;
|
||||
var dy = pt.clientY - startY;
|
||||
var w = modal.offsetWidth;
|
||||
var h = modal.offsetHeight;
|
||||
var maxX = window.innerWidth - w;
|
||||
var maxY = window.innerHeight - h;
|
||||
var x = Math.max(0, Math.min(maxX, originX + dx));
|
||||
var y = Math.max(0, Math.min(maxY, originY + dy));
|
||||
modal.style.left = x + 'px';
|
||||
modal.style.top = y + 'px';
|
||||
}
|
||||
|
||||
function onUp() { dragging = false; }
|
||||
|
||||
handle.addEventListener('mousedown', onDown);
|
||||
handle.addEventListener('touchstart', onDown, { passive: false });
|
||||
document.addEventListener('mousemove', onMove);
|
||||
document.addEventListener('touchmove', onMove, { passive: false });
|
||||
document.addEventListener('mouseup', onUp);
|
||||
document.addEventListener('touchend', onUp);
|
||||
}
|
||||
|
||||
function open(opts) {
|
||||
if (backdrop && backdrop.parentNode) close();
|
||||
if (!backdrop) build();
|
||||
|
||||
currentOpts = opts;
|
||||
prevFocus = document.activeElement;
|
||||
|
||||
modal.classList.remove('is-dragged');
|
||||
modal.style.position = '';
|
||||
modal.style.left = '';
|
||||
modal.style.top = '';
|
||||
|
||||
titleEl.textContent = opts.title || '';
|
||||
|
||||
bodyEl.textContent = '';
|
||||
if (opts.body instanceof Node) {
|
||||
bodyEl.appendChild(opts.body);
|
||||
} else if (typeof opts.body === 'string') {
|
||||
@@ -124,14 +115,24 @@
|
||||
var confirmOpts = opts.confirm || {};
|
||||
var cancelOpts = opts.cancel || {};
|
||||
|
||||
var confirmBtn = document.createElement('button');
|
||||
confirmBtn.type = 'button';
|
||||
confirmBtn.textContent = confirmOpts.label || 'OK';
|
||||
confirmBtn.className = 'btn' + (confirmOpts.danger ? ' danger' : '');
|
||||
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.className = 'btn';
|
||||
cancelBtn.setAttribute('data-modal-close', '');
|
||||
|
||||
footerEl.textContent = '';
|
||||
var footerEl = document.createElement('div');
|
||||
footerEl.className = 'panel-footer';
|
||||
if (opts.swapButtons) {
|
||||
footerEl.appendChild(confirmBtn);
|
||||
footerEl.appendChild(cancelBtn);
|
||||
@@ -140,7 +141,20 @@
|
||||
footerEl.appendChild(confirmBtn);
|
||||
}
|
||||
|
||||
document.body.appendChild(backdrop);
|
||||
modal.appendChild(header);
|
||||
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 () {
|
||||
if (cancelOpts.autofocus) {
|
||||
@@ -156,22 +170,6 @@
|
||||
}
|
||||
}, 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 {
|
||||
close: close,
|
||||
setConfirmDisabled: function (d) { confirmBtn.disabled = !!d; },
|
||||
@@ -179,20 +177,30 @@
|
||||
};
|
||||
}
|
||||
|
||||
function close() {
|
||||
if (!backdrop || !backdrop.parentNode) return;
|
||||
if (onKeydown) {
|
||||
document.removeEventListener('keydown', onKeydown);
|
||||
onKeydown = null;
|
||||
}
|
||||
backdrop.parentNode.removeChild(backdrop);
|
||||
currentOpts = null;
|
||||
var toRestore = prevFocus;
|
||||
prevFocus = null;
|
||||
if (toRestore && toRestore.focus) {
|
||||
try { toRestore.focus(); } catch (e) {}
|
||||
}
|
||||
// Server-rendered dialogs. A swap into #modal-host replaces whatever modal
|
||||
// is open (e.g. the merge step replacing the move dialog); focus returns
|
||||
// to where it was before the first one opened.
|
||||
var hostPrevFocus = null;
|
||||
// History restores swap without event details; they never target the host.
|
||||
function isHost(e) {
|
||||
return !!(e.detail && e.detail.target && e.detail.target.id === 'modal-host');
|
||||
}
|
||||
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.closeModal = close;
|
||||
|
||||
@@ -3,40 +3,6 @@ function encodePickedPath(p) {
|
||||
return '/' + p.replace(/^\/+/, '').split('/').map(encodeURIComponent).join('/');
|
||||
}
|
||||
|
||||
// postReplace POSTs to action with the optional form body, then loads target
|
||||
// into the current history entry — so the action and its result occupy one
|
||||
// entry instead of two, and back-navigation skips past the stale pre-mutation
|
||||
// snapshot in bfcache. body may be null for empty POSTs.
|
||||
//
|
||||
// We can't just call window.location.replace(target): when target differs from
|
||||
// the current URL only by fragment, the browser updates the URL bar without
|
||||
// re-fetching, so a server-side mutation wouldn't be reflected. Instead,
|
||||
// rewrite the current entry's URL via history.replaceState, then reload — the
|
||||
// reload always re-fetches and preserves the (new) URL including its fragment.
|
||||
function navigateReplace(target) {
|
||||
window.history.replaceState(null, '', target);
|
||||
window.location.reload();
|
||||
}
|
||||
|
||||
function postReplace(action, body, target) {
|
||||
var init = { method: 'POST', redirect: 'manual' };
|
||||
if (body) {
|
||||
init.headers = { 'Content-Type': 'application/x-www-form-urlencoded' };
|
||||
init.body = body;
|
||||
}
|
||||
fetch(action, init).then(function (res) {
|
||||
if (res.type === 'opaqueredirect' || res.ok) {
|
||||
navigateReplace(target);
|
||||
return;
|
||||
}
|
||||
return res.text().then(function (msg) {
|
||||
alert(msg || ('Request failed (' + res.status + ')'));
|
||||
});
|
||||
}).catch(function () {
|
||||
alert('Network error');
|
||||
});
|
||||
}
|
||||
|
||||
function promptPageName(title, initial, confirmLabel, onName) {
|
||||
var input = document.createElement('input');
|
||||
input.type = 'text';
|
||||
@@ -75,45 +41,10 @@ function newPage() {
|
||||
});
|
||||
}
|
||||
|
||||
// submitMove POSTs a move and navigates on success. When the server reports
|
||||
// the destination folder already exists but can be merged (the source carries
|
||||
// a page and the destination has none), it asks the user to confirm and
|
||||
// retries the same move with &merge=1.
|
||||
function submitMove(action, target) {
|
||||
fetch(action, { method: 'POST', redirect: 'manual' }).then(function (res) {
|
||||
if (res.type === 'opaqueredirect' || res.ok) {
|
||||
navigateReplace(target);
|
||||
return;
|
||||
}
|
||||
if (res.status === 409 && res.headers.get('X-Merge-Available') === '1') {
|
||||
openModal({
|
||||
title: 'Merge folders?',
|
||||
body: 'The destination folder already exists but has no page of its own. Merge this page and its contents into it?',
|
||||
confirm: {
|
||||
label: 'MERGE',
|
||||
onConfirm: function () {
|
||||
closeModal();
|
||||
submitMove(action + '&merge=1', target);
|
||||
}
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
return res.text().then(function (msg) {
|
||||
alert(msg || ('Request failed (' + res.status + ')'));
|
||||
});
|
||||
}).catch(function () {
|
||||
alert('Network error');
|
||||
});
|
||||
}
|
||||
|
||||
function movePage() {
|
||||
var current = decodeURIComponent(window.location.pathname).replace(/\/+$/, '');
|
||||
if (!current) return;
|
||||
var segs = current.split('/').filter(Boolean);
|
||||
var currentName = segs[segs.length - 1] || '';
|
||||
var parent = '/' + segs.slice(0, -1).join('/');
|
||||
if (parent === '/') parent = '/';
|
||||
var parent = '/' + current.split('/').filter(Boolean).slice(0, -1).join('/');
|
||||
|
||||
openTreePicker({
|
||||
title: 'Move — new parent?',
|
||||
@@ -123,53 +54,13 @@ function movePage() {
|
||||
hideFiles: true,
|
||||
confirmLabel: 'NEXT',
|
||||
onSelect: function (newParent) {
|
||||
var input = document.createElement('input');
|
||||
input.type = 'text';
|
||||
input.className = 'input';
|
||||
input.placeholder = 'Page name';
|
||||
input.value = currentName;
|
||||
|
||||
openModal({
|
||||
title: 'Move — new name?',
|
||||
body: input,
|
||||
confirm: {
|
||||
label: 'MOVE',
|
||||
onConfirm: function () {
|
||||
var name = input.value.trim();
|
||||
if (!name) return;
|
||||
var dest = (newParent === '/' ? '' : newParent) + '/' + name;
|
||||
var action = window.location.pathname + '?move=' +
|
||||
encodeURIComponent(dest);
|
||||
var target = encodePickedPath(dest) + '/';
|
||||
closeModal();
|
||||
submitMove(action, target);
|
||||
}
|
||||
}
|
||||
// The server renders the name step and handles what follows
|
||||
// (merge confirmation, errors, redirect).
|
||||
closeModal();
|
||||
htmx.ajax('GET', window.location.pathname + '?dialog=move', {
|
||||
target: '#modal-host',
|
||||
values: { parent: newParent }
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function deletePage() {
|
||||
var decodedPath = decodeURIComponent(window.location.pathname);
|
||||
|
||||
openModal({
|
||||
title: 'Delete page',
|
||||
body: 'Delete ' + decodedPath + ' and everything inside it?',
|
||||
confirm: {
|
||||
label: 'DELETE',
|
||||
danger: true,
|
||||
enterConfirms: false,
|
||||
onConfirm: function () {
|
||||
var p = window.location.pathname.replace(/\/+$/, '');
|
||||
var idx = p.lastIndexOf('/');
|
||||
var parent = idx > 0 ? p.substring(0, idx + 1) : '/';
|
||||
closeModal();
|
||||
postReplace(window.location.pathname + '?delete=1', null, parent);
|
||||
}
|
||||
},
|
||||
cancel: { autofocus: true },
|
||||
swapButtons: true
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -1,15 +1,21 @@
|
||||
document.addEventListener("DOMContentLoaded", function () {
|
||||
var aside = document.querySelector("aside.sidebar");
|
||||
var specials = document.querySelector(".actions-special");
|
||||
if (!aside || !aside.children.length || !specials) return;
|
||||
// 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);
|
||||
});
|
||||
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();
|
||||
})();
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
{{/* 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 "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,13 +1,11 @@
|
||||
{{define "headScripts"}}<script src="/_/page/actions.js"></script>{{end}}
|
||||
|
||||
{{define "headerActions"}}{{if .CanEdit}}
|
||||
<button class="btn btn-block" onclick="newPage()" title="New page (N)">NEW</button>
|
||||
<button class="btn btn-block" data-companion-reveal hidden title="Reveal in file manager">REVEAL</button>
|
||||
<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" 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)">MOVE</button>
|
||||
<button class="btn btn-block danger" onclick="deletePage()" title="Delete page">DELETE</button>
|
||||
<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)">EDIT</a>
|
||||
<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"}}
|
||||
@@ -17,8 +15,11 @@
|
||||
{{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" id="view-settings-btn" onclick="openViewSettings()" title="View & sorting" data-view="{{.View}}" data-sort="{{.Sort}}" data-order="{{.Order}}">view</button>{{end}}</h2>
|
||||
<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}}
|
||||
@@ -41,21 +42,15 @@
|
||||
</tbody>
|
||||
</table>
|
||||
{{end}}
|
||||
{{if .CanEdit}}<script src="/_/page/view-settings.js"></script>{{end}}
|
||||
{{else if not .Content}}
|
||||
{{if not .SpecialContent}}
|
||||
<p class="empty">Empty folder — <a href="?edit">[CREATE]</a></p>
|
||||
{{end}}
|
||||
{{end}}
|
||||
{{if or .Content .SpecialContent}}
|
||||
<script src="/_/page/content.js"></script>
|
||||
{{if not .SuppressTOC}}<script src="/_/page/toc.js"></script>{{end}}
|
||||
<script src="/_/page/tasks.js"></script>
|
||||
<script src="{{asset "page/content.js"}}"></script>
|
||||
{{if not .SuppressTOC}}<script src="{{asset "page/toc.js"}}"></script>{{end}}
|
||||
{{end}}
|
||||
{{if .Content}}
|
||||
<script src="/_/page/sections.js"></script>
|
||||
<script src="{{asset "page/sections.js"}}"></script>
|
||||
{{end}}
|
||||
<script src="/_/page/contents.js"></script>
|
||||
<script src="{{asset "page/contents.js"}}"></script>
|
||||
{{end}}
|
||||
|
||||
{{define "aside"}}{{if .SidebarWidget}}<aside class="sidebar">{{.SidebarWidget}}</aside>{{end}}{{end}}
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
(function () {
|
||||
document.querySelectorAll('input.task-checkbox[data-task-index]').forEach(function (cb) {
|
||||
cb.addEventListener('change', function () {
|
||||
var idx = cb.dataset.taskIndex;
|
||||
var checked = cb.checked;
|
||||
cb.disabled = true;
|
||||
fetch(window.location.pathname + '?toggle=' + idx, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: 'checked=' + checked
|
||||
}).then(function (res) {
|
||||
if (!res.ok) {
|
||||
cb.checked = !checked;
|
||||
alert('Failed to save task state (' + res.status + ')');
|
||||
}
|
||||
}).catch(function () {
|
||||
cb.checked = !checked;
|
||||
alert('Failed to save task state');
|
||||
}).finally(function () {
|
||||
cb.disabled = false;
|
||||
});
|
||||
});
|
||||
});
|
||||
})();
|
||||
@@ -1,32 +1,38 @@
|
||||
document.addEventListener("DOMContentLoaded", function () {
|
||||
var content = document.querySelector("main");
|
||||
if (!content) return;
|
||||
// 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 headings = content.querySelectorAll("h2, h3, h4");
|
||||
if (headings.length < 2) return;
|
||||
|
||||
var nav = document.createElement("nav");
|
||||
nav.className = "toc panel";
|
||||
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 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);
|
||||
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);
|
||||
});
|
||||
content.insertBefore(nav, content.firstChild);
|
||||
}
|
||||
if (document.readyState === "loading") document.addEventListener("DOMContentLoaded", run);
|
||||
else run();
|
||||
})();
|
||||
|
||||
@@ -1,85 +0,0 @@
|
||||
// View-settings modal: lets the user pick the folder listing's view style,
|
||||
// sort key, and order, then persists them by POSTing to the folder with
|
||||
// ?settings. Reuses openModal/closeModal and postReplace from page/actions.js.
|
||||
function openViewSettings() {
|
||||
var btn = document.getElementById('view-settings-btn');
|
||||
var state = {
|
||||
view: (btn && btn.dataset.view) || 'list',
|
||||
sort: (btn && btn.dataset.sort) || 'name',
|
||||
order: (btn && btn.dataset.order) || 'asc'
|
||||
};
|
||||
|
||||
// segmented builds a row of mutually-exclusive .btn toggles bound to a
|
||||
// single state key, marking the current choice with .is-active.
|
||||
function segmented(key, options) {
|
||||
var wrap = document.createElement('div');
|
||||
wrap.className = 'row gap-1';
|
||||
options.forEach(function (opt) {
|
||||
var b = document.createElement('button');
|
||||
b.type = 'button';
|
||||
b.className = 'btn';
|
||||
b.textContent = opt.label;
|
||||
if (state[key] === opt.value) b.classList.add('is-active');
|
||||
b.addEventListener('click', function () {
|
||||
state[key] = opt.value;
|
||||
wrap.querySelectorAll('button').forEach(function (x) {
|
||||
x.classList.remove('is-active');
|
||||
});
|
||||
b.classList.add('is-active');
|
||||
});
|
||||
wrap.appendChild(b);
|
||||
});
|
||||
return wrap;
|
||||
}
|
||||
|
||||
function field(labelText, control) {
|
||||
var row = document.createElement('div');
|
||||
row.className = 'col gap-1';
|
||||
var label = document.createElement('span');
|
||||
label.className = 'caption';
|
||||
label.textContent = labelText;
|
||||
row.appendChild(label);
|
||||
row.appendChild(control);
|
||||
return row;
|
||||
}
|
||||
|
||||
var sortSelect = document.createElement('select');
|
||||
sortSelect.className = 'input';
|
||||
[['name', 'Name'], ['modified', 'Modified'], ['size', 'Size']].forEach(function (o) {
|
||||
var opt = document.createElement('option');
|
||||
opt.value = o[0];
|
||||
opt.textContent = o[1];
|
||||
if (state.sort === o[0]) opt.selected = true;
|
||||
sortSelect.appendChild(opt);
|
||||
});
|
||||
sortSelect.addEventListener('change', function () { state.sort = sortSelect.value; });
|
||||
|
||||
var body = document.createElement('div');
|
||||
body.className = 'col';
|
||||
body.appendChild(field('View style', segmented('view', [
|
||||
{ value: 'list', label: 'List' },
|
||||
{ value: 'thumbnail', label: 'Thumbnail' }
|
||||
])));
|
||||
body.appendChild(field('Sort by', sortSelect));
|
||||
body.appendChild(field('Order', segmented('order', [
|
||||
{ value: 'asc', label: 'Asc' },
|
||||
{ value: 'desc', label: 'Desc' }
|
||||
])));
|
||||
|
||||
openModal({
|
||||
title: 'View settings',
|
||||
body: body,
|
||||
confirm: {
|
||||
label: 'SAVE',
|
||||
onConfirm: function () {
|
||||
var action = window.location.pathname + '?settings';
|
||||
var formBody = 'view=' + encodeURIComponent(state.view) +
|
||||
'&sort=' + encodeURIComponent(state.sort) +
|
||||
'&order=' + encodeURIComponent(state.order);
|
||||
var target = window.location.pathname;
|
||||
closeModal();
|
||||
postReplace(action, formBody, target);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -4,8 +4,8 @@
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Save link</title>
|
||||
<link rel="icon" href="/_/favicon.ico" />
|
||||
<link rel="stylesheet" href="/_/style.css" />
|
||||
<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; }
|
||||
|
||||
@@ -45,17 +45,22 @@
|
||||
}
|
||||
|
||||
// Reading mode: keep the latest scroll fraction recorded (throttled to one
|
||||
// write per frame) so it is ready the moment the editor opens.
|
||||
// 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;
|
||||
center.addEventListener('scroll', function () {
|
||||
if (pending) return;
|
||||
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(key, JSON.stringify({ f: m > 0 ? center.scrollTop / m : 0, t: Date.now() }));
|
||||
sessionStorage.setItem('scrollsync:' + location.pathname,
|
||||
JSON.stringify({ f: m > 0 ? center.scrollTop / m : 0, t: Date.now() }));
|
||||
} catch (e) {}
|
||||
});
|
||||
}, { passive: true });
|
||||
}, { capture: true, passive: true });
|
||||
})();
|
||||
|
||||
@@ -1,63 +1,18 @@
|
||||
// search-suggest.js — instant typeahead dropdown.
|
||||
//
|
||||
// Exposes window.attachSuggestions(inputEl, opts) used by both the header
|
||||
// search box and the editor's "Insert link" modal. Owns: debounced fetching,
|
||||
// request ordering, DOM creation, keyboard handling, open/close lifecycle.
|
||||
// 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(result) — called when the user selects a row
|
||||
// onShowAll(query) — optional; called when the footer row activates
|
||||
// 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 DEBOUNCE_MS = 100;
|
||||
var MIN_QUERY_LEN = 2;
|
||||
|
||||
function escapeHTML(s) {
|
||||
return String(s)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
|
||||
function tokenize(s) {
|
||||
return s.toLowerCase().split(/[^\p{L}\p{N}]+/u).filter(Boolean);
|
||||
}
|
||||
|
||||
// highlight bolds the substring spans in `name` that match any of the
|
||||
// query tokens (case-insensitive). Overlapping/adjacent spans merge.
|
||||
// Returns a safe HTML string.
|
||||
function highlight(name, tokens) {
|
||||
if (!tokens.length) return escapeHTML(name);
|
||||
var lower = name.toLowerCase();
|
||||
var spans = [];
|
||||
tokens.forEach(function (t) {
|
||||
if (!t) return;
|
||||
var idx = lower.indexOf(t);
|
||||
if (idx >= 0) spans.push([idx, idx + t.length]);
|
||||
});
|
||||
if (!spans.length) return escapeHTML(name);
|
||||
spans.sort(function (a, b) { return a[0] - b[0]; });
|
||||
var merged = [spans[0].slice()];
|
||||
for (var i = 1; i < spans.length; i++) {
|
||||
var last = merged[merged.length - 1];
|
||||
if (spans[i][0] <= last[1]) {
|
||||
last[1] = Math.max(last[1], spans[i][1]);
|
||||
} else {
|
||||
merged.push(spans[i].slice());
|
||||
}
|
||||
}
|
||||
var out = '';
|
||||
var cursor = 0;
|
||||
merged.forEach(function (sp) {
|
||||
out += escapeHTML(name.slice(cursor, sp[0]));
|
||||
out += '<strong>' + escapeHTML(name.slice(sp[0], sp[1])) + '</strong>';
|
||||
cursor = sp[1];
|
||||
});
|
||||
out += escapeHTML(name.slice(cursor));
|
||||
return out;
|
||||
}
|
||||
var seq = 0;
|
||||
|
||||
function attachSuggestions(inputEl, opts) {
|
||||
if (!inputEl) return;
|
||||
@@ -68,237 +23,116 @@
|
||||
|
||||
var dropdown = document.createElement('div');
|
||||
dropdown.className = 'suggest-dropdown';
|
||||
dropdown.id = 'suggest-' + (++seq);
|
||||
host.appendChild(dropdown);
|
||||
|
||||
function makeRow(cls, tabbable) {
|
||||
var tr = document.createElement('tr');
|
||||
tr.className = cls;
|
||||
if (tabbable) tr.setAttribute('tabindex', '0');
|
||||
var td = document.createElement('td');
|
||||
tr.appendChild(td);
|
||||
return { tr: tr, td: td };
|
||||
// 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');
|
||||
}
|
||||
|
||||
var state = {
|
||||
results: [],
|
||||
total: 0,
|
||||
query: '',
|
||||
activeIdx: -1,
|
||||
open: false,
|
||||
reqSeq: 0,
|
||||
debounceTimer: null,
|
||||
blurTimer: null,
|
||||
};
|
||||
|
||||
function rowCount() {
|
||||
var n = state.results.length;
|
||||
if (state.results.length === 0 && state.query.length >= MIN_QUERY_LEN) {
|
||||
return 0; // "no matches" row is non-interactive
|
||||
}
|
||||
if (opts.showFooter && state.total > state.results.length) n += 1;
|
||||
return n;
|
||||
}
|
||||
|
||||
function isFooterIdx(idx) {
|
||||
return opts.showFooter
|
||||
&& state.total > state.results.length
|
||||
&& idx === state.results.length;
|
||||
}
|
||||
|
||||
function render() {
|
||||
dropdown.textContent = '';
|
||||
if (!state.open) {
|
||||
dropdown.classList.remove('is-open');
|
||||
return;
|
||||
}
|
||||
var table = document.createElement('table');
|
||||
table.className = 'data-table';
|
||||
var tbody = document.createElement('tbody');
|
||||
table.appendChild(tbody);
|
||||
|
||||
var tokens = tokenize(state.query);
|
||||
if (state.results.length === 0) {
|
||||
var empty = makeRow('is-empty', false);
|
||||
empty.td.textContent = 'No matches';
|
||||
tbody.appendChild(empty.tr);
|
||||
} else {
|
||||
state.results.forEach(function (r, i) {
|
||||
var row = makeRow('suggest-row', true);
|
||||
row.tr.setAttribute('data-idx', String(i));
|
||||
var nameEl = document.createElement('span');
|
||||
nameEl.className = 'suggest-name';
|
||||
nameEl.innerHTML = highlight(r.name, tokens);
|
||||
var pathEl = document.createElement('span');
|
||||
pathEl.className = 'suggest-path';
|
||||
pathEl.textContent = '/' + r.path;
|
||||
row.td.appendChild(nameEl);
|
||||
row.td.appendChild(pathEl);
|
||||
if (i === state.activeIdx) row.tr.classList.add('is-active');
|
||||
row.tr.addEventListener('mousedown', function (e) {
|
||||
// mousedown (not click) so the input doesn't blur-close
|
||||
// the dropdown before the pick handler fires.
|
||||
e.preventDefault();
|
||||
pick(i);
|
||||
});
|
||||
tbody.appendChild(row.tr);
|
||||
});
|
||||
if (opts.showFooter && state.total > state.results.length) {
|
||||
var footer = makeRow('suggest-row suggest-footer', true);
|
||||
footer.td.textContent = 'Show all ' + state.total + ' matches';
|
||||
var footerIdx = state.results.length;
|
||||
if (state.activeIdx === footerIdx) footer.tr.classList.add('is-active');
|
||||
footer.tr.addEventListener('mousedown', function (e) {
|
||||
e.preventDefault();
|
||||
pickFooter();
|
||||
});
|
||||
tbody.appendChild(footer.tr);
|
||||
}
|
||||
}
|
||||
dropdown.appendChild(table);
|
||||
dropdown.classList.add('is-open');
|
||||
}
|
||||
|
||||
function pick(idx) {
|
||||
var r = state.results[idx];
|
||||
if (!r) return;
|
||||
close();
|
||||
if (opts.onPick) opts.onPick(r);
|
||||
}
|
||||
|
||||
function pickFooter() {
|
||||
close();
|
||||
if (opts.onShowAll) {
|
||||
opts.onShowAll(state.query);
|
||||
} else if (inputEl.form) {
|
||||
inputEl.form.submit();
|
||||
} else {
|
||||
window.location.href = '/?q=' + encodeURIComponent(state.query);
|
||||
}
|
||||
function isOpen() {
|
||||
return dropdown.classList.contains('is-open');
|
||||
}
|
||||
|
||||
function open() {
|
||||
state.open = true;
|
||||
render();
|
||||
if (dropdown.firstElementChild) dropdown.classList.add('is-open');
|
||||
}
|
||||
|
||||
function close() {
|
||||
state.open = false;
|
||||
state.activeIdx = -1;
|
||||
render();
|
||||
dropdown.classList.remove('is-open');
|
||||
setActive(-1);
|
||||
}
|
||||
|
||||
function fetchResults(query) {
|
||||
var seq = ++state.reqSeq;
|
||||
fetch('/_search?q=' + encodeURIComponent(query), {
|
||||
credentials: 'same-origin',
|
||||
headers: { 'Accept': 'application/json' },
|
||||
}).then(function (r) {
|
||||
if (!r.ok) throw new Error('HTTP ' + r.status);
|
||||
return r.json();
|
||||
}).then(function (resp) {
|
||||
if (seq !== state.reqSeq) return; // stale
|
||||
state.results = resp.results || [];
|
||||
state.total = resp.total || 0;
|
||||
state.query = resp.query || query;
|
||||
state.activeIdx = -1;
|
||||
open();
|
||||
}).catch(function () {
|
||||
if (seq !== state.reqSeq) return;
|
||||
state.results = [];
|
||||
state.total = 0;
|
||||
close();
|
||||
});
|
||||
}
|
||||
|
||||
function onInput() {
|
||||
var q = inputEl.value.trim();
|
||||
state.query = q;
|
||||
if (state.debounceTimer) clearTimeout(state.debounceTimer);
|
||||
if (q.length < MIN_QUERY_LEN) {
|
||||
state.reqSeq++; // invalidate any in-flight response
|
||||
state.results = [];
|
||||
state.total = 0;
|
||||
close();
|
||||
return;
|
||||
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) {}
|
||||
}
|
||||
state.debounceTimer = setTimeout(function () {
|
||||
fetchResults(q);
|
||||
}, DEBOUNCE_MS);
|
||||
}
|
||||
|
||||
function moveActive(delta) {
|
||||
var n = rowCount();
|
||||
var n = rows().length;
|
||||
if (n === 0) return;
|
||||
var next = state.activeIdx + delta;
|
||||
var next = activeIdx + delta;
|
||||
if (next < 0) next = n - 1;
|
||||
if (next >= n) next = 0;
|
||||
state.activeIdx = next;
|
||||
render();
|
||||
// Keep the active row in view.
|
||||
var active = dropdown.querySelector('tr.is-active');
|
||||
if (active && active.scrollIntoView) {
|
||||
try { active.scrollIntoView({ block: 'nearest' }); } catch (e) {}
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
function activateCurrent() {
|
||||
if (state.activeIdx < 0) return false;
|
||||
if (isFooterIdx(state.activeIdx)) {
|
||||
pickFooter();
|
||||
return true;
|
||||
}
|
||||
pick(state.activeIdx);
|
||||
return true;
|
||||
}
|
||||
|
||||
inputEl.addEventListener('input', onInput);
|
||||
inputEl.addEventListener('focus', function () {
|
||||
if (state.blurTimer) {
|
||||
clearTimeout(state.blurTimer);
|
||||
state.blurTimer = null;
|
||||
}
|
||||
if (inputEl.value.trim().length >= MIN_QUERY_LEN
|
||||
&& (state.results.length || state.query)) {
|
||||
dropdown.addEventListener('htmx:afterSwap', function () {
|
||||
activeIdx = -1;
|
||||
if (document.activeElement === inputEl && dropdown.firstElementChild) {
|
||||
open();
|
||||
} else {
|
||||
close();
|
||||
}
|
||||
});
|
||||
inputEl.addEventListener('blur', function () {
|
||||
// Delay so click/mousedown on a row still resolves.
|
||||
state.blurTimer = setTimeout(close, 150);
|
||||
|
||||
// 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') {
|
||||
if (!state.open) return;
|
||||
e.preventDefault();
|
||||
moveActive(1);
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
if (!state.open) return;
|
||||
e.preventDefault();
|
||||
moveActive(-1);
|
||||
} else if (e.key === 'Escape') {
|
||||
if (!state.open) return;
|
||||
e.preventDefault();
|
||||
close();
|
||||
} else if (e.key === 'Enter') {
|
||||
if (state.open && state.activeIdx >= 0) {
|
||||
// Without an active row: native submit (full results page).
|
||||
var row = rows()[activeIdx];
|
||||
if (row) {
|
||||
e.preventDefault();
|
||||
activateCurrent();
|
||||
pick(row);
|
||||
}
|
||||
// else: native form submit behaviour (full results page)
|
||||
} else if (e.key === 'Tab') {
|
||||
if (!state.open || rowCount() === 0) return;
|
||||
if (rows().length === 0) return;
|
||||
e.preventDefault();
|
||||
moveActive(e.shiftKey ? -1 : 1);
|
||||
}
|
||||
});
|
||||
|
||||
// Click outside the host closes the dropdown.
|
||||
document.addEventListener('mousedown', function (e) {
|
||||
if (!state.open) return;
|
||||
if (host.contains(e.target)) return;
|
||||
close();
|
||||
});
|
||||
|
||||
return {
|
||||
close: close,
|
||||
destroy: function () {
|
||||
@@ -310,14 +144,7 @@
|
||||
|
||||
window.attachSuggestions = attachSuggestions;
|
||||
|
||||
// Auto-bind to the header search input. Header search submits the form
|
||||
// for the "show all" action; we route to a navigate-on-pick handler.
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
var input = document.querySelector('header .search-input');
|
||||
if (!input) return;
|
||||
attachSuggestions(input, {
|
||||
showFooter: true,
|
||||
onPick: function (r) { window.location.href = r.url; },
|
||||
});
|
||||
attachSuggestions(document.querySelector('header .search-input'), { showFooter: true });
|
||||
});
|
||||
})();
|
||||
|
||||
@@ -52,19 +52,19 @@ function createSearchPage(name) {
|
||||
});
|
||||
}
|
||||
|
||||
// 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 createLink = document.querySelector('[data-create-page]');
|
||||
if (createLink) {
|
||||
createLink.addEventListener('click', function (e) {
|
||||
e.preventDefault();
|
||||
createSearchPage(createLink.getAttribute('data-create-page'));
|
||||
});
|
||||
}
|
||||
|
||||
// Focus the search input on results pages so Tab steps directly into the
|
||||
// first match — the input sits immediately before the results in DOM
|
||||
// order, so the natural tab sequence is input → first result → next, …
|
||||
var input = document.querySelector('.search-input');
|
||||
if (input && input.value) {
|
||||
input.focus();
|
||||
@@ -72,3 +72,12 @@ document.addEventListener('DOMContentLoaded', function () {
|
||||
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,5 +1,3 @@
|
||||
{{define "headScripts"}}<script src="/_/search/actions.js"></script>{{end}}
|
||||
|
||||
{{define "searchQuery"}}{{.Query}}{{end}}
|
||||
|
||||
{{define "content"}}
|
||||
@@ -43,7 +41,7 @@
|
||||
{{end}}
|
||||
|
||||
{{define "headerActions"}}
|
||||
<button class="btn btn-block" onclick="rebuildIndex()" title="Rebuild search index">REBUILD INDEX</button>
|
||||
<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"}}
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
{{/* 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}}
|
||||
@@ -48,6 +48,28 @@
|
||||
--reading-width: 60rem;
|
||||
}
|
||||
|
||||
/* Cross-document view transitions: page navigations stay full reloads, but the
|
||||
browser holds the old frame until the new page is ready and swaps them
|
||||
without a blank flash. The header and tree rail are named so they swap in
|
||||
place (no crossfade) — only the rest of the page fades, and only briefly. */
|
||||
@view-transition { navigation: auto; }
|
||||
body > header { view-transition-name: app-header; }
|
||||
.tree-sidebar { view-transition-name: tree-sidebar; }
|
||||
::view-transition-group(*),
|
||||
::view-transition-old(*),
|
||||
::view-transition-new(*) { animation-duration: 0.12s; }
|
||||
::view-transition-group(app-header),
|
||||
::view-transition-group(tree-sidebar),
|
||||
::view-transition-old(app-header),
|
||||
::view-transition-new(app-header),
|
||||
::view-transition-old(tree-sidebar),
|
||||
::view-transition-new(tree-sidebar) { animation: none; }
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
::view-transition-group(*),
|
||||
::view-transition-old(*),
|
||||
::view-transition-new(*) { animation: none; }
|
||||
}
|
||||
|
||||
/* Anchor / TOC jumps are handled by the .center scroll container's own
|
||||
scroll-padding-top (the header no longer overlaps content in the app-shell),
|
||||
so no html-level scroll padding is needed. */
|
||||
@@ -219,6 +241,20 @@ footer {
|
||||
.danger { color: var(--danger); }
|
||||
.danger:hover { color: var(--danger-hover); }
|
||||
.btn.is-active { color: var(--primary-hover); }
|
||||
/* Segmented choice: radios styled as .btn toggles. The label is the button;
|
||||
the input stays in the accessibility tree and keyboard order. */
|
||||
.segmented input {
|
||||
position: absolute;
|
||||
opacity: 0;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
margin: 0;
|
||||
}
|
||||
.segmented .btn { position: relative; }
|
||||
.segmented .btn:has(input:checked) { color: var(--primary-hover); }
|
||||
.segmented .btn:has(input:focus-visible) { outline: 1px solid var(--secondary); }
|
||||
fieldset { border: 0; margin: 0; padding: 0; min-width: 0; }
|
||||
legend { padding: 0; margin-bottom: var(--space-1); } /* not a flex item: .gap-* skips it */
|
||||
|
||||
/* === Form controls ===
|
||||
.input baseline is shared by search-input, modal inputs, and the editor
|
||||
@@ -389,6 +425,12 @@ main > h2 {
|
||||
.dropdown-menu.scrollable { max-height: 23rem; overflow-y: auto; }
|
||||
|
||||
.actions-special > .btn {text-align: left;}
|
||||
/* Wrapper for the out-of-band swapped page actions; lays out as if absent. */
|
||||
.page-actions { display: contents; }
|
||||
/* Header actions carry an icon and a label: the mobile dropdown shows the
|
||||
label, the inline desktop bar (see responsive block) the icon. */
|
||||
.btn-icon { display: none; }
|
||||
.btn-icon svg { display: block; }
|
||||
|
||||
/* === Suggestion dropdown (header search + editor link picker) ===
|
||||
Anchored to a position:relative host. Mirrors .dropdown-menu visuals with
|
||||
@@ -407,11 +449,13 @@ main > h2 {
|
||||
}
|
||||
.suggest-dropdown.is-open { display: block; }
|
||||
.suggest-row { cursor: pointer; }
|
||||
.suggest-row > td { padding: 0.4rem 0.6rem; }
|
||||
.suggest-row > td { padding: 0; }
|
||||
/* The link fills the row, so every part of it is the click target. */
|
||||
.suggest-link { display: block; padding: 0.4rem 0.6rem; color: inherit; text-decoration: none; }
|
||||
.suggest-name, .suggest-path { display: block; }
|
||||
.suggest-name { color: var(--text); }
|
||||
.suggest-path { color: var(--text-muted); font-size: 0.8rem; margin-top: 0.1rem; }
|
||||
.suggest-footer > td { color: var(--link); font-size: var(--font-sm); }
|
||||
.suggest-footer .suggest-link { color: var(--link); font-size: var(--font-sm); }
|
||||
|
||||
/* === Editor toolbar ===
|
||||
Single non-wrapping row that scrolls horizontally (swipe on mobile) rather
|
||||
@@ -707,17 +751,33 @@ aside.sidebar:empty { display: none; }
|
||||
padding: 0.25rem var(--space-1);
|
||||
cursor: pointer;
|
||||
}
|
||||
.tree-row:hover, .tree-row.is-selected { background: var(--bg-panel-hover); }
|
||||
.tree-row.is-selected {
|
||||
border-left: 3px solid var(--primary);
|
||||
padding-left: calc(var(--space-2) - 3px);
|
||||
}
|
||||
.tree-row:hover { background: var(--bg-panel-hover); }
|
||||
.tree-row.is-active > .tree-name { color: var(--secondary); }
|
||||
.tree-row.is-disabled { color: var(--text-muted); cursor: default; }
|
||||
.tree-row.is-disabled:hover { background: none; }
|
||||
.tree-chevron { text-align: center; flex-shrink: 0; }
|
||||
.tree-chevron { width: 1.25rem; color: var(--secondary); }
|
||||
.tree-chevron.is-leaf { visibility: hidden; }
|
||||
/* Expandable folders are <details>; the chevron follows its open state. */
|
||||
.tree-node > summary { list-style: none; }
|
||||
.tree-node > summary::-webkit-details-marker { display: none; }
|
||||
.tree-node > summary > .tree-chevron::before { content: "\25b8"; }
|
||||
.tree-node[open] > summary > .tree-chevron::before { content: "\25be"; }
|
||||
/* Picker rows select through a visually hidden radio in the name label. */
|
||||
.tree-name input[type="radio"] {
|
||||
position: absolute;
|
||||
opacity: 0;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
margin: 0;
|
||||
}
|
||||
.tree-row:has(input:checked) {
|
||||
background: var(--bg-panel-hover);
|
||||
border-left: 3px solid var(--primary);
|
||||
padding-left: calc(var(--space-2) - 3px);
|
||||
}
|
||||
.tree-row:has(input:focus-visible) { outline: 1px solid var(--secondary); }
|
||||
label.tree-name { flex: 1; cursor: pointer; position: relative; }
|
||||
.tree-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.tree-children {
|
||||
margin-left: var(--space-2);
|
||||
@@ -797,13 +857,46 @@ aside.sidebar:empty { display: none; }
|
||||
.diary-cal-grid td.cal-current,
|
||||
.diary-cal-grid td.cal-current a { color: var(--primary-hover); }
|
||||
.btn-block.cal-current { color: var(--primary-hover); }
|
||||
/* Calendar events and weight under a diary day heading. */
|
||||
.diary-day-cal { margin: var(--space-3) 0 0; }
|
||||
.diary-day-cal p { margin: 0; }
|
||||
|
||||
/* === Calendars ===
|
||||
Header maintenance chip + dropdown, and the calendars folder page. */
|
||||
.maint-toggle { display: inline-flex; align-items: center; }
|
||||
.maint-count { margin-left: 0.3rem; }
|
||||
.maint-toggle.is-idle { color: var(--text-muted); }
|
||||
.maint-menu {
|
||||
min-width: 20rem;
|
||||
max-width: calc(100vw - 2rem);
|
||||
padding: var(--space-2) var(--space-3);
|
||||
white-space: normal;
|
||||
}
|
||||
/* Two columns so dates line up; an empty when-cell keeps the grid intact. */
|
||||
.maint-list {
|
||||
display: grid;
|
||||
grid-template-columns: max-content 1fr;
|
||||
column-gap: var(--space-2);
|
||||
row-gap: 0.15rem;
|
||||
font-size: var(--font-sm);
|
||||
}
|
||||
.maint-list > .muted { grid-column: 1 / -1; }
|
||||
.maint-list + .panel-header { margin-top: var(--space-3); }
|
||||
.maint-when { color: var(--text-muted); white-space: nowrap; }
|
||||
.maint-all { margin-top: var(--space-3); padding: 0.3rem 0 0; border-top: var(--border-dashed); }
|
||||
.cal-scroll { overflow-x: auto; }
|
||||
.cal-table .nowrap { white-space: nowrap; }
|
||||
.cal-error { color: var(--danger); font-size: var(--font-sm); }
|
||||
.cal-sync { margin-top: var(--space-3); }
|
||||
|
||||
/* === Fitness dashboard ===
|
||||
Server-rendered inline SVG charts. Geometry comes precomputed from Go;
|
||||
colors and strokes are applied here via classes so the inline SVG follows
|
||||
the theme palette. */
|
||||
.fitness-chart { padding: var(--space-3); }
|
||||
.fitness-chart { position: relative; padding: var(--space-3); }
|
||||
.fitness-chart-header { margin-bottom: var(--space-2); }
|
||||
.fitness-controls { gap: var(--space-3); }
|
||||
.fitness-absolute { display: flex; align-items: center; gap: var(--space-1); font-size: var(--font-sm); color: var(--text-muted); }
|
||||
.fitness-range { width: auto; font-size: var(--font-sm); }
|
||||
.fitness-empty {
|
||||
border: var(--border-dashed);
|
||||
@@ -817,8 +910,24 @@ aside.sidebar:empty { display: none; }
|
||||
.fitness-svg .chart-label { fill: var(--text-muted); font-size: var(--font-xs); }
|
||||
.fitness-svg .chart-line { fill: none; stroke: var(--link); stroke-width: 1.5; }
|
||||
.fitness-svg .chart-dot { fill: var(--link); }
|
||||
.fitness-svg .chart-goal { stroke: var(--primary-hover); stroke-dasharray: 4 3; }
|
||||
.fitness-svg .chart-goal-label { fill: var(--primary-hover); font-size: var(--font-xs); }
|
||||
.fitness-svg.is-dense .chart-dot { display: none; }
|
||||
.fitness-svg:not(.is-hover) :is(.chart-cursor, .chart-focus) { display: none; }
|
||||
.fitness-svg .chart-cursor { stroke: var(--text-muted); stroke-dasharray: 2 2; }
|
||||
.fitness-svg .chart-focus { fill: var(--link-hover); stroke: var(--bg); stroke-width: 1.5; }
|
||||
.fitness-svg .chart-goal { stroke: var(--danger); stroke-dasharray: 4 3; }
|
||||
.fitness-svg .chart-goal-label { fill: var(--danger-hover); font-size: var(--font-xs); }
|
||||
.fitness-tip {
|
||||
position: absolute;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: var(--space-1) var(--space-2);
|
||||
background: var(--bg);
|
||||
border: var(--border);
|
||||
font-size: var(--font-sm);
|
||||
white-space: nowrap;
|
||||
pointer-events: none;
|
||||
}
|
||||
.fitness-tip[hidden] { display: none; }
|
||||
|
||||
/* === Responsive === */
|
||||
@media (max-width: 1100px) {
|
||||
@@ -840,7 +949,9 @@ aside.sidebar:empty { display: none; }
|
||||
@media (min-width: 1101px) {
|
||||
.actions-toggle, .actions-special {display: none;}
|
||||
.actions, .actions-menu, .actions.dropdown-menu.is-open {display: contents;}
|
||||
.actions-menu .btn-block { width: auto; padding: 0;}
|
||||
.actions-menu .btn-block { width: auto; padding: 0; align-items: center; }
|
||||
.actions-menu .btn-icon { display: inline-block; }
|
||||
.actions-menu .btn-label { display: none; }
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
|
||||
@@ -1,43 +1,29 @@
|
||||
// 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 joinPath(parent, name) {
|
||||
if (parent === '/' || parent === '') return '/' + name;
|
||||
return parent.replace(/\/+$/, '') + '/' + name;
|
||||
}
|
||||
|
||||
function encodePath(p) {
|
||||
if (p === '/' || p === '') return '/';
|
||||
return '/' + p.replace(/^\/+/, '').split('/').map(encodeURIComponent).join('/') + '/';
|
||||
}
|
||||
|
||||
function fetchFolder(path) {
|
||||
return fetch(encodePath(path) + '?tree=1', { credentials: 'same-origin' })
|
||||
.then(function (r) {
|
||||
if (!r.ok) throw new Error('HTTP ' + r.status);
|
||||
return r.json();
|
||||
});
|
||||
}
|
||||
|
||||
function openTreePicker(opts) {
|
||||
opts = opts || {};
|
||||
var mode = opts.mode || 'folder';
|
||||
var initialPath = opts.initialPath || '/';
|
||||
var allowRoot = opts.allowRoot !== false;
|
||||
var hideFiles = !!opts.hideFiles;
|
||||
var preselect = opts.preselect || null;
|
||||
|
||||
var container = document.createElement('div');
|
||||
|
||||
var treeEl = document.createElement('div');
|
||||
treeEl.className = 'tree-picker panel';
|
||||
|
||||
treeEl.textContent = '…';
|
||||
var selectedPathEl = document.createElement('div');
|
||||
selectedPathEl.className = 'tree-selected-path muted';
|
||||
selectedPathEl.textContent = '\u00a0';
|
||||
|
||||
selectedPathEl.textContent = ' ';
|
||||
container.appendChild(treeEl);
|
||||
container.appendChild(selectedPathEl);
|
||||
|
||||
var selected = null; // { path, kind, rowEl }
|
||||
function checked() {
|
||||
return treeEl.querySelector('input[name="pick"]:checked');
|
||||
}
|
||||
|
||||
var handle = openModal({
|
||||
title: opts.title || 'Pick',
|
||||
@@ -46,236 +32,34 @@
|
||||
label: opts.confirmLabel || 'SELECT',
|
||||
initiallyDisabled: true,
|
||||
onConfirm: function () {
|
||||
if (!selected) return;
|
||||
var pick = checked();
|
||||
if (!pick) return;
|
||||
handle.close();
|
||||
if (opts.onSelect) opts.onSelect(selected.path, selected.kind);
|
||||
if (opts.onSelect) opts.onSelect(pick.value, pick.dataset.kind);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
function setSelected(path, kind, rowEl) {
|
||||
if (selected && selected.rowEl) selected.rowEl.classList.remove('is-selected');
|
||||
selected = { path: path, kind: kind, rowEl: rowEl };
|
||||
rowEl.classList.add('is-selected');
|
||||
selectedPathEl.textContent = path;
|
||||
handle.setConfirmDisabled(false);
|
||||
function sync() {
|
||||
var pick = checked();
|
||||
selectedPathEl.textContent = pick ? pick.value : ' ';
|
||||
handle.setConfirmDisabled(!pick);
|
||||
}
|
||||
treeEl.addEventListener('change', sync);
|
||||
|
||||
function isSelectable(kind) {
|
||||
if (mode === 'any') return true;
|
||||
if (mode === 'folder') return kind === 'folder';
|
||||
if (mode === 'file') return kind === 'file';
|
||||
return false;
|
||||
}
|
||||
|
||||
// buildRow returns { rowEl, expand(): Promise }. `expand` is a no-op
|
||||
// for files and idempotent for folders (resolves with the already-
|
||||
// loaded children on repeat calls).
|
||||
function buildRow(parentPath, name, kind) {
|
||||
var row = document.createElement('div');
|
||||
row.className = 'tree-row';
|
||||
if (!isSelectable(kind)) row.classList.add('is-disabled');
|
||||
|
||||
var chevron = document.createElement('span');
|
||||
chevron.className = 'tree-chevron';
|
||||
if (kind === 'folder') {
|
||||
chevron.textContent = '\u25b8'; // ▸
|
||||
} else {
|
||||
chevron.classList.add('is-leaf');
|
||||
var url = '/?tree&pick=' + mode +
|
||||
(opts.hideFiles ? '' : '&files=1') +
|
||||
(opts.allowRoot !== false ? '&root=1' : '') +
|
||||
'&expandTo=' + encodeURIComponent(opts.initialPath || '/') +
|
||||
(opts.preselect ? '&select=' + encodeURIComponent(opts.preselect) : '');
|
||||
htmx.ajax('GET', url, { target: treeEl, swap: 'innerHTML' }).then(function () {
|
||||
sync();
|
||||
var pick = checked();
|
||||
if (pick) {
|
||||
try { pick.closest('.tree-row').scrollIntoView({ block: 'nearest' }); } catch (e) {}
|
||||
}
|
||||
row.appendChild(chevron);
|
||||
|
||||
var label = document.createElement('span');
|
||||
label.className = 'tree-name';
|
||||
label.textContent = name;
|
||||
row.appendChild(label);
|
||||
|
||||
var fullPath = joinPath(parentPath, name);
|
||||
var childrenEl = null;
|
||||
var loadPromise = null;
|
||||
var isOpen = false;
|
||||
|
||||
function expand() {
|
||||
if (kind !== 'folder') return Promise.resolve(null);
|
||||
if (isOpen) return loadPromise || Promise.resolve(childrenEl);
|
||||
if (!childrenEl) {
|
||||
childrenEl = document.createElement('div');
|
||||
childrenEl.className = 'tree-children';
|
||||
}
|
||||
row.parentNode.insertBefore(childrenEl, row.nextSibling);
|
||||
chevron.textContent = '\u25be'; // ▾
|
||||
isOpen = true;
|
||||
if (!loadPromise) {
|
||||
loadPromise = loadInto(fullPath, childrenEl);
|
||||
}
|
||||
return loadPromise;
|
||||
}
|
||||
|
||||
function collapse() {
|
||||
if (kind !== 'folder' || !isOpen) return;
|
||||
if (childrenEl && childrenEl.parentNode) {
|
||||
childrenEl.parentNode.removeChild(childrenEl);
|
||||
}
|
||||
chevron.textContent = '\u25b8';
|
||||
isOpen = false;
|
||||
}
|
||||
|
||||
chevron.addEventListener('click', function (e) {
|
||||
e.stopPropagation();
|
||||
if (isOpen) collapse(); else expand();
|
||||
});
|
||||
|
||||
row.addEventListener('click', function () {
|
||||
if (isSelectable(kind)) {
|
||||
setSelected(fullPath, kind, row);
|
||||
} else if (kind === 'folder') {
|
||||
if (isOpen) collapse(); else expand();
|
||||
}
|
||||
});
|
||||
|
||||
if (kind === 'folder') {
|
||||
row.addEventListener('dblclick', function (e) {
|
||||
e.preventDefault();
|
||||
if (isOpen) collapse(); else expand();
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
rowEl: row,
|
||||
name: name,
|
||||
kind: kind,
|
||||
childrenEl: function () { return childrenEl; },
|
||||
expand: expand
|
||||
};
|
||||
}
|
||||
|
||||
// loadInto fetches folderPath and populates `target` with rows. Returns
|
||||
// an array of the row objects on success, or [] on failure.
|
||||
function loadInto(folderPath, target) {
|
||||
target.textContent = '';
|
||||
var loading = document.createElement('div');
|
||||
loading.className = 'tree-row is-disabled';
|
||||
loading.textContent = '\u2026';
|
||||
target.appendChild(loading);
|
||||
|
||||
return fetchFolder(folderPath).then(function (resp) {
|
||||
target.textContent = '';
|
||||
var rows = [];
|
||||
(resp.entries || []).forEach(function (e) {
|
||||
if (hideFiles && e.kind !== 'folder') return;
|
||||
var r = buildRow(resp.path, e.name, e.kind);
|
||||
target.appendChild(r.rowEl);
|
||||
rows.push(r);
|
||||
});
|
||||
if (rows.length === 0) {
|
||||
var empty = document.createElement('div');
|
||||
empty.className = 'tree-row is-disabled';
|
||||
empty.textContent = '(empty)';
|
||||
target.appendChild(empty);
|
||||
}
|
||||
return rows;
|
||||
}).catch(function () {
|
||||
target.textContent = '';
|
||||
var err = document.createElement('div');
|
||||
err.className = 'tree-row';
|
||||
err.textContent = '(failed — tap to retry)';
|
||||
err.addEventListener('click', function () {
|
||||
loadInto(folderPath, target);
|
||||
});
|
||||
target.appendChild(err);
|
||||
return [];
|
||||
});
|
||||
}
|
||||
|
||||
// Root selection row — visible when allowRoot and mode accepts folders.
|
||||
if (allowRoot && isSelectable('folder')) {
|
||||
var rootRow = document.createElement('div');
|
||||
rootRow.className = 'tree-row';
|
||||
var rootChev = document.createElement('span');
|
||||
rootChev.className = 'tree-chevron is-leaf';
|
||||
rootRow.appendChild(rootChev);
|
||||
var rootLabel = document.createElement('span');
|
||||
rootLabel.className = 'tree-name';
|
||||
rootLabel.textContent = '/';
|
||||
rootRow.appendChild(rootLabel);
|
||||
rootRow.addEventListener('click', function () {
|
||||
setSelected('/', 'folder', rootRow);
|
||||
});
|
||||
treeEl.appendChild(rootRow);
|
||||
}
|
||||
|
||||
var rootChildren = document.createElement('div');
|
||||
treeEl.appendChild(rootChildren);
|
||||
|
||||
// Expand the ancestor chain of initialPath so the user lands in
|
||||
// context. For root, just load root children.
|
||||
var segs = (initialPath || '/').split('/').filter(Boolean);
|
||||
var preselectSegs = preselect
|
||||
? preselect.split('/').filter(Boolean)
|
||||
: null;
|
||||
|
||||
loadInto('/', rootChildren).then(function (rows) {
|
||||
return expandChain(rows, segs, '/');
|
||||
}).then(function () {
|
||||
if (preselectSegs === null) return;
|
||||
if (preselectSegs.length === 0) {
|
||||
// Preselect root itself (if allowed).
|
||||
if (allowRoot && isSelectable('folder')) {
|
||||
var root = treeEl.querySelector('.tree-row');
|
||||
if (root) setSelected('/', 'folder', root);
|
||||
}
|
||||
return;
|
||||
}
|
||||
selectByPath(preselectSegs);
|
||||
});
|
||||
|
||||
// expandChain walks `segments`, looking up each by name in the current
|
||||
// row list, expanding it, and recursing into its children.
|
||||
function expandChain(rows, segments, basePath) {
|
||||
if (segments.length === 0) return Promise.resolve();
|
||||
var seg = segments[0];
|
||||
var match = null;
|
||||
for (var i = 0; i < rows.length; i++) {
|
||||
if (rows[i].kind === 'folder' && rows[i].name === seg) {
|
||||
match = rows[i];
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!match) return Promise.resolve();
|
||||
return match.expand().then(function (childRows) {
|
||||
return expandChain(childRows || [], segments.slice(1), joinPath(basePath, seg));
|
||||
});
|
||||
}
|
||||
|
||||
// selectByPath walks the visible tree rows to locate the row matching
|
||||
// `segments` and marks it selected. Assumes its ancestors are already
|
||||
// expanded (expandChain ran first).
|
||||
function selectByPath(segments) {
|
||||
var container = rootChildren;
|
||||
var path = '/';
|
||||
for (var i = 0; i < segments.length; i++) {
|
||||
var seg = segments[i];
|
||||
var kids = container.children;
|
||||
var found = null;
|
||||
for (var j = 0; j < kids.length; j++) {
|
||||
var row = kids[j];
|
||||
if (!row.classList || !row.classList.contains('tree-row')) continue;
|
||||
var nm = row.querySelector('.tree-name');
|
||||
if (nm && nm.textContent === seg) { found = row; break; }
|
||||
}
|
||||
if (!found) return;
|
||||
path = joinPath(path, seg);
|
||||
if (i === segments.length - 1) {
|
||||
if (isSelectable('folder')) setSelected(path, 'folder', found);
|
||||
try { found.scrollIntoView({ block: 'nearest' }); } catch (e) {}
|
||||
return;
|
||||
}
|
||||
var next = found.nextSibling;
|
||||
if (!next || !next.classList || !next.classList.contains('tree-children')) return;
|
||||
container = next;
|
||||
}
|
||||
}
|
||||
|
||||
return handle;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,215 +0,0 @@
|
||||
;(function () {
|
||||
// This script owns the .tree-scroll child of the aside, not the aside itself.
|
||||
var container = document.querySelector("aside.tree-sidebar .tree-scroll")
|
||||
if (!container) return
|
||||
|
||||
function joinPath(parent, name) {
|
||||
if (parent === "/" || parent === "") return "/" + name
|
||||
return parent.replace(/\/+$/, "") + "/" + name
|
||||
}
|
||||
|
||||
function encodeSegments(p) {
|
||||
return p.replace(/^\/+/, "").split("/").map(encodeURIComponent).join("/")
|
||||
}
|
||||
|
||||
function folderHref(p) {
|
||||
if (p === "/" || p === "") return "/"
|
||||
return "/" + encodeSegments(p) + "/"
|
||||
}
|
||||
|
||||
function fetchFolder(path, expandTo) {
|
||||
var url = folderHref(path) + "?tree=1&foldersOnly=1"
|
||||
if (expandTo) url += "&expandTo=" + encodeURIComponent(expandTo)
|
||||
return fetch(url, { credentials: "same-origin" }).then(function (r) {
|
||||
if (!r.ok) throw new Error("HTTP " + r.status)
|
||||
return r.json()
|
||||
})
|
||||
}
|
||||
|
||||
// Current page as a canonical, DECODED wiki path ("/" for root, no trailing
|
||||
// slash otherwise). pathname is percent-encoded; the tree's entry names (and
|
||||
// thus fullPath) are decoded, so we must decode here too or paths containing
|
||||
// spaces / non-ASCII never match a row and the chain never expands.
|
||||
var activePath = (function () {
|
||||
var p = window.location.pathname.replace(/\/+$/, "")
|
||||
if (p === "") return "/"
|
||||
try {
|
||||
return decodeURIComponent(p)
|
||||
} catch (e) {
|
||||
return p
|
||||
}
|
||||
})()
|
||||
var activeRow = null
|
||||
|
||||
function disabledRow(text) {
|
||||
var row = document.createElement("div")
|
||||
row.className = "tree-row is-disabled"
|
||||
row.textContent = text
|
||||
return row
|
||||
}
|
||||
|
||||
// renderInto fills containerEl with rows for `entries` (children of
|
||||
// parentPath). Folders carrying a `children` array (the pre-expanded
|
||||
// ancestor chain from ?expandTo) are opened immediately and recurse.
|
||||
function renderInto(containerEl, parentPath, entries) {
|
||||
entries.forEach(function (entry) {
|
||||
var built = buildRow(parentPath, entry)
|
||||
containerEl.appendChild(built.row)
|
||||
if (built.preExpand) built.open()
|
||||
})
|
||||
}
|
||||
|
||||
function buildRow(parentPath, entry) {
|
||||
var fullPath = joinPath(parentPath, entry.name)
|
||||
var row = document.createElement("div")
|
||||
row.className = "tree-row"
|
||||
|
||||
var chevron = document.createElement("span")
|
||||
chevron.className = "tree-chevron"
|
||||
chevron.textContent = "▸"
|
||||
row.appendChild(chevron)
|
||||
|
||||
var link = document.createElement("a")
|
||||
link.className = "tree-name tree-folder"
|
||||
link.textContent = entry.name
|
||||
link.href = folderHref(fullPath)
|
||||
row.appendChild(link)
|
||||
|
||||
if (fullPath === activePath) {
|
||||
row.classList.add("is-active")
|
||||
activeRow = row
|
||||
}
|
||||
|
||||
var preChildren = entry.children ? entry.children : null
|
||||
var childrenEl = null
|
||||
var loaded = false
|
||||
var isOpen = false
|
||||
var isLeaf = false
|
||||
|
||||
function ensureChildrenEl() {
|
||||
if (!childrenEl) {
|
||||
childrenEl = document.createElement("div")
|
||||
childrenEl.className = "tree-children"
|
||||
}
|
||||
return childrenEl
|
||||
}
|
||||
|
||||
function markLeaf() {
|
||||
isLeaf = true
|
||||
isOpen = false
|
||||
chevron.textContent = "▸"
|
||||
chevron.classList.add("is-leaf")
|
||||
if (childrenEl && childrenEl.parentNode) {
|
||||
childrenEl.parentNode.removeChild(childrenEl)
|
||||
}
|
||||
}
|
||||
|
||||
function loadChildren() {
|
||||
var el = ensureChildrenEl()
|
||||
el.textContent = ""
|
||||
el.appendChild(disabledRow("…"))
|
||||
fetchFolder(fullPath)
|
||||
.then(function (resp) {
|
||||
el.textContent = ""
|
||||
loaded = true
|
||||
if (!resp.entries || resp.entries.length === 0) {
|
||||
markLeaf()
|
||||
return
|
||||
}
|
||||
renderInto(el, fullPath, resp.entries)
|
||||
})
|
||||
.catch(function () {
|
||||
el.textContent = ""
|
||||
var err = document.createElement("div")
|
||||
err.className = "tree-row"
|
||||
err.textContent = "(failed — tap to retry)"
|
||||
err.addEventListener("click", function (e) {
|
||||
e.stopPropagation()
|
||||
loadChildren()
|
||||
})
|
||||
el.appendChild(err)
|
||||
})
|
||||
}
|
||||
|
||||
function open() {
|
||||
if (isOpen || isLeaf) return
|
||||
var el = ensureChildrenEl()
|
||||
row.parentNode.insertBefore(el, row.nextSibling)
|
||||
chevron.textContent = "▾" // ▾
|
||||
isOpen = true
|
||||
if (!loaded) {
|
||||
if (preChildren) {
|
||||
renderInto(el, fullPath, preChildren)
|
||||
loaded = true
|
||||
} else {
|
||||
loadChildren()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function close() {
|
||||
if (!isOpen) return
|
||||
if (childrenEl && childrenEl.parentNode) {
|
||||
childrenEl.parentNode.removeChild(childrenEl)
|
||||
}
|
||||
chevron.textContent = "▸"
|
||||
isOpen = false
|
||||
}
|
||||
|
||||
// Clicking the name link navigates (folder) or opens the file; clicking
|
||||
// anywhere else on the row toggles expansion for folders.
|
||||
row.addEventListener("click", function (e) {
|
||||
if (e.target.closest("a.tree-name")) return
|
||||
if (isOpen) close()
|
||||
else open()
|
||||
})
|
||||
|
||||
return { row: row, open: open, preExpand: !!preChildren }
|
||||
}
|
||||
|
||||
function render() {
|
||||
container.textContent = ""
|
||||
var listing = document.createElement("div")
|
||||
container.appendChild(listing)
|
||||
listing.appendChild(disabledRow("…"))
|
||||
|
||||
fetchFolder("/", activePath === "/" ? "" : activePath)
|
||||
.then(function (resp) {
|
||||
listing.textContent = ""
|
||||
renderInto(listing, "/", resp.entries)
|
||||
if (activeRow) {
|
||||
try {
|
||||
activeRow.scrollIntoView({ block: "nearest" })
|
||||
} catch (e) {}
|
||||
}
|
||||
})
|
||||
.catch(function () {
|
||||
listing.textContent = ""
|
||||
var err = document.createElement("div")
|
||||
err.className = "tree-row"
|
||||
err.textContent = "(failed — tap to retry)"
|
||||
err.addEventListener("click", function () {
|
||||
render()
|
||||
})
|
||||
listing.appendChild(err)
|
||||
})
|
||||
}
|
||||
|
||||
function setupMenuItem() {
|
||||
var specials = document.querySelector(".actions-special")
|
||||
if (!specials) return
|
||||
|
||||
var item = document.createElement("button")
|
||||
item.type = "button"
|
||||
item.className = "btn btn-block"
|
||||
item.title = "Folders"
|
||||
item.textContent = "FOLDERS"
|
||||
item.addEventListener("click", function () {
|
||||
if (typeof openOverlay === "function") openOverlay(container)
|
||||
})
|
||||
specials.appendChild(item)
|
||||
}
|
||||
|
||||
render()
|
||||
setupMenuItem()
|
||||
})()
|
||||
@@ -0,0 +1,18 @@
|
||||
{{/* 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}}
|
||||
@@ -0,0 +1,677 @@
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,283 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
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())
|
||||
}
|
||||
@@ -0,0 +1,296 @@
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
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 "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)
|
||||
}
|
||||
}
|
||||
@@ -335,7 +335,7 @@ type calendarData struct {
|
||||
Years []calYear
|
||||
}
|
||||
|
||||
var diaryCalTmpl = template.Must(template.ParseFS(assets, "assets/diary/calendar.html"))
|
||||
var diaryCalTmpl = template.Must(template.New("calendar.html").Funcs(assetFuncs).ParseFS(assets, "assets/diary/calendar.html"))
|
||||
|
||||
func computeCalendarWidget(diaryRootFS, diaryRootURL, fsPath string, depth int) template.HTML {
|
||||
today := time.Now()
|
||||
@@ -533,6 +533,8 @@ type diarySection struct {
|
||||
EditURL string // year-file section edit URL ("" = no edit button)
|
||||
Body template.HTML // rendered markdown body (excludes the heading line)
|
||||
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 {
|
||||
@@ -623,6 +625,7 @@ func renderDiaryYear(yearFS, yearURL string) template.HTML {
|
||||
|
||||
out := buildFileSections(sections, photos, yearURL)
|
||||
out = appendVirtualEntries(out, sections, photos, year, yearURL)
|
||||
attachCalendarLines(out, year)
|
||||
return renderDiaryContent(out)
|
||||
}
|
||||
|
||||
@@ -746,6 +749,28 @@ func appendVirtualEntries(existing []diarySection, sections [][]byte, photos []d
|
||||
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.
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
node_modules/
|
||||
.tmp/
|
||||
test-results/
|
||||
playwright-report/
|
||||
@@ -0,0 +1,58 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"name": "datascape-e2e",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"test": "playwright test"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
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",
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,27 @@
|
||||
// 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());
|
||||
@@ -0,0 +1,155 @@
|
||||
// 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);
|
||||
});
|
||||
@@ -0,0 +1,392 @@
|
||||
// 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`);
|
||||
});
|
||||
@@ -92,7 +92,7 @@ func (r *embedRenderer) render(w util.BufWriter, source []byte, node ast.Node, e
|
||||
w.WriteString(strconv.Itoa(embedThumbW))
|
||||
w.WriteString(`" alt="`)
|
||||
w.Write(util.EscapeHTML([]byte(name)))
|
||||
w.WriteString(`"></a>`)
|
||||
w.WriteString(`" loading="lazy"></a>`)
|
||||
if captioned {
|
||||
w.WriteString(`<figcaption>`)
|
||||
}
|
||||
|
||||
@@ -2,14 +2,12 @@ package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"log"
|
||||
"math"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"net/url"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -20,10 +18,6 @@ func init() {
|
||||
pageTypeHandlers = append(pageTypeHandlers, &fitnessHandler{})
|
||||
}
|
||||
|
||||
// waistlineExportFile is the exact filename the user copies into the folder.
|
||||
// The single file is always the latest export — no glob, no multi-file merge.
|
||||
const waistlineExportFile = "waistline_export.json"
|
||||
|
||||
type fitnessHandler struct{}
|
||||
|
||||
// redirect: the fitness dashboard has no virtual URLs; everything renders
|
||||
@@ -34,16 +28,15 @@ func (f *fitnessHandler) redirect(root, fsPath, urlPath string, r *http.Request)
|
||||
|
||||
// handle renders the dashboard for folders whose .page-settings declares
|
||||
// type = fitness. Markdown content and the folder listing stay visible so
|
||||
// the user can verify an uploaded export arrived.
|
||||
// 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
|
||||
}
|
||||
weightSel := validFitnessRange(r.URL.Query().Get("weight"), "3m")
|
||||
weeklySel := validFitnessRange(r.URL.Query().Get("weekly"), "1y")
|
||||
q := r.URL.Query()
|
||||
return &specialPage{
|
||||
Content: renderFitnessDashboard(fsPath, weightSel, weeklySel),
|
||||
Content: renderFitnessDashboard(fsPath, chartViewFrom(q, "weight", "3m"), chartViewFrom(q, "weekly", "1y")),
|
||||
SuppressTOC: true,
|
||||
}
|
||||
}
|
||||
@@ -72,6 +65,20 @@ func validFitnessRange(v, fallback string) string {
|
||||
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",
|
||||
}
|
||||
}
|
||||
|
||||
func rangeMonths(v string) int {
|
||||
for _, r := range fitnessRanges {
|
||||
if r.Value == v {
|
||||
@@ -81,95 +88,6 @@ func rangeMonths(v string) int {
|
||||
return 0
|
||||
}
|
||||
|
||||
// === Waistline export parsing ===
|
||||
|
||||
// wlNum is a number in a Waistline export: values appear as JSON numbers,
|
||||
// numeric strings, null, or are absent. Unparsable values read as not-ok
|
||||
// instead of failing the whole export parse.
|
||||
type wlNum struct {
|
||||
val float64
|
||||
ok bool
|
||||
}
|
||||
|
||||
func (n *wlNum) UnmarshalJSON(b []byte) error {
|
||||
s := strings.Trim(string(b), `"`)
|
||||
if s == "" || s == "null" {
|
||||
return nil
|
||||
}
|
||||
v, err := strconv.ParseFloat(s, 64)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
n.val = v
|
||||
n.ok = true
|
||||
return nil
|
||||
}
|
||||
|
||||
// Calorie tracking was removed pending a rethink of the (undocumented)
|
||||
// per-item formula; only the weight series is read from the export.
|
||||
type wlExport struct {
|
||||
Diary []wlDiaryEntry `json:"diary"`
|
||||
Settings json.RawMessage `json:"settings"`
|
||||
}
|
||||
|
||||
type wlDiaryEntry struct {
|
||||
DateTime string `json:"dateTime"`
|
||||
Stats struct {
|
||||
Weight wlNum `json:"weight"`
|
||||
} `json:"stats"`
|
||||
}
|
||||
|
||||
// exportDate extracts the calendar day from a Waistline dateTime. The values
|
||||
// are UTC-midnight timestamps; taking the first 10 characters avoids time
|
||||
// zone conversions shifting the day.
|
||||
func exportDate(s string) (time.Time, bool) {
|
||||
if len(s) < 10 {
|
||||
return time.Time{}, false
|
||||
}
|
||||
t, err := time.Parse("2006-01-02", s[:10])
|
||||
if err != nil {
|
||||
return time.Time{}, false
|
||||
}
|
||||
return t, true
|
||||
}
|
||||
|
||||
// goalValue extracts settings.goals.<key>.goal-list[0].goal[0] — the first
|
||||
// weekday slot of the shared goal. Best-effort: any missing or unparsable
|
||||
// level means "no goal line", never an error.
|
||||
func goalValue(settings json.RawMessage, key string) (float64, bool) {
|
||||
var s struct {
|
||||
Goals map[string]json.RawMessage `json:"goals"`
|
||||
}
|
||||
if json.Unmarshal(settings, &s) != nil {
|
||||
return 0, false
|
||||
}
|
||||
var g struct {
|
||||
GoalList []struct {
|
||||
Goal []wlNum `json:"goal"`
|
||||
} `json:"goal-list"`
|
||||
}
|
||||
if json.Unmarshal(s.Goals[key], &g) != nil {
|
||||
return 0, false
|
||||
}
|
||||
if len(g.GoalList) == 0 || len(g.GoalList[0].Goal) == 0 {
|
||||
return 0, false
|
||||
}
|
||||
n := g.GoalList[0].Goal[0]
|
||||
return n.val, n.ok
|
||||
}
|
||||
|
||||
func exportWeightUnit(settings json.RawMessage) string {
|
||||
var s struct {
|
||||
Units struct {
|
||||
Weight string `json:"weight"`
|
||||
} `json:"units"`
|
||||
}
|
||||
if json.Unmarshal(settings, &s) == nil && s.Units.Weight != "" {
|
||||
return s.Units.Weight
|
||||
}
|
||||
return "kg"
|
||||
}
|
||||
|
||||
// === Series extraction ===
|
||||
|
||||
type weightPoint struct {
|
||||
@@ -178,26 +96,34 @@ type weightPoint struct {
|
||||
days int // >0: weekly mean over this many measured days
|
||||
}
|
||||
|
||||
// extractWeights computes the weight series from the export: one point per
|
||||
// diary entry with stats.weight. Duplicate dates (shouldn't happen) — last
|
||||
// one wins.
|
||||
func extractWeights(ex *wlExport) []weightPoint {
|
||||
byDate := map[time.Time]float64{}
|
||||
for _, e := range ex.Diary {
|
||||
day, ok := exportDate(e.DateTime)
|
||||
if !ok || !e.Stats.Weight.ok {
|
||||
// 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
|
||||
}
|
||||
byDate[day] = e.Stats.Weight.val
|
||||
}
|
||||
weights := make([]weightPoint, 0, len(byDate))
|
||||
for d, v := range byDate {
|
||||
weights = append(weights, weightPoint{date: d, value: v})
|
||||
}
|
||||
sort.Slice(weights, func(i, j int) bool { return weights[i].date.Before(weights[j].date) })
|
||||
return weights
|
||||
}
|
||||
|
||||
// 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))
|
||||
}
|
||||
@@ -280,8 +206,9 @@ type fitnessTickVM struct {
|
||||
Pos, Label, Anchor string
|
||||
}
|
||||
|
||||
// fitnessDotVM is one data point; Date and Value feed the hover tooltip.
|
||||
type fitnessDotVM struct {
|
||||
X, Y, Title string
|
||||
X, Y, Date, Value string
|
||||
}
|
||||
|
||||
type fitnessGoalVM struct {
|
||||
@@ -289,10 +216,11 @@ type fitnessGoalVM struct {
|
||||
}
|
||||
|
||||
type fitnessChartVM struct {
|
||||
Title string
|
||||
Param string
|
||||
Options []fitnessOptVM
|
||||
Empty bool
|
||||
Title string
|
||||
Param string
|
||||
Options []fitnessOptVM
|
||||
Absolute bool
|
||||
Empty bool
|
||||
|
||||
ViewW, ViewH string
|
||||
PlotX, PlotY, PlotR, PlotB string
|
||||
@@ -301,6 +229,7 @@ type fitnessChartVM struct {
|
||||
Goal *fitnessGoalVM
|
||||
Lines []string // polyline points attributes
|
||||
Dots []fitnessDotVM
|
||||
Dense bool // too many points to draw markers
|
||||
}
|
||||
|
||||
type fitnessDashVM struct {
|
||||
@@ -308,13 +237,13 @@ type fitnessDashVM struct {
|
||||
Charts []fitnessChartVM
|
||||
}
|
||||
|
||||
func newChartVM(title, param, sel string) 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 == sel}
|
||||
opts[i] = fitnessOptVM{r.Value, r.Label, r.Value == view.Range}
|
||||
}
|
||||
return fitnessChartVM{
|
||||
Title: title, Param: param, Options: opts,
|
||||
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),
|
||||
@@ -323,7 +252,7 @@ func newChartVM(title, param, sel string) fitnessChartVM {
|
||||
}
|
||||
|
||||
// yTickVMs places gridlines at fixed multiples of step across the y domain
|
||||
// (always 5 kg guides on weight charts, regardless of range).
|
||||
// (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 {
|
||||
@@ -346,12 +275,12 @@ func (vm *fitnessChartVM) setGoal(sc chartScale, goal float64, label string) {
|
||||
// buildWeightChart renders a weight line chart. It serves both the per-day
|
||||
// series and the weekly-mean series (points carrying days > 0); the line is
|
||||
// drawn continuous across days/weeks without a measurement.
|
||||
func buildWeightChart(all []weightPoint, goal float64, hasGoal bool, sel, param, title, unit string, today time.Time) fitnessChartVM {
|
||||
vm := newChartVM(title, param, sel)
|
||||
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(sel); m > 0 {
|
||||
if m := rangeMonths(view.Range); m > 0 {
|
||||
x0 = today.AddDate(0, -m, 0)
|
||||
points = filterWeights(all, x0)
|
||||
if len(points) == 0 {
|
||||
@@ -391,21 +320,31 @@ func buildWeightChart(all []weightPoint, goal float64, hasGoal bool, sel, param,
|
||||
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, 5)
|
||||
vm.XTicks = timeXTicks(sc, 4)
|
||||
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. Point markers carry the hover
|
||||
// <title>; on dense ranges they are dropped and the bare line stays
|
||||
// legible. A single point in range renders as a dot.
|
||||
// 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 {
|
||||
label := p.date.Format("2006-01-02") + ": " + svgNum(p.value) + " " + unit
|
||||
if p.days > 0 {
|
||||
label = fmt.Sprintf("Week of %s: %s %s (%d days)",
|
||||
p.date.Format("2006-01-02"), svgNum(p.value), unit, p.days)
|
||||
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,
|
||||
}
|
||||
return fitnessDotVM{X: svgNum(sc.x(p.date)), Y: svgNum(sc.y(p.value)), Title: label}
|
||||
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
|
||||
@@ -419,11 +358,10 @@ func buildWeightChart(all []weightPoint, goal float64, hasGoal bool, sel, param,
|
||||
}
|
||||
vm.Lines = append(vm.Lines, b.String())
|
||||
}
|
||||
if len(points) <= 100 {
|
||||
for _, p := range points {
|
||||
vm.Dots = append(vm.Dots, dot(p))
|
||||
}
|
||||
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))
|
||||
@@ -441,34 +379,101 @@ func filterWeights(points []weightPoint, from time.Time) []weightPoint {
|
||||
return out
|
||||
}
|
||||
|
||||
// timeXTicks places n+1 evenly spaced date labels across a continuous time
|
||||
// axis; the last label is end-anchored so it stays inside the viewBox.
|
||||
func timeXTicks(sc chartScale, n int) []fitnessTickVM {
|
||||
span := sc.x1.Sub(sc.x0)
|
||||
var out []fitnessTickVM
|
||||
prev := ""
|
||||
for i := 0; i <= n; i++ {
|
||||
t := sc.x0.Add(time.Duration(float64(span) * float64(i) / float64(n)))
|
||||
label := t.Format("2006-01-02")
|
||||
if label == prev {
|
||||
continue
|
||||
// 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
|
||||
}
|
||||
prev = label
|
||||
}
|
||||
out := make([]fitnessTickVM, 0, len(ticks))
|
||||
for i, t := range ticks {
|
||||
x := sc.x(t)
|
||||
anchor := "middle"
|
||||
if i == n {
|
||||
switch {
|
||||
case x-chartLeft < 24:
|
||||
anchor = "start"
|
||||
case chartW-chartRight-x < 24:
|
||||
anchor = "end"
|
||||
}
|
||||
out = append(out, fitnessTickVM{Pos: svgNum(sc.x(t)), Label: label, Anchor: anchor})
|
||||
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.ParseFS(assets, "assets/fitness/main.html"))
|
||||
var fitnessTmpl = template.Must(template.New("main.html").Funcs(assetFuncs).ParseFS(assets, "assets/fitness/main.html"))
|
||||
|
||||
func renderFitnessDashboard(fsPath, weightSel, weeklySel string) template.HTML {
|
||||
data := buildFitnessDash(fsPath, weightSel, weeklySel, time.Now())
|
||||
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)
|
||||
@@ -477,28 +482,23 @@ func renderFitnessDashboard(fsPath, weightSel, weeklySel string) template.HTML {
|
||||
return template.HTML(buf.String())
|
||||
}
|
||||
|
||||
// buildFitnessDash reads and parses the export per request — no caching, no
|
||||
// indexes. A read or parse failure (including a truncated mid-upload file)
|
||||
// becomes an inline notice; the page itself always renders.
|
||||
func buildFitnessDash(fsPath, weightSel, weeklySel string, now time.Time) fitnessDashVM {
|
||||
raw, err := os.ReadFile(filepath.Join(fsPath, waistlineExportFile))
|
||||
if err != nil {
|
||||
return fitnessDashVM{Notice: "No Waistline export found — upload " + waistlineExportFile + " to this folder."}
|
||||
}
|
||||
var ex wlExport
|
||||
if err := json.Unmarshal(raw, &ex); err != nil {
|
||||
return fitnessDashVM{Notice: "Could not read " + waistlineExportFile + ": " + err.Error()}
|
||||
// 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 := extractWeights(&ex)
|
||||
wGoal, hasWGoal := goalValue(ex.Settings, "weight")
|
||||
unit := exportWeightUnit(ex.Settings)
|
||||
weights := fitnessWeights(today)
|
||||
wGoal, hasWGoal := weightGoal(fsPath)
|
||||
const unit = "kg"
|
||||
|
||||
return fitnessDashVM{Charts: []fitnessChartVM{
|
||||
buildWeightChart(weights, wGoal, hasWGoal, weightSel,
|
||||
buildWeightChart(weights, wGoal, hasWGoal, weightView,
|
||||
"weight", "Weight ("+unit+")", unit, today),
|
||||
buildWeightChart(weeklyMeanWeights(weights), wGoal, hasWGoal, weeklySel,
|
||||
buildWeightChart(weeklyMeanWeights(weights), wGoal, hasWGoal, weeklyView,
|
||||
"weekly", "Weekly average weight ("+unit+")", unit, today),
|
||||
}}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,4 +2,8 @@ module datascape
|
||||
|
||||
go 1.26.1
|
||||
|
||||
require github.com/yuin/goldmark v1.8.2
|
||||
require (
|
||||
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
|
||||
)
|
||||
|
||||
@@ -1,2 +1,6 @@
|
||||
github.com/emersion/go-ical v0.0.0-20250609112844-439c63cef608 h1:5XWaET4YAcppq3l1/Yh2ay5VmQjUdq6qhJuucdGbmOY=
|
||||
github.com/emersion/go-ical v0.0.0-20250609112844-439c63cef608/go.mod h1:BEksegNspIkjCQfmzWgsgbu6KdeJ/4LwUZs7DMBzjzw=
|
||||
github.com/teambition/rrule-go v1.8.2 h1:lIjpjvWTj9fFUZCmuoVDrKVOtdiyzbzc93qTmRVe/J8=
|
||||
github.com/teambition/rrule-go v1.8.2/go.mod h1:Ieq5AbrKGciP1V//Wq8ktsTXwSwJHDD5mD/wLBGl3p4=
|
||||
github.com/yuin/goldmark v1.8.2 h1:kEGpgqJXdgbkhcOgBxkC0X0PmoPG1ZyoZ117rDVp4zE=
|
||||
github.com/yuin/goldmark v1.8.2/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
|
||||
|
||||
@@ -2,12 +2,9 @@ package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"embed"
|
||||
"encoding/hex"
|
||||
"flag"
|
||||
"html/template"
|
||||
"io/fs"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/url"
|
||||
@@ -17,31 +14,20 @@ import (
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
_ "time/tzdata" // calendar TZIDs must resolve even without system zoneinfo
|
||||
)
|
||||
|
||||
//go:embed assets
|
||||
var assets embed.FS
|
||||
|
||||
// editorBundleVersion is a short content hash of the vendored CodeMirror bundle,
|
||||
// appended as ?v=… to its <script> src. The bundle is served immutable under a
|
||||
// stable filename, so without this query a rebuilt bundle would never reach a
|
||||
// client that already cached the old one (this is the editor cache-bust knob).
|
||||
var editorBundleVersion = hashAsset("assets/editor/vendor/codemirror.bundle.js")
|
||||
|
||||
func hashAsset(name string) string {
|
||||
b, err := assets.ReadFile(name)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
sum := sha256.Sum256(b)
|
||||
return hex.EncodeToString(sum[:])[:12]
|
||||
}
|
||||
|
||||
// tmplFuncs is shared by every layout-based template: the edit template appends
|
||||
// editorBundleVersion to its CodeMirror <script> src to cache-bust the bundle.
|
||||
// tmplFuncs is shared by every layout-based template: asset links versioned
|
||||
// static files (see static.go); the layout header renders the maintenance
|
||||
// widget.
|
||||
var tmplFuncs = template.FuncMap{
|
||||
"editorBundleVersion": func() string { return editorBundleVersion },
|
||||
"fileIcon": fileIcon,
|
||||
"asset": assetURL,
|
||||
"fileIcon": fileIcon,
|
||||
"icon": readIcon,
|
||||
"maintenanceWidget": maintenanceWidget,
|
||||
}
|
||||
|
||||
var (
|
||||
@@ -88,8 +74,18 @@ func main() {
|
||||
user := flag.String("user", "", "basic auth username (empty = no auth)")
|
||||
pass := flag.String("pass", "", "basic auth password")
|
||||
reindexInterval := flag.Duration("reindex-interval", 30*time.Minute, "periodic search index rebuild interval (0 disables)")
|
||||
calendars := flag.String("calendars", "Calendars", "calendars folder, relative to -dir (empty disables)")
|
||||
tz := flag.String("tz", "", "time zone for calendars and the diary, e.g. Europe/Berlin (default: system)")
|
||||
flag.Parse()
|
||||
|
||||
if *tz != "" {
|
||||
loc, err := time.LoadLocation(*tz)
|
||||
if err != nil {
|
||||
log.Fatalf("-tz: %v", err)
|
||||
}
|
||||
time.Local = loc
|
||||
}
|
||||
|
||||
root, err := filepath.Abs(*wikiDir)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
@@ -108,20 +104,22 @@ func main() {
|
||||
|
||||
initMarkdown()
|
||||
|
||||
if *calendars != "" {
|
||||
calendarsDir = filepath.Join(root, filepath.FromSlash(*calendars))
|
||||
rel, err := filepath.Rel(root, calendarsDir)
|
||||
if err != nil || rel == "." || strings.HasPrefix(rel, "..") {
|
||||
log.Fatalf("-calendars must be a folder inside -dir")
|
||||
}
|
||||
calendarsURL = "/" + filepath.ToSlash(rel) + "/"
|
||||
}
|
||||
|
||||
authKey, err := loadOrCreateAuthKey(root)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
h := &handler{root: root, user: *user, pass: *pass, authKey: authKey}
|
||||
|
||||
staticFS, _ := fs.Sub(assets, "assets")
|
||||
static := http.StripPrefix("/_/", http.FileServer(http.FS(staticFS)))
|
||||
http.Handle("/_/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if strings.HasPrefix(r.URL.Path, "/_/fonts/") || strings.HasPrefix(r.URL.Path, "/_/editor/vendor/") {
|
||||
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
|
||||
}
|
||||
static.ServeHTTP(w, r)
|
||||
}))
|
||||
http.Handle("/_/", serveStatic())
|
||||
http.HandleFunc("/_logout", h.handleLogout)
|
||||
http.HandleFunc("/_reindex", h.handleReindex)
|
||||
http.HandleFunc("/_search", h.handleSearchSuggest)
|
||||
@@ -158,6 +156,10 @@ func main() {
|
||||
}(*reindexInterval)
|
||||
}
|
||||
|
||||
if calendarsDir != "" {
|
||||
startCalendarSync()
|
||||
}
|
||||
|
||||
log.Printf("datascape listening on %s, wiki at %s", *addr, root)
|
||||
log.Fatal(http.ListenAndServe(*addr, nil))
|
||||
}
|
||||
@@ -173,14 +175,21 @@ type reqStartKeyT struct{}
|
||||
|
||||
var reqStartKey = reqStartKeyT{}
|
||||
|
||||
// elapsedMS returns the milliseconds since the request entered ServeHTTP.
|
||||
func elapsedMS(r *http.Request) int64 {
|
||||
// requestStart returns when the request entered ServeHTTP, or now if unknown.
|
||||
func requestStart(r *http.Request) time.Time {
|
||||
if start, ok := r.Context().Value(reqStartKey).(time.Time); ok {
|
||||
return time.Since(start).Milliseconds()
|
||||
return start
|
||||
}
|
||||
return 0
|
||||
return time.Now()
|
||||
}
|
||||
|
||||
// renderTimer is embedded in template data. The footer calls RenderMS while
|
||||
// the template executes, so the time includes the page's own rendering (and
|
||||
// the header's maintenance widget), not just the work before it.
|
||||
type renderTimer struct{ start time.Time }
|
||||
|
||||
func (t renderTimer) RenderMS() int64 { return time.Since(t.start).Milliseconds() }
|
||||
|
||||
func (h *handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
r = r.WithContext(context.WithValue(r.Context(), reqStartKey, time.Now()))
|
||||
|
||||
@@ -188,6 +197,14 @@ func (h *handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// Dotfiles are never served: .page-settings can hold the CalDAV
|
||||
// password and .auth-key signs sessions. Nothing links to them (listing,
|
||||
// tree and search hide them already).
|
||||
if hasDotSegment(r.URL.Path) {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
if strings.HasPrefix(r.URL.Path, thumbURLPrefix+"/") {
|
||||
h.handleThumb(w, r)
|
||||
return
|
||||
@@ -255,6 +272,17 @@ func (h *handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
// hasDotSegment reports whether any segment of the cleaned URL path starts
|
||||
// with a dot.
|
||||
func hasDotSegment(p string) bool {
|
||||
for _, seg := range strings.Split(path.Clean("/"+p), "/") {
|
||||
if strings.HasPrefix(seg, ".") {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// slashedURL returns u's path with a trailing slash appended and the query
|
||||
// string intact — the canonical page form that relative destinations resolve
|
||||
// against.
|
||||
@@ -273,6 +301,10 @@ func (h *handler) serveDir(w http.ResponseWriter, r *http.Request, urlPath, fsPa
|
||||
h.handlePost(w, r, urlPath, fsPath)
|
||||
return
|
||||
}
|
||||
if r.URL.Query().Has("dialog") {
|
||||
h.serveDialog(w, r, urlPath, fsPath)
|
||||
return
|
||||
}
|
||||
|
||||
for _, ph := range pageTypeHandlers {
|
||||
if target, ok := ph.redirect(h.root, fsPath, urlPath, r); ok {
|
||||
@@ -374,20 +406,21 @@ func (h *handler) serveDir(w http.ResponseWriter, r *http.Request, urlPath, fsPa
|
||||
Content: rendered,
|
||||
Entries: entries,
|
||||
View: view,
|
||||
Sort: sortKey,
|
||||
Order: order,
|
||||
SpecialContent: specialContent,
|
||||
SidebarWidget: sidebarWidget,
|
||||
SuppressTOC: suppressTOC,
|
||||
}
|
||||
if !editMode {
|
||||
data.Tree = sidebarTree(h.root, urlPath)
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
t := pageTmpl
|
||||
if editMode {
|
||||
t = editTmpl
|
||||
}
|
||||
data.RenderMS = elapsedMS(r)
|
||||
if err := t.ExecuteTemplate(w, "layout", data); err != nil {
|
||||
data.renderTimer = renderTimer{requestStart(r)}
|
||||
if err := t.ExecuteTemplate(w, layoutTemplate(w, r, editMode), data); err != nil {
|
||||
log.Printf("template error: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -399,7 +432,18 @@ func (h *handler) handlePost(w http.ResponseWriter, r *http.Request, urlPath, fs
|
||||
return
|
||||
}
|
||||
if _, ok := query["move"]; ok {
|
||||
h.handleMove(w, r, urlPath, fsPath, query.Get("move"), query.Has("merge"))
|
||||
// The destination comes either whole (?move=/dst, or the merge
|
||||
// dialog's move field) or as the move dialog's parent + name.
|
||||
// Body fields take precedence over the bare ?move query key.
|
||||
if err := r.ParseForm(); err != nil {
|
||||
http.Error(w, "bad request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
dst := r.FormValue("move")
|
||||
if name := strings.TrimSpace(r.FormValue("name")); dst == "" && name != "" {
|
||||
dst = strings.TrimRight(r.FormValue("parent"), "/") + "/" + name
|
||||
}
|
||||
h.handleMove(w, r, urlPath, fsPath, dst, r.FormValue("merge") != "")
|
||||
return
|
||||
}
|
||||
if query.Has("toggle") {
|
||||
@@ -414,6 +458,10 @@ func (h *handler) handlePost(w http.ResponseWriter, r *http.Request, urlPath, fs
|
||||
h.handleSettings(w, r, urlPath, fsPath)
|
||||
return
|
||||
}
|
||||
if query.Has("calsync") {
|
||||
h.handleCalendarSync(w, r, urlPath, fsPath)
|
||||
return
|
||||
}
|
||||
|
||||
if err := r.ParseForm(); err != nil {
|
||||
http.Error(w, "bad request", http.StatusBadRequest)
|
||||
@@ -525,12 +573,28 @@ func (h *handler) handlePost(w http.ResponseWriter, r *http.Request, urlPath, fs
|
||||
// Returns nil if the file does not exist.
|
||||
// Format: one "key = value" pair per line; lines starting with # are comments.
|
||||
func readPageSettings(dir string) *pageSettings {
|
||||
kv := readSettingsFile(dir)
|
||||
if kv == nil {
|
||||
return nil
|
||||
}
|
||||
// Unknown or missing values fall back to the defaults.
|
||||
return &pageSettings{
|
||||
Type: kv["type"],
|
||||
View: validateView(kv["view"]),
|
||||
Sort: validateSort(kv["sort"]),
|
||||
Order: validateOrder(kv["order"]),
|
||||
}
|
||||
}
|
||||
|
||||
// readSettingsFile returns every key of dir's .page-settings (last one wins),
|
||||
// or nil if the file does not exist. Other features (e.g. the calendars
|
||||
// folder) keep their own keys in the same file.
|
||||
func readSettingsFile(dir string) map[string]string {
|
||||
data, err := os.ReadFile(filepath.Join(dir, ".page-settings"))
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
// Defaults; overridden only by valid values present in the file.
|
||||
s := &pageSettings{View: viewList, Sort: sortName, Order: orderAsc}
|
||||
kv := map[string]string{}
|
||||
for _, line := range strings.Split(string(data), "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" || strings.HasPrefix(line, "#") {
|
||||
@@ -540,17 +604,7 @@ func readPageSettings(dir string) *pageSettings {
|
||||
if len(parts) != 2 {
|
||||
continue
|
||||
}
|
||||
value := strings.TrimSpace(parts[1])
|
||||
switch strings.TrimSpace(parts[0]) {
|
||||
case "type":
|
||||
s.Type = value
|
||||
case "view":
|
||||
s.View = validateView(value)
|
||||
case "sort":
|
||||
s.Sort = validateSort(value)
|
||||
case "order":
|
||||
s.Order = validateOrder(value)
|
||||
}
|
||||
kv[strings.TrimSpace(parts[0])] = strings.TrimSpace(parts[1])
|
||||
}
|
||||
return s
|
||||
return kv
|
||||
}
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"html/template"
|
||||
"log"
|
||||
"sort"
|
||||
"time"
|
||||
)
|
||||
|
||||
// calendarsURL is the page URL of calendarsDir ("/Calendars/").
|
||||
var calendarsURL string
|
||||
|
||||
// maintenanceHorizon is how many days after today the header widget lists.
|
||||
const maintenanceHorizon = 7
|
||||
|
||||
type maintItem struct {
|
||||
When string // "03:00" today; "Sa 27.09." or "Sa 27.09. 03:00" later
|
||||
Summary string
|
||||
}
|
||||
|
||||
type maintWidgetData struct {
|
||||
Count int // occurrences today (the badge)
|
||||
Today []maintItem
|
||||
Upcoming []maintItem
|
||||
PageURL string
|
||||
}
|
||||
|
||||
var maintWidgetTmpl = template.Must(template.New("widget.html").
|
||||
Funcs(template.FuncMap{"icon": readIcon}).
|
||||
ParseFS(assets, "assets/calendar/widget.html"))
|
||||
|
||||
// maintenanceWidget renders the header chip and its dropdown. It is a
|
||||
// template func so layout.html can call it without every page's data struct
|
||||
// carrying it. Empty when no maintenance calendar is configured.
|
||||
func maintenanceWidget() template.HTML {
|
||||
if calendarsDir == "" {
|
||||
return ""
|
||||
}
|
||||
names := readCalendarSettings(calendarsDir).Maintenance
|
||||
if len(names) == 0 {
|
||||
return ""
|
||||
}
|
||||
now := time.Now()
|
||||
today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.Local)
|
||||
tomorrow := today.AddDate(0, 0, 1)
|
||||
|
||||
data := maintWidgetData{PageURL: calendarsURL}
|
||||
for _, ev := range calendarOccurrences(calendarsDir, names, today, today.AddDate(0, 0, maintenanceHorizon+1)) {
|
||||
// Anything that started before tomorrow is due today, including
|
||||
// multi-day events still running.
|
||||
if ev.Start.Before(tomorrow) {
|
||||
item := maintItem{Summary: ev.Summary}
|
||||
if !ev.AllDay && !ev.Start.Before(today) {
|
||||
item.When = ev.Start.Format("15:04")
|
||||
}
|
||||
data.Today = append(data.Today, item)
|
||||
continue
|
||||
}
|
||||
when := calDayLabel(ev.Start)
|
||||
if !ev.AllDay {
|
||||
when += " " + ev.Start.Format("15:04")
|
||||
}
|
||||
data.Upcoming = append(data.Upcoming, maintItem{When: when, Summary: ev.Summary})
|
||||
}
|
||||
data.Count = len(data.Today)
|
||||
|
||||
var buf bytes.Buffer
|
||||
if err := maintWidgetTmpl.Execute(&buf, data); err != nil {
|
||||
log.Printf("maintenance widget template: %v", err)
|
||||
return ""
|
||||
}
|
||||
return template.HTML(buf.String())
|
||||
}
|
||||
|
||||
// maintSeriesRow is one task in the calendars page's maintenance table.
|
||||
type maintSeriesRow struct {
|
||||
Summary string
|
||||
Rule string
|
||||
Last string // most recent occurrence before today
|
||||
Next string // first occurrence today or later
|
||||
next time.Time
|
||||
}
|
||||
|
||||
// maintenanceWindow bounds the table's occurrence search, in days each way
|
||||
// from today. A year covers yearly tasks.
|
||||
const maintenanceWindow = 366
|
||||
|
||||
// maintenanceSeries lists every task of the named calendars in dir: one row
|
||||
// per recurring series with its last and next date, plus upcoming single
|
||||
// events. Sorted by next date; series without a next date come last.
|
||||
func maintenanceSeries(dir string, names []string, now time.Time) []maintSeriesRow {
|
||||
today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.Local)
|
||||
evs := calendarOccurrences(dir, names, today.AddDate(0, 0, -maintenanceWindow), today.AddDate(0, 0, maintenanceWindow))
|
||||
|
||||
type key struct{ calendar, uid string }
|
||||
rows := map[key]*maintSeriesRow{}
|
||||
var order []*maintSeriesRow
|
||||
for _, ev := range evs {
|
||||
if ev.Series == nil {
|
||||
if ev.Start.Before(today) {
|
||||
continue
|
||||
}
|
||||
order = append(order, &maintSeriesRow{
|
||||
Summary: ev.Summary,
|
||||
Rule: "once",
|
||||
Next: calDayLabel(ev.Start),
|
||||
next: ev.Start,
|
||||
})
|
||||
continue
|
||||
}
|
||||
k := key{ev.Calendar, ev.Series.UID}
|
||||
row := rows[k]
|
||||
if row == nil {
|
||||
row = &maintSeriesRow{Summary: ev.Series.Summary, Rule: ev.Series.Rule}
|
||||
rows[k] = row
|
||||
order = append(order, row)
|
||||
}
|
||||
// Occurrences arrive sorted by start: keep overwriting Last, take
|
||||
// the first one from today on as Next.
|
||||
if ev.Start.Before(today) {
|
||||
row.Last = calDayLabel(ev.Start)
|
||||
} else if row.next.IsZero() {
|
||||
row.next = ev.Start
|
||||
row.Next = calDayLabel(ev.Start)
|
||||
}
|
||||
}
|
||||
|
||||
out := make([]maintSeriesRow, 0, len(order))
|
||||
for _, r := range order {
|
||||
out = append(out, *r)
|
||||
}
|
||||
sort.SliceStable(out, func(i, j int) bool {
|
||||
a, b := out[i].next, out[j].next
|
||||
if a.IsZero() != b.IsZero() {
|
||||
return b.IsZero()
|
||||
}
|
||||
if !a.Equal(b) {
|
||||
return a.Before(b)
|
||||
}
|
||||
return out[i].Summary < out[j].Summary
|
||||
})
|
||||
return out
|
||||
}
|
||||
@@ -14,8 +14,7 @@ import (
|
||||
// The one exception is a merge: when the source carries a page (index.md) and
|
||||
// the destination folder has none, the source's contents can fill the empty
|
||||
// container without clobbering anything. That merge only proceeds when merge
|
||||
// is true; otherwise the client is told a merge is available so it can ask the
|
||||
// user to confirm.
|
||||
// is true; otherwise the client is asked to confirm it.
|
||||
func (h *handler) handleMove(w http.ResponseWriter, r *http.Request, srcURL, srcFsPath, dstURL string, merge bool) {
|
||||
oldPath := normalizeMovePath(srcURL)
|
||||
if oldPath == "/" {
|
||||
@@ -51,9 +50,12 @@ func (h *handler) handleMove(w http.ResponseWriter, r *http.Request, srcURL, src
|
||||
return
|
||||
}
|
||||
if !merge {
|
||||
// Ask the client to confirm the merge. The header lets it tell
|
||||
// this apart from an ordinary conflict.
|
||||
w.Header().Set("X-Merge-Available", "1")
|
||||
// Ask the user to confirm: htmx clients get the merge dialog as
|
||||
// the next step; others a conflict they can retry with &merge=1.
|
||||
if isHX(r) {
|
||||
renderDialog(w, "merge", struct{ Dest string }{newPath})
|
||||
return
|
||||
}
|
||||
http.Error(w, "destination already exists — merge folders?", http.StatusConflict)
|
||||
return
|
||||
}
|
||||
@@ -94,7 +96,7 @@ func (h *handler) handleMove(w http.ResponseWriter, r *http.Request, srcURL, src
|
||||
folderIndexRenameSubtree(strings.TrimPrefix(oldPath, "/"), strings.TrimPrefix(newPath, "/"))
|
||||
}
|
||||
|
||||
http.Redirect(w, r, fileURL(strings.TrimPrefix(newPath, "/"))+"/", http.StatusSeeOther)
|
||||
redirectAfter(w, r, pageURL(newPath))
|
||||
}
|
||||
|
||||
// canMergeMove reports whether moving onto an existing destination should
|
||||
@@ -169,7 +171,7 @@ func (h *handler) handleDelete(w http.ResponseWriter, r *http.Request, urlPath,
|
||||
return
|
||||
}
|
||||
folderIndexRemoveSubtree(strings.TrimPrefix(normalizeMovePath(urlPath), "/"))
|
||||
http.Redirect(w, r, parentURL(urlPath), http.StatusSeeOther)
|
||||
redirectAfter(w, r, pageURL(parentURL(urlPath)))
|
||||
}
|
||||
|
||||
// normalizeMovePath returns the absolute path with any trailing slash removed,
|
||||
|
||||
@@ -23,7 +23,7 @@ func (h *handler) handleSettings(w http.ResponseWriter, r *http.Request, urlPath
|
||||
http.Error(w, "write failed: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, urlPath, http.StatusSeeOther)
|
||||
redirectAfter(w, r, pageURL(urlPath))
|
||||
}
|
||||
|
||||
// writePageSettings performs a read-modify-write of <dir>/.page-settings,
|
||||
|
||||
@@ -11,7 +11,7 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
var quickAddTmpl = template.Must(template.ParseFS(assets, "assets/quickadd.html"))
|
||||
var quickAddTmpl = template.Must(template.New("quickadd.html").Funcs(assetFuncs).ParseFS(assets, "assets/quickadd.html"))
|
||||
|
||||
type quickAddData struct {
|
||||
To, URL, Title string
|
||||
|
||||
@@ -58,12 +58,11 @@ type pageData struct {
|
||||
Content template.HTML
|
||||
Entries []entry
|
||||
View string // listing view style: "list" or "thumbnail"
|
||||
Sort string // listing sort key: "name" / "modified" / "size"
|
||||
Order string // listing sort order: "asc" / "desc"
|
||||
SpecialContent template.HTML
|
||||
SidebarWidget template.HTML
|
||||
SuppressTOC bool
|
||||
RenderMS int64
|
||||
Tree template.HTML // sidebar folder tree; empty in edit mode
|
||||
renderTimer
|
||||
}
|
||||
|
||||
// Allowed values for the listing view settings. Unknown values in the file or
|
||||
|
||||
@@ -2,6 +2,7 @@ package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"html/template"
|
||||
"io/fs"
|
||||
"log"
|
||||
"net/http"
|
||||
@@ -12,6 +13,7 @@ import (
|
||||
"sync"
|
||||
"time"
|
||||
"unicode"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
// searchSectionCap bounds how many rows each results section renders. The
|
||||
@@ -46,7 +48,8 @@ type searchPageData struct {
|
||||
PageTotal int
|
||||
FileTotal int
|
||||
IndexBuiltAt time.Time
|
||||
RenderMS int64
|
||||
Tree template.HTML // sidebar folder tree
|
||||
renderTimer
|
||||
}
|
||||
|
||||
// indexEntry is the shared scoreable core of both folder and file index
|
||||
@@ -130,10 +133,11 @@ func (h *handler) handleSearch(w http.ResponseWriter, r *http.Request) {
|
||||
PageTotal: len(pages),
|
||||
FileTotal: len(files),
|
||||
IndexBuiltAt: builtAt,
|
||||
Tree: sidebarTree(h.root, "/"),
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
data.RenderMS = elapsedMS(r)
|
||||
if err := searchTmpl.ExecuteTemplate(w, "layout", data); err != nil {
|
||||
data.renderTimer = renderTimer{requestStart(r)}
|
||||
if err := searchTmpl.ExecuteTemplate(w, layoutTemplate(w, r, false), data); err != nil {
|
||||
log.Printf("search template error: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -278,7 +282,7 @@ func scoreName(nameLower string, nameTokens []string, qLower string, qTokens []s
|
||||
if best < 20 {
|
||||
best = 20
|
||||
}
|
||||
case fuzzy && levenshtein(w, qt) <= 2:
|
||||
case fuzzy && withinTwoEdits(w, qt):
|
||||
if best < 5 {
|
||||
best = 5
|
||||
}
|
||||
@@ -289,20 +293,23 @@ func scoreName(nameLower string, nameTokens []string, qLower string, qTokens []s
|
||||
return score
|
||||
}
|
||||
|
||||
// handleSearchSuggest serves the JSON typeahead for the header dropdown and
|
||||
// the editor's link picker. Caps results at 5; reports total so the UI can
|
||||
// surface a "show all" footer when more matches exist. Empty/whitespace query
|
||||
// is a no-op (200 with empty results), not a 400 — every keystroke fires this.
|
||||
type suggestResult struct {
|
||||
Name string `json:"name"`
|
||||
Path string `json:"path"`
|
||||
URL string `json:"url"`
|
||||
}
|
||||
|
||||
// handleSearchSuggest serves the typeahead: rendered rows for the dropdown
|
||||
// (header search, editor link dialog; htmx requests) and JSON for the
|
||||
// editor's [[ autocomplete. Caps results at 5; reports total so the dropdown
|
||||
// can offer a "show all" footer (?footer) when more matches exist. An
|
||||
// empty/whitespace query is a no-op (200, no results), not a 400 — every
|
||||
// keystroke fires this.
|
||||
func (h *handler) handleSearchSuggest(w http.ResponseWriter, r *http.Request) {
|
||||
if !h.checkAuth(w, r) {
|
||||
return
|
||||
}
|
||||
query := strings.TrimSpace(r.URL.Query().Get("q"))
|
||||
type suggestResult struct {
|
||||
Name string `json:"name"`
|
||||
Path string `json:"path"`
|
||||
URL string `json:"url"`
|
||||
}
|
||||
type suggestResp struct {
|
||||
Query string `json:"query"`
|
||||
Results []suggestResult `json:"results"`
|
||||
@@ -324,12 +331,92 @@ func (h *handler) handleSearchSuggest(w http.ResponseWriter, r *http.Request) {
|
||||
})
|
||||
}
|
||||
}
|
||||
// htmx (the typeahead dropdown) gets rendered rows; other callers (the
|
||||
// editor's [[ autocomplete) keep the JSON.
|
||||
w.Header().Set("Vary", "HX-Request")
|
||||
if isHX(r) {
|
||||
renderSuggest(w, resp.Query, resp.Results, resp.Total, r.URL.Query().Has("footer"))
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
if err := json.NewEncoder(w).Encode(resp); err != nil {
|
||||
log.Printf("search suggest encode error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
var suggestTmpl = template.Must(template.ParseFS(assets, "assets/search/suggest.html"))
|
||||
|
||||
// suggestMinLen is the shortest query (in characters) the typeahead answers;
|
||||
// shorter ones render nothing, which closes the dropdown.
|
||||
const suggestMinLen = 2
|
||||
|
||||
type suggestRow struct {
|
||||
Name, Path, URL string
|
||||
Highlighted template.HTML
|
||||
}
|
||||
|
||||
// renderSuggest writes the typeahead dropdown for query. footer adds a
|
||||
// "Show all" row linking to the full results page when some were cut off.
|
||||
func renderSuggest(w http.ResponseWriter, query string, results []suggestResult, total int, footer bool) {
|
||||
data := struct {
|
||||
Show bool
|
||||
Results []suggestRow
|
||||
Total int
|
||||
AllURL string
|
||||
}{Show: utf8.RuneCountInString(query) >= suggestMinLen, Total: total}
|
||||
tokens := suggestTokens(query)
|
||||
for _, res := range results {
|
||||
data.Results = append(data.Results, suggestRow{res.Name, res.Path, res.URL, highlightMatches(res.Name, tokens)})
|
||||
}
|
||||
if footer && total > len(results) {
|
||||
data.AllURL = "/?q=" + url.QueryEscape(query)
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
if err := suggestTmpl.ExecuteTemplate(w, "suggest", data); err != nil {
|
||||
log.Printf("suggest template error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// suggestTokens lowercases query and splits it on anything that is not a
|
||||
// letter or digit.
|
||||
func suggestTokens(query string) []string {
|
||||
return strings.FieldsFunc(strings.ToLower(query), func(r rune) bool {
|
||||
return !unicode.IsLetter(r) && !unicode.IsNumber(r)
|
||||
})
|
||||
}
|
||||
|
||||
// highlightMatches bolds the first occurrence of each token in name
|
||||
// (case-insensitive), merging overlapping spans, and escapes the rest.
|
||||
func highlightMatches(name string, tokens []string) template.HTML {
|
||||
lower := strings.ToLower(name)
|
||||
if len(lower) != len(name) {
|
||||
// Lowercasing changed byte lengths, so offsets would not line up.
|
||||
return template.HTML(template.HTMLEscapeString(name))
|
||||
}
|
||||
type span struct{ from, to int }
|
||||
var spans []span
|
||||
for _, t := range tokens {
|
||||
if i := strings.Index(lower, t); i >= 0 {
|
||||
spans = append(spans, span{i, i + len(t)})
|
||||
}
|
||||
}
|
||||
sort.Slice(spans, func(i, j int) bool { return spans[i].from < spans[j].from })
|
||||
var b strings.Builder
|
||||
cursor := 0
|
||||
for i := 0; i < len(spans); i++ {
|
||||
sp := spans[i]
|
||||
for i+1 < len(spans) && spans[i+1].from <= sp.to {
|
||||
i++
|
||||
sp.to = max(sp.to, spans[i].to)
|
||||
}
|
||||
b.WriteString(template.HTMLEscapeString(name[cursor:sp.from]))
|
||||
b.WriteString("<strong>" + template.HTMLEscapeString(name[sp.from:sp.to]) + "</strong>")
|
||||
cursor = sp.to
|
||||
}
|
||||
b.WriteString(template.HTMLEscapeString(name[cursor:]))
|
||||
return template.HTML(b.String())
|
||||
}
|
||||
|
||||
// handleReindex rebuilds the folder index synchronously and returns 204.
|
||||
// The frontend reloads the page on success. Serialized via buildMu so a
|
||||
// double-click waits rather than running two walks in parallel.
|
||||
@@ -575,6 +662,17 @@ func tokenize(s string) []string {
|
||||
return tokens
|
||||
}
|
||||
|
||||
// withinTwoEdits reports whether a and b are at most 2 edits apart. Lengths
|
||||
// differing by more than 2 rule that out without running levenshtein, which
|
||||
// skips nearly every word pair in the per-query scoring loop.
|
||||
func withinTwoEdits(a, b string) bool {
|
||||
d := utf8.RuneCountInString(a) - utf8.RuneCountInString(b)
|
||||
if d < -2 || d > 2 {
|
||||
return false
|
||||
}
|
||||
return levenshtein(a, b) <= 2
|
||||
}
|
||||
|
||||
// levenshtein returns the edit distance between a and b. Operates on runes so
|
||||
// multi-byte characters count as one edit.
|
||||
func levenshtein(a, b string) int {
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"compress/gzip"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"io/fs"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// assetVersions maps each embedded asset (path relative to assets/, e.g.
|
||||
// "page/toc.js") to a short hash of its content. Templates reference assets
|
||||
// through assetURL, which appends the hash as ?v=…, so a versioned URL names
|
||||
// one exact file content and can be cached forever; a rebuilt asset gets a
|
||||
// new URL.
|
||||
var assetVersions = hashAssets()
|
||||
|
||||
func hashAssets() map[string]string {
|
||||
versions := map[string]string{}
|
||||
_ = fs.WalkDir(assets, "assets", func(p string, d fs.DirEntry, err error) error {
|
||||
if err != nil || d.IsDir() {
|
||||
return err
|
||||
}
|
||||
b, err := assets.ReadFile(p)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
sum := sha256.Sum256(b)
|
||||
versions[strings.TrimPrefix(p, "assets/")] = hex.EncodeToString(sum[:])[:12]
|
||||
return nil
|
||||
})
|
||||
return versions
|
||||
}
|
||||
|
||||
// assetGzip holds a gzip copy of every embedded asset that compresses well,
|
||||
// keyed like assetVersions. Built once at startup so serving compressed costs
|
||||
// no CPU per request; already-compressed files (fonts) are left out.
|
||||
var assetGzip = gzipAssets()
|
||||
|
||||
func gzipAssets() map[string][]byte {
|
||||
out := map[string][]byte{}
|
||||
_ = fs.WalkDir(assets, "assets", func(p string, d fs.DirEntry, err error) error {
|
||||
if err != nil || d.IsDir() {
|
||||
return err
|
||||
}
|
||||
b, err := assets.ReadFile(p)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
zw, _ := gzip.NewWriterLevel(&buf, gzip.BestCompression)
|
||||
zw.Write(b)
|
||||
zw.Close()
|
||||
// Not worth a Content-Encoding round trip unless it saves ≥10%.
|
||||
if buf.Len() < len(b)*9/10 {
|
||||
out[strings.TrimPrefix(p, "assets/")] = buf.Bytes()
|
||||
}
|
||||
return nil
|
||||
})
|
||||
return out
|
||||
}
|
||||
|
||||
// acceptsGzip reports whether the client accepts a gzip response body.
|
||||
func acceptsGzip(r *http.Request) bool {
|
||||
for _, enc := range strings.Split(r.Header.Get("Accept-Encoding"), ",") {
|
||||
enc, q, _ := strings.Cut(strings.TrimSpace(enc), ";")
|
||||
if strings.TrimSpace(enc) == "gzip" && strings.ReplaceAll(q, " ", "") != "q=0" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// assetURL is the "asset" template func: {{asset "style.css"}} renders
|
||||
// /_/style.css?v=<hash>. An unknown name fails the template so a typo or a
|
||||
// removed file surfaces as a render error instead of a silent 404.
|
||||
func assetURL(name string) (string, error) {
|
||||
v, ok := assetVersions[name]
|
||||
if !ok {
|
||||
return "", fmt.Errorf("unknown asset %q", name)
|
||||
}
|
||||
return "/_/" + name + "?v=" + v, nil
|
||||
}
|
||||
|
||||
// assetFuncs is for the standalone fragment templates that do not take the
|
||||
// full tmplFuncs.
|
||||
var assetFuncs = template.FuncMap{"asset": assetURL}
|
||||
|
||||
// serveStatic serves the embedded assets under /_/. Embedded files carry no
|
||||
// modtime, so http.FileServer alone sends no validators and browsers refetch
|
||||
// every asset on every page. Instead:
|
||||
// - a URL carrying the current ?v= hash is immutable;
|
||||
// - fonts are immutable too (referenced unversioned from style.css, never
|
||||
// change);
|
||||
// - anything else (unversioned or stale ?v=, e.g. icons from CSS) gets the
|
||||
// content hash as ETag and revalidates, answered with 304 when unchanged.
|
||||
//
|
||||
// Assets with a precompressed copy (see assetGzip) are sent gzipped to
|
||||
// clients that accept it; the gzip variant gets its own ETag.
|
||||
func serveStatic() http.Handler {
|
||||
staticFS, _ := fs.Sub(assets, "assets")
|
||||
static := http.StripPrefix("/_/", http.FileServer(http.FS(staticFS)))
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
name := strings.TrimPrefix(r.URL.Path, "/_/")
|
||||
v, ok := assetVersions[name]
|
||||
gz, hasGz := assetGzip[name]
|
||||
useGz := hasGz && acceptsGzip(r)
|
||||
if hasGz {
|
||||
w.Header().Set("Vary", "Accept-Encoding")
|
||||
}
|
||||
switch {
|
||||
case ok && r.URL.Query().Get("v") == v, strings.HasPrefix(name, "fonts/"):
|
||||
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
|
||||
case ok:
|
||||
w.Header().Set("Cache-Control", "no-cache")
|
||||
if useGz {
|
||||
w.Header().Set("ETag", `"`+v+`-gz"`)
|
||||
} else {
|
||||
w.Header().Set("ETag", `"`+v+`"`)
|
||||
}
|
||||
}
|
||||
if useGz {
|
||||
w.Header().Set("Content-Encoding", "gzip")
|
||||
// ServeContent derives Content-Type from the name's extension.
|
||||
http.ServeContent(w, r, name, time.Time{}, bytes.NewReader(gz))
|
||||
return
|
||||
}
|
||||
static.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"compress/gzip"
|
||||
"io"
|
||||
"io/fs"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"regexp"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// Every {{asset "…"}} in the templates must name an embedded file, or the page
|
||||
// fails to render.
|
||||
func TestTemplateAssetsExist(t *testing.T) {
|
||||
ref := regexp.MustCompile(`\{\{asset "([^"]+)"\}\}`)
|
||||
_ = fs.WalkDir(assets, "assets", func(p string, d fs.DirEntry, err error) error {
|
||||
if err != nil || d.IsDir() {
|
||||
return err
|
||||
}
|
||||
b, _ := assets.ReadFile(p)
|
||||
for _, m := range ref.FindAllSubmatch(b, -1) {
|
||||
if _, err := assetURL(string(m[1])); err != nil {
|
||||
t.Errorf("%s: %v", p, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func TestServeStaticCaching(t *testing.T) {
|
||||
h := serveStatic()
|
||||
get := func(url, ifNoneMatch string) *httptest.ResponseRecorder {
|
||||
r := httptest.NewRequest(http.MethodGet, url, nil)
|
||||
if ifNoneMatch != "" {
|
||||
r.Header.Set("If-None-Match", ifNoneMatch)
|
||||
}
|
||||
w := httptest.NewRecorder()
|
||||
h.ServeHTTP(w, r)
|
||||
return w
|
||||
}
|
||||
v := assetVersions["style.css"]
|
||||
|
||||
versioned, _ := assetURL("style.css")
|
||||
if w := get(versioned, ""); w.Code != 200 || w.Header().Get("Cache-Control") != "public, max-age=31536000, immutable" {
|
||||
t.Errorf("versioned: %d %q", w.Code, w.Header().Get("Cache-Control"))
|
||||
}
|
||||
for _, u := range []string{"/_/style.css", "/_/style.css?v=stale"} {
|
||||
w := get(u, "")
|
||||
if w.Code != 200 || w.Header().Get("Cache-Control") != "no-cache" || w.Header().Get("ETag") != `"`+v+`"` {
|
||||
t.Errorf("%s: %d %q %q", u, w.Code, w.Header().Get("Cache-Control"), w.Header().Get("ETag"))
|
||||
}
|
||||
}
|
||||
if w := get("/_/style.css", `"`+v+`"`); w.Code != http.StatusNotModified {
|
||||
t.Errorf("revalidate: got %d, want 304", w.Code)
|
||||
}
|
||||
if w := get("/_/fonts/IosevkaSlab.woff2", ""); w.Header().Get("Cache-Control") != "public, max-age=31536000, immutable" {
|
||||
t.Errorf("font: %q", w.Header().Get("Cache-Control"))
|
||||
}
|
||||
}
|
||||
|
||||
func TestServeStaticGzip(t *testing.T) {
|
||||
h := serveStatic()
|
||||
get := func(url, acceptEncoding, ifNoneMatch string) *httptest.ResponseRecorder {
|
||||
r := httptest.NewRequest(http.MethodGet, url, nil)
|
||||
if acceptEncoding != "" {
|
||||
r.Header.Set("Accept-Encoding", acceptEncoding)
|
||||
}
|
||||
if ifNoneMatch != "" {
|
||||
r.Header.Set("If-None-Match", ifNoneMatch)
|
||||
}
|
||||
w := httptest.NewRecorder()
|
||||
h.ServeHTTP(w, r)
|
||||
return w
|
||||
}
|
||||
want, _ := assets.ReadFile("assets/style.css")
|
||||
|
||||
w := get("/_/style.css", "gzip, deflate, br", "")
|
||||
if w.Code != 200 || w.Header().Get("Content-Encoding") != "gzip" || w.Header().Get("Vary") != "Accept-Encoding" {
|
||||
t.Fatalf("gzip: %d %q %q", w.Code, w.Header().Get("Content-Encoding"), w.Header().Get("Vary"))
|
||||
}
|
||||
if ct := w.Header().Get("Content-Type"); !strings.HasPrefix(ct, "text/css") {
|
||||
t.Errorf("content-type: %q", ct)
|
||||
}
|
||||
zr, err := gzip.NewReader(w.Body)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, _ := io.ReadAll(zr)
|
||||
if !bytes.Equal(got, want) {
|
||||
t.Errorf("gzip body does not decompress to style.css")
|
||||
}
|
||||
|
||||
etag := w.Header().Get("ETag")
|
||||
if w := get("/_/style.css", "gzip", etag); w.Code != http.StatusNotModified {
|
||||
t.Errorf("gzip revalidate: got %d, want 304", w.Code)
|
||||
}
|
||||
|
||||
for _, ae := range []string{"", "identity", "gzip;q=0"} {
|
||||
w := get("/_/style.css", ae, "")
|
||||
if w.Header().Get("Content-Encoding") != "" || !bytes.Equal(w.Body.Bytes(), want) {
|
||||
t.Errorf("Accept-Encoding %q: got encoding %q", ae, w.Header().Get("Content-Encoding"))
|
||||
}
|
||||
}
|
||||
|
||||
if w := get("/_/fonts/IosevkaSlab.woff2", "gzip", ""); w.Header().Get("Content-Encoding") != "" {
|
||||
t.Errorf("font should not be re-compressed")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package main
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestHighlightMatches(t *testing.T) {
|
||||
cases := []struct{ name, query, want string }{
|
||||
{"Project Alpha", "alpha", "Project <strong>Alpha</strong>"},
|
||||
// Overlapping and adjacent spans merge.
|
||||
{"Project Alpha", "pro ject", "<strong>Project</strong> Alpha"},
|
||||
{"Project Alpha", "roj proj", "<strong>Proj</strong>ect Alpha"},
|
||||
{"a<b>&c", "b", "a<<strong>b</strong>>&c"},
|
||||
{"Notes", "zzz", "Notes"},
|
||||
// Lowercasing changes the byte length: no highlight, still escaped.
|
||||
{"İstanbul <x>", "stan", "İstanbul <x>"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := string(highlightMatches(c.name, suggestTokens(c.query))); got != c.want {
|
||||
t.Errorf("highlightMatches(%q, %q) = %q, want %q", c.name, c.query, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -17,18 +17,19 @@ var taskCheckboxRe = regexp.MustCompile(`<input(?: checked="")? disabled="" type
|
||||
// then a `[ ]` / `[x]` / `[X]` checkbox marker.
|
||||
var taskLineRe = regexp.MustCompile(`^(\s*[-*+]\s+)\[([ xX])\]`)
|
||||
|
||||
// rewriteTaskCheckboxes enables and indexes the task checkboxes in rendered
|
||||
// HTML so JS can wire them up. Each checkbox gains a data-task-index matching
|
||||
// its position among task list items in source order; the disabled attribute
|
||||
// is removed so the user can toggle them.
|
||||
// rewriteTaskCheckboxes makes the task checkboxes in rendered HTML toggle
|
||||
// their source line. The disabled attribute is removed and each checkbox
|
||||
// posts itself to ?toggle=N (htmx), N being its position among task list
|
||||
// items in source order. An unchecked box sends no value, i.e. checked=false;
|
||||
// a failed save flips the box back.
|
||||
func rewriteTaskCheckboxes(in []byte) []byte {
|
||||
idx := 0
|
||||
return taskCheckboxRe.ReplaceAllFunc(in, func(match []byte) []byte {
|
||||
checked := bytes.Contains(match, []byte("checked"))
|
||||
var out bytes.Buffer
|
||||
out.WriteString(`<input type="checkbox" class="task-checkbox" data-task-index="`)
|
||||
out.WriteString(`<input type="checkbox" class="task-checkbox" name="checked" value="true" hx-post="?toggle=`)
|
||||
out.WriteString(strconv.Itoa(idx))
|
||||
out.WriteByte('"')
|
||||
out.WriteString(`" hx-swap="none" hx-disabled-elt="this" hx-on::response-error="this.checked = !this.checked" hx-on::send-error="this.checked = !this.checked"`)
|
||||
if checked {
|
||||
out.WriteString(` checked=""`)
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
@@ -48,8 +49,13 @@ var (
|
||||
thumbLocks = map[string]*sync.Mutex{}
|
||||
thumbLocksMu sync.Mutex
|
||||
|
||||
digestCache = map[string]digestEntry{}
|
||||
digestCache = map[string]digestEntry{}
|
||||
digestCacheMu sync.Mutex
|
||||
|
||||
// thumbGenSlots bounds concurrent thumbnail generation. A grid of uncached
|
||||
// photos otherwise decodes one full-size image per browser connection at
|
||||
// once, which on the NAS means memory pressure and CPU thrash.
|
||||
thumbGenSlots = make(chan struct{}, runtime.NumCPU())
|
||||
)
|
||||
|
||||
// digestEntry remembers the digest of a source file so repeated requests do
|
||||
@@ -179,7 +185,10 @@ func (h *handler) serveThumbnail(w http.ResponseWriter, r *http.Request, srcFS s
|
||||
return
|
||||
}
|
||||
|
||||
if err := generateThumb(cacheFS, write); err != nil {
|
||||
thumbGenSlots <- struct{}{}
|
||||
err = generateThumb(cacheFS, write)
|
||||
<-thumbGenSlots
|
||||
if err != nil {
|
||||
log.Printf("thumb %s: %v", rel, err)
|
||||
http.Error(w, "thumbnail failed", http.StatusInternalServerError)
|
||||
return
|
||||
@@ -199,6 +208,9 @@ func pathDigest(srcFS string, info os.FileInfo) string {
|
||||
// On a cache hit (path + mtime + size unchanged) the returned data is nil,
|
||||
// so the caller knows to open the file itself. On a miss the file is read
|
||||
// once and the contents are returned for the caller to reuse.
|
||||
//
|
||||
// Hits come from memory or, after a restart, from the on-disk index (see
|
||||
// digestIndexPath), so an unchanged file is hashed only once ever.
|
||||
func sourceDigest(srcFS string, info os.FileInfo) (string, []byte, error) {
|
||||
digestCacheMu.Lock()
|
||||
d, ok := digestCache[srcFS]
|
||||
@@ -207,18 +219,43 @@ func sourceDigest(srcFS string, info os.FileInfo) (string, []byte, error) {
|
||||
return d.hex, nil, nil
|
||||
}
|
||||
|
||||
indexFS := digestIndexPath(srcFS, info)
|
||||
if b, err := os.ReadFile(indexFS); err == nil && len(b) == sha256.Size*2 {
|
||||
h := string(b)
|
||||
rememberDigest(srcFS, info, h)
|
||||
return h, nil, nil
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(srcFS)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
sum := sha256.Sum256(data)
|
||||
h := hex.EncodeToString(sum[:])
|
||||
rememberDigest(srcFS, info, h)
|
||||
|
||||
// Best effort: a failed write only costs a re-hash after the next restart.
|
||||
if err := os.MkdirAll(filepath.Dir(indexFS), 0755); err == nil {
|
||||
if err := writeFileAtomic(indexFS, []byte(h), 0644); err != nil {
|
||||
log.Printf("thumb digest index %s: %v", indexFS, err)
|
||||
}
|
||||
}
|
||||
return h, data, nil
|
||||
}
|
||||
|
||||
// digestIndexPath is where the content digest of srcFS is persisted, keyed on
|
||||
// path + mtime + size like pathDigest, so overwriting the file misses and
|
||||
// re-hashes. Entries for changed or deleted files are left behind; each is 64
|
||||
// bytes.
|
||||
func digestIndexPath(srcFS string, info os.FileInfo) string {
|
||||
key := pathDigest(srcFS, info)
|
||||
return filepath.Join(thumbCacheDir, "digests", key[:2], key)
|
||||
}
|
||||
|
||||
func rememberDigest(srcFS string, info os.FileInfo, h string) {
|
||||
digestCacheMu.Lock()
|
||||
digestCache[srcFS] = digestEntry{mtime: info.ModTime(), size: info.Size(), hex: h}
|
||||
digestCacheMu.Unlock()
|
||||
|
||||
return h, data, nil
|
||||
}
|
||||
|
||||
func serveThumb(w http.ResponseWriter, r *http.Request, cacheFS string) {
|
||||
|
||||
@@ -57,6 +57,14 @@ func resizeBox(src image.Image, width int) image.Image {
|
||||
}
|
||||
dst := image.NewRGBA(image.Rect(0, 0, dstW, dstH))
|
||||
|
||||
// JPEGs decode to *image.YCbCr. Reading its planes directly avoids an
|
||||
// interface call and a colour conversion per source pixel, which makes the
|
||||
// resize ~4x faster; everything else takes the generic At() path.
|
||||
avg := boxAvg
|
||||
if ycc, ok := src.(*image.YCbCr); ok {
|
||||
avg = func(_ image.Image, r image.Rectangle) color.RGBA { return boxAvgYCbCr(ycc, r) }
|
||||
}
|
||||
|
||||
for y := 0; y < dstH; y++ {
|
||||
sy0 := y * srcH / dstH
|
||||
sy1 := (y + 1) * srcH / dstH
|
||||
@@ -69,23 +77,42 @@ func resizeBox(src image.Image, width int) image.Image {
|
||||
if sx1 == sx0 {
|
||||
sx1 = sx0 + 1
|
||||
}
|
||||
var r, g, bl, n uint64
|
||||
for sy := sy0; sy < sy1; sy++ {
|
||||
for sx := sx0; sx < sx1; sx++ {
|
||||
sr, sg, sb, _ := src.At(b.Min.X+sx, b.Min.Y+sy).RGBA()
|
||||
r += uint64(sr >> 8)
|
||||
g += uint64(sg >> 8)
|
||||
bl += uint64(sb >> 8)
|
||||
n++
|
||||
}
|
||||
}
|
||||
dst.SetRGBA(x, y, color.RGBA{
|
||||
R: uint8(r / n),
|
||||
G: uint8(g / n),
|
||||
B: uint8(bl / n),
|
||||
A: 255,
|
||||
})
|
||||
box := image.Rect(sx0, sy0, sx1, sy1).Add(b.Min)
|
||||
dst.SetRGBA(x, y, avg(src, box))
|
||||
}
|
||||
}
|
||||
return dst
|
||||
}
|
||||
|
||||
// boxAvg returns the mean colour of the pixels of src inside r.
|
||||
func boxAvg(src image.Image, r image.Rectangle) color.RGBA {
|
||||
var red, g, bl, n uint64
|
||||
for y := r.Min.Y; y < r.Max.Y; y++ {
|
||||
for x := r.Min.X; x < r.Max.X; x++ {
|
||||
sr, sg, sb, _ := src.At(x, y).RGBA()
|
||||
red += uint64(sr >> 8)
|
||||
g += uint64(sg >> 8)
|
||||
bl += uint64(sb >> 8)
|
||||
n++
|
||||
}
|
||||
}
|
||||
return color.RGBA{R: uint8(red / n), G: uint8(g / n), B: uint8(bl / n), A: 255}
|
||||
}
|
||||
|
||||
// boxAvgYCbCr is boxAvg for *image.YCbCr, reading the sample planes directly.
|
||||
// Each pixel is still converted to RGB before averaging so out-of-gamut
|
||||
// samples clamp exactly as they do through At().
|
||||
func boxAvgYCbCr(src *image.YCbCr, r image.Rectangle) color.RGBA {
|
||||
var red, g, bl, n uint64
|
||||
for y := r.Min.Y; y < r.Max.Y; y++ {
|
||||
for x := r.Min.X; x < r.Max.X; x++ {
|
||||
ci := src.COffset(x, y)
|
||||
sr, sg, sb := color.YCbCrToRGB(src.Y[src.YOffset(x, y)], src.Cb[ci], src.Cr[ci])
|
||||
red += uint64(sr)
|
||||
g += uint64(sg)
|
||||
bl += uint64(sb)
|
||||
n++
|
||||
}
|
||||
}
|
||||
return color.RGBA{R: uint8(red / n), G: uint8(g / n), B: uint8(bl / n), A: 255}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"image"
|
||||
"image/jpeg"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// testPhoto returns a JPEG-decoded (so *image.YCbCr, 4:2:0) image with an odd
|
||||
// size and a non-zero origin to exercise the plane offset arithmetic.
|
||||
func testPhoto(t testing.TB, w, h int) *image.YCbCr {
|
||||
t.Helper()
|
||||
src := image.NewRGBA(image.Rect(0, 0, w, h))
|
||||
for i := range src.Pix {
|
||||
src.Pix[i] = uint8(i*7 + i/w)
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
if err := jpeg.Encode(&buf, src, &jpeg.Options{Quality: 90}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
img, err := jpeg.Decode(&buf)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return img.(*image.YCbCr).SubImage(image.Rect(3, 5, w, h)).(*image.YCbCr)
|
||||
}
|
||||
|
||||
func TestResizeBoxYCbCrMatchesGeneric(t *testing.T) {
|
||||
src := testPhoto(t, 1001, 667)
|
||||
fast := resizeBox(src, 120).(*image.RGBA)
|
||||
// Hide the concrete type so resizeBox takes the generic At() path.
|
||||
slow := resizeBox(struct{ image.Image }{src}, 120).(*image.RGBA)
|
||||
if fast.Bounds() != slow.Bounds() {
|
||||
t.Fatalf("bounds %v != %v", fast.Bounds(), slow.Bounds())
|
||||
}
|
||||
for i := range fast.Pix {
|
||||
if fast.Pix[i] != slow.Pix[i] {
|
||||
t.Fatalf("pixel byte %d: fast %d, generic %d", i, fast.Pix[i], slow.Pix[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkResizeBox(b *testing.B) {
|
||||
src := testPhoto(b, 4000, 3000)
|
||||
b.Run("ycbcr", func(b *testing.B) {
|
||||
for i := 0; i < b.N; i++ {
|
||||
resizeBox(src, 300)
|
||||
}
|
||||
})
|
||||
b.Run("generic", func(b *testing.B) {
|
||||
for i := 0; i < b.N; i++ {
|
||||
resizeBox(struct{ image.Image }{src}, 300)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// After a restart (empty memory cache) the digest comes from the on-disk index
|
||||
// without reading the source file.
|
||||
func TestSourceDigestPersists(t *testing.T) {
|
||||
old := thumbCacheDir
|
||||
thumbCacheDir = t.TempDir()
|
||||
t.Cleanup(func() { thumbCacheDir = old })
|
||||
|
||||
src := filepath.Join(t.TempDir(), "a.jpg")
|
||||
if err := os.WriteFile(src, []byte("photo"), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
info, _ := os.Stat(src)
|
||||
h1, data, err := sourceDigest(src, info)
|
||||
if err != nil || data == nil {
|
||||
t.Fatalf("first call: %v, data=%v", err, data)
|
||||
}
|
||||
|
||||
digestCacheMu.Lock()
|
||||
delete(digestCache, src)
|
||||
digestCacheMu.Unlock()
|
||||
// Removing the source proves the second call does not read it again.
|
||||
if err := os.Remove(src); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
h2, data, err := sourceDigest(src, info)
|
||||
if err != nil || data != nil || h2 != h1 {
|
||||
t.Fatalf("after restart: h=%s data=%v err=%v, want %s from index", h2, data, err, h1)
|
||||
}
|
||||
}
|
||||
@@ -1,30 +1,71 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"bytes"
|
||||
"html/template"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
var treeTmpl = template.Must(template.New("tree").ParseFS(assets, "assets/tree.html"))
|
||||
|
||||
// treeOpts selects what a folder tree renders. The same markup serves the
|
||||
// sidebar (links) and the tree picker (radio inputs named "pick").
|
||||
type treeOpts struct {
|
||||
Files bool // list files, not just folders
|
||||
Pick string // "" renders links; "folder", "file" or "any" renders a picker for that kind
|
||||
Active string // wiki path of the current page, highlighted (sidebar)
|
||||
Select string // wiki path of the initially checked row (picker)
|
||||
}
|
||||
|
||||
// query returns the ?tree query that lazy-loads a folder's children with the
|
||||
// same options.
|
||||
func (o treeOpts) query() string {
|
||||
q := "?tree"
|
||||
if o.Files {
|
||||
q += "&files=1"
|
||||
}
|
||||
if o.Pick != "" {
|
||||
q += "&pick=" + o.Pick
|
||||
}
|
||||
return q
|
||||
}
|
||||
|
||||
type treeEntry struct {
|
||||
Name string `json:"name"`
|
||||
Kind string `json:"kind"`
|
||||
// Children is populated only along the expandTo chain (see handleTree);
|
||||
// omitted otherwise so the flat picker listing keeps its original shape.
|
||||
Children []treeEntry `json:"children,omitempty"`
|
||||
Name string
|
||||
Kind string // "folder" or "file"
|
||||
// HasChildren reports whether a folder has any visible entries (under the
|
||||
// same files filter), so empty folders render without a chevron.
|
||||
HasChildren bool
|
||||
}
|
||||
|
||||
type treeResponse struct {
|
||||
Path string `json:"path"`
|
||||
Entries []treeEntry `json:"entries"`
|
||||
// treeRow is one rendered row. Folders with children are expandable: either
|
||||
// pre-expanded (Children set, along the requested path) or lazy-loaded from
|
||||
// ChildrenURL when first opened.
|
||||
type treeRow struct {
|
||||
Name, Path, Kind string
|
||||
URL string // page URL (sidebar links)
|
||||
Link bool // render the name as a link
|
||||
Selectable bool // render a picker radio
|
||||
Active, Checked bool
|
||||
Expandable bool
|
||||
Children []treeRow
|
||||
ChildrenURL string
|
||||
}
|
||||
|
||||
// handleTree responds with a JSON listing of the immediate children of the
|
||||
// folder at fsPath. Hidden entries and `index.md` are filtered. Files are not
|
||||
// descended — the client lazy-loads children on expand.
|
||||
// handleTree responds with the rendered rows of the folder at fsPath:
|
||||
//
|
||||
// ?tree sidebar rows (folders only, links)
|
||||
// &files=1 include files
|
||||
// &pick=folder|file|any picker rows selecting that kind
|
||||
// &expandTo=/a/b pre-expand the chain down to /a/b
|
||||
// &select=/a/b check that row
|
||||
// &root=1 lead with a row for "/" itself (picker)
|
||||
func (h *handler) handleTree(w http.ResponseWriter, r *http.Request, urlPath, fsPath string) {
|
||||
info, err := os.Stat(fsPath)
|
||||
if err != nil {
|
||||
@@ -40,30 +81,98 @@ func (h *handler) handleTree(w http.ResponseWriter, r *http.Request, urlPath, fs
|
||||
return
|
||||
}
|
||||
|
||||
foldersOnly := r.URL.Query().Has("foldersOnly")
|
||||
q := r.URL.Query()
|
||||
o := treeOpts{Files: q.Has("files"), Select: q.Get("select")}
|
||||
switch p := q.Get("pick"); p {
|
||||
case "", "folder", "file", "any":
|
||||
o.Pick = p
|
||||
default:
|
||||
http.Error(w, "bad pick mode", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
entries, err := listTreeEntries(fsPath, foldersOnly)
|
||||
dir := normalizeMovePath(urlPath)
|
||||
var expand []string
|
||||
if segs, base := treePathSegments(q.Get("expandTo")), treePathSegments(dir); len(segs) >= len(base) &&
|
||||
strings.Join(segs[:len(base)], "/") == strings.Join(base, "/") {
|
||||
expand = segs[len(base):]
|
||||
}
|
||||
rows, err := buildTreeRows(fsPath, dir, expand, o)
|
||||
if err != nil {
|
||||
http.Error(w, "read failed", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
if expandTo := r.URL.Query().Get("expandTo"); expandTo != "" {
|
||||
expandTreeChain(fsPath, entries, treePathSegments(expandTo), foldersOnly)
|
||||
if dir == "/" && q.Has("root") && o.Pick != "" {
|
||||
root := newTreeRow(treeEntry{Name: "/", Kind: "folder"}, "/", o)
|
||||
rows = append([]treeRow{root}, rows...)
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
if err := treeTmpl.ExecuteTemplate(w, "rows", rows); err != nil {
|
||||
log.Printf("tree template error: %v", err)
|
||||
}
|
||||
|
||||
resp := treeResponse{Path: canonicalTreePath(urlPath), Entries: entries}
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
_ = json.NewEncoder(w).Encode(resp)
|
||||
}
|
||||
|
||||
// canonicalTreePath returns the URL path in the form used by the picker:
|
||||
// "/" for root, otherwise stripped of any trailing slash.
|
||||
func canonicalTreePath(urlPath string) string {
|
||||
if urlPath == "" || urlPath == "/" {
|
||||
return "/"
|
||||
// sidebarTree renders the navigation tree for the page at urlPath: the root
|
||||
// listing with the chain down to the page expanded and the page highlighted.
|
||||
// The tree is part of the page itself, so the rail never paints empty.
|
||||
func sidebarTree(root, urlPath string) template.HTML {
|
||||
page := normalizeMovePath(urlPath)
|
||||
rows, err := buildTreeRows(root, "/", treePathSegments(page), treeOpts{Active: page})
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return "/" + strings.Trim(urlPath, "/")
|
||||
var buf bytes.Buffer
|
||||
if err := treeTmpl.ExecuteTemplate(&buf, "rows", rows); err != nil {
|
||||
log.Printf("tree template error: %v", err)
|
||||
return ""
|
||||
}
|
||||
return template.HTML(buf.String())
|
||||
}
|
||||
|
||||
// buildTreeRows lists the folder fsPath (wiki path dir) and expands the
|
||||
// folder named by expand[0], recursing down the chain. The walk stops at the
|
||||
// deepest segment that still exists. Segments only ever match real directory
|
||||
// names from listTreeEntries, so this cannot traverse outside the tree.
|
||||
func buildTreeRows(fsPath, dir string, expand []string, o treeOpts) ([]treeRow, error) {
|
||||
entries, err := listTreeEntries(fsPath, !o.Files)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rows := make([]treeRow, len(entries))
|
||||
for i, e := range entries {
|
||||
p := path.Join(dir, e.Name)
|
||||
rows[i] = newTreeRow(e, p, o)
|
||||
if len(expand) > 0 && e.Kind == "folder" && e.Name == expand[0] && e.HasChildren {
|
||||
kids, err := buildTreeRows(filepath.Join(fsPath, e.Name), p, expand[1:], o)
|
||||
if err == nil {
|
||||
rows[i].Children = kids
|
||||
}
|
||||
}
|
||||
}
|
||||
return rows, nil
|
||||
}
|
||||
|
||||
func newTreeRow(e treeEntry, p string, o treeOpts) treeRow {
|
||||
row := treeRow{
|
||||
Name: e.Name,
|
||||
Path: p,
|
||||
Kind: e.Kind,
|
||||
URL: pageURL(p),
|
||||
Link: o.Pick == "",
|
||||
Active: o.Pick == "" && p == o.Active,
|
||||
Expandable: e.HasChildren,
|
||||
}
|
||||
if e.Kind == "file" {
|
||||
row.URL = fileURL(strings.TrimPrefix(p, "/"))
|
||||
}
|
||||
if o.Pick != "" {
|
||||
row.Selectable = o.Pick == "any" || o.Pick == e.Kind
|
||||
row.Checked = row.Selectable && p == o.Select
|
||||
}
|
||||
if row.Expandable {
|
||||
row.ChildrenURL = row.URL + o.query()
|
||||
}
|
||||
return row
|
||||
}
|
||||
|
||||
// listTreeEntries returns the immediate children of fsPath, filtering hidden
|
||||
@@ -81,7 +190,11 @@ func listTreeEntries(fsPath string, foldersOnly bool) ([]treeEntry, error) {
|
||||
continue
|
||||
}
|
||||
if e.IsDir() {
|
||||
folders = append(folders, treeEntry{Name: name, Kind: "folder"})
|
||||
folders = append(folders, treeEntry{
|
||||
Name: name,
|
||||
Kind: "folder",
|
||||
HasChildren: folderHasChildren(filepath.Join(fsPath, name), foldersOnly),
|
||||
})
|
||||
} else {
|
||||
if foldersOnly || name == "index.md" {
|
||||
continue
|
||||
@@ -94,6 +207,33 @@ func listTreeEntries(fsPath string, foldersOnly bool) ([]treeEntry, error) {
|
||||
return append(folders, files...), nil
|
||||
}
|
||||
|
||||
// folderHasChildren reports whether fsPath contains at least one entry that
|
||||
// listTreeEntries would list. It reads the directory in small batches and
|
||||
// stops at the first match, so large folders stay cheap. Unreadable folders
|
||||
// report false.
|
||||
func folderHasChildren(fsPath string, foldersOnly bool) bool {
|
||||
f, err := os.Open(fsPath)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
defer f.Close()
|
||||
for {
|
||||
batch, err := f.ReadDir(32)
|
||||
for _, e := range batch {
|
||||
name := e.Name()
|
||||
if strings.HasPrefix(name, ".") {
|
||||
continue
|
||||
}
|
||||
if e.IsDir() || (!foldersOnly && name != "index.md") {
|
||||
return true
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// treePathSegments splits a wiki path into its non-empty segments.
|
||||
func treePathSegments(p string) []string {
|
||||
var segs []string
|
||||
@@ -104,28 +244,3 @@ func treePathSegments(p string) []string {
|
||||
}
|
||||
return segs
|
||||
}
|
||||
|
||||
// expandTreeChain walks segs, matching each against a folder in entries by
|
||||
// name, loading that folder's children in place, and recursing. The walk stops
|
||||
// at the deepest matching segment, so a stale or deleted path simply expands as
|
||||
// far as it still exists. Segments only ever match real directory names from
|
||||
// listTreeEntries (no "." or ".." entries), so this cannot traverse outside the
|
||||
// listed tree.
|
||||
func expandTreeChain(fsPath string, entries []treeEntry, segs []string, foldersOnly bool) {
|
||||
if len(segs) == 0 {
|
||||
return
|
||||
}
|
||||
for i := range entries {
|
||||
if entries[i].Kind != "folder" || entries[i].Name != segs[0] {
|
||||
continue
|
||||
}
|
||||
childFs := filepath.Join(fsPath, segs[0])
|
||||
kids, err := listTreeEntries(childFs, foldersOnly)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
entries[i].Children = kids
|
||||
expandTreeChain(childFs, entries[i].Children, segs[1:], foldersOnly)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestHandleTreeExpandTo(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
for _, d := range []string{"Projects/A/Deep", "Other/B"} {
|
||||
if err := os.MkdirAll(filepath.Join(root, filepath.FromSlash(d)), 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
h := &handler{root: root}
|
||||
get := func(urlPath, query string) string {
|
||||
r := httptest.NewRequest("GET", urlPath+"?"+query, nil)
|
||||
w := httptest.NewRecorder()
|
||||
h.handleTree(w, r, urlPath, filepath.Join(root, filepath.FromSlash(strings.Trim(urlPath, "/"))))
|
||||
return w.Body.String()
|
||||
}
|
||||
|
||||
// A chain below the listed folder is pre-expanded.
|
||||
if body := get("/Projects/", "tree&expandTo=/Projects/A"); !strings.Contains(body, `href="/Projects/A/Deep/"`) {
|
||||
t.Errorf("expandTo below the folder did not expand:\n%s", body)
|
||||
}
|
||||
// Lazy children keep the picker options.
|
||||
if body := get("/", "tree&pick=folder&root=1"); !strings.Contains(body, `hx-get="/Projects/?tree&pick=folder"`) ||
|
||||
!strings.Contains(body, `value="/"`) {
|
||||
t.Errorf("picker rows missing lazy URL or root row:\n%s", body)
|
||||
}
|
||||
w := httptest.NewRecorder()
|
||||
h.handleTree(w, httptest.NewRequest("GET", "/?tree&pick=bogus", nil), "/", root)
|
||||
if w.Code != 400 {
|
||||
t.Errorf("bad pick mode: got %d, want 400", w.Code)
|
||||
}
|
||||
}
|
||||