Compare commits

...

18 Commits

Author SHA1 Message Date
luxick d719b53404 Add weekly averages for weight 2026-06-12 14:10:00 +02:00
luxick f870a12cd5 Adjust button sizes 2026-06-11 14:48:04 +02:00
luxick 0b62cd50f3 Fitness dashboard without calorie tracker
Does not compute correctly yet
2026-06-11 13:03:54 +02:00
luxick fde4eff12d Fitness dashboard v1 2026-06-11 12:55:43 +02:00
luxick 4f14b39d16 Improve Editor on mobile 2026-06-09 22:18:16 +02:00
luxick 204e89dbce Improve wiki linking 2026-06-09 18:36:40 +02:00
luxick 5525a03179 Pin toolbar to the bottom on mobile 2026-06-05 10:47:38 +02:00
luxick 11cae7df36 Update editor layout. add delete line button 2026-06-05 10:34:40 +02:00
luxick 7be8bec446 Improve mobile editing 2026-06-05 10:27:56 +02:00
luxick de3abed6d7 Use CodeMirror editor 2026-06-05 10:10:58 +02:00
luxick 4e24f876c9 Allow compainion opening in thumbnail view 2026-06-04 09:22:53 +02:00
luxick 0478bc6305 Add Readme info for view settings 2026-06-04 08:16:55 +02:00
luxick f85c29ba42 View switching feature 2026-05-29 09:21:19 +02:00
luxick 5844a870ce Cleanup 2026-05-28 21:34:46 +02:00
luxick 51bf489449 Refactoring of calendar widget 2026-05-28 21:28:16 +02:00
luxick 61e50c033f Rework diary to focus on yearly pages 2026-05-28 13:54:44 +02:00
luxick 20a6bac3d6 Unify tables and listings 2026-05-27 20:09:52 +02:00
luxick e089e0b2c3 CSS Refactor 2026-05-27 10:52:47 +02:00
40 changed files with 3444 additions and 1490 deletions
+3
View File
@@ -8,3 +8,6 @@ datascape
*.exe *.exe
bin/ bin/
companion/datascape-companion-* companion/datascape-companion-*
# Editor build tooling deps (the built bundle is committed; node_modules is not)
editor-build/node_modules/
+27 -3
View File
@@ -16,12 +16,35 @@ go build .
make deploy make deploy
``` ```
### Editor bundle (the one build-pipeline exception)
The page editor uses CodeMirror 6, vendored as a single pre-built IIFE at
`assets/editor/vendor/codemirror.bundle.js` and embedded via `embed.FS`. This is
the **only** deliberate exception to the "no build pipeline" rule below — it is a
one-time, committed artifact, not a runtime build. `go build` / `make deploy`
never touch Node and only consume the committed bundle.
Regenerate the bundle **only** when upgrading the `@codemirror/*` versions:
```bash
# bump versions in editor-build/package.json first, then:
make editor # runs `npm ci && npm run build` in editor-build/, rewrites the vendored bundle
```
Commit the regenerated `codemirror.bundle.js` and the updated
`editor-build/package-lock.json`. `editor-build/node_modules/` is gitignored.
The bundle is served immutable under a stable filename, so the edit template
appends `?v=<content-hash>` to its `<script>` src (`editorBundleVersion` in
`main.go`). The hash changes whenever the bundle bytes change, so a rebuilt
bundle busts client caches automatically — no manual version bump needed.
## HTTP API Surface ## HTTP API Surface
| Method | Path | Behaviour | | Method | Path | Behaviour |
|--------|------|-----------| |--------|------|-----------|
| GET | `/{path}/` | If folder exists: render `index.md` + list contents. If not: show empty create prompt. | | GET | `/{path}/` | If folder exists: render `index.md` + list contents. If not: show empty create prompt. |
| GET | `/{path}/?edit` | Mobile-friendly editor with `index.md` content in a textarea | | GET | `/{path}/?edit` | CodeMirror 6 editor initialized with `index.md` content |
| POST | `/{path}` | Write `index.md` to disk; creates the folder if it does not exist yet | | POST | `/{path}` | Write `index.md` to disk; creates the folder if it does not exist yet |
Non-existent paths without a trailing slash redirect to the slash form (GET only — POSTs Non-existent paths without a trailing slash redirect to the slash form (GET only — POSTs
@@ -49,7 +72,7 @@ Prefer separate, human-readable `.html` files over inlined HTML strings in Go. E
## Frontend Rules ## Frontend Rules
- Vanilla JS only — no frameworks, no build pipeline - Vanilla JS only — no frameworks, no build pipeline (the single exception is the vendored CodeMirror editor bundle; see Build & Deploy)
- Each feature gets its own JS file; global behaviour goes in `global-shortcuts.js` - Each feature gets its own JS file; global behaviour goes in `global-shortcuts.js`
- Do not inline JS in templates or merge unrelated features into one file - Do not inline JS in templates or merge unrelated features into one file
- `ALT+SHIFT` is the modifier for all keyboard shortcuts — do not introduce others - `ALT+SHIFT` is the modifier for all keyboard shortcuts — do not introduce others
@@ -88,7 +111,8 @@ When building features, apply this order:
## Date Formatting ## Date Formatting
- General UI dates (file listings, metadata): ISO `YYYY-MM-DD` - General UI dates (file listings, metadata): ISO `YYYY-MM-DD`
- Diary long-form dates: German locale, e.g. `Mittwoch, 1. April 2026` — use `formatGermanDate` in `diary.go`; Go's `time.Format` is English-only so locale names are kept in maps keyed by `time.Weekday` / `time.Month` - Diary headings (year/month/day) are also ISO short form: `# 2026`, `## 2026-05`, `### 2026-05-28`. No long-form rendering.
- Calendar widget month names are German; the `germanMonths` map in `diary.go` keeps the labels keyed by `time.Month` since Go's `time.Format` is English-only.
## What to Avoid ## What to Avoid
+13 -1
View File
@@ -4,7 +4,10 @@ COMPANION_WIN := companion/datascape-companion-windows-amd64.exe
COMPANION_LIN := companion/datascape-companion-linux-amd64 COMPANION_LIN := companion/datascape-companion-linux-amd64
COMPANION_SRCS := $(wildcard cmd/companion/*.go) $(wildcard cmd/companion/*.html) go.mod go.sum COMPANION_SRCS := $(wildcard cmd/companion/*.go) $(wildcard cmd/companion/*.html) go.mod go.sum
.PHONY: deploy companion companion-windows companion-linux companion-release EDITOR_BUNDLE := assets/editor/vendor/codemirror.bundle.js
EDITOR_SRCS := $(wildcard editor-build/*.js) editor-build/package.json editor-build/package-lock.json
.PHONY: deploy companion companion-windows companion-linux companion-release editor
# Cross-compiled companion artifacts the wiki binary embeds. Both must exist # Cross-compiled companion artifacts the wiki binary embeds. Both must exist
# before `go build .` so embed.FS picks them up. # before `go build .` so embed.FS picks them up.
@@ -24,6 +27,15 @@ companion:
mkdir -p bin mkdir -p bin
go build -o bin/ ./cmd/companion go build -o bin/ ./cmd/companion
# Regenerate the vendored CodeMirror bundle. One-time/dev-only step: run after
# upgrading the @codemirror/* versions in editor-build/package.json. The built
# artifact ($(EDITOR_BUNDLE)) is committed; `go build` only consumes it and
# never runs Node.
editor: $(EDITOR_BUNDLE)
$(EDITOR_BUNDLE): $(EDITOR_SRCS)
cd editor-build && npm ci && npm run build
deploy: companion-release deploy: companion-release
GOOS=linux GOARCH=arm GOARM=7 go build -o datascape-arm . GOOS=linux GOARCH=arm GOARM=7 go build -o datascape-arm .
ssh $(NAS) 'kill $$(cat /share/homes/luxick/.local/bin/datascape.pid) 2>/dev/null; rm -f /share/homes/luxick/.local/bin/datascape.pid' ssh $(NAS) 'kill $$(cat /share/homes/luxick/.local/bin/datascape.pid) 2>/dev/null; rm -f /share/homes/luxick/.local/bin/datascape.pid'
+39 -15
View File
@@ -6,6 +6,8 @@ Minimal self-hosted personal wiki. Folders are pages.
- **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** every folder is a page. Place an `index.md` inside a folder and it renders as HTML. Drop any other files (PDFs, images, etc.) alongside it and they appear in the listing below the content. Navigating to a path that does not exist shows a **[CREATE]** prompt.
- **View settings** per folder, display the file listing as a list or thumbnail grid and pick the sort key/order, via the **view** button in the `Files` header. See the [View Settings](#view-settings) section.
- **Search** search across all page names (folder names) in the wiki, accessible from the navigation bar. - **Search** search across all page names (folder names) in the wiki, accessible from the navigation bar.
- **Wikilinks** link between pages with `[[Page Name]]` syntax. When a page is renamed or moved, all wikilinks pointing to it are rewritten automatically to reflect the new path. - **Wikilinks** link between pages with `[[Page Name]]` syntax. When a page is renamed or moved, all wikilinks pointing to it are rewritten automatically to reflect the new path.
@@ -42,9 +44,22 @@ go run . -dir ./wiki -addr :8080 -user me -pass secret
| `-pass` | _(none)_ | Basic auth password | | `-pass` | _(none)_ | Basic auth password |
| `-reindex-interval` | `30m` | Periodic search index rebuild interval (`0` disables) | | `-reindex-interval` | `30m` | Periodic search index rebuild interval (`0` disables) |
## View Settings
The **view** button in a folder's `Files` header sets how its listing renders,
persisting three keys to `.page-settings`:
| Key | Values (default first) |
|------|------------------------|
| `view` | `list`, `thumbnail` |
| `sort` | `name`, `modified`, `size` (folders always sort by name, grouped first) |
| `order` | `asc`, `desc` |
## Special Folder Types ## Special Folder Types
A folder can opt into special rendering by adding a `.page-settings` file: A folder can opt into special rendering by adding a `.page-settings` file. The
same file also holds the [View Settings](#view-settings) keys; only the `type`
key selects a special renderer:
``` ```
type = diary type = diary
@@ -52,36 +67,45 @@ type = diary
### Diary ### Diary
Designed for a chronological photo diary. Expected structure: Designed for a chronological photo diary. The whole year lives in a single
file as ISO-headed sections; photos are loose JPEGs named with a date prefix.
``` ```
FolderName/ FolderName/
.page-settings ← type = diary .page-settings ← type = diary
YYYY/ YYYY/
YYYY-MM-DD Desc.jpg ← photos named with date prefix index.md ← `# YYYY` + `## YYYY-MM` + `### YYYY-MM-DD` sections
MM/ YYYY-MM-DD Desc.jpg ← photos named with the date they belong to
DD/
index.md ← diary entry for that day
``` ```
| View | What renders | 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
| Year (`YYYY/`) | Section per month with link and photo count | yet contain are rendered as **virtual** headings with an `[edit]` button that
| Month (`MM/`) | Section per day with entry content and photo grid | splices a new section into the year file at the right chronological position;
| Day (`DD/`) | Entry content and photo grid | virtual day headings still carry photos for that date. Past years render
every month/day slot; the current year stops at today; future years skip
virtual entries entirely. The file may contain non-date headings (e.g.
`## Events``### Festival` between `# YYYY` and `## YYYY-01`); these keep
their document position.
Days with photos but no `index.md` still appear in the month view and can be created by clicking their heading link. A sidebar calendar widget shows one month grid at a time; the month-name
button opens a dropdown of all twelve months, and a separate year dropdown
jumps between years. Day cells link to the matching anchor on the year page
regardless of whether the date has a real section yet.
#### Persistent date links #### Persistent date links
Each diary root exposes three stable paths intended for browser bookmarks. They redirect to the current dated URL on every visit: Each diary root exposes three stable paths intended for browser bookmarks.
They resolve against the year page rather than separate per-day URLs:
| Path | Redirects to | | Path | Redirects to |
|------|-------------| |------|-------------|
| `<diary>/today/` | `<diary>/YYYY/MM/DD/` (or `…/?edit` if the day folder does not exist yet) | | `<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/MM/` | | `<diary>/this-month/` | `<diary>/YYYY/#YYYY-MM` |
| `<diary>/this-year/` | `<diary>/YYYY/` | | `<diary>/this-year/` | `<diary>/YYYY/` |
Legacy `YYYY/MM/` and `YYYY/MM/DD/` URLs (no longer the canonical form) redirect to the matching anchor on the year page.
## Quick-Add Bookmarklet ## Quick-Add Bookmarklet
Replace `wiki.host` with your wiki host and `/Topics/Bookmarks/` with the destination page (one bookmarklet per target): Replace `wiki.host` with your wiki host and `/Topics/Bookmarks/` with the destination page (one bookmarklet per target):
+6 -4
View File
@@ -115,13 +115,15 @@
function wireFileLinks() { function wireFileLinks() {
if (!state.available) return; if (!state.available) return;
document.addEventListener('click', function (e) { document.addEventListener('click', function (e) {
var item = e.target.closest && e.target.closest('.listing-item'); if (!e.target.closest) return;
if (!item) return; // Match both listing styles: table rows expose the file link inside
var anchor = e.target.closest('a'); // a .list-item row; thumbnail tiles are bare a.thumb-tile anchors.
var anchor = e.target.closest('.list-item a, a.thumb-tile');
if (!anchor) return; if (!anchor) return;
var item = anchor.closest('.list-item');
// Only intercept the primary file link, and only for files (not folders). // Only intercept the primary file link, and only for files (not folders).
// Folders end with "/" — let the browser navigate normally. // Folders end with "/" — let the browser navigate normally.
var path = item.dataset.path || anchor.getAttribute('href'); var path = (item && item.dataset.path) || anchor.getAttribute('href');
if (!path || path.endsWith('/')) return; if (!path || path.endsWith('/')) return;
// Allow modified clicks (open in new tab, etc.) to pass through. // Allow modified clicks (open in new tab, etc.) to pass through.
if (e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return; if (e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return;
+9 -6
View File
@@ -1,22 +1,24 @@
<div class="diary-cal"> <div class="diary-cal panel panel-sidebar"
data-display-year="{{.DisplayYear}}"
data-display-month="{{.DisplayMonth}}">
<div class="panel-header"><a href="{{.DiaryURL}}">Chronological</a></div> <div class="panel-header"><a href="{{.DiaryURL}}">Chronological</a></div>
<div class="diary-cal-nav"> <div class="diary-cal-nav">
<a href="{{.MonthURL}}" class="diary-cal-heading">{{.MonthName}}</a>
<div class="dropdown diary-cal-drop"> <div class="dropdown diary-cal-drop">
<button type="button" class="btn btn-small" data-action="cal-month-drop" aria-expanded="false" title="Monat wählen"></button> <button type="button" class="btn" data-cal-month-link data-action="cal-month-drop" aria-expanded="false" title="Monat wählen"> {{.DisplayMonthName}} </button>
<div class="dropdown-menu scrollable"> <div class="dropdown-menu scrollable">
{{range .AllMonths}}<a class="btn btn-block{{if .IsCurrent}} cal-current{{end}}" href="{{.URL}}">{{.Name}}</a>{{end}} {{range .Months}}<a class="btn btn-block" data-cal-month-jump="{{.Num}}" href="{{.AnchorURL}}">{{.Name}}</a>{{end}}
</div> </div>
</div> </div>
<a href="{{.YearURL}}" class="diary-cal-heading">{{.DisplayYear}}</a> <a href="{{.YearURL}}" class="diary-cal-heading">{{.DisplayYear}}</a>
<div class="dropdown diary-cal-drop"> <div class="dropdown diary-cal-drop">
<button type="button" class="btn btn-small" data-action="cal-year-drop" aria-expanded="false" title="Jahr wählen"></button> <button type="button" class="btn" data-action="cal-year-drop" aria-expanded="false" title="Jahr wählen"></button>
<div class="dropdown-menu align-right scrollable"> <div class="dropdown-menu align-right scrollable">
{{range .Years}}<a class="btn btn-block{{if .IsCurrent}} cal-current{{end}}" href="{{.URL}}">{{.Num}}</a>{{end}} {{range .Years}}<a class="btn btn-block{{if .IsCurrent}} cal-current{{end}}" href="{{.URL}}">{{.Num}}</a>{{end}}
</div> </div>
</div> </div>
</div> </div>
<table class="diary-cal-grid"> {{range .Months}}
<table class="diary-cal-grid" data-cal-month="{{.Num}}"{{if ne .Num $.DisplayMonth}} hidden{{end}}>
<thead> <thead>
<tr><th>Mo</th><th>Di</th><th>Mi</th><th>Do</th><th>Fr</th><th>Sa</th><th>So</th></tr> <tr><th>Mo</th><th>Di</th><th>Mi</th><th>Do</th><th>Fr</th><th>Sa</th><th>So</th></tr>
</thead> </thead>
@@ -25,5 +27,6 @@
{{end}} {{end}}
</tbody> </tbody>
</table> </table>
{{end}}
</div> </div>
<script src="/_/diary/calendar.js"></script> <script src="/_/diary/calendar.js"></script>
+44
View File
@@ -2,4 +2,48 @@
var cal = document.querySelector(".diary-cal"); var cal = document.querySelector(".diary-cal");
if (!cal) return; if (!cal) return;
cal.querySelectorAll(".dropdown > button").forEach(wireDropdown); cal.querySelectorAll(".dropdown > button").forEach(wireDropdown);
var displayYear = parseInt(cal.dataset.displayYear, 10);
var current = parseInt(cal.dataset.displayMonth, 10);
var monthLabel = cal.querySelector("[data-cal-month-link]");
var months = {};
cal.querySelectorAll("[data-cal-month]").forEach(function (t) {
months[parseInt(t.dataset.calMonth, 10)] = t;
});
var jumpLinks = {};
cal.querySelectorAll("[data-cal-month-jump]").forEach(function (a) {
jumpLinks[parseInt(a.dataset.calMonthJump, 10)] = a;
});
function show(m) {
if (m === current) return;
if (!months[m]) return;
months[current].hidden = true;
months[m].hidden = false;
current = m;
if (monthLabel) {
var label = jumpLinks[m];
if (label) monthLabel.textContent = " " + label.textContent + " ";
}
}
// Dropdown month picks scroll via the href anchor; we also swap the grid
// so the calendar reflects what the user just navigated to.
Object.keys(jumpLinks).forEach(function (key) {
var a = jumpLinks[key];
a.addEventListener("click", function () { show(parseInt(key, 10)); });
});
// Any in-page anchor click (#YYYY-MM or #YYYY-MM-DD) updates the calendar
// so it tracks the user's focus through the year page.
function syncFromHash() {
var h = window.location.hash;
if (!h) return;
var m = h.match(/^#(\d{4})-(\d{2})(?:-\d{2})?$/);
if (!m) return;
if (parseInt(m[1], 10) !== displayYear) return;
show(parseInt(m[2], 10));
}
window.addEventListener("hashchange", syncFromHash);
syncFromHash();
})(); })();
+13
View File
@@ -0,0 +1,13 @@
{{range .Sections}}
{{if eq .Level 1}}<h1 id="{{.ID}}">{{.Heading}}{{if .EditURL}} <a href="{{.EditURL}}" class="btn btn-small">edit</a>{{end}}</h1>
{{else if eq .Level 2}}<h2 id="{{.ID}}">{{.Heading}}{{if .EditURL}} <a href="{{.EditURL}}" class="btn btn-small">edit</a>{{end}}</h2>
{{else}}<h3 id="{{.ID}}">{{.Heading}}{{if .EditURL}} <a href="{{.EditURL}}" class="btn btn-small">edit</a>{{end}}</h3>{{end}}
{{if .Body}}{{.Body}}{{end}}
{{if .Photos}}
<div class="photo-grid">
{{range .Photos}}
<a href="{{.URL}}" target="_blank"><img src="{{.ThumbURL}}" alt="" loading="lazy"></a>
{{end}}
</div>
{{end}}
{{end}}
-7
View File
@@ -1,7 +0,0 @@
{{if .Photos}}
<div class="photo-grid">
{{range .Photos}}
<a href="{{.URL}}" target="_blank"><img src="{{.ThumbURL}}" alt="" loading="lazy"></a>
{{end}}
</div>
{{end}}
-13
View File
@@ -1,13 +0,0 @@
{{range .Days}}
<h2 id="{{.ID}}">{{if .URL}}<a href="{{.URL}}">{{.Heading}}</a>{{else}}{{.Heading}}{{end}}
{{if .EditURL}}<a href="{{.EditURL}}" class="btn btn-small">edit</a>{{end}}
</h2>
{{if .Content}}{{.Content}}{{end}}
{{if .Photos}}
<div class="photo-grid">
{{range .Photos}}
<a href="{{.URL}}" target="_blank"><img src="{{.ThumbURL}}" alt="" loading="lazy"></a>
{{end}}
</div>
{{end}}
{{end}}
-11
View File
@@ -1,11 +0,0 @@
<h2 id="months">Monate</h2>
{{range .Months}}
<h3 id="{{.ID}}"><a href="{{.URL}}">{{.Name}}</a></h3>
{{if .Photos}}
<div class="photo-grid">
{{range .Photos}}
<a href="{{.URL}}" target="_blank"><img src="{{.ThumbURL}}" alt="" loading="lazy"></a>
{{end}}
</div>
{{end}}
{{end}}
-86
View File
@@ -1,86 +0,0 @@
window.EditorLists = (function () {
function detectListPrefix(lineText) {
var m;
m = lineText.match(/^(\s*)(- \[[ x]\] )/);
if (m) return { indent: m[1], prefix: m[2], type: 'task' };
m = lineText.match(/^(\s*)([-*+] )/);
if (m) return { indent: m[1], prefix: m[2], type: 'unordered' };
m = lineText.match(/^(\s*)(\d+)\. /);
if (m) return { indent: m[1], prefix: m[2] + '. ', type: 'ordered', num: parseInt(m[2], 10) };
m = lineText.match(/^(\s*)(> )/);
if (m) return { indent: m[1], prefix: m[2], type: 'blockquote' };
return null;
}
function continuationPrefix(info) {
if (info.type === 'task') return info.indent + '- [ ] ';
if (info.type === 'ordered') return info.indent + (info.num + 1) + '. ';
return info.indent + info.prefix;
}
function renumberOrderedList(text, fromLineIndex, indent, startNum) {
var lines = text.split('\n');
var re = new RegExp('^' + indent.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '(\\d+)\\. ');
var num = startNum !== undefined ? startNum : null;
for (var i = fromLineIndex; i < lines.length; i++) {
var m = lines[i].match(re);
if (!m) break;
if (num === null) num = parseInt(m[1], 10);
var newNumStr = String(num);
if (m[1] !== newNumStr) {
lines[i] = indent + newNumStr + '. ' + lines[i].slice(m[0].length);
}
num++;
}
return lines.join('\n');
}
function handleEnterKey(text, cursorPos) {
var before = text.slice(0, cursorPos);
var after = text.slice(cursorPos);
var lineStart = before.lastIndexOf('\n') + 1;
var lineEnd = text.indexOf('\n', cursorPos);
if (lineEnd === -1) lineEnd = text.length;
var fullLine = text.slice(lineStart, lineEnd);
var info = detectListPrefix(fullLine);
if (!info) return null;
var contentAfterPrefix = fullLine.slice(info.indent.length + info.prefix.length);
if (contentAfterPrefix.trim() === '') {
var newText = text.slice(0, lineStart) + '\n' + after;
var newCursor = lineStart + 1;
if (info.type === 'ordered') {
var lineIndex = text.slice(0, lineStart).split('\n').length;
newText = renumberOrderedList(newText, lineIndex, info.indent);
}
return { text: newText, cursor: newCursor };
}
var cont = continuationPrefix(info);
var newText = before + '\n' + cont + after;
var newCursor = cursorPos + 1 + cont.length;
if (info.type === 'ordered') {
var insertedLineIndex = before.split('\n').length;
newText = renumberOrderedList(newText, insertedLineIndex, info.indent);
}
return { text: newText, cursor: newCursor };
}
function deleteOrderedLine(text, cursorPos) {
var lineStart = text.lastIndexOf('\n', cursorPos - 1) + 1;
var lineEnd = text.indexOf('\n', cursorPos);
if (lineEnd === -1) lineEnd = text.length;
var fullLine = text.slice(lineStart, lineEnd);
var info = detectListPrefix(fullLine);
if (!info || info.type !== 'ordered') return null;
var newText = text.slice(0, lineStart) + text.slice(lineEnd === text.length ? lineEnd : lineEnd + 1);
var newCursor = lineStart;
var fromLineIndex = text.slice(0, lineStart).split('\n').length - 1;
newText = renumberOrderedList(newText, fromLineIndex, info.indent, info.num);
return { text: newText, cursor: Math.min(newCursor, newText.length) };
}
return { handleEnterKey: handleEnterKey, deleteOrderedLine: deleteOrderedLine };
})();
+10 -5
View File
@@ -5,11 +5,16 @@
{{define "content"}} {{define "content"}}
<script> <script>
if (sessionStorage.getItem('editor-wide') === '1') document.body.classList.add('editor-wide'); document.body.classList.add('edit-mode');
</script> </script>
<form id="edit-form" class="edit-form" method="POST" action="{{.PostURL}}"> <form id="edit-form" class="edit-form" method="POST" action="{{.PostURL}}">
{{if ge .SectionIndex 0}}<input type="hidden" name="section" value="{{.SectionIndex}}">{{end}} {{if ge .SectionIndex 0}}<input type="hidden" name="section" value="{{.SectionIndex}}">{{end}}
{{if ge .InsertBefore 0}}<input type="hidden" name="insert_before" value="{{.InsertBefore}}">{{end}}
<div class="editor-toolbar"> <div class="editor-toolbar">
<button type="button" class="btn btn-tool" data-action="undo" title="Undo"></button>
<button type="button" class="btn btn-tool" data-action="redo" title="Redo"></button>
<button type="button" class="btn btn-tool" data-action="deleteline" data-key="Y" title="Delete line (Y)">×</button>
<span class="toolbar-sep"></span>
<button type="button" class="btn btn-tool" data-action="bold" data-key="B" title="Bold (B)">**</button> <button type="button" class="btn btn-tool" data-action="bold" data-key="B" title="Bold (B)">**</button>
<button type="button" class="btn btn-tool" data-action="italic" data-key="I" title="Italic (I)">*</button> <button type="button" class="btn btn-tool" data-action="italic" data-key="I" title="Italic (I)">*</button>
<span class="dropdown"> <span class="dropdown">
@@ -67,14 +72,14 @@
<button type="button" class="btn btn-tool btn-block" data-action="movie" data-key="V" title="Import movie (V)">Import movie</button> <button type="button" class="btn btn-tool btn-block" data-action="movie" data-key="V" title="Import movie (V)">Import movie</button>
</div> </div>
</span> </span>
<span class="toolbar-sep"></span>
<button type="button" class="btn btn-tool" data-action="wide" data-key="Z" title="Toggle wide mode (Z)"></button>
</div> </div>
<textarea name="content" id="editor" autofocus>{{.RawContent}}</textarea> <div id="editor" class="editor-cm"></div>
<textarea name="content" id="editor-content" hidden>{{.RawContent}}</textarea>
</form> </form>
<script src="/_/editor/lists.js"></script> <script src="/_/editor/vendor/codemirror.bundle.js?v={{editorBundleVersion}}"></script>
<script src="/_/editor/tables.js"></script> <script src="/_/editor/tables.js"></script>
<script src="/_/editor/dates.js"></script> <script src="/_/editor/dates.js"></script>
<script src="/_/editor/movie.js"></script> <script src="/_/editor/movie.js"></script>
<script src="/_/editor/wikicomplete.js"></script>
<script src="/_/editor/main.js"></script> <script src="/_/editor/main.js"></script>
{{end}} {{end}}
+214 -119
View File
@@ -1,71 +1,183 @@
(function () { (function () {
var textarea = document.getElementById('editor'); var mount = document.getElementById('editor');
if (!textarea) return; var hidden = document.getElementById('editor-content');
if (!mount || !hidden || !window.CM) return;
var form = textarea.closest('form'); var form = hidden.closest('form');
// --- DOM helpers --- var T = EditorTables;
var D = EditorDates;
var M = EditorMovie;
// Route every edit through execCommand so the browser's native undo/redo // --- CodeMirror setup ---
// stack is preserved. Direct assignment to textarea.value would wipe it.
function replaceRange(start, end, text) { // Shift+Enter (new table row below) / Shift+Delete (delete table row) run at
textarea.focus(); // highest precedence so they win over CM's default newline/forward-delete.
textarea.selectionStart = start; // Returning false (no table at cursor) lets CM fall back to its default.
textarea.selectionEnd = end; function tableKey(fn) {
document.execCommand('insertText', false, text); return function (view) {
var result = fn(view.state.doc.toString(), view.state.selection.main.head);
if (!result) return false;
dispatchFullReplace(result);
return true;
};
} }
function wrap(before, after, placeholder) { var tableKeymap = [
var start = textarea.selectionStart; { key: 'Shift-Enter', run: tableKey(T.insertRowBelow) },
var end = textarea.selectionEnd; { key: 'Shift-Delete', run: tableKey(T.deleteRow) },
var hadSelection = end > start; ];
var selected = hadSelection ? textarea.value.slice(start, end) : placeholder;
replaceRange(start, end, before + selected + after);
if (!hadSelection) {
textarea.selectionStart = start + before.length;
textarea.selectionEnd = start + before.length + placeholder.length;
}
}
function linePrefix(prefix) { var state = CM.EditorState.create({
var start = textarea.selectionStart; doc: hidden.value,
var lineStart = textarea.value.lastIndexOf('\n', start - 1) + 1; extensions: [
replaceRange(lineStart, lineStart, prefix); CM.history(),
textarea.selectionStart = textarea.selectionEnd = start + prefix.length; CM.drawSelection(),
CM.indentOnInput(),
CM.EditorView.lineWrapping,
// Enable native browser spellcheck on the contenteditable surface
// (CM6 leaves it off by default). autocapitalize helps prose entry
// on the Android/mobile path; CM's DOM observer absorbs corrections.
CM.EditorView.contentAttributes.of({ spellcheck: 'true', autocapitalize: 'sentences' }),
CM.markdown({ base: CM.markdownLanguage }),
CM.syntaxHighlighting(CM.highlightStyle),
CM.closeBrackets(),
CM.autocompletion({ override: [WikiComplete.source] }),
CM.theme,
CM.Prec.highest(CM.keymap.of(tableKeymap)),
CM.keymap.of([].concat(
CM.closeBracketsKeymap,
CM.completionKeymap,
CM.markdownKeymap,
CM.defaultKeymap,
CM.historyKeymap,
[CM.indentWithTab]
)),
],
});
var view = new CM.EditorView({ state: state, parent: mount });
view.focus();
// --- CM document helpers ---
function dispatchFullReplace(result) {
view.dispatch({
changes: { from: 0, to: view.state.doc.length, insert: result.text },
selection: { anchor: result.cursor },
scrollIntoView: true,
});
view.focus();
} }
function insertAtCursor(s) { function insertAtCursor(s) {
replaceRange(textarea.selectionStart, textarea.selectionEnd, s); var sel = view.state.selection.main;
view.dispatch({
changes: { from: sel.from, to: sel.to, insert: s },
selection: { anchor: sel.from + s.length },
scrollIntoView: true,
});
view.focus();
} }
function applyResult(result) { function wrap(before, after, placeholder) {
var oldText = textarea.value; var sel = view.state.selection.main;
var newText = result.text; var hadSelection = sel.to > sel.from;
var prefixLen = 0; var selected = hadSelection ? view.state.sliceDoc(sel.from, sel.to) : placeholder;
var maxPrefix = Math.min(oldText.length, newText.length); var insert = before + selected + after;
while (prefixLen < maxPrefix && oldText.charCodeAt(prefixLen) === newText.charCodeAt(prefixLen)) { var anchor, head;
prefixLen++; if (hadSelection) {
anchor = head = sel.from + insert.length;
} else {
anchor = sel.from + before.length;
head = anchor + placeholder.length;
} }
var oldEnd = oldText.length; view.dispatch({
var newEnd = newText.length; changes: { from: sel.from, to: sel.to, insert: insert },
while (oldEnd > prefixLen && newEnd > prefixLen selection: { anchor: anchor, head: head },
&& oldText.charCodeAt(oldEnd - 1) === newText.charCodeAt(newEnd - 1)) { scrollIntoView: true,
oldEnd--; });
newEnd--; view.focus();
} }
replaceRange(prefixLen, oldEnd, newText.slice(prefixLen, newEnd));
textarea.selectionStart = textarea.selectionEnd = result.cursor; function linePrefix(prefix) {
var sel = view.state.selection.main;
var line = view.state.doc.lineAt(sel.from);
view.dispatch({
changes: { from: line.from, to: line.from, insert: prefix },
selection: { anchor: sel.from + prefix.length },
scrollIntoView: true,
});
view.focus();
} }
function applyTableOp(fn, arg) { function applyTableOp(fn, arg) {
var result = arg !== undefined var text = view.state.doc.toString();
? fn(textarea.value, textarea.selectionStart, arg) var pos = view.state.selection.main.head;
: fn(textarea.value, textarea.selectionStart); var result = arg !== undefined ? fn(text, pos, arg) : fn(text, pos);
if (result) applyResult(result); if (result) dispatchFullReplace(result);
} }
// Adapter passed to movie.js so it reads/writes the CM document instead of a
// textarea (replace() dispatches a transaction; cursor lands after the block).
var movieCtx = {
getValue: function () { return view.state.doc.toString(); },
replace: function (start, end, text) {
view.dispatch({
changes: { from: start, to: end, insert: text },
selection: { anchor: start + text.length },
scrollIntoView: true,
});
view.focus();
},
};
// --- Content sync ---
// Serialize the CM document into the hidden textarea on submit only — Save
// must not depend on CM focus or async state (decision 8). requestSubmit
// fires this listener even for the ALT+SHIFT+S path.
function syncContent() {
hidden.value = view.state.doc.toString();
}
form.addEventListener('submit', syncContent);
// --- Actions ---
var actions = {
save: function () { form.requestSubmit(); },
undo: function () { CM.undo(view); view.focus(); },
redo: function () { CM.redo(view); view.focus(); },
deleteline: function () { CM.deleteLine(view); view.focus(); },
bold: function () { wrap('**', '**', 'bold text'); },
italic: function () { wrap('*', '*', 'italic text'); },
h1: function () { linePrefix('# '); },
h2: function () { linePrefix('## '); },
h3: function () { linePrefix('### '); },
code: function () { wrap('`', '`', 'code'); },
codeblock: function () { wrap('```\n', '\n```', 'code'); },
quote: function () { linePrefix('> '); },
link: function () { wrap('[', '](url)', 'link text'); },
wikilink: insertWikilink,
ul: function () { linePrefix('- '); },
ol: function () { linePrefix('1. '); },
task: function () { linePrefix('- [ ] '); },
hr: function () { wrap('\n\n---\n\n', '', ''); },
fmttable: function () { applyTableOp(T.formatTableText); },
tblalignleft: function () { applyTableOp(T.setColumnAlignment, 'left'); },
tblaligncenter: function () { applyTableOp(T.setColumnAlignment, 'center'); },
tblalignright: function () { applyTableOp(T.setColumnAlignment, 'right'); },
tblinsertcol: function () { applyTableOp(T.insertColumn); },
tbldeletecol: function () { applyTableOp(T.deleteColumn); },
tblinsertrow: function () { applyTableOp(T.insertRow); },
tbldeleterow: function () { applyTableOp(T.deleteRow); },
dateiso: function () { insertAtCursor(D.isoDate()); },
datelong: function () { insertAtCursor(D.longDate()); },
movie: function () { M.run(movieCtx); },
};
// isValidWikiTarget mirrors the Go validator in wikilinks.go — absolute // isValidWikiTarget mirrors the Go validator in wikilinks.go — absolute
// path, no empty/dot segments. Used to gate the INSERT confirm button. // path, no empty/dot segments. Used to gate the modal's INSERT button.
function isValidWikiTarget(p) { function isValidWikiTarget(p) {
if (!p || p[0] !== '/') return false; if (!p || p[0] !== '/') return false;
var trimmed = p.replace(/^\/+|\/+$/g, ''); var trimmed = p.replace(/^\/+|\/+$/g, '');
@@ -77,23 +189,28 @@
return true; return true;
} }
// Wiki link button (ALT+SHIFT+P): open a modal with a target field backed by
// full /_search typeahead plus an optional display-text field, then insert
// [[target]] or [[target::display]] at the cursor. (Inline `[[` typing uses
// the folder-scoped completion in wikicomplete.js instead.)
function insertWikilink() { function insertWikilink() {
var sel = textarea.value.slice(textarea.selectionStart, textarea.selectionEnd); var sel = view.state.selection.main;
var selectedText = view.state.sliceDoc(sel.from, sel.to);
var container = document.createElement('div'); var container = document.createElement('div');
var targetWrap = document.createElement('div'); var targetWrap = document.createElement('div');
var targetInput = document.createElement('input'); var targetInput = document.createElement('input');
targetInput.type = 'text'; targetInput.type = 'text';
targetInput.className = 'modal-input'; targetInput.className = 'input';
targetInput.placeholder = 'Page path or search…'; targetInput.placeholder = 'Page path or search…';
targetWrap.appendChild(targetInput); targetWrap.appendChild(targetInput);
var displayInput = document.createElement('input'); var displayInput = document.createElement('input');
displayInput.type = 'text'; displayInput.type = 'text';
displayInput.className = 'modal-input'; displayInput.className = 'input';
displayInput.placeholder = 'Display text (optional)'; displayInput.placeholder = 'Display text (optional)';
if (sel) displayInput.value = sel; if (selectedText) displayInput.value = selectedText;
container.appendChild(targetWrap); container.appendChild(targetWrap);
container.appendChild(displayInput); container.appendChild(displayInput);
@@ -131,47 +248,6 @@
}); });
} }
// --- Actions ---
var T = EditorTables;
var L = EditorLists;
var D = EditorDates;
var M = EditorMovie;
var actions = {
save: function () { form.submit(); },
bold: function () { wrap('**', '**', 'bold text'); },
italic: function () { wrap('*', '*', 'italic text'); },
h1: function () { linePrefix('# '); },
h2: function () { linePrefix('## '); },
h3: function () { linePrefix('### '); },
code: function () { wrap('`', '`', 'code'); },
codeblock: function () { wrap('```\n', '\n```', 'code'); },
quote: function () { linePrefix('> '); },
link: function () { wrap('[', '](url)', 'link text'); },
wikilink: insertWikilink,
ul: function () { linePrefix('- '); },
ol: function () { linePrefix('1. '); },
task: function () { linePrefix('- [ ] '); },
hr: function () { wrap('\n\n---\n\n', '', ''); },
fmttable: function () { applyTableOp(T.formatTableText); },
tblalignleft: function () { applyTableOp(T.setColumnAlignment, 'left'); },
tblaligncenter: function () { applyTableOp(T.setColumnAlignment, 'center'); },
tblalignright: function () { applyTableOp(T.setColumnAlignment, 'right'); },
tblinsertcol: function () { applyTableOp(T.insertColumn); },
tbldeletecol: function () { applyTableOp(T.deleteColumn); },
tblinsertrow: function () { applyTableOp(T.insertRow); },
tbldeleterow: function () { applyTableOp(T.deleteRow); },
dateiso: function () { insertAtCursor(D.isoDate()); },
datelong: function () { insertAtCursor(D.longDate()); },
movie: function () { M.run(textarea); },
wide: function () {
var enabled = !document.body.classList.contains('editor-wide');
document.body.classList.toggle('editor-wide', enabled);
sessionStorage.setItem('editor-wide', enabled ? '1' : '0');
},
};
// --- Keyboard shortcut registration --- // --- Keyboard shortcut registration ---
var keyMap = {}; var keyMap = {};
@@ -185,6 +261,19 @@
} }
}); });
// Keep the editor focused when a toolbar button is tapped. Without this the
// button steals focus on mousedown, which dismisses the mobile soft keyboard
// before the action runs (and view.focus() can't reopen it without a direct
// gesture). preventDefault on mousedown blocks the focus shift; click still
// fires. Scoped to the toolbar so header SAVE/CANCEL are unaffected. Includes
// dropdown toggles, which also must not pull focus off the editor.
var toolbar = document.querySelector('.editor-toolbar');
if (toolbar) {
toolbar.addEventListener('mousedown', function (e) {
if (e.target.closest('.btn')) e.preventDefault();
});
}
document.addEventListener('keydown', function (e) { document.addEventListener('keydown', function (e) {
if (!e.altKey || !e.shiftKey) return; if (!e.altKey || !e.shiftKey) return;
// Shift+digit produces a layout-dependent character in e.key (e.g. "!" // Shift+digit produces a layout-dependent character in e.key (e.g. "!"
@@ -197,32 +286,38 @@
} }
}); });
// --- Textarea key handling ---
textarea.addEventListener('keydown', function (e) {
if (e.key === 'Delete' && e.shiftKey) {
var result = T.deleteRow(textarea.value, textarea.selectionStart)
|| L.deleteOrderedLine(textarea.value, textarea.selectionStart);
if (!result) return;
e.preventDefault();
applyResult(result);
return;
}
if (e.key === 'Enter' && e.shiftKey) {
var result = T.insertRowBelow(textarea.value, textarea.selectionStart);
if (!result) return;
e.preventDefault();
applyResult(result);
return;
}
if (e.key !== 'Enter') return;
var result = L.handleEnterKey(textarea.value, textarea.selectionStart);
if (!result) return;
e.preventDefault();
applyResult(result);
});
// --- Dropdowns --- // --- Dropdowns ---
document.querySelectorAll('.dropdown-toggle').forEach(wireDropdown); // The toolbar scrolls horizontally (so it clips its absolutely-positioned
// menus) and on mobile is fixed to the bottom of the viewport. Pin an open
// menu to the viewport so it escapes the clip, opening upward when there
// isn't room below it (the bottom-toolbar case).
function pinMenu(toggle, menu) {
if (!menu.classList.contains('is-open')) return;
var r = toggle.getBoundingClientRect();
var vh = window.innerHeight;
menu.style.position = 'fixed';
menu.style.overflowY = 'auto';
var spaceBelow = vh - r.bottom;
var spaceAbove = r.top;
if (spaceBelow < menu.offsetHeight + 8 && spaceAbove > spaceBelow) {
menu.style.top = 'auto';
menu.style.bottom = (vh - r.top) + 'px';
menu.style.maxHeight = (spaceAbove - 8) + 'px';
} else {
menu.style.bottom = 'auto';
menu.style.top = r.bottom + 'px';
menu.style.maxHeight = (spaceBelow - 8) + 'px';
}
var left = Math.min(r.left, document.documentElement.clientWidth - menu.offsetWidth - 4);
menu.style.left = Math.max(4, left) + 'px';
}
document.querySelectorAll('.dropdown-toggle').forEach(function (toggle) {
wireDropdown(toggle);
var menu = toggle.parentElement.querySelector('.dropdown-menu');
if (!menu || !toolbar || !toolbar.contains(toggle)) return;
// Runs after wireDropdown's own click handler has toggled is-open.
toggle.addEventListener('click', function () { pinMenu(toggle, menu); });
});
})(); })();
+15 -21
View File
@@ -48,26 +48,20 @@ window.EditorMovie = (function () {
return out.join('\n'); return out.join('\n');
} }
function replaceRange(ta, start, end, text) { // ctx is the CM adapter from main.js: { getValue(), replace(start,end,text) }.
ta.focus(); function insertOrReplace(ctx, markup) {
ta.selectionStart = start; var t = ctx.getValue() || '';
ta.selectionEnd = end;
document.execCommand('insertText', false, text);
}
function insertOrReplace(ta, markup) {
var t = ta.value || '';
var b = t.indexOf(BEGIN); var b = t.indexOf(BEGIN);
var e = t.indexOf(END); var e = t.indexOf(END);
if (b !== -1 && e !== -1 && e > b) { if (b !== -1 && e !== -1 && e > b) {
replaceRange(ta, b, e + END.length, markup); ctx.replace(b, e + END.length, markup);
} else { } else {
var h = t.match(/^#{1,6}\s+.+?\s*$/m); var h = t.match(/^#{1,6}\s+.+?\s*$/m);
if (h) { if (h) {
var idx = t.indexOf(h[0]) + h[0].length; var idx = t.indexOf(h[0]) + h[0].length;
replaceRange(ta, idx, idx, '\n\n' + markup); ctx.replace(idx, idx, '\n\n' + markup);
} else { } else {
replaceRange(ta, 0, 0, t ? markup + '\n\n' : markup); ctx.replace(0, 0, t ? markup + '\n\n' : markup);
} }
} }
} }
@@ -105,7 +99,7 @@ window.EditorMovie = (function () {
var input = document.createElement('input'); var input = document.createElement('input');
input.type = 'text'; input.type = 'text';
input.className = 'modal-input'; input.className = 'input';
input.placeholder = 'OMDb API key'; input.placeholder = 'OMDb API key';
body.appendChild(input); body.appendChild(input);
@@ -125,10 +119,10 @@ window.EditorMovie = (function () {
}); });
} }
function importWithKey(textarea, key, initialTitle) { function importWithKey(ctx, key, initialTitle) {
var input = document.createElement('input'); var input = document.createElement('input');
input.type = 'text'; input.type = 'text';
input.className = 'modal-input'; input.className = 'input';
input.placeholder = 'Title, optionally with (YYYY)'; input.placeholder = 'Title, optionally with (YYYY)';
input.value = initialTitle; input.value = initialTitle;
@@ -148,7 +142,7 @@ window.EditorMovie = (function () {
data.Error === 'Invalid API key!') { data.Error === 'Invalid API key!') {
localStorage.removeItem(STORAGE_KEY); localStorage.removeItem(STORAGE_KEY);
promptForKey(true, function (newKey) { promptForKey(true, function (newKey) {
importWithKey(textarea, newKey, raw); importWithKey(ctx, newKey, raw);
}); });
return; return;
} }
@@ -157,7 +151,7 @@ window.EditorMovie = (function () {
(data && data.Error) || 'Movie not found.'); (data && data.Error) || 'Movie not found.');
return; return;
} }
insertOrReplace(textarea, buildBlock(data)); insertOrReplace(ctx, buildBlock(data));
}) })
.catch(function () { .catch(function () {
showMessage('Import failed', 'OMDb lookup failed.'); showMessage('Import failed', 'OMDb lookup failed.');
@@ -167,16 +161,16 @@ window.EditorMovie = (function () {
}); });
} }
function run(textarea) { function run(ctx) {
var initialTitle = firstHeading(textarea.value || ''); var initialTitle = firstHeading(ctx.getValue() || '');
var key = localStorage.getItem(STORAGE_KEY); var key = localStorage.getItem(STORAGE_KEY);
if (!key) { if (!key) {
promptForKey(false, function (newKey) { promptForKey(false, function (newKey) {
importWithKey(textarea, newKey, initialTitle); importWithKey(ctx, newKey, initialTitle);
}); });
return; return;
} }
importWithKey(textarea, key, initialTitle); importWithKey(ctx, key, initialTitle);
} }
return { run: run }; return { run: run };
File diff suppressed because one or more lines are too long
+115
View File
@@ -0,0 +1,115 @@
// wikicomplete.js — the `[[` wikilink autocomplete source for CodeMirror.
//
// A level-by-level folder/file browser scoped to the path typed so far. It
// fires only once the `[[` token's content begins with `/` (targets are
// absolute; free-text search lives in the toolbar modal instead). The content
// is split into a parent path (up to and including the last `/`) and a partial
// segment (the text after it); the parent's children are fetched from the
// existing `?tree=1` endpoint and filtered to names containing the partial
// (case-insensitive substring).
//
// Picking a folder inserts `<name>/` and re-opens the popup to drill one level
// deeper; picking a file inserts `<name>` and stops. Only the current segment
// is replaced, so the trailing `]]` stays put and the cursor parks before it,
// leaving room to type a `::display` alias. Exposes window.WikiComplete.source
// for main.js to register via CM's autocompletion().
window.WikiComplete = (function () {
// treeURL builds the `?tree=1` request URL for an absolute parent path,
// percent-encoding each segment. A leading/trailing slash is tolerated;
// root resolves to `/?tree=1`.
function treeURL(parent) {
var trimmed = parent.replace(/^\/+/, '').replace(/\/+$/, '');
if (trimmed === '') return '/?tree=1';
var enc = trimmed.split('/').map(encodeURIComponent).join('/');
return '/' + enc + '/?tree=1';
}
function fetchTree(parent) {
return fetch(treeURL(parent), {
credentials: 'same-origin',
headers: { 'Accept': 'application/json' },
}).then(function (r) {
// A 404 means the parent folder doesn't exist (typo, or a path under
// a file) — treat it as "no completions", not an error.
if (r.status === 404) return null;
if (!r.ok) throw new Error('HTTP ' + r.status);
return r.json();
});
}
// makeApply builds the apply() for a chosen entry. It replaces only the
// current segment ([from, to]); the trailing `]]` is untouched, so the
// cursor ends up parked before it. Folders append `/` and re-open the popup
// to drill into the next level; files terminate.
function makeApply(name, kind) {
return function (view, completion, from, to) {
var isFolder = kind === 'folder';
var insert = isFolder ? name + '/' : name;
view.dispatch({
changes: { from: from, to: to, insert: insert },
selection: { anchor: from + insert.length },
scrollIntoView: true,
});
if (isFolder) {
// Re-open after the transaction so the completion plugin sees
// the updated document (the next level's parent path).
setTimeout(function () { CM.startCompletion(view); }, 0);
}
};
}
// CM completion source. Activates when `[[` is followed by content that
// begins with `/`. The content is split at its last `/` into a parent path
// and a partial segment; the parent's children are fetched, filtered to
// names containing the partial (case-insensitive substring), and offered
// with name-only labels.
function source(context) {
var match = context.matchBefore(/\[\[[^\]\n]*/);
if (!match) return null;
var content = context.state.sliceDoc(match.from + 2, context.pos);
if (content[0] !== '/') return null;
var lastSlash = content.lastIndexOf('/');
var parent = content.slice(0, lastSlash + 1);
var partial = content.slice(lastSlash + 1);
// Replace only the current segment: from the start of the partial up to
// the next `/` or `]` (or end of line). This narrows re-edits inside an
// existing `[[…]]` so drilling doesn't duplicate trailing text.
var from = match.from + 2 + lastSlash + 1;
var line = context.state.doc.lineAt(context.pos);
var to = context.pos;
while (to < line.to) {
var ch = context.state.sliceDoc(to, to + 1);
if (ch === '/' || ch === ']') break;
to++;
}
return new Promise(function (resolve) {
if (context.aborted) { resolve(null); return; }
fetchTree(parent).then(function (resp) {
if (context.aborted || !resp) { resolve(null); return; }
var needle = partial.toLowerCase();
var options = (resp.entries || []).reduce(function (acc, e) {
if (needle && e.name.toLowerCase().indexOf(needle) === -1) {
return acc;
}
acc.push({
label: e.name,
type: e.kind === 'folder' ? 'folder' : 'file',
apply: makeApply(e.name, e.kind),
});
return acc;
}, []);
// No validFor: the source re-runs on each keystroke, so every
// edit (more chars, backspace, or a `/` that drills into the
// next folder) re-fetches and re-filters from scratch.
resolve({ from: from, to: to, options: options });
}).catch(function () {
resolve(null);
});
});
}
return { source: source };
})();
+10
View File
@@ -0,0 +1,10 @@
// Fitness dashboard range dropdowns: changing one reloads the page with that
// chart's query parameter updated. Plain GET navigation — each range is a
// distinct, bookmarkable view, so no postReplace/history handling is needed.
document.addEventListener('change', function (e) {
var sel = e.target.closest('[data-fitness-range]');
if (!sel) return;
var url = new URL(window.location.href);
url.searchParams.set(sel.dataset.fitnessRange, sel.value);
window.location.href = url.toString();
});
+43
View File
@@ -0,0 +1,43 @@
{{define "fitnessChart"}}
<section class="fitness-chart panel">
<div class="fitness-chart-header row space-between">
<span class="caption">{{.Title}}</span>
<select class="input fitness-range" data-fitness-range="{{.Param}}" aria-label="{{.Title}} time range">
{{range .Options}}<option value="{{.Value}}"{{if .Selected}} selected{{end}}>{{.Label}}</option>{{end}}
</select>
</div>
{{if .Empty}}
<p class="fitness-empty is-empty">No data in this range.</p>
{{else}}
<svg class="fitness-svg" viewBox="0 0 {{.ViewW}} {{.ViewH}}" role="img" aria-label="{{.Title}}">
{{range .YTicks}}
<line class="chart-grid" x1="{{$.PlotX}}" y1="{{.Pos}}" x2="{{$.PlotR}}" y2="{{.Pos}}"/>
<text class="chart-label" x="{{$.YLabelX}}" y="{{.Pos}}" text-anchor="end" dominant-baseline="middle">{{.Label}}</text>
{{end}}
{{range .XTicks}}
<text class="chart-label" x="{{.Pos}}" y="{{$.XLabelY}}" text-anchor="{{.Anchor}}">{{.Label}}</text>
{{end}}
<line class="chart-axis" x1="{{.PlotX}}" y1="{{.PlotY}}" x2="{{.PlotX}}" y2="{{.PlotB}}"/>
<line class="chart-axis" x1="{{.PlotX}}" y1="{{.PlotB}}" x2="{{.PlotR}}" y2="{{.PlotB}}"/>
{{range .Lines}}
<polyline class="chart-line" points="{{.}}"/>
{{end}}
{{range .Dots}}
<circle class="chart-dot" cx="{{.X}}" cy="{{.Y}}" r="2.5"><title>{{.Title}}</title></circle>
{{end}}
{{if .Goal}}
<line class="chart-goal" x1="{{.PlotX}}" y1="{{.Goal.Y}}" x2="{{.PlotR}}" y2="{{.Goal.Y}}"/>
<text class="chart-goal-label" x="{{.PlotR}}" y="{{.Goal.LabelY}}" text-anchor="end">{{.Goal.Label}}</text>
{{end}}
</svg>
{{end}}
</section>
{{end}}
<div class="fitness-dash col">
{{if .Notice}}
<p class="muted">{{.Notice}}</p>
{{else}}
{{range .Charts}}{{template "fitnessChart" .}}{{end}}
{{end}}
</div>
<script src="/_/fitness/fitness.js"></script>
+4 -4
View File
@@ -2,7 +2,7 @@
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1, interactive-widget=resizes-content" />
<title>{{.Title}}</title> <title>{{.Title}}</title>
<link rel="icon" href="/_/favicon.ico" /> <link rel="icon" href="/_/favicon.ico" />
<link rel="preload" href="/_/fonts/IosevkaEtoile.woff2" as="font" type="font/woff2" crossorigin /> <link rel="preload" href="/_/fonts/IosevkaEtoile.woff2" as="font" type="font/woff2" crossorigin />
@@ -17,16 +17,16 @@
</head> </head>
<body> <body>
<header> <header>
<nav class="breadcrumb"> <nav class="breadcrumb row">
<a href="/" tabindex="-1" title="Home"><svg class="logo" viewBox="0 0 26.052269 26.052269" xmlns="http://www.w3.org/2000/svg"><g fill="none" stroke="currentColor" stroke-linejoin="miter" transform="matrix(0.05463483,8.1519706e-6,-8.1519706e-6,0.05463483,-64.560546,-24.6949)"><rect x="1188.537" y="457.92056" width="461.87488" height="462.15189" stroke-width="20.2288"/><path d="m1348.9955 456.59572.046 309.36839" stroke-width="19.6849"/><path d="m1200.3996 765.80237 441.8362-.0659" stroke-width="19.6849"/><path d="m1648.2897 620.244-299.2012.0446" stroke-width="20.5676"/><path d="m1491.6148 909.24806-.021-136.93117" stroke-width="19.6849"/><rect x="1191.6504" y="461.66092" width="457.09634" height="457.09634" stroke-width="19.6761"/></g></svg></a> <a href="/" tabindex="-1" title="Home"><svg class="logo" viewBox="0 0 26.052269 26.052269" xmlns="http://www.w3.org/2000/svg"><g fill="none" stroke="currentColor" stroke-linejoin="miter" transform="matrix(0.05463483,8.1519706e-6,-8.1519706e-6,0.05463483,-64.560546,-24.6949)"><rect x="1188.537" y="457.92056" width="461.87488" height="462.15189" stroke-width="20.2288"/><path d="m1348.9955 456.59572.046 309.36839" stroke-width="19.6849"/><path d="m1200.3996 765.80237 441.8362-.0659" stroke-width="19.6849"/><path d="m1648.2897 620.244-299.2012.0446" stroke-width="20.5676"/><path d="m1491.6148 909.24806-.021-136.93117" stroke-width="19.6849"/><rect x="1191.6504" y="461.66092" width="457.09634" height="457.09634" stroke-width="19.6761"/></g></svg></a>
{{if .ParentURL}}<a class="nav-up" href="{{.ParentURL}}" tabindex="-1" title="Up" aria-label="Up">Up <svg viewBox="0 0 16 16" width="1em" height="1em" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linejoin="miter" stroke-linecap="square"><path d="M8 13V3M3 8l5-5 5 5"/></svg></a>{{end}} {{if .ParentURL}}<a class="nav-up" href="{{.ParentURL}}" tabindex="-1" title="Up" aria-label="Up">Up <svg viewBox="0 0 16 16" width="1em" height="1em" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linejoin="miter" stroke-linecap="square"><path d="M8 13V3M3 8l5-5 5 5"/></svg></a>{{end}}
</nav> </nav>
{{if not .EditMode}} {{if not .EditMode}}
<form class="search-form" action="/" method="get"> <form class="search-form" action="/" method="get">
<input class="search-input" type="search" name="q" value="{{block "searchQuery" .}}{{end}}" placeholder="Search…" title="Search (F)" autocomplete="off" autocorrect="off" autocapitalize="off" spellcheck="false" /> <input class="input search-input" type="search" name="q" value="{{block "searchQuery" .}}{{end}}" placeholder="Search…" title="Search (F)" autocomplete="off" autocorrect="off" autocapitalize="off" spellcheck="false" />
</form> </form>
{{end}} {{end}}
<div class="header-actions">{{block "headerActions" .}}{{end}}</div> <div class="header-actions row">{{block "headerActions" .}}{{end}}</div>
</header> </header>
<div class="page-wrap"> <div class="page-wrap">
<main> <main>
+4 -4
View File
@@ -15,20 +15,20 @@
backdrop.className = 'modal-backdrop'; backdrop.className = 'modal-backdrop';
modal = document.createElement('div'); modal = document.createElement('div');
modal.className = 'modal'; modal.className = 'modal panel panel-floating';
modal.setAttribute('role', 'dialog'); modal.setAttribute('role', 'dialog');
modal.setAttribute('aria-modal', 'true'); modal.setAttribute('aria-modal', 'true');
var header = document.createElement('div'); var header = document.createElement('div');
header.className = 'modal-header'; header.className = 'panel-header';
titleEl = document.createElement('span'); titleEl = document.createElement('span');
header.appendChild(titleEl); header.appendChild(titleEl);
bodyEl = document.createElement('div'); bodyEl = document.createElement('div');
bodyEl.className = 'modal-body'; bodyEl.className = 'panel-body';
footerEl = document.createElement('div'); footerEl = document.createElement('div');
footerEl.className = 'modal-footer'; footerEl.className = 'panel-footer';
cancelBtn = document.createElement('button'); cancelBtn = document.createElement('button');
cancelBtn.type = 'button'; cancelBtn.type = 'button';
+3 -3
View File
@@ -36,7 +36,7 @@ function postReplace(action, body, target) {
function promptPageName(title, initial, confirmLabel, onName) { function promptPageName(title, initial, confirmLabel, onName) {
var input = document.createElement('input'); var input = document.createElement('input');
input.type = 'text'; input.type = 'text';
input.className = 'modal-input'; input.className = 'input';
input.placeholder = 'Page name'; input.placeholder = 'Page name';
if (initial) input.value = initial; if (initial) input.value = initial;
openModal({ openModal({
@@ -89,7 +89,7 @@ function movePage() {
onSelect: function (newParent) { onSelect: function (newParent) {
var input = document.createElement('input'); var input = document.createElement('input');
input.type = 'text'; input.type = 'text';
input.className = 'modal-input'; input.className = 'input';
input.placeholder = 'Page name'; input.placeholder = 'Page name';
input.value = currentName; input.value = currentName;
@@ -99,7 +99,7 @@ function movePage() {
var linksLabel = document.createElement('label'); var linksLabel = document.createElement('label');
linksLabel.htmlFor = linksCheckbox.id; linksLabel.htmlFor = linksCheckbox.id;
linksLabel.className = 'modal-checkbox'; linksLabel.className = 'row';
linksLabel.appendChild(linksCheckbox); linksLabel.appendChild(linksCheckbox);
linksLabel.appendChild(document.createTextNode('Update links')); linksLabel.appendChild(document.createTextNode('Update links'));
+1 -1
View File
@@ -42,7 +42,7 @@
function addTask(sectionIndex, headingId) { function addTask(sectionIndex, headingId) {
var input = document.createElement('input'); var input = document.createElement('input');
input.type = 'text'; input.type = 'text';
input.className = 'modal-input'; input.className = 'input';
input.placeholder = 'Task description'; input.placeholder = 'Task description';
var ctrl = openModal({ var ctrl = openModal({
title: 'Add task', title: 'Add task',
+23 -9
View File
@@ -8,16 +8,30 @@
<div class="content">{{.SpecialContent}}</div> <div class="content">{{.SpecialContent}}</div>
{{end}} {{end}}
{{if .Entries}} {{if .Entries}}
<h2 id="files">Files <button class="btn btn-small" data-companion-reveal hidden title="Open folder in file manager">open</button></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" id="view-settings-btn" onclick="openViewSettings()" title="View &amp; sorting" data-view="{{.View}}" data-sort="{{.Sort}}" data-order="{{.Order}}">view</button>{{end}}</h2>
<div class="listing"> {{if eq .View "thumbnail"}}
<div class="thumb-grid">
{{range .Entries}} {{range .Entries}}
<div class="listing-item" data-path="{{.URL}}"> <a class="thumb-tile" href="{{.URL}}" title="{{.Name}}">
<span class="icon">{{.Icon}}</span> {{if .ThumbURL}}<img class="thumb-img" src="{{.ThumbURL}}" alt="" loading="lazy" width="300">{{else}}<span class="thumb-icon">{{.Icon}}</span>{{end}}
<a href="{{.URL}}">{{.Name}}</a> <span class="thumb-label truncate">{{.Name}}</span>
<span class="meta">{{.Meta}}</span> </a>
</div>
{{end}} {{end}}
</div> </div>
{{else}}
<table class="data-table panel">
<tbody>
{{range .Entries}}
<tr class="list-item" data-path="{{.URL}}">
<td class="icon">{{.Icon}}</td>
<td class="name"><a href="{{.URL}}">{{.Name}}</a></td>
<td class="meta">{{.Meta}}</td>
</tr>
{{end}}
</tbody>
</table>
{{end}}
{{if .CanEdit}}<script src="/_/page/view-settings.js"></script>{{end}}
{{else if not .Content}} {{else if not .Content}}
{{if not .SpecialContent}} {{if not .SpecialContent}}
<p class="empty">Empty folder — <a href="?edit">[CREATE]</a></p> <p class="empty">Empty folder — <a href="?edit">[CREATE]</a></p>
@@ -26,7 +40,7 @@
{{if or .Content .SpecialContent}} {{if or .Content .SpecialContent}}
<script src="/_/page/content.js"></script> <script src="/_/page/content.js"></script>
<script src="/_/page/anchors.js"></script> <script src="/_/page/anchors.js"></script>
<script src="/_/page/toc.js"></script> {{if not .SuppressTOC}}<script src="/_/page/toc.js"></script>{{end}}
<script src="/_/page/tasks.js"></script> <script src="/_/page/tasks.js"></script>
{{end}} {{end}}
{{if .Content}} {{if .Content}}
@@ -35,7 +49,7 @@
<script src="/_/page/sidebar-fab.js"></script> <script src="/_/page/sidebar-fab.js"></script>
{{end}} {{end}}
{{define "sidebar"}}{{if .CanEdit}}<nav class="actions"> {{define "sidebar"}}{{if .CanEdit}}<nav class="actions panel panel-sidebar">
<div class="panel-header">ACTIONS</div> <div class="panel-header">ACTIONS</div>
<button class="btn btn-block" onclick="newPage()" title="New page (N)">NEW PAGE</button> <button class="btn btn-block" onclick="newPage()" title="New page (N)">NEW PAGE</button>
<a class="btn btn-block" href="?edit" title="Edit page (E)">EDIT PAGE</a> <a class="btn btn-block" href="?edit" title="Edit page (E)">EDIT PAGE</a>
+2
View File
@@ -6,7 +6,9 @@
// Section 0 is pre-heading content, editable via full-page edit. // Section 0 is pre-heading content, editable via full-page edit.
// Sections 1..N each start at a heading; that is the index sent to the server. // Sections 1..N each start at a heading; that is the index sent to the server.
// Skip headings that already carry a server-rendered edit link.
headings.forEach(function (h, i) { headings.forEach(function (h, i) {
if (h.querySelector('a.btn')) return;
var a = document.createElement('a'); var a = document.createElement('a');
a.href = '?edit&section=' + (i + 1); a.href = '?edit&section=' + (i + 1);
a.className = 'btn btn-small'; a.className = 'btn btn-small';
+1 -1
View File
@@ -6,7 +6,7 @@ document.addEventListener("DOMContentLoaded", function () {
if (headings.length < 2) return; if (headings.length < 2) return;
var nav = document.createElement("nav"); var nav = document.createElement("nav");
nav.className = "toc"; nav.className = "toc panel panel-sidebar";
var header = document.createElement("div"); var header = document.createElement("div");
header.className = "panel-header"; header.className = "panel-header";
+85
View File
@@ -0,0 +1,85 @@
// View-settings modal: lets the user pick the folder listing's view style,
// sort key, and order, then persists them by POSTing to the folder with
// ?settings. Reuses openModal/closeModal and postReplace from page/actions.js.
function openViewSettings() {
var btn = document.getElementById('view-settings-btn');
var state = {
view: (btn && btn.dataset.view) || 'list',
sort: (btn && btn.dataset.sort) || 'name',
order: (btn && btn.dataset.order) || 'asc'
};
// segmented builds a row of mutually-exclusive .btn toggles bound to a
// single state key, marking the current choice with .is-active.
function segmented(key, options) {
var wrap = document.createElement('div');
wrap.className = 'row gap-1';
options.forEach(function (opt) {
var b = document.createElement('button');
b.type = 'button';
b.className = 'btn';
b.textContent = opt.label;
if (state[key] === opt.value) b.classList.add('is-active');
b.addEventListener('click', function () {
state[key] = opt.value;
wrap.querySelectorAll('button').forEach(function (x) {
x.classList.remove('is-active');
});
b.classList.add('is-active');
});
wrap.appendChild(b);
});
return wrap;
}
function field(labelText, control) {
var row = document.createElement('div');
row.className = 'col gap-1';
var label = document.createElement('span');
label.className = 'caption';
label.textContent = labelText;
row.appendChild(label);
row.appendChild(control);
return row;
}
var sortSelect = document.createElement('select');
sortSelect.className = 'input';
[['name', 'Name'], ['modified', 'Modified'], ['size', 'Size']].forEach(function (o) {
var opt = document.createElement('option');
opt.value = o[0];
opt.textContent = o[1];
if (state.sort === o[0]) opt.selected = true;
sortSelect.appendChild(opt);
});
sortSelect.addEventListener('change', function () { state.sort = sortSelect.value; });
var body = document.createElement('div');
body.className = 'col';
body.appendChild(field('View style', segmented('view', [
{ value: 'list', label: 'List' },
{ value: 'thumbnail', label: 'Thumbnail' }
])));
body.appendChild(field('Sort by', sortSelect));
body.appendChild(field('Order', segmented('order', [
{ value: 'asc', label: 'Asc' },
{ value: 'desc', label: 'Desc' }
])));
openModal({
title: 'View settings',
body: body,
confirm: {
label: 'SAVE',
onConfirm: function () {
var action = window.location.pathname + '?settings';
var formBody = 'view=' + encodeURIComponent(state.view) +
'&sort=' + encodeURIComponent(state.sort) +
'&order=' + encodeURIComponent(state.order);
var target = window.location.pathname;
closeModal();
postReplace(action, formBody, target);
}
}
});
}
+31 -21
View File
@@ -70,6 +70,15 @@
dropdown.className = 'suggest-dropdown'; dropdown.className = 'suggest-dropdown';
host.appendChild(dropdown); host.appendChild(dropdown);
function makeRow(cls, tabbable) {
var tr = document.createElement('tr');
tr.className = cls;
if (tabbable) tr.setAttribute('tabindex', '0');
var td = document.createElement('td');
tr.appendChild(td);
return { tr: tr, td: td };
}
var state = { var state = {
results: [], results: [],
total: 0, total: 0,
@@ -102,49 +111,50 @@
dropdown.classList.remove('is-open'); dropdown.classList.remove('is-open');
return; return;
} }
var table = document.createElement('table');
table.className = 'data-table';
var tbody = document.createElement('tbody');
table.appendChild(tbody);
var tokens = tokenize(state.query); var tokens = tokenize(state.query);
if (state.results.length === 0) { if (state.results.length === 0) {
var empty = document.createElement('div'); var empty = makeRow('is-empty', false);
empty.className = 'suggest-row is-empty'; empty.td.textContent = 'No matches';
empty.textContent = 'No matches'; tbody.appendChild(empty.tr);
dropdown.appendChild(empty);
} else { } else {
state.results.forEach(function (r, i) { state.results.forEach(function (r, i) {
var row = document.createElement('button'); var row = makeRow('suggest-row', true);
row.type = 'button'; row.tr.setAttribute('data-idx', String(i));
row.className = 'suggest-row';
row.setAttribute('data-idx', String(i));
var nameEl = document.createElement('span'); var nameEl = document.createElement('span');
nameEl.className = 'suggest-name'; nameEl.className = 'suggest-name';
nameEl.innerHTML = highlight(r.name, tokens); nameEl.innerHTML = highlight(r.name, tokens);
var pathEl = document.createElement('span'); var pathEl = document.createElement('span');
pathEl.className = 'suggest-path'; pathEl.className = 'suggest-path';
pathEl.textContent = '/' + r.path; pathEl.textContent = '/' + r.path;
row.appendChild(nameEl); row.td.appendChild(nameEl);
row.appendChild(pathEl); row.td.appendChild(pathEl);
if (i === state.activeIdx) row.classList.add('is-active'); if (i === state.activeIdx) row.tr.classList.add('is-active');
row.addEventListener('mousedown', function (e) { row.tr.addEventListener('mousedown', function (e) {
// mousedown (not click) so the input doesn't blur-close // mousedown (not click) so the input doesn't blur-close
// the dropdown before the pick handler fires. // the dropdown before the pick handler fires.
e.preventDefault(); e.preventDefault();
pick(i); pick(i);
}); });
dropdown.appendChild(row); tbody.appendChild(row.tr);
}); });
if (opts.showFooter && state.total > state.results.length) { if (opts.showFooter && state.total > state.results.length) {
var footer = document.createElement('button'); var footer = makeRow('suggest-row suggest-footer', true);
footer.type = 'button'; footer.td.textContent = 'Show all ' + state.total + ' matches';
footer.className = 'suggest-row suggest-footer';
footer.textContent = 'Show all ' + state.total + ' matches';
var footerIdx = state.results.length; var footerIdx = state.results.length;
if (state.activeIdx === footerIdx) footer.classList.add('is-active'); if (state.activeIdx === footerIdx) footer.tr.classList.add('is-active');
footer.addEventListener('mousedown', function (e) { footer.tr.addEventListener('mousedown', function (e) {
e.preventDefault(); e.preventDefault();
pickFooter(); pickFooter();
}); });
dropdown.appendChild(footer); tbody.appendChild(footer.tr);
} }
} }
dropdown.appendChild(table);
dropdown.classList.add('is-open'); dropdown.classList.add('is-open');
} }
@@ -225,7 +235,7 @@
state.activeIdx = next; state.activeIdx = next;
render(); render();
// Keep the active row in view. // Keep the active row in view.
var active = dropdown.querySelector('.suggest-row.is-active'); var active = dropdown.querySelector('tr.is-active');
if (active && active.scrollIntoView) { if (active && active.scrollIntoView) {
try { active.scrollIntoView({ block: 'nearest' }); } catch (e) {} try { active.scrollIntoView({ block: 'nearest' }); } catch (e) {}
} }
+467 -750
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -28,7 +28,7 @@
var container = document.createElement('div'); var container = document.createElement('div');
var treeEl = document.createElement('div'); var treeEl = document.createElement('div');
treeEl.className = 'tree-picker'; treeEl.className = 'tree-picker panel';
var selectedPathEl = document.createElement('div'); var selectedPathEl = document.createElement('div');
selectedPathEl.className = 'tree-selected-path muted'; selectedPathEl.className = 'tree-selected-path muted';
+512 -340
View File
@@ -5,10 +5,12 @@ import (
"fmt" "fmt"
"html/template" "html/template"
"log" "log"
"net/http"
"net/url" "net/url"
"os" "os"
"path" "path"
"path/filepath" "path/filepath"
"regexp"
"sort" "sort"
"strconv" "strconv"
"strings" "strings"
@@ -21,9 +23,31 @@ func init() {
type diaryHandler struct{} type diaryHandler struct{}
// redirect resolves the persistent date links (today, this-month, this-year) // redirect handles diary-specific redirect cases. The year page is the only
// inside any diary root to a dated URL. Returns ok=false otherwise. // real diary page; month and day URLs are aliases that collapse to a year
func (d *diaryHandler) redirect(root, fsPath, urlPath string) (string, bool) { // page anchor (or to the year-file editor when ?edit is set).
//
// 1. `today` / `this-month` / `this-year` shortcuts resolve directly to a
// year+anchor target (or insert flow when today's section is missing).
// 2. A virtual month URL (/diary/<root>/YYYY/MM/) redirects to
// /diary/<root>/YYYY/#YYYY-MM (or to ?edit&section=N when ?edit is set).
// 3. A virtual day URL (/diary/<root>/YYYY/MM/DD/) redirects to
// /diary/<root>/YYYY/#YYYY-MM-DD (or to the section / insert_before
// editor flow when ?edit is set).
//
// Returns ok=false when the request is not a diary-handled redirect.
func (d *diaryHandler) redirect(root, fsPath, urlPath string, r *http.Request) (string, bool) {
if target, ok := d.dateShortcutRedirect(root, fsPath, urlPath); ok {
return target, true
}
if r.Method != http.MethodGet {
return "", false
}
_, edit := r.URL.Query()["edit"]
return d.virtualURLRedirect(root, fsPath, urlPath, edit)
}
func (d *diaryHandler) dateShortcutRedirect(root, fsPath, urlPath string) (string, bool) {
base := path.Base(strings.TrimSuffix(urlPath, "/")) base := path.Base(strings.TrimSuffix(urlPath, "/"))
switch base { switch base {
case "today", "this-month", "this-year": case "today", "this-month", "this-year":
@@ -42,65 +66,129 @@ func (d *diaryHandler) redirect(root, fsPath, urlPath string) (string, bool) {
year := fmt.Sprintf("%d", now.Year()) year := fmt.Sprintf("%d", now.Year())
month := fmt.Sprintf("%02d", int(now.Month())) month := fmt.Sprintf("%02d", int(now.Month()))
day := fmt.Sprintf("%02d", now.Day()) day := fmt.Sprintf("%02d", now.Day())
yearURL := path.Join(diaryRootURL, year) + "/"
switch base { switch base {
case "today": case "today":
target := path.Join(diaryRootURL, year, month, day) + "/" dayHeading := fmt.Sprintf("%s-%s-%s", year, month, day)
dayFS := filepath.Join(diaryRootFS, year, month, day) raw, _ := os.ReadFile(filepath.Join(diaryRootFS, year, "index.md"))
if _, err := os.Stat(dayFS); err != nil { sections := splitSections(raw)
target += "?edit" if _, found := findSectionIndex(sections, dayHeading); found {
return yearURL + "#" + dayHeading, true
} }
return target, true // Missing day: route through the insert flow so today's section
// is spliced in at the right chronological position.
insertIdx := computeInsertIndex(sections, dayHeading)
return fmt.Sprintf("%s?edit&insert_before=%d&heading=%s",
yearURL, insertIdx, url.QueryEscape(dayHeading)), true
case "this-month": case "this-month":
return path.Join(diaryRootURL, year, month) + "/", true return yearURL + "#" + fmt.Sprintf("%s-%s", year, month), true
case "this-year": case "this-year":
return path.Join(diaryRootURL, year) + "/", true return yearURL, true
} }
return "", false return "", false
} }
// defaultHeading returns the German long-form date as the editor pre-fill // virtualURLRedirect collapses month/day URLs onto the year page. For
// heading for a diary day folder (depth 3 inside a diary root). // non-edit GETs the target is `/YYYY/#YYYY-MM[-DD]`. For ?edit GETs the
func (d *diaryHandler) defaultHeading(root, fsPath, urlPath string) (string, bool) { // target is the year-file editor URL (section edit when the section exists,
depth, _, _, ok := findDiaryContext(root, fsPath, urlPath) // otherwise insert_before+heading for new days, whole-year edit for new
if !ok || depth != 3 { // months). Returns ok=false when fsPath is a real folder (preferring the
// real folder over the virtual redirect lets users recover from an
// unfinished migration).
func (d *diaryHandler) virtualURLRedirect(root, fsPath, urlPath string, edit bool) (string, bool) {
depth, diaryRootFS, diaryRootURL, ok := findDiaryContext(root, fsPath, urlPath)
if !ok || (depth != 2 && depth != 3) {
return "", false return "", false
} }
day, err := strconv.Atoi(filepath.Base(fsPath)) if info, err := os.Stat(fsPath); err == nil && info.IsDir() {
if err != nil {
return "", false return "", false
} }
monthFS := filepath.Dir(fsPath)
month, err := strconv.Atoi(filepath.Base(monthFS))
if err != nil || month < 1 || month > 12 {
return "", false
}
year, err := strconv.Atoi(filepath.Base(filepath.Dir(monthFS)))
if err != nil {
return "", false
}
return formatGermanDate(time.Date(year, time.Month(month), day, 0, 0, 0, 0, time.UTC)), true
}
func (d *diaryHandler) handle(root, fsPath, urlPath string) *specialPage { year, month, day, ok := parseDiaryURLParts(fsPath, depth)
if !ok {
return "", false
}
yearFS := filepath.Join(diaryRootFS, year)
yearURL := path.Join(diaryRootURL, year) + "/"
if !edit {
anchor := fmt.Sprintf("%s-%s", year, month)
if depth == 3 {
anchor = fmt.Sprintf("%s-%s-%s", year, month, day)
}
return yearURL + "#" + anchor, true
}
raw, _ := os.ReadFile(filepath.Join(yearFS, "index.md"))
sections := splitSections(raw)
if depth == 2 {
target := fmt.Sprintf("%s-%s", year, month)
if idx, found := findSectionIndex(sections, target); found {
return fmt.Sprintf("%s?edit&section=%d", yearURL, idx), true
}
return yearURL + "?edit", true
}
target := fmt.Sprintf("%s-%s-%s", year, month, day)
if idx, found := findSectionIndex(sections, target); found {
return fmt.Sprintf("%s?edit&section=%d", yearURL, idx), true
}
insertIdx := computeInsertIndex(sections, target)
return fmt.Sprintf("%s?edit&insert_before=%d&heading=%s",
yearURL, insertIdx, url.QueryEscape(target)), true
}
// parseDiaryURLParts extracts year/month/day from fsPath based on depth.
// depth=1 returns year only; depth=2 returns year+month; depth=3 returns all.
func parseDiaryURLParts(fsPath string, depth int) (year, month, day string, ok bool) {
parts := []string{}
cur := fsPath
for i := 0; i < depth; i++ {
parts = append([]string{filepath.Base(cur)}, parts...)
cur = filepath.Dir(cur)
}
switch depth {
case 1:
return parts[0], "", "", true
case 2:
return parts[0], parts[1], "", true
case 3:
return parts[0], parts[1], parts[2], true
}
return "", "", "", false
}
func (d *diaryHandler) handle(root, fsPath, urlPath string, _ *http.Request) *specialPage {
depth, diaryRootFS, diaryRootURL, ok := findDiaryContext(root, fsPath, urlPath) depth, diaryRootFS, diaryRootURL, ok := findDiaryContext(root, fsPath, urlPath)
if !ok { if !ok {
return nil return nil
} }
widget := computeCalendarWidget(diaryRootFS, diaryRootURL, fsPath, depth) widget := computeCalendarWidget(diaryRootFS, diaryRootURL, fsPath, depth)
if depth == 0 { if depth == 0 {
return &specialPage{Widget: widget} return &specialPage{Widget: widget, SuppressTOC: true}
} }
var content template.HTML year, _, _, ok := parseDiaryURLParts(fsPath, depth)
switch depth { if !ok {
case 1: return &specialPage{Widget: widget, SuppressTOC: true}
content = renderDiaryYear(fsPath, urlPath)
case 2:
content = renderDiaryMonth(fsPath, urlPath)
case 3:
content = renderDiaryDay(fsPath, urlPath)
} }
return &specialPage{Content: content, SuppressListing: true, Widget: widget} if depth == 1 {
yearFS := filepath.Join(diaryRootFS, year)
yearURL := path.Join(diaryRootURL, year) + "/"
content := renderDiaryYear(yearFS, yearURL)
return &specialPage{
Content: content,
SuppressContent: true,
SuppressListing: true,
SuppressTOC: true,
Widget: widget,
}
}
// depth 2/3 only reach here when a real folder exists at the path
// (unfinished migration). The virtual URL would have been redirected
// in `redirect()` otherwise. Render the folder normally; just add the
// calendar widget.
return &specialPage{Widget: widget, SuppressTOC: true}
} }
// findDiaryContext walks up from fsPath toward root looking for a // findDiaryContext walks up from fsPath toward root looking for a
@@ -128,6 +216,118 @@ func findDiaryContext(root, fsPath, urlPath string) (depth int, diaryRootFS, dia
return 0, "", "", false return 0, "", "", false
} }
// headingTextRe matches an ATX heading at the start of a section. The
// heading text is everything after the `#`s and the required space, on the
// first line.
var headingTextRe = regexp.MustCompile(`^(#{1,6})\s+([^\n]*)`)
// sectionHeading returns the heading level (1..6) and trimmed text of a
// section produced by splitSections. Returns level=0 for the pre-heading
// section (index 0).
func sectionHeading(section []byte) (level int, text string) {
m := headingTextRe.FindSubmatch(section)
if m == nil {
return 0, ""
}
return len(m[1]), strings.TrimSpace(string(m[2]))
}
// findSectionIndex returns the absolute section index whose heading text
// matches target (e.g. "2026-05" or "2026-05-28"). Returns the first match.
func findSectionIndex(sections [][]byte, target string) (int, bool) {
for i := 1; i < len(sections); i++ {
_, text := sectionHeading(sections[i])
if text == target {
return i, true
}
}
return 0, false
}
// computeInsertIndex returns the section index at which a new date heading
// (target = `YYYY-MM` or `YYYY-MM-DD`) should be spliced in to keep date
// sections chronologically ordered. Only date-format headings participate in
// the comparison; non-date headings (e.g. `## Events` in a year intro) are
// skipped so the new section is placed relative to the surrounding date
// sections, not the intro. Falls back to len(sections) when target is
// greater than every date heading. ISO formatting means string comparison
// is equivalent to chronological order.
func computeInsertIndex(sections [][]byte, target string) int {
for i := 1; i < len(sections); i++ {
_, text := sectionHeading(sections[i])
if !isDateHeading(text) {
continue
}
if text > target {
return i
}
}
return len(sections)
}
// isDateHeading reports whether text is exactly a `YYYY`, `YYYY-MM`, or
// `YYYY-MM-DD` token. Used by the insert-index search to ignore non-date
// section headings.
func isDateHeading(text string) bool {
switch len(text) {
case 4, 7, 10:
default:
return false
}
if _, err := time.Parse("2006", text[:4]); err != nil {
return false
}
if len(text) >= 7 {
if text[4] != '-' {
return false
}
if _, err := time.Parse("2006-01", text[:7]); err != nil {
return false
}
}
if len(text) == 10 {
if text[7] != '-' {
return false
}
if _, err := time.Parse("2006-01-02", text); err != nil {
return false
}
}
return true
}
// daysWithEntriesByMonth returns a `month → set[day]` map of `### YYYY-MM-DD`
// sections in the year's index.md. Used by the calendar widget to populate
// all 12 month grids in a single file read.
func daysWithEntriesByMonth(yearFS string, year int) map[int]map[int]bool {
out := map[int]map[int]bool{}
raw, err := os.ReadFile(filepath.Join(yearFS, "index.md"))
if err != nil {
return out
}
yearPrefix := fmt.Sprintf("%d-", year)
sections := splitSections(raw)
for i := 1; i < len(sections); i++ {
level, text := sectionHeading(sections[i])
if level != 3 || !strings.HasPrefix(text, yearPrefix) || len(text) < 10 {
continue
}
m, err := strconv.Atoi(text[5:7])
if err != nil || m < 1 || m > 12 {
continue
}
d, err := strconv.Atoi(text[8:10])
if err != nil || d < 1 || d > 31 {
continue
}
if out[m] == nil {
out[m] = map[int]bool{}
}
out[m][d] = true
}
return out
}
type calDay struct { type calDay struct {
Num int Num int
URL string URL string
@@ -142,32 +342,31 @@ type calYear struct {
IsCurrent bool IsCurrent bool
} }
type calMonth struct { // calMonthGrid carries everything the template needs to render one month's
// grid plus the dropdown / heading entry that targets it. The calendar
// widget ships all 12 in the initial HTML; JS swaps which one is visible.
type calMonthGrid struct {
Num int Num int
Name string Name string
URL string AnchorURL string // "#YYYY-MM" on a year page, full URL otherwise
IsCurrent bool Weeks [][]calDay
} }
type calendarData struct { type calendarData struct {
DisplayYear int DisplayYear int
DisplayMonth int DisplayMonth int
MonthName string DisplayMonthName string // pre-resolved so the template doesn't need arithmetic
DiaryURL string DiaryURL string
YearURL string YearURL string
MonthURL string Months []calMonthGrid
PrevMonURL string
NextMonURL string
Weeks [][]calDay
Years []calYear Years []calYear
AllMonths []calMonth
} }
var diaryCalTmpl = template.Must(template.ParseFS(assets, "assets/diary/calendar.html")) var diaryCalTmpl = template.Must(template.ParseFS(assets, "assets/diary/calendar.html"))
func computeCalendarWidget(diaryRootFS, diaryRootURL, fsPath string, depth int) template.HTML { func computeCalendarWidget(diaryRootFS, diaryRootURL, fsPath string, depth int) template.HTML {
today := time.Now() today := time.Now()
var displayYear, displayMonth, currentDay int var displayYear, displayMonth, currentDay, currentMonth int
switch depth { switch depth {
case 0: case 0:
@@ -212,73 +411,55 @@ func computeCalendarWidget(diaryRootFS, diaryRootURL, fsPath string, depth int)
displayYear = y displayYear = y
displayMonth = m displayMonth = m
currentDay = d currentDay = d
currentMonth = m
default: default:
return "" return ""
} }
// Which days in the display month have diary subfolders? yearFS := filepath.Join(diaryRootFS, fmt.Sprintf("%d", displayYear))
monthFSPath := filepath.Join(diaryRootFS, hasDayEntryByMonth := daysWithEntriesByMonth(yearFS, displayYear)
fmt.Sprintf("%d", displayYear),
fmt.Sprintf("%02d", displayMonth))
dayEntries, _ := os.ReadDir(monthFSPath)
hasDayEntry := map[int]bool{}
for _, e := range dayEntries {
if !e.IsDir() {
continue
}
d, err := strconv.Atoi(e.Name())
if err != nil || d < 1 || d > 31 {
continue
}
hasDayEntry[d] = true
}
// Build calendar grid with Monday as first column.
firstDay := time.Date(displayYear, time.Month(displayMonth), 1, 0, 0, 0, 0, time.UTC)
startOffset := int(firstDay.Weekday()+6) % 7
daysInMonth := time.Date(displayYear, time.Month(displayMonth)+1, 0, 0, 0, 0, 0, time.UTC).Day()
monthURLBase := path.Join(diaryRootURL,
fmt.Sprintf("%d", displayYear),
fmt.Sprintf("%02d", displayMonth)) + "/"
var weeks [][]calDay
week := make([]calDay, 7)
col := startOffset
for d := 1; d <= daysInMonth; d++ {
dayURL := path.Join(monthURLBase, fmt.Sprintf("%02d", d)) + "/"
cell := calDay{Num: d, HasEntry: hasDayEntry[d]}
if cell.HasEntry {
cell.URL = dayURL
} else {
cell.URL = dayURL + "?edit"
}
cell.IsCurrent = d == currentDay
cell.IsToday = d == today.Day() &&
time.Month(displayMonth) == today.Month() &&
displayYear == today.Year()
week[col] = cell
col++
if col == 7 {
weeks = append(weeks, week)
week = make([]calDay, 7)
col = 0
}
}
if col > 0 {
weeks = append(weeks, week)
}
prev := time.Date(displayYear, time.Month(displayMonth)-1, 1, 0, 0, 0, 0, time.UTC)
next := time.Date(displayYear, time.Month(displayMonth)+1, 1, 0, 0, 0, 0, time.UTC)
prevMonURL := path.Join(diaryRootURL,
fmt.Sprintf("%d", prev.Year()),
fmt.Sprintf("%02d", int(prev.Month()))) + "/"
nextMonURL := path.Join(diaryRootURL,
fmt.Sprintf("%d", next.Year()),
fmt.Sprintf("%02d", int(next.Month()))) + "/"
yearURL := path.Join(diaryRootURL, fmt.Sprintf("%d", displayYear)) + "/" yearURL := path.Join(diaryRootURL, fmt.Sprintf("%d", displayYear)) + "/"
// On a year page, in-year month/day links collapse to anchors so the
// browser scrolls within the current page instead of navigating away.
// On the diary root (depth=0), all links remain full URLs.
pageYear := 0
if depth >= 1 {
pageYear = displayYear
}
monthAnchor := func(year, month int) string {
if pageYear == year {
return fmt.Sprintf("#%d-%02d", year, month)
}
return path.Join(diaryRootURL,
fmt.Sprintf("%d", year),
fmt.Sprintf("%02d", month)) + "/"
}
dayAnchor := func(year, month, day int) string {
if pageYear == year {
return fmt.Sprintf("#%d-%02d-%02d", year, month, day)
}
return path.Join(diaryRootURL,
fmt.Sprintf("%d", year),
fmt.Sprintf("%02d", month),
fmt.Sprintf("%02d", day)) + "/"
}
months := make([]calMonthGrid, 12)
for m := 1; m <= 12; m++ {
var cd int
if m == currentMonth {
cd = currentDay
}
months[m-1] = calMonthGrid{
Num: m,
Name: germanMonths[time.Month(m)],
AnchorURL: monthAnchor(displayYear, m),
Weeks: buildMonthGrid(displayYear, m, today, cd, hasDayEntryByMonth[m], dayAnchor),
}
}
// Collect all year subdirectories in diary root (descending). // Collect all year subdirectories in diary root (descending).
yearEntries, _ := os.ReadDir(diaryRootFS) yearEntries, _ := os.ReadDir(diaryRootFS)
var years []calYear var years []calYear
@@ -307,31 +488,14 @@ func computeCalendarWidget(diaryRootFS, diaryRootURL, fsPath string, depth int)
} }
sort.Slice(years, func(i, j int) bool { return years[i].Num > years[j].Num }) sort.Slice(years, func(i, j int) bool { return years[i].Num > years[j].Num })
// All 12 months, ascending, linked to current display year.
allMonths := make([]calMonth, 12)
for m := 1; m <= 12; m++ {
allMonths[m-1] = calMonth{
Num: m,
Name: germanMonths[time.Month(m)],
URL: path.Join(diaryRootURL,
fmt.Sprintf("%d", displayYear),
fmt.Sprintf("%02d", m)) + "/",
IsCurrent: m == displayMonth,
}
}
data := calendarData{ data := calendarData{
DisplayYear: displayYear, DisplayYear: displayYear,
DisplayMonth: displayMonth, DisplayMonth: displayMonth,
MonthName: germanMonths[time.Month(displayMonth)], DisplayMonthName: months[displayMonth-1].Name,
DiaryURL: diaryRootURL, DiaryURL: diaryRootURL,
YearURL: yearURL, YearURL: yearURL,
MonthURL: monthURLBase, Months: months,
PrevMonURL: prevMonURL,
NextMonURL: nextMonURL,
Weeks: weeks,
Years: years, Years: years,
AllMonths: allMonths,
} }
var buf bytes.Buffer var buf bytes.Buffer
@@ -342,6 +506,44 @@ func computeCalendarWidget(diaryRootFS, diaryRootURL, fsPath string, depth int)
return template.HTML(buf.String()) return template.HTML(buf.String())
} }
// buildMonthGrid renders one month's day cells as a Monday-first week grid.
// hasDayEntry maps day-of-month → has a diary entry. dayAnchor produces the
// in-page anchor (or full URL when crossing pages); empty days link to the
// same anchor — every day exists on the year page as either a real or
// virtual section, so navigation is enough. Page creation happens via the
// [edit] button on the heading itself.
func buildMonthGrid(year, month int, today time.Time, currentDay int, hasDayEntry map[int]bool, dayAnchor func(int, int, int) string) [][]calDay {
firstDay := time.Date(year, time.Month(month), 1, 0, 0, 0, 0, time.UTC)
startOffset := int(firstDay.Weekday()+6) % 7
daysInMonth := time.Date(year, time.Month(month)+1, 0, 0, 0, 0, 0, time.UTC).Day()
var weeks [][]calDay
week := make([]calDay, 7)
col := startOffset
for d := 1; d <= daysInMonth; d++ {
cell := calDay{
Num: d,
HasEntry: hasDayEntry[d],
URL: dayAnchor(year, month, d),
}
cell.IsCurrent = currentDay > 0 && d == currentDay
cell.IsToday = d == today.Day() &&
time.Month(month) == today.Month() &&
year == today.Year()
week[col] = cell
col++
if col == 7 {
weeks = append(weeks, week)
week = make([]calDay, 7)
col = 0
}
}
if col > 0 {
weeks = append(weeks, week)
}
return weeks
}
// diaryPhoto is a photo file whose name starts with a YYYY-MM-DD date prefix. // diaryPhoto is a photo file whose name starts with a YYYY-MM-DD date prefix.
type diaryPhoto struct { type diaryPhoto struct {
Date time.Time Date time.Time
@@ -350,41 +552,20 @@ type diaryPhoto struct {
ThumbURL string ThumbURL string
} }
type diaryMonthSummary struct { // diarySection is one rendered section of the diary content (year, month, or
ID string // day). Edit URLs point back into the year file's section editor so per-day
Name string // editing works from any slice page.
URL string type diarySection struct {
Level int // 1, 2, or 3
ID string // anchor id (e.g. "2026-05-28")
Heading string // displayed heading text
EditURL string // year-file section edit URL ("" = no edit button)
Body template.HTML // rendered markdown body (excludes the heading line)
Photos []diaryPhoto Photos []diaryPhoto
} }
type diaryDaySection struct { type diaryContentData struct {
ID string Sections []diarySection
Heading string
URL string
EditURL string
Content template.HTML
Photos []diaryPhoto
}
type diaryYearData struct {
Months []diaryMonthSummary
Year int
}
type diaryMonthData struct{ Days []diaryDaySection }
type diaryDayData struct{ Photos []diaryPhoto }
var diaryYearTmpl = template.Must(template.ParseFS(assets, "assets/diary/year.html"))
var diaryMonthTmpl = template.Must(template.ParseFS(assets, "assets/diary/month.html"))
var diaryDayTmpl = template.Must(template.ParseFS(assets, "assets/diary/day.html"))
var germanWeekdays = map[time.Weekday]string{
time.Sunday: "Sonntag",
time.Monday: "Montag",
time.Tuesday: "Dienstag",
time.Wednesday: "Mittwoch",
time.Thursday: "Donnerstag",
time.Friday: "Freitag",
time.Saturday: "Samstag",
} }
var germanMonths = map[time.Month]string{ var germanMonths = map[time.Month]string{
@@ -402,15 +583,6 @@ var germanMonths = map[time.Month]string{
time.December: "Dezember", time.December: "Dezember",
} }
func formatGermanDate(t time.Time) string {
return fmt.Sprintf("%s, %d. %s %d",
germanWeekdays[t.Weekday()],
t.Day(),
germanMonths[t.Month()],
t.Year(),
)
}
var photoExts = map[string]bool{ var photoExts = map[string]bool{
".jpg": true, ".jpeg": true, ".png": true, ".gif": true, ".webp": true, ".jpg": true, ".jpeg": true, ".png": true, ".gif": true, ".webp": true,
} }
@@ -453,196 +625,196 @@ func yearPhotos(yearFsPath, yearURLPath string) []diaryPhoto {
return photos return photos
} }
// renderDiaryYear renders month sections with photo counts for a year folder. var diaryContentTmpl = template.Must(template.ParseFS(assets, "assets/diary/content.html"))
func renderDiaryYear(fsPath, urlPath string) template.HTML {
year, err := strconv.Atoi(filepath.Base(fsPath)) // sectionBody strips the first heading line and returns the rendered body.
// Used so the diary template can emit the heading explicitly (with edit URL)
// while still rendering the section body via goldmark.
func sectionBody(section []byte) template.HTML {
body := stripFirstHeading(section)
if len(bytes.TrimSpace(body)) == 0 {
return ""
}
return renderMarkdown(body)
}
// renderDiaryYear renders the year page: every section from the year file
// (with photos attached to `### YYYY-MM-DD` headings) plus virtual entries
// for every month/day slot the file doesn't yet contain.
func renderDiaryYear(yearFS, yearURL string) template.HTML {
year, err := strconv.Atoi(filepath.Base(yearFS))
if err != nil { if err != nil {
return "" return ""
} }
raw, _ := os.ReadFile(filepath.Join(yearFS, "index.md"))
sections := splitSections(raw)
photos := yearPhotos(yearFS, yearURL)
photos := yearPhotos(fsPath, urlPath) out := buildFileSections(sections, photos, yearURL)
out = appendVirtualEntries(out, sections, photos, year, yearURL)
return renderDiaryContent(out)
}
// Collect month numbers from both subdirectories and photo filenames so // buildFileSections converts the year file's sections (skipping the
// years that contain only photos (no diary entries) still list months. // pre-heading section 0) into rendered diarySection entries. Photos are
monthSet := map[int]bool{} // attached to level-3 headings whose text parses as `YYYY-MM-DD`.
monthDirs := map[int]string{} func buildFileSections(sections [][]byte, photos []diaryPhoto, yearURL string) []diarySection {
entries, _ := os.ReadDir(fsPath) var out []diarySection
for _, e := range entries { for i := 1; i < len(sections); i++ {
if !e.IsDir() { level, text := sectionHeading(sections[i])
if level == 0 {
continue continue
} }
n, err := strconv.Atoi(e.Name()) sec := diarySection{
if err != nil || n < 1 || n > 12 { Level: level,
ID: text,
Heading: text,
EditURL: fmt.Sprintf("%s?edit&section=%d", yearURL, i),
Body: sectionBody(sections[i]),
}
if level == 3 {
if y, m, d, ok := parseISODate(text); ok {
sec.Photos = filterPhotos(photos, y, m, d)
}
}
out = append(out, sec)
}
return out
}
// appendVirtualEntries inserts virtual month and day sections for every
// `## YYYY-MM` / `### YYYY-MM-DD` slot in `year` that lacks a real section.
// Virtual day sections carry photos when present. Each virtual entry's
// EditURL routes through the insert-before flow so clicking [edit] splices
// the section into the year file at the right chronological position.
//
// Scope: past years get all 12 months / 365(6) days; the current year stops
// at today; future years are returned unchanged.
//
// Interleave: real date sections (`## YYYY-MM`, `### YYYY-MM-DD`) keep their
// document position; virtual entries are spliced in lexicographic ID order
// before the next real date section. Non-date headings (e.g. `## Events` →
// `### Festival` in a year intro) are left where the user wrote them.
func appendVirtualEntries(existing []diarySection, sections [][]byte, photos []diaryPhoto, year int, yearURL string) []diarySection {
today := time.Now()
if year > today.Year() {
return existing
}
coveredMonth := map[string]bool{}
coveredDay := map[string]bool{}
for _, s := range existing {
switch {
case s.Level == 2 && len(s.ID) == 7 && isDateHeading(s.ID):
coveredMonth[s.ID] = true
case s.Level == 3 && len(s.ID) == 10 && isDateHeading(s.ID):
coveredDay[s.ID] = true
}
}
photoByDay := map[string][]diaryPhoto{}
for _, p := range photos {
if p.Date.Year() != year {
continue continue
} }
monthSet[n] = true photoByDay[p.Date.Format("2006-01-02")] = append(photoByDay[p.Date.Format("2006-01-02")], p)
monthDirs[n] = e.Name()
}
for _, p := range photos {
if p.Date.Year() == year {
monthSet[int(p.Date.Month())] = true
}
} }
monthNums := make([]int, 0, len(monthSet)) lastDay := time.Date(year, time.December, 31, 0, 0, 0, 0, time.UTC)
for m := range monthSet { if year == today.Year() {
monthNums = append(monthNums, m) lastDay = time.Date(year, today.Month(), today.Day(), 0, 0, 0, 0, time.UTC)
} }
sort.Ints(monthNums)
var months []diaryMonthSummary var virtual []diarySection
for _, monthNum := range monthNums { for d := time.Date(year, time.January, 1, 0, 0, 0, 0, time.UTC); !d.After(lastDay); d = d.AddDate(0, 0, 1) {
var monthPhotos []diaryPhoto if d.Day() == 1 {
for _, p := range photos { monthID := d.Format("2006-01")
if p.Date.Year() == year && int(p.Date.Month()) == monthNum { if !coveredMonth[monthID] {
monthPhotos = append(monthPhotos, p) idx := computeInsertIndex(sections, monthID)
} virtual = append(virtual, diarySection{
} Level: 2,
monthDate := time.Date(year, time.Month(monthNum), 1, 0, 0, 0, 0, time.UTC) ID: monthID,
dirName, ok := monthDirs[monthNum] Heading: monthID,
if !ok { EditURL: fmt.Sprintf("%s?edit&insert_before=%d&heading=%s&level=%s",
dirName = fmt.Sprintf("%02d", monthNum) yearURL, idx, url.QueryEscape(monthID), url.QueryEscape("##")),
}
months = append(months, diaryMonthSummary{
ID: monthDate.Format("2006-01"),
Name: fmt.Sprintf("%s %d", germanMonths[monthDate.Month()], year),
URL: path.Join(urlPath, dirName) + "/",
Photos: monthPhotos,
}) })
} }
var buf bytes.Buffer
if err := diaryYearTmpl.Execute(&buf, diaryYearData{Months: months, Year: year}); err != nil {
log.Printf("diary year template: %v", err)
return ""
} }
return template.HTML(buf.String()) dayID := d.Format("2006-01-02")
} if !coveredDay[dayID] {
idx := computeInsertIndex(sections, dayID)
// renderDiaryMonth renders a section per day, each with its markdown content virtual = append(virtual, diarySection{
// and photos sourced from the parent year folder. Level: 3,
func renderDiaryMonth(fsPath, urlPath string) template.HTML { ID: dayID,
yearFsPath := filepath.Dir(fsPath) Heading: dayID,
yearURLPath := parentURL(urlPath) EditURL: fmt.Sprintf("%s?edit&insert_before=%d&heading=%s",
yearURL, idx, url.QueryEscape(dayID)),
year, err := strconv.Atoi(filepath.Base(yearFsPath)) Photos: photoByDay[dayID],
if err != nil {
return ""
}
monthNum, err := strconv.Atoi(filepath.Base(fsPath))
if err != nil || monthNum < 1 || monthNum > 12 {
return ""
}
allPhotos := yearPhotos(yearFsPath, yearURLPath)
var monthPhotos []diaryPhoto
for _, p := range allPhotos {
if p.Date.Year() == year && int(p.Date.Month()) == monthNum {
monthPhotos = append(monthPhotos, p)
}
}
// Collect day numbers from subdirectories and from photo filenames.
daySet := map[int]bool{}
dayDirs := map[int]string{} // day number → actual directory name
entries, _ := os.ReadDir(fsPath)
for _, e := range entries {
if !e.IsDir() {
continue
}
d, err := strconv.Atoi(e.Name())
if err != nil || d < 1 || d > 31 {
continue
}
daySet[d] = true
dayDirs[d] = e.Name()
}
for _, p := range monthPhotos {
daySet[p.Date.Day()] = true
}
days := make([]int, 0, len(daySet))
for d := range daySet {
days = append(days, d)
}
sort.Ints(days)
var sections []diaryDaySection
for _, dayNum := range days {
date := time.Date(year, time.Month(monthNum), dayNum, 0, 0, 0, 0, time.UTC)
heading := date.Format("2006-01-02")
dayURL := path.Join(urlPath, fmt.Sprintf("%02d", dayNum)) + "/"
var content template.HTML
if dirName, ok := dayDirs[dayNum]; ok {
dayURL = path.Join(urlPath, dirName) + "/"
dayFsPath := filepath.Join(fsPath, dirName)
if raw, err := os.ReadFile(filepath.Join(dayFsPath, "index.md")); err == nil && len(raw) > 0 {
raw = stripFirstHeading(raw)
content = renderMarkdown(raw)
}
}
var photos []diaryPhoto
for _, p := range monthPhotos {
if p.Date.Day() == dayNum {
photos = append(photos, p)
}
}
sections = append(sections, diaryDaySection{
ID: date.Format("2006-01-02"),
Heading: heading,
URL: dayURL,
EditURL: dayURL + "?edit",
Content: content,
Photos: photos,
}) })
} }
}
if len(virtual) == 0 {
return existing
}
out := make([]diarySection, 0, len(existing)+len(virtual))
vi := 0
for _, s := range existing {
if isRealDateSection(s) {
for vi < len(virtual) && virtual[vi].ID < s.ID {
out = append(out, virtual[vi])
vi++
}
}
out = append(out, s)
}
for vi < len(virtual) {
out = append(out, virtual[vi])
vi++
}
return out
}
// isRealDateSection reports whether a rendered diarySection is one of the
// date-headed slots (`## YYYY-MM` or `### YYYY-MM-DD`) the virtual-entry
// interleave sorts against.
func isRealDateSection(s diarySection) bool {
switch s.Level {
case 2:
return len(s.ID) == 7 && isDateHeading(s.ID)
case 3:
return len(s.ID) == 10 && isDateHeading(s.ID)
}
return false
}
// parseISODate parses "YYYY-MM-DD" leading characters of s. Returns ok=false
// if the prefix does not match.
func parseISODate(s string) (year, month, day int, ok bool) {
if len(s) < 10 {
return 0, 0, 0, false
}
t, err := time.Parse("2006-01-02", s[:10])
if err != nil {
return 0, 0, 0, false
}
return t.Year(), int(t.Month()), t.Day(), true
}
func filterPhotos(photos []diaryPhoto, year, month, day int) []diaryPhoto {
var out []diaryPhoto
for _, p := range photos {
if p.Date.Year() == year && int(p.Date.Month()) == month && p.Date.Day() == day {
out = append(out, p)
}
}
return out
}
func renderDiaryContent(sections []diarySection) template.HTML {
var buf bytes.Buffer var buf bytes.Buffer
if err := diaryMonthTmpl.Execute(&buf, diaryMonthData{Days: sections}); err != nil { if err := diaryContentTmpl.Execute(&buf, diaryContentData{Sections: sections}); err != nil {
log.Printf("diary month template: %v", err) log.Printf("diary content template: %v", err)
return ""
}
return template.HTML(buf.String())
}
// renderDiaryDay renders the photo grid for a single day, sourcing photos
// from the grandparent year folder.
func renderDiaryDay(fsPath, urlPath string) template.HTML {
monthFsPath := filepath.Dir(fsPath)
yearFsPath := filepath.Dir(monthFsPath)
yearURLPath := parentURL(parentURL(urlPath))
year, err := strconv.Atoi(filepath.Base(yearFsPath))
if err != nil {
return ""
}
monthNum, err := strconv.Atoi(filepath.Base(monthFsPath))
if err != nil {
return ""
}
dayNum, err := strconv.Atoi(filepath.Base(fsPath))
if err != nil {
return ""
}
allPhotos := yearPhotos(yearFsPath, yearURLPath)
var photos []diaryPhoto
for _, p := range allPhotos {
if p.Date.Year() == year && int(p.Date.Month()) == monthNum && p.Date.Day() == dayNum {
photos = append(photos, p)
}
}
if len(photos) == 0 {
return ""
}
var buf bytes.Buffer
if err := diaryDayTmpl.Execute(&buf, diaryDayData{Photos: photos}); err != nil {
log.Printf("diary day template: %v", err)
return "" return ""
} }
return template.HTML(buf.String()) return template.HTML(buf.String())
+22
View File
@@ -0,0 +1,22 @@
// Builds the vendored CodeMirror 6 bundle consumed by the page editor.
//
// Output is an IIFE that assigns the CM primitives the editor scripts need
// onto window.CM (see entry.js). The editor JS is loaded as plain global
// scripts, not ES modules, so there is no runtime module loader.
//
// Run via `make editor` (or `npm run build` here) after changing CM versions.
// The committed artifact at assets/editor/vendor/codemirror.bundle.js is the
// only thing `go build` ever sees.
import * as esbuild from "esbuild";
await esbuild.build({
entryPoints: ["entry.js"],
bundle: true,
format: "iife",
minify: true,
target: ["es2018"],
legalComments: "none",
outfile: "../assets/editor/vendor/codemirror.bundle.js",
});
console.log("built assets/editor/vendor/codemirror.bundle.js");
+107
View File
@@ -0,0 +1,107 @@
// CodeMirror 6 bundle entry point.
//
// Imports only the CM packages the editor needs (keep the bundle small for the
// mobile/VPN path) and exposes them on window.CM for the global-style editor
// scripts. The editor theme and markdown highlight palette draw from the app's
// :root CSS variables so there are no hardcoded colors/spacing here.
import { EditorState, EditorSelection, Compartment, Prec } from "@codemirror/state";
import { EditorView, keymap, drawSelection } from "@codemirror/view";
import { history, historyKeymap, defaultKeymap, indentWithTab, undo, redo, deleteLine } from "@codemirror/commands";
import { markdown, markdownLanguage, markdownKeymap } from "@codemirror/lang-markdown";
import { syntaxHighlighting, HighlightStyle, indentOnInput } from "@codemirror/language";
import {
autocompletion,
closeBrackets,
closeBracketsKeymap,
completionKeymap,
startCompletion,
} from "@codemirror/autocomplete";
import { tags } from "@lezer/highlight";
// Editor chrome. Colors/spacing/fonts come from :root variables; var() works
// here because HighlightStyle/theme emit real CSS rules.
const theme = EditorView.theme(
{
"&": {
backgroundColor: "var(--bg)",
color: "var(--text)",
fontSize: "0.9rem",
border: "var(--border)",
borderTop: "none",
},
"&.cm-focused": { outline: "none" },
".cm-scroller": {
fontFamily: '"Iosevka Slab", monospace',
lineHeight: "1.6",
minHeight: "60vh",
},
".cm-content": {
padding: "var(--space-4)",
caretColor: "var(--text)",
},
".cm-cursor, .cm-dropCursor": { borderLeftColor: "var(--text)" },
"&.cm-focused .cm-selectionBackground, .cm-selectionBackground, .cm-content ::selection":
{ backgroundColor: "var(--bg-panel-hover)" },
".cm-activeLine": { backgroundColor: "transparent" },
".cm-tooltip": {
backgroundColor: "var(--bg-panel)",
border: "var(--border-dashed)",
color: "var(--text)",
},
".cm-tooltip.cm-tooltip-autocomplete > ul": {
fontFamily: '"Iosevka Slab", monospace',
fontSize: "var(--font-sm)",
},
".cm-tooltip-autocomplete ul li[aria-selected]": {
backgroundColor: "var(--bg-panel-hover)",
color: "var(--text)",
},
".cm-completionDetail": {
color: "var(--text-muted)",
fontStyle: "normal",
marginLeft: "var(--space-3)",
},
},
{ dark: true }
);
const highlightStyle = HighlightStyle.define([
{ tag: [tags.heading1, tags.heading2, tags.heading3, tags.heading4, tags.heading5, tags.heading6], color: "var(--secondary)", fontWeight: "bold" },
{ tag: tags.strong, fontWeight: "bold", color: "var(--text)" },
{ tag: tags.emphasis, fontStyle: "italic" },
{ tag: tags.strikethrough, textDecoration: "line-through" },
{ tag: [tags.link, tags.url], color: "var(--link)" },
{ tag: tags.monospace, color: "var(--primary-hover)" },
{ tag: tags.quote, color: "var(--text-muted)", fontStyle: "italic" },
{ tag: [tags.list, tags.contentSeparator], color: "var(--secondary)" },
{ tag: [tags.processingInstruction, tags.meta], color: "var(--text-muted)" },
]);
window.CM = {
EditorState,
EditorSelection,
Compartment,
Prec,
EditorView,
keymap,
drawSelection,
history,
historyKeymap,
defaultKeymap,
indentWithTab,
undo,
redo,
deleteLine,
markdown,
markdownLanguage,
markdownKeymap,
syntaxHighlighting,
indentOnInput,
autocompletion,
closeBrackets,
closeBracketsKeymap,
completionKeymap,
startCompletion,
theme,
highlightStyle,
};
+730
View File
@@ -0,0 +1,730 @@
{
"name": "datascape-editor-build",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "datascape-editor-build",
"version": "1.0.0",
"devDependencies": {
"@codemirror/autocomplete": "^6.18.0",
"@codemirror/commands": "^6.7.0",
"@codemirror/lang-markdown": "^6.3.0",
"@codemirror/language": "^6.10.0",
"@codemirror/state": "^6.4.0",
"@codemirror/view": "^6.34.0",
"@lezer/highlight": "^1.2.0",
"esbuild": "^0.24.0"
}
},
"node_modules/@codemirror/autocomplete": {
"version": "6.20.3",
"resolved": "https://registry.npmjs.org/@codemirror/autocomplete/-/autocomplete-6.20.3.tgz",
"integrity": "sha512-tlosUqb+3BbxCxZdu4tKeRghPFC+QM7q4X5YhKV2eCmPG+1r2F3f4AaSz5sCrFqUtX4Jh20VFTKecl16MgiV9g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@codemirror/language": "^6.0.0",
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.17.0",
"@lezer/common": "^1.0.0"
}
},
"node_modules/@codemirror/commands": {
"version": "6.10.3",
"resolved": "https://registry.npmjs.org/@codemirror/commands/-/commands-6.10.3.tgz",
"integrity": "sha512-JFRiqhKu+bvSkDLI+rUhJwSxQxYb759W5GBezE8Uc8mHLqC9aV/9aTC7yJSqCtB3F00pylrLCwnyS91Ap5ej4Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"@codemirror/language": "^6.0.0",
"@codemirror/state": "^6.6.0",
"@codemirror/view": "^6.27.0",
"@lezer/common": "^1.1.0"
}
},
"node_modules/@codemirror/lang-css": {
"version": "6.3.1",
"resolved": "https://registry.npmjs.org/@codemirror/lang-css/-/lang-css-6.3.1.tgz",
"integrity": "sha512-kr5fwBGiGtmz6l0LSJIbno9QrifNMUusivHbnA1H6Dmqy4HZFte3UAICix1VuKo0lMPKQr2rqB+0BkKi/S3Ejg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@codemirror/autocomplete": "^6.0.0",
"@codemirror/language": "^6.0.0",
"@codemirror/state": "^6.0.0",
"@lezer/common": "^1.0.2",
"@lezer/css": "^1.1.7"
}
},
"node_modules/@codemirror/lang-html": {
"version": "6.4.11",
"resolved": "https://registry.npmjs.org/@codemirror/lang-html/-/lang-html-6.4.11.tgz",
"integrity": "sha512-9NsXp7Nwp891pQchI7gPdTwBuSuT3K65NGTHWHNJ55HjYcHLllr0rbIZNdOzas9ztc1EUVBlHou85FFZS4BNnw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@codemirror/autocomplete": "^6.0.0",
"@codemirror/lang-css": "^6.0.0",
"@codemirror/lang-javascript": "^6.0.0",
"@codemirror/language": "^6.4.0",
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.17.0",
"@lezer/common": "^1.0.0",
"@lezer/css": "^1.1.0",
"@lezer/html": "^1.3.12"
}
},
"node_modules/@codemirror/lang-javascript": {
"version": "6.2.5",
"resolved": "https://registry.npmjs.org/@codemirror/lang-javascript/-/lang-javascript-6.2.5.tgz",
"integrity": "sha512-zD4e5mS+50htS7F+TYjBPsiIFGanfVqg4HyUz6WNFikgOPf2BgKlx+TQedI1w6n/IqRBVBbBWmGFdLB/7uxO4A==",
"dev": true,
"license": "MIT",
"dependencies": {
"@codemirror/autocomplete": "^6.0.0",
"@codemirror/language": "^6.6.0",
"@codemirror/lint": "^6.0.0",
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.17.0",
"@lezer/common": "^1.0.0",
"@lezer/javascript": "^1.0.0"
}
},
"node_modules/@codemirror/lang-markdown": {
"version": "6.5.0",
"resolved": "https://registry.npmjs.org/@codemirror/lang-markdown/-/lang-markdown-6.5.0.tgz",
"integrity": "sha512-0K40bZ35jpHya6FriukbgaleaqzBLZfOh7HuzqbMxBXkbYMJDxfF39c23xOgxFezR+3G+tR2/Mup+Xk865OMvw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@codemirror/autocomplete": "^6.7.1",
"@codemirror/lang-html": "^6.0.0",
"@codemirror/language": "^6.3.0",
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.0.0",
"@lezer/common": "^1.2.1",
"@lezer/markdown": "^1.0.0"
}
},
"node_modules/@codemirror/language": {
"version": "6.12.3",
"resolved": "https://registry.npmjs.org/@codemirror/language/-/language-6.12.3.tgz",
"integrity": "sha512-QwCZW6Tt1siP37Jet9Tb02Zs81TQt6qQrZR2H+eGMcFsL1zMrk2/b9CLC7/9ieP1fjIUMgviLWMmgiHoJrj+ZA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.23.0",
"@lezer/common": "^1.5.0",
"@lezer/highlight": "^1.0.0",
"@lezer/lr": "^1.0.0",
"style-mod": "^4.0.0"
}
},
"node_modules/@codemirror/lint": {
"version": "6.9.6",
"resolved": "https://registry.npmjs.org/@codemirror/lint/-/lint-6.9.6.tgz",
"integrity": "sha512-6Kp7r6XfCi/D/5sdXieMfg9pJU1bUEx96WITuLU6ESaKizCz0QHFMjY/TaFSbigDdEAIgi93itLBIUETP4oK+A==",
"dev": true,
"license": "MIT",
"dependencies": {
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.42.0",
"crelt": "^1.0.5"
}
},
"node_modules/@codemirror/state": {
"version": "6.6.0",
"resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.6.0.tgz",
"integrity": "sha512-4nbvra5R5EtiCzr9BTHiTLc+MLXK2QGiAVYMyi8PkQd3SR+6ixar/Q/01Fa21TBIDOZXgeWV4WppsQolSreAPQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@marijn/find-cluster-break": "^1.0.0"
}
},
"node_modules/@codemirror/view": {
"version": "6.43.0",
"resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.43.0.tgz",
"integrity": "sha512-V7ZCLQO3Jus9hzh2jVCCPW3mO4IBMr43O37PqSUYautJSnnJF41YlgLw21x0fLJTYvJ+Vkm6Gp+qKGH9pltgXA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@codemirror/state": "^6.6.0",
"crelt": "^1.0.6",
"style-mod": "^4.1.0",
"w3c-keyname": "^2.2.4"
}
},
"node_modules/@esbuild/aix-ppc64": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.24.2.tgz",
"integrity": "sha512-thpVCb/rhxE/BnMLQ7GReQLLN8q9qbHmI55F4489/ByVg2aQaQ6kbcLb6FHkocZzQhxc4gx0sCk0tJkKBFzDhA==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"aix"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-arm": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.24.2.tgz",
"integrity": "sha512-tmwl4hJkCfNHwFB3nBa8z1Uy3ypZpxqxfTQOcHX+xRByyYgunVbZ9MzUUfb0RxaHIMnbHagwAxuTL+tnNM+1/Q==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-arm64": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.24.2.tgz",
"integrity": "sha512-cNLgeqCqV8WxfcTIOeL4OAtSmL8JjcN6m09XIgro1Wi7cF4t/THaWEa7eL5CMoMBdjoHOTh/vwTO/o2TRXIyzg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-x64": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.24.2.tgz",
"integrity": "sha512-B6Q0YQDqMx9D7rvIcsXfmJfvUYLoP722bgfBlO5cGvNVb5V/+Y7nhBE3mHV9OpxBf4eAS2S68KZztiPaWq4XYw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/darwin-arm64": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.24.2.tgz",
"integrity": "sha512-kj3AnYWc+CekmZnS5IPu9D+HWtUI49hbnyqk0FLEJDbzCIQt7hg7ucF1SQAilhtYpIujfaHr6O0UHlzzSPdOeA==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/darwin-x64": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.24.2.tgz",
"integrity": "sha512-WeSrmwwHaPkNR5H3yYfowhZcbriGqooyu3zI/3GGpF8AyUdsrrP0X6KumITGA9WOyiJavnGZUwPGvxvwfWPHIA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/freebsd-arm64": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.24.2.tgz",
"integrity": "sha512-UN8HXjtJ0k/Mj6a9+5u6+2eZ2ERD7Edt1Q9IZiB5UZAIdPnVKDoG7mdTVGhHJIeEml60JteamR3qhsr1r8gXvg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/freebsd-x64": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.24.2.tgz",
"integrity": "sha512-TvW7wE/89PYW+IevEJXZ5sF6gJRDY/14hyIGFXdIucxCsbRmLUcjseQu1SyTko+2idmCw94TgyaEZi9HUSOe3Q==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-arm": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.24.2.tgz",
"integrity": "sha512-n0WRM/gWIdU29J57hJyUdIsk0WarGd6To0s+Y+LwvlC55wt+GT/OgkwoXCXvIue1i1sSNWblHEig00GBWiJgfA==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-arm64": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.24.2.tgz",
"integrity": "sha512-7HnAD6074BW43YvvUmE/35Id9/NB7BeX5EoNkK9obndmZBUk8xmJJeU7DwmUeN7tkysslb2eSl6CTrYz6oEMQg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-ia32": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.24.2.tgz",
"integrity": "sha512-sfv0tGPQhcZOgTKO3oBE9xpHuUqguHvSo4jl+wjnKwFpapx+vUDcawbwPNuBIAYdRAvIDBfZVvXprIj3HA+Ugw==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-loong64": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.24.2.tgz",
"integrity": "sha512-CN9AZr8kEndGooS35ntToZLTQLHEjtVB5n7dl8ZcTZMonJ7CCfStrYhrzF97eAecqVbVJ7APOEe18RPI4KLhwQ==",
"cpu": [
"loong64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-mips64el": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.24.2.tgz",
"integrity": "sha512-iMkk7qr/wl3exJATwkISxI7kTcmHKE+BlymIAbHO8xanq/TjHaaVThFF6ipWzPHryoFsesNQJPE/3wFJw4+huw==",
"cpu": [
"mips64el"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-ppc64": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.24.2.tgz",
"integrity": "sha512-shsVrgCZ57Vr2L8mm39kO5PPIb+843FStGt7sGGoqiiWYconSxwTiuswC1VJZLCjNiMLAMh34jg4VSEQb+iEbw==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-riscv64": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.24.2.tgz",
"integrity": "sha512-4eSFWnU9Hhd68fW16GD0TINewo1L6dRrB+oLNNbYyMUAeOD2yCK5KXGK1GH4qD/kT+bTEXjsyTCiJGHPZ3eM9Q==",
"cpu": [
"riscv64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-s390x": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.24.2.tgz",
"integrity": "sha512-S0Bh0A53b0YHL2XEXC20bHLuGMOhFDO6GN4b3YjRLK//Ep3ql3erpNcPlEFed93hsQAjAQDNsvcK+hV90FubSw==",
"cpu": [
"s390x"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-x64": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.24.2.tgz",
"integrity": "sha512-8Qi4nQcCTbLnK9WoMjdC9NiTG6/E38RNICU6sUNqK0QFxCYgoARqVqxdFmWkdonVsvGqWhmm7MO0jyTqLqwj0Q==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/netbsd-arm64": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.24.2.tgz",
"integrity": "sha512-wuLK/VztRRpMt9zyHSazyCVdCXlpHkKm34WUyinD2lzK07FAHTq0KQvZZlXikNWkDGoT6x3TD51jKQ7gMVpopw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/netbsd-x64": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.24.2.tgz",
"integrity": "sha512-VefFaQUc4FMmJuAxmIHgUmfNiLXY438XrL4GDNV1Y1H/RW3qow68xTwjZKfj/+Plp9NANmzbH5R40Meudu8mmw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openbsd-arm64": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.24.2.tgz",
"integrity": "sha512-YQbi46SBct6iKnszhSvdluqDmxCJA+Pu280Av9WICNwQmMxV7nLRHZfjQzwbPs3jeWnuAhE9Jy0NrnJ12Oz+0A==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openbsd-x64": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.24.2.tgz",
"integrity": "sha512-+iDS6zpNM6EnJyWv0bMGLWSWeXGN/HTaF/LXHXHwejGsVi+ooqDfMCCTerNFxEkM3wYVcExkeGXNqshc9iMaOA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/sunos-x64": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.24.2.tgz",
"integrity": "sha512-hTdsW27jcktEvpwNHJU4ZwWFGkz2zRJUz8pvddmXPtXDzVKTTINmlmga3ZzwcuMpUvLw7JkLy9QLKyGpD2Yxig==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"sunos"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-arm64": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.24.2.tgz",
"integrity": "sha512-LihEQ2BBKVFLOC9ZItT9iFprsE9tqjDjnbulhHoFxYQtQfai7qfluVODIYxt1PgdoyQkz23+01rzwNwYfutxUQ==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-ia32": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.24.2.tgz",
"integrity": "sha512-q+iGUwfs8tncmFC9pcnD5IvRHAzmbwQ3GPS5/ceCyHdjXubwQWI12MKWSNSMYLJMq23/IUCvJMS76PDqXe1fxA==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-x64": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.24.2.tgz",
"integrity": "sha512-7VTgWzgMGvup6aSqDPLiW5zHaxYJGTO4OokMjIlrCtf+VpEL+cXKtCvg723iguPYI5oaUNdS+/V7OU2gvXVWEg==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@lezer/common": {
"version": "1.5.2",
"resolved": "https://registry.npmjs.org/@lezer/common/-/common-1.5.2.tgz",
"integrity": "sha512-sxQE460fPZyU3sdc8lafxiPwJHBzZRy/udNFynGQky1SePYBdhkBl1kOagA9uT3pxR8K09bOrmTUqA9wb/PjSQ==",
"dev": true,
"license": "MIT"
},
"node_modules/@lezer/css": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/@lezer/css/-/css-1.3.3.tgz",
"integrity": "sha512-RzBo8r+/6QJeow7aPHIpGVIH59xTcJXp399820gZoMo9noQDRVpJLheIBUicYwKcsbOYoBRoLZlf2720dG/4Tg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@lezer/common": "^1.2.0",
"@lezer/highlight": "^1.0.0",
"@lezer/lr": "^1.3.0"
}
},
"node_modules/@lezer/highlight": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/@lezer/highlight/-/highlight-1.2.3.tgz",
"integrity": "sha512-qXdH7UqTvGfdVBINrgKhDsVTJTxactNNxLk7+UMwZhU13lMHaOBlJe9Vqp907ya56Y3+ed2tlqzys7jDkTmW0g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@lezer/common": "^1.3.0"
}
},
"node_modules/@lezer/html": {
"version": "1.3.13",
"resolved": "https://registry.npmjs.org/@lezer/html/-/html-1.3.13.tgz",
"integrity": "sha512-oI7n6NJml729m7pjm9lvLvmXbdoMoi2f+1pwSDJkl9d68zGr7a9Btz8NdHTGQZtW2DA25ybeuv/SyDb9D5tseg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@lezer/common": "^1.2.0",
"@lezer/highlight": "^1.0.0",
"@lezer/lr": "^1.0.0"
}
},
"node_modules/@lezer/javascript": {
"version": "1.5.4",
"resolved": "https://registry.npmjs.org/@lezer/javascript/-/javascript-1.5.4.tgz",
"integrity": "sha512-vvYx3MhWqeZtGPwDStM2dwgljd5smolYD2lR2UyFcHfxbBQebqx8yjmFmxtJ/E6nN6u1D9srOiVWm3Rb4tmcUA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@lezer/common": "^1.2.0",
"@lezer/highlight": "^1.1.3",
"@lezer/lr": "^1.3.0"
}
},
"node_modules/@lezer/lr": {
"version": "1.4.10",
"resolved": "https://registry.npmjs.org/@lezer/lr/-/lr-1.4.10.tgz",
"integrity": "sha512-rnCpTIBafOx4mRp43xOxDJbFipJm/c0cia/V5TiGlhmMa+wsSdoGmUN3w5Bqrks/09Q/D4tNAmWaT8p6NRi77A==",
"dev": true,
"license": "MIT",
"dependencies": {
"@lezer/common": "^1.0.0"
}
},
"node_modules/@lezer/markdown": {
"version": "1.6.4",
"resolved": "https://registry.npmjs.org/@lezer/markdown/-/markdown-1.6.4.tgz",
"integrity": "sha512-N0SxazMj4k65DBfaf1azqtMZd6u7MqluP84/NZnB/io8Td9aleFmAhz9hcbvSfsxT5tdYlJ5qgv5aMJGY4zEtA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@lezer/common": "^1.5.0",
"@lezer/highlight": "^1.0.0"
}
},
"node_modules/@marijn/find-cluster-break": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/@marijn/find-cluster-break/-/find-cluster-break-1.0.2.tgz",
"integrity": "sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g==",
"dev": true,
"license": "MIT"
},
"node_modules/crelt": {
"version": "1.0.6",
"resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.6.tgz",
"integrity": "sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==",
"dev": true,
"license": "MIT"
},
"node_modules/esbuild": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.24.2.tgz",
"integrity": "sha512-+9egpBW8I3CD5XPe0n6BfT5fxLzxrlDzqydF3aviG+9ni1lDC/OvMHcxqEFV0+LANZG5R1bFMWfUrjVsdwxJvA==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"bin": {
"esbuild": "bin/esbuild"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"@esbuild/aix-ppc64": "0.24.2",
"@esbuild/android-arm": "0.24.2",
"@esbuild/android-arm64": "0.24.2",
"@esbuild/android-x64": "0.24.2",
"@esbuild/darwin-arm64": "0.24.2",
"@esbuild/darwin-x64": "0.24.2",
"@esbuild/freebsd-arm64": "0.24.2",
"@esbuild/freebsd-x64": "0.24.2",
"@esbuild/linux-arm": "0.24.2",
"@esbuild/linux-arm64": "0.24.2",
"@esbuild/linux-ia32": "0.24.2",
"@esbuild/linux-loong64": "0.24.2",
"@esbuild/linux-mips64el": "0.24.2",
"@esbuild/linux-ppc64": "0.24.2",
"@esbuild/linux-riscv64": "0.24.2",
"@esbuild/linux-s390x": "0.24.2",
"@esbuild/linux-x64": "0.24.2",
"@esbuild/netbsd-arm64": "0.24.2",
"@esbuild/netbsd-x64": "0.24.2",
"@esbuild/openbsd-arm64": "0.24.2",
"@esbuild/openbsd-x64": "0.24.2",
"@esbuild/sunos-x64": "0.24.2",
"@esbuild/win32-arm64": "0.24.2",
"@esbuild/win32-ia32": "0.24.2",
"@esbuild/win32-x64": "0.24.2"
}
},
"node_modules/style-mod": {
"version": "4.1.3",
"resolved": "https://registry.npmjs.org/style-mod/-/style-mod-4.1.3.tgz",
"integrity": "sha512-i/n8VsZydrugj3Iuzll8+x/00GH2vnYsk1eomD8QiRrSAeW6ItbCQDtfXCeJHd0iwiNagqjQkvpvREEPtW3IoQ==",
"dev": true,
"license": "MIT"
},
"node_modules/w3c-keyname": {
"version": "2.2.8",
"resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz",
"integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==",
"dev": true,
"license": "MIT"
}
}
}
+20
View File
@@ -0,0 +1,20 @@
{
"name": "datascape-editor-build",
"private": true,
"version": "1.0.0",
"description": "One-time build tooling for the vendored CodeMirror 6 editor bundle. Dev-only: `go build` never runs Node, it only consumes the committed bundle artifact.",
"type": "module",
"scripts": {
"build": "node build.mjs"
},
"devDependencies": {
"@codemirror/autocomplete": "^6.18.0",
"@codemirror/commands": "^6.7.0",
"@codemirror/lang-markdown": "^6.3.0",
"@codemirror/language": "^6.10.0",
"@codemirror/state": "^6.4.0",
"@codemirror/view": "^6.34.0",
"@lezer/highlight": "^1.2.0",
"esbuild": "^0.24.0"
}
}
+504
View File
@@ -0,0 +1,504 @@
package main
import (
"bytes"
"encoding/json"
"fmt"
"html/template"
"log"
"math"
"net/http"
"os"
"path/filepath"
"sort"
"strconv"
"strings"
"time"
)
func init() {
pageTypeHandlers = append(pageTypeHandlers, &fitnessHandler{})
}
// waistlineExportFile is the exact filename the user copies into the folder.
// The single file is always the latest export — no glob, no multi-file merge.
const waistlineExportFile = "waistline_export.json"
type fitnessHandler struct{}
// redirect: the fitness dashboard has no virtual URLs; everything renders
// inside the normal GET /{path}/ flow.
func (f *fitnessHandler) redirect(root, fsPath, urlPath string, r *http.Request) (string, bool) {
return "", false
}
// handle renders the dashboard for folders whose .page-settings declares
// type = fitness. Markdown content and the folder listing stay visible so
// the user can verify an uploaded export arrived.
func (f *fitnessHandler) handle(root, fsPath, urlPath string, r *http.Request) *specialPage {
s := readPageSettings(fsPath)
if s == nil || s.Type != "fitness" {
return nil
}
weightSel := validFitnessRange(r.URL.Query().Get("weight"), "3m")
weeklySel := validFitnessRange(r.URL.Query().Get("weekly"), "1y")
return &specialPage{
Content: renderFitnessDashboard(fsPath, weightSel, weeklySel),
SuppressTOC: true,
}
}
// === Time ranges ===
type fitnessRange struct {
Value string
Label string
Months int // 0 = all data
}
var fitnessRanges = []fitnessRange{
{"1m", "1 month", 1},
{"3m", "3 months", 3},
{"1y", "1 year", 12},
{"all", "All", 0},
}
func validFitnessRange(v, fallback string) string {
for _, r := range fitnessRanges {
if r.Value == v {
return v
}
}
return fallback
}
func rangeMonths(v string) int {
for _, r := range fitnessRanges {
if r.Value == v {
return r.Months
}
}
return 0
}
// === Waistline export parsing ===
// wlNum is a number in a Waistline export: values appear as JSON numbers,
// numeric strings, null, or are absent. Unparsable values read as not-ok
// instead of failing the whole export parse.
type wlNum struct {
val float64
ok bool
}
func (n *wlNum) UnmarshalJSON(b []byte) error {
s := strings.Trim(string(b), `"`)
if s == "" || s == "null" {
return nil
}
v, err := strconv.ParseFloat(s, 64)
if err != nil {
return nil
}
n.val = v
n.ok = true
return nil
}
// Calorie tracking was removed pending a rethink of the (undocumented)
// per-item formula; only the weight series is read from the export.
type wlExport struct {
Diary []wlDiaryEntry `json:"diary"`
Settings json.RawMessage `json:"settings"`
}
type wlDiaryEntry struct {
DateTime string `json:"dateTime"`
Stats struct {
Weight wlNum `json:"weight"`
} `json:"stats"`
}
// exportDate extracts the calendar day from a Waistline dateTime. The values
// are UTC-midnight timestamps; taking the first 10 characters avoids time
// zone conversions shifting the day.
func exportDate(s string) (time.Time, bool) {
if len(s) < 10 {
return time.Time{}, false
}
t, err := time.Parse("2006-01-02", s[:10])
if err != nil {
return time.Time{}, false
}
return t, true
}
// goalValue extracts settings.goals.<key>.goal-list[0].goal[0] — the first
// weekday slot of the shared goal. Best-effort: any missing or unparsable
// level means "no goal line", never an error.
func goalValue(settings json.RawMessage, key string) (float64, bool) {
var s struct {
Goals map[string]json.RawMessage `json:"goals"`
}
if json.Unmarshal(settings, &s) != nil {
return 0, false
}
var g struct {
GoalList []struct {
Goal []wlNum `json:"goal"`
} `json:"goal-list"`
}
if json.Unmarshal(s.Goals[key], &g) != nil {
return 0, false
}
if len(g.GoalList) == 0 || len(g.GoalList[0].Goal) == 0 {
return 0, false
}
n := g.GoalList[0].Goal[0]
return n.val, n.ok
}
func exportWeightUnit(settings json.RawMessage) string {
var s struct {
Units struct {
Weight string `json:"weight"`
} `json:"units"`
}
if json.Unmarshal(settings, &s) == nil && s.Units.Weight != "" {
return s.Units.Weight
}
return "kg"
}
// === Series extraction ===
type weightPoint struct {
date time.Time
value float64
days int // >0: weekly mean over this many measured days
}
// extractWeights computes the weight series from the export: one point per
// diary entry with stats.weight. Duplicate dates (shouldn't happen) — last
// one wins.
func extractWeights(ex *wlExport) []weightPoint {
byDate := map[time.Time]float64{}
for _, e := range ex.Diary {
day, ok := exportDate(e.DateTime)
if !ok || !e.Stats.Weight.ok {
continue
}
byDate[day] = e.Stats.Weight.val
}
weights := make([]weightPoint, 0, len(byDate))
for d, v := range byDate {
weights = append(weights, weightPoint{date: d, value: v})
}
sort.Slice(weights, func(i, j int) bool { return weights[i].date.Before(weights[j].date) })
return weights
}
func mondayOf(t time.Time) time.Time {
return t.AddDate(0, 0, -((int(t.Weekday()) + 6) % 7))
}
// weeklyMeanWeights buckets the daily weight series into ISO weeks (Monday
// start) and averages over the days that have a measurement. Weeks without
// any measurement produce no point.
func weeklyMeanWeights(points []weightPoint) []weightPoint {
type acc struct {
sum float64
n int
}
byWeek := map[time.Time]*acc{}
for _, p := range points {
w := mondayOf(p.date)
a := byWeek[w]
if a == nil {
a = &acc{}
byWeek[w] = a
}
a.sum += p.value
a.n++
}
out := make([]weightPoint, 0, len(byWeek))
for w, a := range byWeek {
out = append(out, weightPoint{date: w, value: a.sum / float64(a.n), days: a.n})
}
sort.Slice(out, func(i, j int) bool { return out[i].date.Before(out[j].date) })
return out
}
// === SVG geometry ===
// Chart canvas in viewBox units. The SVG scales to container width via
// viewBox + width:100%, so these only set proportions and text size.
const (
chartW = 560.0
chartH = 240.0
chartLeft = 46.0
chartRight = 8.0
chartTop = 10.0
chartBottom = 24.0
)
// svgNum formats a coordinate or display value: rounded to 2 decimals,
// trailing zeros trimmed.
func svgNum(v float64) string {
return strconv.FormatFloat(math.Round(v*100)/100, 'f', -1, 64)
}
type chartScale struct {
x0, x1 time.Time
y0, y1 float64
}
func (s chartScale) x(t time.Time) float64 {
span := s.x1.Sub(s.x0).Seconds()
if span <= 0 {
return chartLeft
}
return chartLeft + (chartW-chartLeft-chartRight)*t.Sub(s.x0).Seconds()/span
}
func (s chartScale) y(v float64) float64 {
span := s.y1 - s.y0
if span <= 0 {
return chartH - chartBottom
}
return chartH - chartBottom - (chartH-chartBottom-chartTop)*(v-s.y0)/span
}
// === View models ===
type fitnessOptVM struct {
Value, Label string
Selected bool
}
type fitnessTickVM struct {
Pos, Label, Anchor string
}
type fitnessDotVM struct {
X, Y, Title string
}
type fitnessGoalVM struct {
Y, LabelY, Label string
}
type fitnessChartVM struct {
Title string
Param string
Options []fitnessOptVM
Empty bool
ViewW, ViewH string
PlotX, PlotY, PlotR, PlotB string
YLabelX, XLabelY string
YTicks, XTicks []fitnessTickVM
Goal *fitnessGoalVM
Lines []string // polyline points attributes
Dots []fitnessDotVM
}
type fitnessDashVM struct {
Notice string
Charts []fitnessChartVM
}
func newChartVM(title, param, sel string) fitnessChartVM {
opts := make([]fitnessOptVM, len(fitnessRanges))
for i, r := range fitnessRanges {
opts[i] = fitnessOptVM{r.Value, r.Label, r.Value == sel}
}
return fitnessChartVM{
Title: title, Param: param, Options: opts,
ViewW: svgNum(chartW), ViewH: svgNum(chartH),
PlotX: svgNum(chartLeft), PlotY: svgNum(chartTop),
PlotR: svgNum(chartW - chartRight), PlotB: svgNum(chartH - chartBottom),
YLabelX: svgNum(chartLeft - 5), XLabelY: svgNum(chartH - chartBottom + 14),
}
}
// yTickVMs places gridlines at fixed multiples of step across the y domain
// (always 5 kg guides on weight charts, regardless of range).
func yTickVMs(sc chartScale, step float64) []fitnessTickVM {
var out []fitnessTickVM
for v := math.Ceil(sc.y0/step) * step; v <= sc.y1+step/1e6; v += step {
out = append(out, fitnessTickVM{Pos: svgNum(sc.y(v)), Label: svgNum(v)})
}
return out
}
func (vm *fitnessChartVM) setGoal(sc chartScale, goal float64, label string) {
gy := sc.y(goal)
ly := gy - 4
if ly < chartTop+10 {
ly = gy + 12
}
vm.Goal = &fitnessGoalVM{Y: svgNum(gy), LabelY: svgNum(ly), Label: label}
}
// === Chart builders ===
// buildWeightChart renders a weight line chart. It serves both the per-day
// series and the weekly-mean series (points carrying days > 0); the line is
// drawn continuous across days/weeks without a measurement.
func buildWeightChart(all []weightPoint, goal float64, hasGoal bool, sel, param, title, unit string, today time.Time) fitnessChartVM {
vm := newChartVM(title, param, sel)
points := all
var x0, x1 time.Time
if m := rangeMonths(sel); m > 0 {
x0 = today.AddDate(0, -m, 0)
points = filterWeights(all, x0)
if len(points) == 0 {
vm.Empty = true
return vm
}
x1 = today
if last := points[len(points)-1].date; last.After(x1) {
x1 = last
}
} else {
if len(points) == 0 {
vm.Empty = true
return vm
}
x0 = points[0].date
x1 = points[len(points)-1].date
}
// Degenerate domain (single point on All): widen so the dot sits inside
// the plot instead of on its edge.
if !x0.Before(x1) {
x0 = x0.AddDate(0, 0, -1)
x1 = x1.AddDate(0, 0, 1)
}
lo, hi := points[0].value, points[0].value
for _, p := range points {
lo = min(lo, p.value)
hi = max(hi, p.value)
}
if hasGoal {
lo = min(lo, goal)
hi = max(hi, goal)
}
pad := (hi - lo) * 0.05
if pad == 0 {
pad = 1
}
sc := chartScale{x0, x1, lo - pad, hi + pad}
vm.YTicks = yTickVMs(sc, 5)
vm.XTicks = timeXTicks(sc, 4)
// One continuous polyline through every point in range — days without a
// measurement do not break the line. Point markers carry the hover
// <title>; on dense ranges they are dropped and the bare line stays
// legible. A single point in range renders as a dot.
dot := func(p weightPoint) fitnessDotVM {
label := p.date.Format("2006-01-02") + ": " + svgNum(p.value) + " " + unit
if p.days > 0 {
label = fmt.Sprintf("Week of %s: %s %s (%d days)",
p.date.Format("2006-01-02"), svgNum(p.value), unit, p.days)
}
return fitnessDotVM{X: svgNum(sc.x(p.date)), Y: svgNum(sc.y(p.value)), Title: label}
}
if len(points) >= 2 {
var b strings.Builder
for i, p := range points {
if i > 0 {
b.WriteByte(' ')
}
b.WriteString(svgNum(sc.x(p.date)))
b.WriteByte(',')
b.WriteString(svgNum(sc.y(p.value)))
}
vm.Lines = append(vm.Lines, b.String())
}
if len(points) <= 100 {
for _, p := range points {
vm.Dots = append(vm.Dots, dot(p))
}
}
if hasGoal {
vm.setGoal(sc, goal, "goal "+svgNum(goal))
}
return vm
}
func filterWeights(points []weightPoint, from time.Time) []weightPoint {
var out []weightPoint
for _, p := range points {
if !p.date.Before(from) {
out = append(out, p)
}
}
return out
}
// timeXTicks places n+1 evenly spaced date labels across a continuous time
// axis; the last label is end-anchored so it stays inside the viewBox.
func timeXTicks(sc chartScale, n int) []fitnessTickVM {
span := sc.x1.Sub(sc.x0)
var out []fitnessTickVM
prev := ""
for i := 0; i <= n; i++ {
t := sc.x0.Add(time.Duration(float64(span) * float64(i) / float64(n)))
label := t.Format("2006-01-02")
if label == prev {
continue
}
prev = label
anchor := "middle"
if i == n {
anchor = "end"
}
out = append(out, fitnessTickVM{Pos: svgNum(sc.x(t)), Label: label, Anchor: anchor})
}
return out
}
// === Rendering ===
var fitnessTmpl = template.Must(template.ParseFS(assets, "assets/fitness/main.html"))
func renderFitnessDashboard(fsPath, weightSel, weeklySel string) template.HTML {
data := buildFitnessDash(fsPath, weightSel, weeklySel, time.Now())
var buf bytes.Buffer
if err := fitnessTmpl.Execute(&buf, data); err != nil {
log.Printf("fitness template: %v", err)
return ""
}
return template.HTML(buf.String())
}
// buildFitnessDash reads and parses the export per request — no caching, no
// indexes. A read or parse failure (including a truncated mid-upload file)
// becomes an inline notice; the page itself always renders.
func buildFitnessDash(fsPath, weightSel, weeklySel string, now time.Time) fitnessDashVM {
raw, err := os.ReadFile(filepath.Join(fsPath, waistlineExportFile))
if err != nil {
return fitnessDashVM{Notice: "No Waistline export found — upload " + waistlineExportFile + " to this folder."}
}
var ex wlExport
if err := json.Unmarshal(raw, &ex); err != nil {
return fitnessDashVM{Notice: "Could not read " + waistlineExportFile + ": " + err.Error()}
}
today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.UTC)
weights := extractWeights(&ex)
wGoal, hasWGoal := goalValue(ex.Settings, "weight")
unit := exportWeightUnit(ex.Settings)
return fitnessDashVM{Charts: []fitnessChartVM{
buildWeightChart(weights, wGoal, hasWGoal, weightSel,
"weight", "Weight ("+unit+")", unit, today),
buildWeightChart(weeklyMeanWeights(weights), wGoal, hasWGoal, weeklySel,
"weekly", "Weekly average weight ("+unit+")", unit, today),
}}
}
+107 -33
View File
@@ -2,7 +2,9 @@ package main
import ( import (
"context" "context"
"crypto/sha256"
"embed" "embed"
"encoding/hex"
"flag" "flag"
"html/template" "html/template"
"io/fs" "io/fs"
@@ -19,37 +21,55 @@ import (
//go:embed assets //go:embed assets
var assets embed.FS var assets embed.FS
// editorBundleVersion is a short content hash of the vendored CodeMirror bundle,
// appended as ?v=… to its <script> src. The bundle is served immutable under a
// stable filename, so without this query a rebuilt bundle would never reach a
// client that already cached the old one (this is the editor cache-bust knob).
var editorBundleVersion = hashAsset("assets/editor/vendor/codemirror.bundle.js")
func hashAsset(name string) string {
b, err := assets.ReadFile(name)
if err != nil {
return ""
}
sum := sha256.Sum256(b)
return hex.EncodeToString(sum[:])[:12]
}
var ( var (
pageTmpl = template.Must(template.ParseFS(assets, "assets/layout.html", "assets/page/main.html")) pageTmpl = template.Must(template.ParseFS(assets, "assets/layout.html", "assets/page/main.html"))
editTmpl = template.Must(template.ParseFS(assets, "assets/layout.html", "assets/editor/main.html")) editTmpl = template.Must(template.New("edit").Funcs(template.FuncMap{
"editorBundleVersion": func() string { return editorBundleVersion },
}).ParseFS(assets, "assets/layout.html", "assets/editor/main.html"))
searchTmpl = template.Must(template.ParseFS(assets, "assets/layout.html", "assets/search/main.html")) searchTmpl = template.Must(template.ParseFS(assets, "assets/layout.html", "assets/search/main.html"))
) )
// specialPage is the result returned by a pageTypeHandler. // specialPage is the result returned by a pageTypeHandler.
// Content is injected into the page after the standard markdown content. // Content is injected into the page after the standard markdown content.
// SuppressContent hides the markdown-rendered content (handler owns rendering).
// SuppressListing hides the default file/folder listing. // SuppressListing hides the default file/folder listing.
// Widget is a persistent sidebar widget rendered outside the main content area. // Widget is a persistent sidebar widget rendered outside the main content area.
type specialPage struct { type specialPage struct {
Content template.HTML Content template.HTML
SuppressContent bool
SuppressListing bool SuppressListing bool
SuppressTOC bool
Widget template.HTML Widget template.HTML
} }
// pageTypeHandler is implemented by each special folder type (diary, gallery, …). // pageTypeHandler is implemented by each special folder type (diary, gallery, …).
// handle returns nil when the handler does not apply to the given path. // handle returns nil when the handler does not apply to the given path. The
// request is passed read-only (e.g. query params selecting a view variant);
// mutations belong in the POST flow, not here.
// redirect returns ok=true with an absolute URL when the request should be // redirect returns ok=true with an absolute URL when the request should be
// short-circuited with a 302 redirect (e.g. persistent date links in a diary). // short-circuited with a 302 redirect (e.g. persistent date links in a diary,
// defaultHeading returns ok=true with custom pre-fill heading text for the // or virtual diary URLs in edit mode that delegate to the year file's editor).
// editor when no index.md exists yet (e.g. German long-form date for a diary
// day). Handlers that don't need a hook should return the zero value.
// //
// When adding a new hook, prefer a sibling method here over folding logic // When adding a new hook, prefer a sibling method here over folding logic
// into main.go or render.go. If this list grows much beyond three, consider // into main.go or render.go.
// collapsing into a single overrides struct returned per request.
type pageTypeHandler interface { type pageTypeHandler interface {
handle(root, fsPath, urlPath string) *specialPage handle(root, fsPath, urlPath string, r *http.Request) *specialPage
redirect(root, fsPath, urlPath string) (target string, ok bool) redirect(root, fsPath, urlPath string, r *http.Request) (target string, ok bool)
defaultHeading(root, fsPath, urlPath string) (heading string, ok bool)
} }
// pageTypeHandlers is the registry. Each type registers itself via init(). // pageTypeHandlers is the registry. Each type registers itself via init().
@@ -91,7 +111,7 @@ func main() {
staticFS, _ := fs.Sub(assets, "assets") staticFS, _ := fs.Sub(assets, "assets")
static := http.StripPrefix("/_/", http.FileServer(http.FS(staticFS))) static := http.StripPrefix("/_/", http.FileServer(http.FS(staticFS)))
http.Handle("/_/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { http.Handle("/_/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if strings.HasPrefix(r.URL.Path, "/_/fonts/") { if strings.HasPrefix(r.URL.Path, "/_/fonts/") || strings.HasPrefix(r.URL.Path, "/_/editor/vendor/") {
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable") w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
} }
static.ServeHTTP(w, r) static.ServeHTTP(w, r)
@@ -218,7 +238,7 @@ func (h *handler) serveDir(w http.ResponseWriter, r *http.Request, urlPath, fsPa
} }
for _, ph := range pageTypeHandlers { for _, ph := range pageTypeHandlers {
if target, ok := ph.redirect(h.root, fsPath, urlPath); ok { if target, ok := ph.redirect(h.root, fsPath, urlPath, r); ok {
http.Redirect(w, r, target, http.StatusFound) http.Redirect(w, r, target, http.StatusFound)
return return
} }
@@ -229,31 +249,38 @@ func (h *handler) serveDir(w http.ResponseWriter, r *http.Request, urlPath, fsPa
// Determine section index (-1 = whole page). // Determine section index (-1 = whole page).
sectionIndex := -1 sectionIndex := -1
insertBefore := -1
if editMode { if editMode {
if s := r.URL.Query().Get("section"); s != "" { if s := r.URL.Query().Get("section"); s != "" {
if n, err := strconv.Atoi(s); err == nil && n >= 0 { if n, err := strconv.Atoi(s); err == nil && n >= 0 {
sectionIndex = n sectionIndex = n
} }
} }
if s := r.URL.Query().Get("insert_before"); s != "" {
if n, err := strconv.Atoi(s); err == nil && n >= 0 {
insertBefore = n
}
} }
var rendered template.HTML
if len(rawMD) > 0 && !editMode {
rendered = renderMarkdown(rawMD)
} }
var special *specialPage var special *specialPage
if !editMode { if !editMode {
for _, ph := range pageTypeHandlers { for _, ph := range pageTypeHandlers {
if special = ph.handle(h.root, fsPath, urlPath); special != nil { if special = ph.handle(h.root, fsPath, urlPath, r); special != nil {
break break
} }
} }
} }
var rendered template.HTML
if len(rawMD) > 0 && !editMode && (special == nil || !special.SuppressContent) {
rendered = renderMarkdown(rawMD)
}
view, sortKey, order := readPageSettings(fsPath).viewSettings()
var entries []entry var entries []entry
if !editMode && (special == nil || !special.SuppressListing) { if !editMode && (special == nil || !special.SuppressListing) {
entries = listEntries(fsPath, urlPath) entries = listEntries(fsPath, urlPath, sortKey, order)
} }
title := pageTitle(urlPath) title := pageTitle(urlPath)
@@ -263,26 +290,32 @@ func (h *handler) serveDir(w http.ResponseWriter, r *http.Request, urlPath, fsPa
var specialContent template.HTML var specialContent template.HTML
var sidebarWidget template.HTML var sidebarWidget template.HTML
suppressTOC := false
if special != nil { if special != nil {
specialContent = special.Content specialContent = special.Content
sidebarWidget = special.Widget sidebarWidget = special.Widget
suppressTOC = special.SuppressTOC
} }
rawContent := string(rawMD) rawContent := string(rawMD)
if editMode && sectionIndex >= 0 { if editMode && insertBefore >= 0 {
heading := r.URL.Query().Get("heading")
level := r.URL.Query().Get("level")
if level == "" {
level = "###"
}
if heading != "" {
rawContent = level + " " + heading + "\n\n"
} else {
rawContent = ""
}
} else if editMode && sectionIndex >= 0 {
sections := splitSections(rawMD) sections := splitSections(rawMD)
if sectionIndex < len(sections) { if sectionIndex < len(sections) {
rawContent = string(sections[sectionIndex]) rawContent = string(sections[sectionIndex])
} }
} else if editMode && rawContent == "" && urlPath != "/" { } else if editMode && rawContent == "" && urlPath != "/" {
heading := pageTitle(urlPath) rawContent = "# " + pageTitle(urlPath) + "\n\n"
for _, ph := range pageTypeHandlers {
if custom, ok := ph.defaultHeading(h.root, fsPath, urlPath); ok {
heading = custom
break
}
}
rawContent = "# " + heading + "\n\n"
} }
parent := "" parent := ""
@@ -296,12 +329,17 @@ func (h *handler) serveDir(w http.ResponseWriter, r *http.Request, urlPath, fsPa
EditMode: editMode, EditMode: editMode,
IsRoot: urlPath == "/", IsRoot: urlPath == "/",
SectionIndex: sectionIndex, SectionIndex: sectionIndex,
InsertBefore: insertBefore,
PostURL: urlPath, PostURL: urlPath,
RawContent: rawContent, RawContent: rawContent,
Content: rendered, Content: rendered,
Entries: entries, Entries: entries,
View: view,
Sort: sortKey,
Order: order,
SpecialContent: specialContent, SpecialContent: specialContent,
SidebarWidget: sidebarWidget, SidebarWidget: sidebarWidget,
SuppressTOC: suppressTOC,
} }
w.Header().Set("Content-Type", "text/html; charset=utf-8") w.Header().Set("Content-Type", "text/html; charset=utf-8")
@@ -341,6 +379,10 @@ func (h *handler) handlePost(w http.ResponseWriter, r *http.Request, urlPath, fs
h.handleAddTask(w, r, urlPath, fsPath) h.handleAddTask(w, r, urlPath, fsPath)
return return
} }
if query.Has("settings") {
h.handleSettings(w, r, urlPath, fsPath)
return
}
if err := r.ParseForm(); err != nil { if err := r.ParseForm(); err != nil {
http.Error(w, "bad request", http.StatusBadRequest) http.Error(w, "bad request", http.StatusBadRequest)
@@ -350,9 +392,33 @@ func (h *handler) handlePost(w http.ResponseWriter, r *http.Request, urlPath, fs
indexPath := filepath.Join(fsPath, "index.md") indexPath := filepath.Join(fsPath, "index.md")
redirectTarget := urlPath redirectTarget := urlPath
// If a section index was submitted, splice the edited section back into // insert_before splices a new section into the file *at* index N rather
// the full file rather than replacing the whole document. // than replacing index N (used by the diary "create new day" flow).
if s := r.FormValue("section"); s != "" { // section replaces the section at index N (used by per-section edits).
// Exactly one of insert_before / section should be set; insert_before
// wins if both are present.
if s := r.FormValue("insert_before"); s != "" {
insertIndex, err := strconv.Atoi(s)
if err != nil || insertIndex < 0 {
http.Error(w, "bad insert_before", http.StatusBadRequest)
return
}
rawMD, _ := os.ReadFile(indexPath)
sections := splitSections(rawMD)
if insertIndex > len(sections) {
insertIndex = len(sections)
}
newSection := []byte(content)
inserted := make([][]byte, 0, len(sections)+1)
inserted = append(inserted, sections[:insertIndex]...)
inserted = append(inserted, newSection)
inserted = append(inserted, sections[insertIndex:]...)
content = string(joinSections(inserted))
ids := headingIDs([]byte(content))
if insertIndex-1 >= 0 && insertIndex-1 < len(ids) {
redirectTarget = urlPath + "#" + ids[insertIndex-1]
}
} else if s := r.FormValue("section"); s != "" {
sectionIndex, err := strconv.Atoi(s) sectionIndex, err := strconv.Atoi(s)
if err != nil || sectionIndex < 0 { if err != nil || sectionIndex < 0 {
http.Error(w, "bad section", http.StatusBadRequest) http.Error(w, "bad section", http.StatusBadRequest)
@@ -410,7 +476,8 @@ func readPageSettings(dir string) *pageSettings {
if err != nil { if err != nil {
return nil return nil
} }
s := &pageSettings{} // Defaults; overridden only by valid values present in the file.
s := &pageSettings{View: viewList, Sort: sortName, Order: orderAsc}
for _, line := range strings.Split(string(data), "\n") { for _, line := range strings.Split(string(data), "\n") {
line = strings.TrimSpace(line) line = strings.TrimSpace(line)
if line == "" || strings.HasPrefix(line, "#") { if line == "" || strings.HasPrefix(line, "#") {
@@ -420,9 +487,16 @@ func readPageSettings(dir string) *pageSettings {
if len(parts) != 2 { if len(parts) != 2 {
continue continue
} }
value := strings.TrimSpace(parts[1])
switch strings.TrimSpace(parts[0]) { switch strings.TrimSpace(parts[0]) {
case "type": case "type":
s.Type = strings.TrimSpace(parts[1]) s.Type = value
case "view":
s.View = validateView(value)
case "sort":
s.Sort = validateSort(value)
case "order":
s.Order = validateOrder(value)
} }
} }
return s return s
+89
View File
@@ -0,0 +1,89 @@
package main
import (
"net/http"
"os"
"path/filepath"
"strings"
)
// handleSettings persists the listing view/sort/order to the folder's
// .page-settings file. Values are validated against the allowed sets (unknown
// values fall back to defaults). Triggered by POST /{path}?settings.
func (h *handler) handleSettings(w http.ResponseWriter, r *http.Request, urlPath, fsPath string) {
if err := r.ParseForm(); err != nil {
http.Error(w, "bad request", http.StatusBadRequest)
return
}
view := validateView(r.FormValue("view"))
sortKey := validateSort(r.FormValue("sort"))
order := validateOrder(r.FormValue("order"))
if err := writePageSettings(fsPath, view, sortKey, order); err != nil {
http.Error(w, "write failed: "+err.Error(), http.StatusInternalServerError)
return
}
http.Redirect(w, r, urlPath, http.StatusSeeOther)
}
// writePageSettings performs a read-modify-write of <dir>/.page-settings,
// updating the view/sort/order lines while preserving every other line
// (other keys, comments, blank lines, ordering) verbatim. Missing keys are
// appended. The write is atomic (temp file + rename).
func writePageSettings(dir, view, sortKey, order string) error {
if err := os.MkdirAll(dir, 0755); err != nil {
return err
}
p := filepath.Join(dir, ".page-settings")
existing, err := os.ReadFile(p)
if err != nil && !os.IsNotExist(err) {
return err
}
updated := updateSettingsLines(existing, view, sortKey, order)
return writeFileAtomic(p, updated, 0644)
}
// updateSettingsLines rewrites the view/sort/order lines in existing while
// leaving all other lines untouched. Every occurrence of a known key is
// updated (so the reader's last-wins parse stays consistent); keys absent from
// the file are appended in a stable order. The result always ends in a newline.
func updateSettingsLines(existing []byte, view, sortKey, order string) []byte {
targets := map[string]string{"view": view, "sort": sortKey, "order": order}
appendOrder := []string{"view", "sort", "order"}
seen := map[string]bool{}
var lines []string
if len(existing) > 0 {
s := string(existing)
s = strings.TrimSuffix(s, "\n")
lines = strings.Split(s, "\n")
}
for i, line := range lines {
trimmed := strings.TrimSpace(line)
if trimmed == "" || strings.HasPrefix(trimmed, "#") {
continue
}
eq := strings.IndexByte(line, '=')
if eq < 0 {
continue
}
key := strings.TrimSpace(line[:eq])
if val, ok := targets[key]; ok {
lines[i] = key + " = " + val
seen[key] = true
}
}
for _, k := range appendOrder {
if !seen[k] {
lines = append(lines, k+" = "+targets[k])
}
}
out := strings.Join(lines, "\n")
if out != "" {
out += "\n"
}
return []byte(out)
}
+121 -11
View File
@@ -4,10 +4,12 @@ import (
"bytes" "bytes"
"fmt" "fmt"
"html/template" "html/template"
"net/url"
"os" "os"
"path" "path"
"sort" "sort"
"strings" "strings"
"time"
"github.com/yuin/goldmark" "github.com/yuin/goldmark"
"github.com/yuin/goldmark/extension" "github.com/yuin/goldmark/extension"
@@ -31,6 +33,13 @@ func initMarkdown(root string) {
type entry struct { type entry struct {
Icon template.HTML Icon template.HTML
Name, URL, Meta string Name, URL, Meta string
// ThumbURL is set for thumbnailable files; the thumbnail view renders an
// <img> when it is non-empty and falls back to Icon otherwise.
ThumbURL string
// modTime/size carry the raw sort keys; the template only reads the
// formatted Meta string.
modTime time.Time
size int64
} }
type pageData struct { type pageData struct {
@@ -40,18 +49,73 @@ type pageData struct {
EditMode bool EditMode bool
IsRoot bool IsRoot bool
SectionIndex int // -1 = whole page; >=0 = section being edited SectionIndex int // -1 = whole page; >=0 = section being edited
InsertBefore int // -1 = no insert; >=0 = splice new section at this index
PostURL string PostURL string
RawContent string RawContent string
Content template.HTML Content template.HTML
Entries []entry Entries []entry
View string // listing view style: "list" or "thumbnail"
Sort string // listing sort key: "name" / "modified" / "size"
Order string // listing sort order: "asc" / "desc"
SpecialContent template.HTML SpecialContent template.HTML
SidebarWidget template.HTML SidebarWidget template.HTML
SuppressTOC bool
RenderMS int64 RenderMS int64
} }
// pageSettings holds the parsed contents of a .page-settings file. // Allowed values for the listing view settings. Unknown values in the file or
// a POST body fall back to the first (default) value of each set.
const (
viewList = "list"
viewThumbnail = "thumbnail"
sortName = "name"
sortModified = "modified"
sortSize = "size"
orderAsc = "asc"
orderDesc = "desc"
)
// pageSettings holds the parsed contents of a .page-settings file. View, Sort,
// and Order are always valid once parsed (defaults applied on read).
type pageSettings struct { type pageSettings struct {
Type string Type string
View string
Sort string
Order string
}
// viewSettings returns the listing view/sort/order, applying defaults when the
// receiver is nil (no .page-settings file).
func (s *pageSettings) viewSettings() (view, sortKey, order string) {
if s == nil {
return viewList, sortName, orderAsc
}
return s.View, s.Sort, s.Order
}
func validateView(v string) string {
if v == viewThumbnail {
return viewThumbnail
}
return viewList
}
func validateSort(v string) string {
switch v {
case sortModified, sortSize:
return v
default:
return sortName
}
}
func validateOrder(v string) string {
if v == orderDesc {
return orderDesc
}
return orderAsc
} }
var ( var (
@@ -71,7 +135,11 @@ func renderMarkdown(raw []byte) template.HTML {
if err := md.Convert(raw, &buf); err != nil { if err := md.Convert(raw, &buf); err != nil {
return "" return ""
} }
return template.HTML(rewriteTaskCheckboxes(buf.Bytes())) out := rewriteTaskCheckboxes(buf.Bytes())
// Goldmark emits a bare `<table>`; tag it so it picks up the shared
// .data-table styling with the grid modifier (per-cell borders + header).
out = bytes.ReplaceAll(out, []byte("<table>"), []byte(`<table class="data-table data-table-grid">`))
return template.HTML(out)
} }
// extractFirstHeading returns the text of the first ATX heading in raw markdown, // extractFirstHeading returns the text of the first ATX heading in raw markdown,
@@ -111,7 +179,7 @@ func parentURL(urlPath string) string {
return parent + "/" return parent + "/"
} }
func listEntries(fsPath, urlPath string) []entry { func listEntries(fsPath, urlPath, sortKey, order string) []entry {
entries, err := os.ReadDir(fsPath) entries, err := os.ReadDir(fsPath)
if err != nil { if err != nil {
return nil return nil
@@ -134,26 +202,32 @@ func listEntries(fsPath, urlPath string) []entry {
Name: name, Name: name,
URL: entryURL + "/", URL: entryURL + "/",
Meta: info.ModTime().Format("2006-01-02"), Meta: info.ModTime().Format("2006-01-02"),
modTime: info.ModTime(),
}) })
} else { } else {
if name == "index.md" { if name == "index.md" {
continue // rendered above, don't list it continue // rendered above, don't list it
} }
files = append(files, entry{ f := entry{
Icon: fileIcon(name), Icon: fileIcon(name),
Name: name, Name: name,
URL: entryURL, URL: entryURL,
Meta: formatSize(info.Size()) + " · " + info.ModTime().Format("2006-01-02"), Meta: formatSize(info.Size()) + " · " + info.ModTime().Format("2006-01-02"),
}) modTime: info.ModTime(),
size: info.Size(),
}
if hasThumbnail(name) {
f.ThumbURL = thumbURL(path.Join(urlPath, url.PathEscape(name)), 300)
}
files = append(files, f)
} }
} }
sort.Slice(folders, func(i, j int) bool { // Folders always sort by name regardless of the chosen key (they have no
return strings.ToLower(folders[i].Name) < strings.ToLower(folders[j].Name) // meaningful byte size); files honor the chosen key. The chosen order
}) // applies to both groups.
sort.Slice(files, func(i, j int) bool { sortEntries(folders, sortName, order)
return strings.ToLower(files[i].Name) < strings.ToLower(files[j].Name) sortEntries(files, sortKey, order)
})
// `..` row mirrors the header Up button so the listing itself is // `..` row mirrors the header Up button so the listing itself is
// navigable without reaching for the header on mobile. Prepended after // navigable without reaching for the header on mobile. Prepended after
@@ -171,6 +245,42 @@ func listEntries(fsPath, urlPath string) []entry {
return out return out
} }
// sortEntries sorts a single group (folders or files) in place by the given
// key, breaking ties on case-insensitive name, then reverses for descending
// order. The stable sort keeps the name tiebreak meaningful.
func sortEntries(group []entry, sortKey, order string) {
sort.SliceStable(group, func(i, j int) bool {
a, b := group[i], group[j]
cmp := 0
switch sortKey {
case sortModified:
if a.modTime.Before(b.modTime) {
cmp = -1
} else if a.modTime.After(b.modTime) {
cmp = 1
}
case sortSize:
if a.size < b.size {
cmp = -1
} else if a.size > b.size {
cmp = 1
}
}
if cmp == 0 {
an, bn := strings.ToLower(a.Name), strings.ToLower(b.Name)
if an < bn {
cmp = -1
} else if an > bn {
cmp = 1
}
}
if order == orderDesc {
return cmp > 0
}
return cmp < 0
})
}
func readIcon(name string) template.HTML { func readIcon(name string) template.HTML {
b, _ := assets.ReadFile("assets/icons/" + name + ".svg") b, _ := assets.ReadFile("assets/icons/" + name + ".svg")
return template.HTML(strings.TrimSpace(string(b))) return template.HTML(strings.TrimSpace(string(b)))
+5 -4
View File
@@ -115,13 +115,14 @@ func wikiTargetHref(target string) string {
return b.String() return b.String()
} }
// wikiTargetExists reports whether the on-disk folder backing the target // wikiTargetExists reports whether the on-disk path backing the target exists
// exists under root. // under root. Any existing path — file or folder — counts as resolved; only a
// missing path is treated as broken.
func wikiTargetExists(root, target string) bool { func wikiTargetExists(root, target string) bool {
target = normalizeWikiTarget(target) target = normalizeWikiTarget(target)
fsPath := filepath.Join(root, filepath.FromSlash(strings.TrimPrefix(target, "/"))) fsPath := filepath.Join(root, filepath.FromSlash(strings.TrimPrefix(target, "/")))
info, err := os.Stat(fsPath) _, err := os.Stat(fsPath)
return err == nil && info.IsDir() return err == nil
} }
// wikiDefaultDisplay returns the last segment of a target, or "/" for the root. // wikiDefaultDisplay returns the last segment of a target, or "/" for the root.