commit 175c89e40063714269cbae986a86d0b0fd5b1790 Author: Senad Uka Date: Sun Jul 12 07:16:01 2026 +0200 Implement Tefter per SPEC.md: NV-style notes with server, sync, PWA, desktop - frontend/: Svelte 4 + TS + Vite app — omnibar (search-and-create), ranked diacritic-insensitive filtering, CodeMirror 6 markdown editor with 400ms autosave, marked+DOMPurify preview with [[wiki-links]], tag filter, undo toast, light/dark, narrow-screen stacked layout, IndexedDB store, pull-then- push sync engine with conflict handling, versioned cache-first service worker + manifest (installable PWA) - server/: tefterd — Go stdlib HTTP + modernc.org/sqlite, /api/v1 sync API (changes/batch/health/import), SHA-256 hashed bearer token, LWW-with- conflict-copies push rules, subcommands: init, token rotate, import simplenote, compact, backup (VACUUM INTO); embeds the frontend bundle - desktop/: Wails v2 shell — single instance, hide-to-tray (fyne systray), global Ctrl+Shift+Space hotkey, quit-on-close flag - Simplenote import (CLI + web upload), idempotent via source_id dedupe; verified against a real 242-note export - Makefile (frontend/server/server-all/desktop/test), GitHub release workflow, README with systemd/Caddy/backup docs Co-Authored-By: Claude Fable 5 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..890aa52 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,113 @@ +name: release + +on: + push: + tags: ['v*'] + workflow_dispatch: + +env: + NODE_VERSION: '22' + GO_VERSION: 'stable' + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: { node-version: '22' } + - uses: actions/setup-go@v5 + with: { go-version: 'stable' } + - name: Build frontend + run: make frontend && rm -rf server/webdist/dist && cp -r frontend/dist server/webdist/dist + - name: Go tests + run: cd server && go vet ./... && go test ./... + + server: + needs: test + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: { node-version: '22' } + - uses: actions/setup-go@v5 + with: { go-version: 'stable' } + - name: Build tefterd for all platforms + run: make server-all VERSION=${{ github.ref_name }} + - uses: actions/upload-artifact@v4 + with: + name: tefterd + path: build/tefterd-* + + desktop: + needs: test + strategy: + matrix: + include: + - os: ubuntu-latest + artifact: tefter-desktop-linux + - os: macos-latest + artifact: tefter-desktop-macos + - os: windows-latest + artifact: tefter-desktop-windows + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: { node-version: '22' } + - uses: actions/setup-go@v5 + with: { go-version: 'stable' } + - name: Linux deps + if: runner.os == 'Linux' + run: sudo apt-get update && sudo apt-get install -y libgtk-3-dev libwebkit2gtk-4.1-dev libx11-dev + - name: Install Wails + run: go install github.com/wailsapp/wails/v2/cmd/wails@latest + - name: Build frontend + run: make frontend VERSION=${{ github.ref_name }} + - name: Copy bundle into desktop + shell: bash + run: | + rm -rf desktop/frontend/dist + mkdir -p desktop/frontend + cp -r frontend/dist desktop/frontend/dist + - name: Wails build (Linux) + if: runner.os == 'Linux' + run: cd desktop && wails build -tags webkit2_41 -ldflags "-X main.Version=${{ github.ref_name }}" + - name: Wails build (macOS) + if: runner.os == 'macOS' + run: | + cd desktop && wails build -ldflags "-X main.Version=${{ github.ref_name }}" + cd build/bin && zip -r Tefter.app.zip Tefter.app + - name: Wails build (Windows) + if: runner.os == 'Windows' + run: cd desktop && wails build -ldflags "-X main.Version=${{ github.ref_name }}" + - name: Package Linux artifact (.desktop file included) + if: runner.os == 'Linux' + run: | + mkdir -p pkg + cp desktop/build/bin/tefter-desktop pkg/ + cp desktop/tefter.desktop pkg/ + cp frontend/public/icon-512.png pkg/tefter.png + tar -C pkg -czf tefter-desktop-linux-amd64.tar.gz . + - uses: actions/upload-artifact@v4 + with: + name: ${{ matrix.artifact }} + path: | + tefter-desktop-linux-amd64.tar.gz + desktop/build/bin/Tefter.app.zip + desktop/build/bin/tefter-desktop.exe + if-no-files-found: ignore + + release: + needs: [server, desktop] + runs-on: ubuntu-latest + if: startsWith(github.ref, 'refs/tags/') + permissions: + contents: write + steps: + - uses: actions/download-artifact@v4 + with: { merge-multiple: true, path: dist } + - uses: softprops/action-gh-release@v2 + with: + files: dist/** + generate_release_notes: true diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c26e591 --- /dev/null +++ b/.gitignore @@ -0,0 +1,12 @@ +node_modules/ +frontend/dist/ +server/webdist/dist/* +desktop/frontend/dist/* +!desktop/frontend/dist/.gitkeep +desktop/build/bin/ +build/ +*.db +*.db-wal +*.db-shm +notes.zip +!server/webdist/dist/.gitkeep diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..64afa84 --- /dev/null +++ b/Makefile @@ -0,0 +1,43 @@ +VERSION ?= $(shell git describe --tags --always --dirty 2>/dev/null || echo dev) +LDFLAGS = -s -w -X main.Version=$(VERSION) +DIST = build + +.PHONY: all frontend server server-all desktop test clean dev + +all: server + +frontend: + cd frontend && npm install --no-audit --no-fund && TEFTER_VERSION=$(VERSION) npx vite build + +# go:embed cannot reach outside the module dir, so the bundle is copied in. +server/webdist/dist: frontend + rm -rf server/webdist/dist + cp -r frontend/dist server/webdist/dist + touch server/webdist/dist/.gitkeep + +server: server/webdist/dist + cd server && CGO_ENABLED=0 go build -trimpath -ldflags '$(LDFLAGS)' -o ../$(DIST)/tefterd . + +server-all: server/webdist/dist + mkdir -p $(DIST) + cd server && for target in linux/amd64 linux/arm64 darwin/arm64 windows/amd64; do \ + os=$${target%/*}; arch=$${target#*/}; ext=; [ $$os = windows ] && ext=.exe; \ + echo "building tefterd-$$os-$$arch"; \ + CGO_ENABLED=0 GOOS=$$os GOARCH=$$arch go build -trimpath -ldflags '$(LDFLAGS)' \ + -o ../$(DIST)/tefterd-$$os-$$arch$$ext . || exit 1; \ + done + +desktop: server/webdist/dist + rm -rf desktop/frontend/dist + mkdir -p desktop/frontend + cp -r frontend/dist desktop/frontend/dist + cd desktop && wails build -ldflags '-X main.Version=$(VERSION)' + +test: + cd server && go vet ./... && TEFTER_REAL_EXPORT=$(CURDIR)/notes.zip go test ./... + +dev: + cd frontend && npx vite + +clean: + rm -rf $(DIST) frontend/dist server/webdist/dist desktop/frontend/dist desktop/build/bin diff --git a/README.md b/README.md new file mode 100644 index 0000000..dd032ce --- /dev/null +++ b/README.md @@ -0,0 +1,110 @@ +# Tefter + +Self-hosted, single-user notes with the classic **Notational Velocity** interaction model: +one omnibar that searches *and* creates, instant filtering on every keystroke, no save +buttons, no dialogs, everything on the keyboard. Markdown editing with preview, +offline-first sync across Linux/macOS/Windows/Android/web. + +One Go binary (`tefterd`) + one SQLite file. The web app (PWA) is embedded in the binary; +the desktop app is the same frontend in a Wails shell. + +## Quick start (server) + +```sh +make server # builds frontend + tefterd into build/tefterd +./build/tefterd init # creates tefter.db, prints your auth token — save it +./build/tefterd # serves on :8420 +``` + +Open `http://localhost:8420`, click `⋯` → set server URL + token → Save. + +### Keyboard model + +| Key | Action | +|---|---| +| type in omnibar | filter notes instantly (title > prefix > contains > body; diacritic-insensitive) | +| `Enter` | open selected note / create note titled with the query | +| `↑` `↓` | move list selection (editor live-previews) | +| `Esc` | editor → omnibar; omnibar → clear | +| `Ctrl/Cmd+L` | focus omnibar, select text | +| `Ctrl/Cmd+Delete` | delete note (undo toast, no confirm) | +| `Ctrl/Cmd+Shift+P` | toggle markdown preview (`[[wiki-links]]` open/create notes) | +| `Ctrl/Cmd+K` | cycle tag filter | +| `Ctrl/Cmd+J` / `+Shift` | next / previous note while editing | + +## CLI + +```sh +tefterd # serve (default), flags: --listen :8420 --db tefter.db +tefterd init # create db + print token +tefterd token rotate # new token (old one stops working) +tefterd import simplenote export.zip # idempotent Simplenote import (also in the web UI) +tefterd compact --days 90 # purge old tombstones +tefterd backup /backups/tefter.db # consistent snapshot (VACUUM INTO) +tefterd version +``` + +Environment: `TEFTER_DB`, `TEFTER_LISTEN` override the flag defaults. No config file. + +## Deployment + +`tefterd` listens on plain HTTP; put TLS in front of it. + +**systemd** — `/etc/systemd/system/tefterd.service`: + +```ini +[Unit] +Description=Tefter notes server +After=network.target + +[Service] +User=tefter +ExecStart=/usr/local/bin/tefterd --db /var/lib/tefter/tefter.db --listen 127.0.0.1:8420 +Restart=on-failure + +[Install] +WantedBy=multi-user.target +``` + +**Caddy** (two lines): + +``` +notes.example.com { + reverse_proxy 127.0.0.1:8420 +} +``` + +**Backup** = one file: + +```sh +tefterd backup /backups/tefter-$(date +%F).db --db /var/lib/tefter/tefter.db +``` + +## Clients + +- **Web / Android:** open the server URL, install as PWA (Chrome: “Add to home screen”). + Fully offline-capable; edits sync on reconnect. +- **Desktop:** `tefter-desktop` (Wails). Single instance, closes to tray, + global shortcut `Ctrl+Shift+Space` (change with `--hotkey` / `TEFTER_HOTKEY`; + `--quit-on-close` disables the tray behavior). Works with no server at all — + configure sync later under `⋯`. + +## Sync model + +Pull-then-push, last-write-wins with **conflict copies** (Simplenote-style). Concurrent +edits of the same note never silently overwrite: the loser comes back as a new note +tagged `conflict` with “(conflicted copy …)” in its title. Delete-vs-edit: the edit wins. +Every client keeps a full offline copy in IndexedDB; a crash mid-sync loses nothing. + +## Development + +```sh +make dev # vite dev server (proxies /api to :8420) +make test # go vet + go test (uses notes.zip for a real-import test when present) +make server # build/tefterd for this machine +make server-all # linux/amd64, linux/arm64, darwin/arm64, windows/amd64 +make desktop # Wails build (needs wails CLI + GTK/WebKit dev packages on Linux) +``` + +Repo layout: `frontend/` (Svelte + TS, all app logic incl. offline store + sync engine), +`server/` (Go, stdlib HTTP + modernc.org/sqlite, no CGO), `desktop/` (Wails shell). diff --git a/SPEC.md b/SPEC.md new file mode 100644 index 0000000..8cf4ffc --- /dev/null +++ b/SPEC.md @@ -0,0 +1,251 @@ +# Tefter — Self-Hosted Notational Velocity Clone + +**Spec version:** 1.0 · **Audience:** Claude Code (implementation agent) + +## 1. Overview + +Tefter is a personal, self-hosted note-taking system that replicates the Notational Velocity (NV) interaction model — a single search/create field, instant incremental filtering, and modeless keyboard-driven editing — with Markdown support and offline-first sync across Linux, macOS, Windows, Android, and web. + +It replaces Simplenote + Notational Velocity for a single user who hosts their own data. + +### Goals +- Pixel-faithful NV *interaction model* (not pixel-faithful visuals): one omnibar that searches AND creates, zero modal dialogs, zero save buttons, everything reachable by keyboard. +- Markdown editing with optional preview. +- Offline-first on every client; sync when connectivity returns. +- Single-user, single-server, trivial deployment: **one Go binary + one SQLite file**. +- Import of an existing Simplenote export archive. + +### Non-Goals +- Multi-user accounts, sharing, collaboration, realtime co-editing. +- End-to-end encryption (TLS in transit + disk encryption at rest is the model). +- Rich text, attachments, images (v1 is plain Markdown text; attachments may come in v2). +- Native Android app (PWA is the Android client). + +## 2. Architecture + +``` +┌─────────────────────────────── your data center ───────────────┐ +│ tefterd (single Go binary) │ +│ ├─ REST sync API /api/v1/... │ +│ ├─ static web app (PWA) embedded via go:embed → / │ +│ └─ SQLite database (modernc.org/sqlite, CGO-free) │ +└─────────────────────────────────────────────────────────────────┘ + ▲ HTTPS (reverse proxy: Caddy/nginx, user-provided) + │ + ┌────────┼─────────────┬───────────────────┐ + │ │ │ │ + Web Android Desktop (Win/mac/Linux) + browser (installed tefter-desktop: Wails v2 binary + (PWA) PWA) wrapping the SAME frontend bundle +``` + +**Key decision — one frontend, three shells.** The web app is the product. It runs: +1. In a browser (PWA, served by `tefterd`). +2. Installed on Android (PWA install prompt). +3. Inside a Wails v2 webview window on desktop (frontend bundle embedded in the desktop binary; works fully offline, no `tefterd` needed to launch). + +Offline storage and the sync engine live in the **frontend** (TypeScript + IndexedDB), so the exact same offline/sync code runs on all five platforms. The Wails Go layer is a thin shell: window, tray, global shortcut, single-instance lock. + +### Components / repo layout + +``` +tefter/ +├─ server/ # tefterd +│ ├─ main.go +│ ├─ api/ # HTTP handlers +│ ├─ store/ # SQLite access layer +│ ├─ importer/ # Simplenote import +│ └─ webdist/ # go:embed of ../frontend/dist +├─ frontend/ # TypeScript + Svelte, Vite build +│ ├─ src/ +│ │ ├─ ui/ # omnibar, list, editor, preview +│ │ ├─ db/ # IndexedDB local store +│ │ └─ sync/ # sync engine +│ └─ dist/ # built bundle (embedded by server & desktop) +├─ desktop/ # Wails v2 app +│ ├─ main.go +│ └─ wails.json +├─ Makefile +└─ .github/workflows/release.yml +``` + +### Tech choices (fixed — do not substitute) +- **Server:** Go ≥1.22, stdlib `net/http` (Go 1.22 routing), `modernc.org/sqlite` (pure Go, no CGO → painless cross-compilation and static binaries). +- **Frontend:** Svelte 4 + TypeScript + Vite. Keep dependencies minimal. Markdown rendering: `marked` + `DOMPurify`. Editor: CodeMirror 6 (markdown language package, minimal setup). +- **Desktop:** Wails v2. +- **No ORM, no heavy frameworks, no CSS framework** — hand-written CSS, this UI is small. + +## 3. Data Model + +### Server SQLite schema + +```sql +CREATE TABLE notes ( + id TEXT PRIMARY KEY, -- UUIDv4, client-generated + content TEXT NOT NULL DEFAULT '', -- full markdown; title = first line + tags TEXT NOT NULL DEFAULT '[]',-- JSON array of strings + created_at INTEGER NOT NULL, -- unix ms, client clock at creation + modified_at INTEGER NOT NULL, -- unix ms, client clock at last edit + deleted INTEGER NOT NULL DEFAULT 0,-- tombstone + version INTEGER NOT NULL -- server-assigned, globally monotonic +); +CREATE INDEX idx_notes_version ON notes(version); + +CREATE TABLE meta (k TEXT PRIMARY KEY, v TEXT); -- schema_version, next_version counter +``` + +- **Title is derived**, never stored: first non-empty line of `content`, markdown heading markers stripped for display. +- `version` is a single global monotonically increasing counter (like a Lamport clock per server). Every accepted write bumps the global counter and stamps the note. This makes "give me everything changed since cursor X" trivial. +- Tombstones are kept forever (personal scale; millions of notes are not expected). A `tefterd compact` subcommand may purge tombstones older than N days. + +### Client store (IndexedDB) + +Object store `notes`: same fields as server, plus: +- `dirty: boolean` — locally modified, not yet pushed. +- `baseVersion: number` — server version this local copy was derived from (0 for never-synced). + +Object store `meta`: `cursor` (last server version pulled), `serverUrl`, `token`. + +## 4. Sync Protocol + +Design: **pull-then-push, last-write-wins with conflict copies** (Simplenote-style). Single user across a handful of devices; CRDTs are overkill. Sync must be safe to interrupt at any point. + +### Endpoints (all under `/api/v1`, JSON, `Authorization: Bearer `) + +| Method | Path | Purpose | +|---|---|---| +| GET | `/changes?since=&limit=500` | Pull notes with `version > cursor`, ordered by version. Returns `{notes: [...], cursor: , more: bool}` | +| POST | `/notes/batch` | Push local changes. Body: `{notes: [{id, content, tags, created_at, modified_at, deleted, baseVersion}]}` | +| GET | `/health` | Liveness + schema version | +| POST | `/import/simplenote` | Multipart upload of Simplenote export zip (also available as CLI) | + +### Push conflict rule (server-side, per note) +1. If note id unknown → insert, assign new version. **Accepted.** +2. If `baseVersion == current version` → clean fast-forward. Update, assign new version. **Accepted.** +3. Else **conflict**: another device changed the note since this client last pulled. + - Keep the server's current content in the original note. + - Apply the incoming content as a **new note** with a new UUID, content prefixed by nothing (content unchanged), and tag `conflict` added; its first line gets ` (conflicted copy YYYY-MM-DD HH:MM)` appended. + - Response marks the note `{id, status: "conflict", conflictCopyId: ...}`. +4. Deletion conflicts: delete vs edit → edit wins (tombstone is overwritten by the edit as a conflict copy is NOT created; the edited version simply survives). + +### Client sync loop +1. On startup, on network regain, after 3 s of edit inactivity, and every 60 s: run sync if online. +2. **Pull**: page through `/changes` from local `cursor`. For each incoming note: if local copy is not dirty → overwrite; if dirty and incoming version > baseVersion → keep local dirty copy, remember conflict will be resolved by server at push. Update `cursor`. +3. **Push**: send all dirty notes in one batch. On `accepted`, clear dirty, set `baseVersion` to returned version. On `conflict`, replace local with server truth and add the returned conflict copy on next pull. +4. All steps idempotent; a crash mid-sync must never lose an edit (edits are persisted to IndexedDB on every debounced change, before any network activity). + +### Auth +- Single bearer token, generated at first server start (`tefterd init` prints it), stored hashed (SHA-256) in `meta`. Rotate with `tefterd token rotate`. +- No accounts, no sessions, no OAuth. TLS is delegated to the user's reverse proxy; `tefterd` listens on localhost/HTTP by default (`--listen :8420`). + +## 5. UI Specification — the Notational Velocity Model + +This section is the heart of the product. The NV model must be reproduced exactly. + +### Layout + +``` +┌──────────────────────────────────────────────┐ +│ [ omnibar: Search or Create ] │ ← always the same field +├──────────────────────────────────────────────┤ +│ Note title · modified · tags │ ← results list +│ Note title · modified · tags │ (selected row highlighted) +│ ... │ +├──────────────────────────────────────────────┤ +│ │ +│ editor (CodeMirror, markdown) │ ← or rendered preview +│ │ +└──────────────────────────────────────────────┘ +``` + +- Default: horizontal split (list above editor). Setting for vertical split (list left, editor right). On narrow screens (<640 px, i.e., Android), list and editor become two stacked views with back navigation. +- No toolbar. No save button. No menus except a small `⋯` for settings/sync status. +- A tiny status dot next to the omnibar: green = synced, yellow = syncing, gray = offline with pending changes. Clicking shows last sync time and pending count. + +### Omnibar behavior (exact) + +1. Focus is in the omnibar when the app opens. +2. Typing filters the list **on every keystroke** (target: <10 ms for 10k notes — filter in memory; all note metadata + content is loaded into RAM from IndexedDB at startup). +3. Ranking: exact title match > title prefix > title contains > body contains. Secondary sort: `modified_at` desc. Empty query shows all notes by `modified_at` desc. +4. Matching is case-insensitive, diacritic-insensitive (č/c, š/s, ž/z fold together), across space-separated terms (AND semantics). +5. **Enter** in the omnibar: + - If a list row is highlighted → open that note in the editor (focus editor, caret at end). + - If the query matches nothing → **create a note whose first line is the query text**, open it in the editor. +6. **↓/↑** from the omnibar moves the list selection without leaving the field; the editor live-previews the selected note (NV behavior). +7. **Esc**: clear omnibar, deselect, focus omnibar. Pressing Esc in the editor returns focus to the omnibar (query preserved). +8. Search term highlighting in list rows and in the editor. + +### Keyboard shortcuts (Cmd on macOS, Ctrl elsewhere) + +| Shortcut | Action | +|---|---| +| Cmd/Ctrl+L | Focus omnibar (select existing text) | +| Enter (omnibar) | Open selection / create note | +| ↑ ↓ (omnibar) | Move list selection | +| Esc | Editor→omnibar; omnibar→clear | +| Cmd/Ctrl+Delete | Delete selected note (moves to tombstone; brief undo toast, no confirm dialog) | +| Cmd/Ctrl+Shift+P | Toggle markdown preview for current note | +| Cmd/Ctrl+K | Cycle tag filter (simple tag dropdown) | +| Cmd/Ctrl+J / Cmd/Ctrl+Shift+J | Next / previous note in list while in editor | + +### Editor +- CodeMirror 6, markdown mode, light syntax styling only (bold headings, dim syntax marks). Monospace or user-set font. No WYSIWYG. +- **Autosave**: debounce 400 ms after last keystroke → write to IndexedDB, mark dirty. Never a save action. +- Preview: `Cmd/Ctrl+Shift+P` swaps the editor pane for rendered markdown (`marked` + `DOMPurify`, GFM: tables, task lists, fenced code, strikethrough). Same shortcut toggles back. Links open in system browser/new tab. Internal `[[Note Title]]` wiki-links open/create that note (NV Alt behavior — nice-to-have, milestone 4). + +### Visual style +- Minimal, native-feeling: system font stack for UI chrome, generous line height in the list, thin 1 px separators. Light and dark theme following OS preference. No animation except the undo toast. + +## 6. Simplenote Import + +Input: the official Simplenote export zip (contains `source/notes.json` with `activeNotes` and `trashedNotes`, each having `id, content, creationDate, lastModified, tags, markdown` fields — verify actual field names against a real export at implementation time and adapt). + +- CLI: `tefterd import simplenote /path/to/export.zip` (server-side, direct to SQLite). +- Web: Settings → Import → upload zip (uses `POST /api/v1/import/simplenote`). +- Mapping: content → content; creationDate/lastModified (ISO 8601) → unix ms; tags → tags; trashedNotes → imported with `deleted=1` tombstones (recoverable via a future trash view; v1 just keeps them synced-invisible). +- Idempotent: re-importing the same zip must not duplicate notes (dedupe by Simplenote id stored in a `source_id` column, or by exact content+creation date hash). +- Print a summary: imported N, skipped M duplicates, T trashed. + +## 7. Desktop Shell (Wails v2) + +- One window containing the frontend bundle (same `frontend/dist` embedded via Wails assets). +- Single-instance lock; second launch focuses the existing window. +- Global OS shortcut (default Cmd/Ctrl+Shift+Space, configurable) shows/hides the window and focuses the omnibar — the classic NV "always at hand" flow. +- Closing the window hides to tray (tray icon: open, sync now, quit). Setting to quit-on-close instead. +- First-run screen: server URL + token fields, "work locally only" option (sync can be configured later). +- Storage is the webview's IndexedDB — the desktop app is fully functional offline and without any server. + +## 8. PWA Requirements (web + Android) + +- `manifest.json` (standalone display, icons 192/512, theme color) + service worker: precache the app shell (cache-first, versioned by build hash); API calls network-only (sync engine handles offline). +- Must pass Lighthouse "installable" check. +- IndexedDB persistence: request `navigator.storage.persist()` on first run. +- Android keyboard ergonomics: omnibar `enterkeyhint="go"`, no zoom-on-focus (font-size ≥16 px). + +## 9. Build, Release, Deployment + +- `make server` → `tefterd` binaries for linux/amd64, linux/arm64, darwin/arm64, windows/amd64 (pure Go, `CGO_ENABLED=0`). +- `make desktop` → Wails builds per platform (requires per-OS runners; GitHub Actions matrix in `release.yml` producing: `tefter-desktop.exe`, `Tefter.app` zip, linux binary + `.desktop` file). +- `make frontend` → Vite build into `frontend/dist`, embedded by both server and desktop builds. +- Versioning: single version stamp injected via `-ldflags` into both binaries and the frontend; `/health` and the About screen show it. +- Server deployment docs (README): systemd unit example, Caddy reverse-proxy example (2 lines), backup = copy one SQLite file (use `tefterd backup /path` which runs `VACUUM INTO` for a consistent snapshot). + +## 10. Milestones + +1. **M1 — Core local app:** frontend with omnibar/list/editor per §5, IndexedDB persistence, markdown preview. Runs from `vite dev`. Acceptance: full NV keyboard flow works offline in a browser. +2. **M2 — Server + sync:** `tefterd` with schema, API, token auth; sync engine per §4. Acceptance: two browser profiles converge; conflict copy created on concurrent edit; kill -9 during sync loses nothing. +3. **M3 — Import + PWA:** Simplenote import (CLI + web), service worker, installable on Android. Acceptance: real export imports idempotently; airplane-mode edit on Android syncs on reconnect. +4. **M4 — Desktop:** Wails shell with tray, global shortcut, single instance; release pipeline. Acceptance: fresh Windows/macOS/Linux machine runs the single artifact with zero prerequisites. +5. **M5 — Polish:** diacritic folding, dark mode, tag filter, `[[wiki-links]]`, `tefterd compact`, undo toast. + +## 11. Acceptance Criteria (global) + +- Search latency <10 ms at 10,000 notes on a mid-range laptop. +- Cold start to focused omnibar <1 s (desktop), <2 s (PWA warm cache). +- Zero data loss under: offline edits, mid-sync crash, concurrent edits on two devices (conflict copy, never silent overwrite). +- `tefterd` runs as one static binary + one `.db` file; no Docker, no external DB, no config file required (flags/env only). +- Entire frontend bundle <500 KB gzipped. + +## 12. Explicitly Out of Scope for the Agent + +Do not add: user registration, e-mail, websockets/realtime, plugins, themes beyond light/dark, mobile-native builds, Electron, ORMs, Redux-style state libraries, CSS frameworks. When in doubt, choose the smaller solution. diff --git a/desktop/frontend/wailsjs/runtime/package.json b/desktop/frontend/wailsjs/runtime/package.json new file mode 100644 index 0000000..1e7c8a5 --- /dev/null +++ b/desktop/frontend/wailsjs/runtime/package.json @@ -0,0 +1,24 @@ +{ + "name": "@wailsapp/runtime", + "version": "2.0.0", + "description": "Wails Javascript runtime library", + "main": "runtime.js", + "types": "runtime.d.ts", + "scripts": { + }, + "repository": { + "type": "git", + "url": "git+https://github.com/wailsapp/wails.git" + }, + "keywords": [ + "Wails", + "Javascript", + "Go" + ], + "author": "Lea Anthony ", + "license": "MIT", + "bugs": { + "url": "https://github.com/wailsapp/wails/issues" + }, + "homepage": "https://github.com/wailsapp/wails#readme" +} diff --git a/desktop/frontend/wailsjs/runtime/runtime.d.ts b/desktop/frontend/wailsjs/runtime/runtime.d.ts new file mode 100644 index 0000000..3bbea84 --- /dev/null +++ b/desktop/frontend/wailsjs/runtime/runtime.d.ts @@ -0,0 +1,330 @@ +/* + _ __ _ __ +| | / /___ _(_) /____ +| | /| / / __ `/ / / ___/ +| |/ |/ / /_/ / / (__ ) +|__/|__/\__,_/_/_/____/ +The electron alternative for Go +(c) Lea Anthony 2019-present +*/ + +export interface Position { + x: number; + y: number; +} + +export interface Size { + w: number; + h: number; +} + +export interface Screen { + isCurrent: boolean; + isPrimary: boolean; + width : number + height : number +} + +// Environment information such as platform, buildtype, ... +export interface EnvironmentInfo { + buildType: string; + platform: string; + arch: string; +} + +// [EventsEmit](https://wails.io/docs/reference/runtime/events#eventsemit) +// emits the given event. Optional data may be passed with the event. +// This will trigger any event listeners. +export function EventsEmit(eventName: string, ...data: any): void; + +// [EventsOn](https://wails.io/docs/reference/runtime/events#eventson) sets up a listener for the given event name. +export function EventsOn(eventName: string, callback: (...data: any) => void): () => void; + +// [EventsOnMultiple](https://wails.io/docs/reference/runtime/events#eventsonmultiple) +// sets up a listener for the given event name, but will only trigger a given number times. +export function EventsOnMultiple(eventName: string, callback: (...data: any) => void, maxCallbacks: number): () => void; + +// [EventsOnce](https://wails.io/docs/reference/runtime/events#eventsonce) +// sets up a listener for the given event name, but will only trigger once. +export function EventsOnce(eventName: string, callback: (...data: any) => void): () => void; + +// [EventsOff](https://wails.io/docs/reference/runtime/events#eventsoff) +// unregisters the listener for the given event name. +export function EventsOff(eventName: string, ...additionalEventNames: string[]): void; + +// [EventsOffAll](https://wails.io/docs/reference/runtime/events#eventsoffall) +// unregisters all listeners. +export function EventsOffAll(): void; + +// [LogPrint](https://wails.io/docs/reference/runtime/log#logprint) +// logs the given message as a raw message +export function LogPrint(message: string): void; + +// [LogTrace](https://wails.io/docs/reference/runtime/log#logtrace) +// logs the given message at the `trace` log level. +export function LogTrace(message: string): void; + +// [LogDebug](https://wails.io/docs/reference/runtime/log#logdebug) +// logs the given message at the `debug` log level. +export function LogDebug(message: string): void; + +// [LogError](https://wails.io/docs/reference/runtime/log#logerror) +// logs the given message at the `error` log level. +export function LogError(message: string): void; + +// [LogFatal](https://wails.io/docs/reference/runtime/log#logfatal) +// logs the given message at the `fatal` log level. +// The application will quit after calling this method. +export function LogFatal(message: string): void; + +// [LogInfo](https://wails.io/docs/reference/runtime/log#loginfo) +// logs the given message at the `info` log level. +export function LogInfo(message: string): void; + +// [LogWarning](https://wails.io/docs/reference/runtime/log#logwarning) +// logs the given message at the `warning` log level. +export function LogWarning(message: string): void; + +// [WindowReload](https://wails.io/docs/reference/runtime/window#windowreload) +// Forces a reload by the main application as well as connected browsers. +export function WindowReload(): void; + +// [WindowReloadApp](https://wails.io/docs/reference/runtime/window#windowreloadapp) +// Reloads the application frontend. +export function WindowReloadApp(): void; + +// [WindowSetAlwaysOnTop](https://wails.io/docs/reference/runtime/window#windowsetalwaysontop) +// Sets the window AlwaysOnTop or not on top. +export function WindowSetAlwaysOnTop(b: boolean): void; + +// [WindowSetSystemDefaultTheme](https://wails.io/docs/next/reference/runtime/window#windowsetsystemdefaulttheme) +// *Windows only* +// Sets window theme to system default (dark/light). +export function WindowSetSystemDefaultTheme(): void; + +// [WindowSetLightTheme](https://wails.io/docs/next/reference/runtime/window#windowsetlighttheme) +// *Windows only* +// Sets window to light theme. +export function WindowSetLightTheme(): void; + +// [WindowSetDarkTheme](https://wails.io/docs/next/reference/runtime/window#windowsetdarktheme) +// *Windows only* +// Sets window to dark theme. +export function WindowSetDarkTheme(): void; + +// [WindowCenter](https://wails.io/docs/reference/runtime/window#windowcenter) +// Centers the window on the monitor the window is currently on. +export function WindowCenter(): void; + +// [WindowSetTitle](https://wails.io/docs/reference/runtime/window#windowsettitle) +// Sets the text in the window title bar. +export function WindowSetTitle(title: string): void; + +// [WindowFullscreen](https://wails.io/docs/reference/runtime/window#windowfullscreen) +// Makes the window full screen. +export function WindowFullscreen(): void; + +// [WindowUnfullscreen](https://wails.io/docs/reference/runtime/window#windowunfullscreen) +// Restores the previous window dimensions and position prior to full screen. +export function WindowUnfullscreen(): void; + +// [WindowIsFullscreen](https://wails.io/docs/reference/runtime/window#windowisfullscreen) +// Returns the state of the window, i.e. whether the window is in full screen mode or not. +export function WindowIsFullscreen(): Promise; + +// [WindowSetSize](https://wails.io/docs/reference/runtime/window#windowsetsize) +// Sets the width and height of the window. +export function WindowSetSize(width: number, height: number): void; + +// [WindowGetSize](https://wails.io/docs/reference/runtime/window#windowgetsize) +// Gets the width and height of the window. +export function WindowGetSize(): Promise; + +// [WindowSetMaxSize](https://wails.io/docs/reference/runtime/window#windowsetmaxsize) +// Sets the maximum window size. Will resize the window if the window is currently larger than the given dimensions. +// Setting a size of 0,0 will disable this constraint. +export function WindowSetMaxSize(width: number, height: number): void; + +// [WindowSetMinSize](https://wails.io/docs/reference/runtime/window#windowsetminsize) +// Sets the minimum window size. Will resize the window if the window is currently smaller than the given dimensions. +// Setting a size of 0,0 will disable this constraint. +export function WindowSetMinSize(width: number, height: number): void; + +// [WindowSetPosition](https://wails.io/docs/reference/runtime/window#windowsetposition) +// Sets the window position relative to the monitor the window is currently on. +export function WindowSetPosition(x: number, y: number): void; + +// [WindowGetPosition](https://wails.io/docs/reference/runtime/window#windowgetposition) +// Gets the window position relative to the monitor the window is currently on. +export function WindowGetPosition(): Promise; + +// [WindowHide](https://wails.io/docs/reference/runtime/window#windowhide) +// Hides the window. +export function WindowHide(): void; + +// [WindowShow](https://wails.io/docs/reference/runtime/window#windowshow) +// Shows the window, if it is currently hidden. +export function WindowShow(): void; + +// [WindowMaximise](https://wails.io/docs/reference/runtime/window#windowmaximise) +// Maximises the window to fill the screen. +export function WindowMaximise(): void; + +// [WindowToggleMaximise](https://wails.io/docs/reference/runtime/window#windowtogglemaximise) +// Toggles between Maximised and UnMaximised. +export function WindowToggleMaximise(): void; + +// [WindowUnmaximise](https://wails.io/docs/reference/runtime/window#windowunmaximise) +// Restores the window to the dimensions and position prior to maximising. +export function WindowUnmaximise(): void; + +// [WindowIsMaximised](https://wails.io/docs/reference/runtime/window#windowismaximised) +// Returns the state of the window, i.e. whether the window is maximised or not. +export function WindowIsMaximised(): Promise; + +// [WindowMinimise](https://wails.io/docs/reference/runtime/window#windowminimise) +// Minimises the window. +export function WindowMinimise(): void; + +// [WindowUnminimise](https://wails.io/docs/reference/runtime/window#windowunminimise) +// Restores the window to the dimensions and position prior to minimising. +export function WindowUnminimise(): void; + +// [WindowIsMinimised](https://wails.io/docs/reference/runtime/window#windowisminimised) +// Returns the state of the window, i.e. whether the window is minimised or not. +export function WindowIsMinimised(): Promise; + +// [WindowIsNormal](https://wails.io/docs/reference/runtime/window#windowisnormal) +// Returns the state of the window, i.e. whether the window is normal or not. +export function WindowIsNormal(): Promise; + +// [WindowSetBackgroundColour](https://wails.io/docs/reference/runtime/window#windowsetbackgroundcolour) +// Sets the background colour of the window to the given RGBA colour definition. This colour will show through for all transparent pixels. +export function WindowSetBackgroundColour(R: number, G: number, B: number, A: number): void; + +// [ScreenGetAll](https://wails.io/docs/reference/runtime/window#screengetall) +// Gets the all screens. Call this anew each time you want to refresh data from the underlying windowing system. +export function ScreenGetAll(): Promise; + +// [BrowserOpenURL](https://wails.io/docs/reference/runtime/browser#browseropenurl) +// Opens the given URL in the system browser. +export function BrowserOpenURL(url: string): void; + +// [Environment](https://wails.io/docs/reference/runtime/intro#environment) +// Returns information about the environment +export function Environment(): Promise; + +// [Quit](https://wails.io/docs/reference/runtime/intro#quit) +// Quits the application. +export function Quit(): void; + +// [Hide](https://wails.io/docs/reference/runtime/intro#hide) +// Hides the application. +export function Hide(): void; + +// [Show](https://wails.io/docs/reference/runtime/intro#show) +// Shows the application. +export function Show(): void; + +// [ClipboardGetText](https://wails.io/docs/reference/runtime/clipboard#clipboardgettext) +// Returns the current text stored on clipboard +export function ClipboardGetText(): Promise; + +// [ClipboardSetText](https://wails.io/docs/reference/runtime/clipboard#clipboardsettext) +// Sets a text on the clipboard +export function ClipboardSetText(text: string): Promise; + +// [OnFileDrop](https://wails.io/docs/reference/runtime/draganddrop#onfiledrop) +// OnFileDrop listens to drag and drop events and calls the callback with the coordinates of the drop and an array of path strings. +export function OnFileDrop(callback: (x: number, y: number ,paths: string[]) => void, useDropTarget: boolean) :void + +// [OnFileDropOff](https://wails.io/docs/reference/runtime/draganddrop#dragandddropoff) +// OnFileDropOff removes the drag and drop listeners and handlers. +export function OnFileDropOff() :void + +// Check if the file path resolver is available +export function CanResolveFilePaths(): boolean; + +// Resolves file paths for an array of files +export function ResolveFilePaths(files: File[]): void + +// Notification types +export interface NotificationOptions { + id: string; + title: string; + subtitle?: string; // macOS and Linux only + body?: string; + categoryId?: string; + data?: { [key: string]: any }; +} + +export interface NotificationAction { + id?: string; + title?: string; + destructive?: boolean; // macOS-specific +} + +export interface NotificationCategory { + id?: string; + actions?: NotificationAction[]; + hasReplyField?: boolean; + replyPlaceholder?: string; + replyButtonTitle?: string; +} + +// [InitializeNotifications](https://wails.io/docs/reference/runtime/notification#initializenotifications) +// Initializes the notification service for the application. +// This must be called before sending any notifications. +export function InitializeNotifications(): Promise; + +// [CleanupNotifications](https://wails.io/docs/reference/runtime/notification#cleanupnotifications) +// Cleans up notification resources and releases any held connections. +export function CleanupNotifications(): Promise; + +// [IsNotificationAvailable](https://wails.io/docs/reference/runtime/notification#isnotificationavailable) +// Checks if notifications are available on the current platform. +export function IsNotificationAvailable(): Promise; + +// [RequestNotificationAuthorization](https://wails.io/docs/reference/runtime/notification#requestnotificationauthorization) +// Requests notification authorization from the user (macOS only). +export function RequestNotificationAuthorization(): Promise; + +// [CheckNotificationAuthorization](https://wails.io/docs/reference/runtime/notification#checknotificationauthorization) +// Checks the current notification authorization status (macOS only). +export function CheckNotificationAuthorization(): Promise; + +// [SendNotification](https://wails.io/docs/reference/runtime/notification#sendnotification) +// Sends a basic notification with the given options. +export function SendNotification(options: NotificationOptions): Promise; + +// [SendNotificationWithActions](https://wails.io/docs/reference/runtime/notification#sendnotificationwithactions) +// Sends a notification with action buttons. Requires a registered category. +export function SendNotificationWithActions(options: NotificationOptions): Promise; + +// [RegisterNotificationCategory](https://wails.io/docs/reference/runtime/notification#registernotificationcategory) +// Registers a notification category that can be used with SendNotificationWithActions. +export function RegisterNotificationCategory(category: NotificationCategory): Promise; + +// [RemoveNotificationCategory](https://wails.io/docs/reference/runtime/notification#removenotificationcategory) +// Removes a previously registered notification category. +export function RemoveNotificationCategory(categoryId: string): Promise; + +// [RemoveAllPendingNotifications](https://wails.io/docs/reference/runtime/notification#removeallpendingnotifications) +// Removes all pending notifications from the notification center. +export function RemoveAllPendingNotifications(): Promise; + +// [RemovePendingNotification](https://wails.io/docs/reference/runtime/notification#removependingnotification) +// Removes a specific pending notification by its identifier. +export function RemovePendingNotification(identifier: string): Promise; + +// [RemoveAllDeliveredNotifications](https://wails.io/docs/reference/runtime/notification#removealldeliverednotifications) +// Removes all delivered notifications from the notification center. +export function RemoveAllDeliveredNotifications(): Promise; + +// [RemoveDeliveredNotification](https://wails.io/docs/reference/runtime/notification#removedeliverednotification) +// Removes a specific delivered notification by its identifier. +export function RemoveDeliveredNotification(identifier: string): Promise; + +// [RemoveNotification](https://wails.io/docs/reference/runtime/notification#removenotification) +// Removes a notification by its identifier (cross-platform convenience function). +export function RemoveNotification(identifier: string): Promise; \ No newline at end of file diff --git a/desktop/frontend/wailsjs/runtime/runtime.js b/desktop/frontend/wailsjs/runtime/runtime.js new file mode 100644 index 0000000..556621e --- /dev/null +++ b/desktop/frontend/wailsjs/runtime/runtime.js @@ -0,0 +1,298 @@ +/* + _ __ _ __ +| | / /___ _(_) /____ +| | /| / / __ `/ / / ___/ +| |/ |/ / /_/ / / (__ ) +|__/|__/\__,_/_/_/____/ +The electron alternative for Go +(c) Lea Anthony 2019-present +*/ + +export function LogPrint(message) { + window.runtime.LogPrint(message); +} + +export function LogTrace(message) { + window.runtime.LogTrace(message); +} + +export function LogDebug(message) { + window.runtime.LogDebug(message); +} + +export function LogInfo(message) { + window.runtime.LogInfo(message); +} + +export function LogWarning(message) { + window.runtime.LogWarning(message); +} + +export function LogError(message) { + window.runtime.LogError(message); +} + +export function LogFatal(message) { + window.runtime.LogFatal(message); +} + +export function EventsOnMultiple(eventName, callback, maxCallbacks) { + return window.runtime.EventsOnMultiple(eventName, callback, maxCallbacks); +} + +export function EventsOn(eventName, callback) { + return EventsOnMultiple(eventName, callback, -1); +} + +export function EventsOff(eventName, ...additionalEventNames) { + return window.runtime.EventsOff(eventName, ...additionalEventNames); +} + +export function EventsOffAll() { + return window.runtime.EventsOffAll(); +} + +export function EventsOnce(eventName, callback) { + return EventsOnMultiple(eventName, callback, 1); +} + +export function EventsEmit(eventName) { + let args = [eventName].slice.call(arguments); + return window.runtime.EventsEmit.apply(null, args); +} + +export function WindowReload() { + window.runtime.WindowReload(); +} + +export function WindowReloadApp() { + window.runtime.WindowReloadApp(); +} + +export function WindowSetAlwaysOnTop(b) { + window.runtime.WindowSetAlwaysOnTop(b); +} + +export function WindowSetSystemDefaultTheme() { + window.runtime.WindowSetSystemDefaultTheme(); +} + +export function WindowSetLightTheme() { + window.runtime.WindowSetLightTheme(); +} + +export function WindowSetDarkTheme() { + window.runtime.WindowSetDarkTheme(); +} + +export function WindowCenter() { + window.runtime.WindowCenter(); +} + +export function WindowSetTitle(title) { + window.runtime.WindowSetTitle(title); +} + +export function WindowFullscreen() { + window.runtime.WindowFullscreen(); +} + +export function WindowUnfullscreen() { + window.runtime.WindowUnfullscreen(); +} + +export function WindowIsFullscreen() { + return window.runtime.WindowIsFullscreen(); +} + +export function WindowGetSize() { + return window.runtime.WindowGetSize(); +} + +export function WindowSetSize(width, height) { + window.runtime.WindowSetSize(width, height); +} + +export function WindowSetMaxSize(width, height) { + window.runtime.WindowSetMaxSize(width, height); +} + +export function WindowSetMinSize(width, height) { + window.runtime.WindowSetMinSize(width, height); +} + +export function WindowSetPosition(x, y) { + window.runtime.WindowSetPosition(x, y); +} + +export function WindowGetPosition() { + return window.runtime.WindowGetPosition(); +} + +export function WindowHide() { + window.runtime.WindowHide(); +} + +export function WindowShow() { + window.runtime.WindowShow(); +} + +export function WindowMaximise() { + window.runtime.WindowMaximise(); +} + +export function WindowToggleMaximise() { + window.runtime.WindowToggleMaximise(); +} + +export function WindowUnmaximise() { + window.runtime.WindowUnmaximise(); +} + +export function WindowIsMaximised() { + return window.runtime.WindowIsMaximised(); +} + +export function WindowMinimise() { + window.runtime.WindowMinimise(); +} + +export function WindowUnminimise() { + window.runtime.WindowUnminimise(); +} + +export function WindowSetBackgroundColour(R, G, B, A) { + window.runtime.WindowSetBackgroundColour(R, G, B, A); +} + +export function ScreenGetAll() { + return window.runtime.ScreenGetAll(); +} + +export function WindowIsMinimised() { + return window.runtime.WindowIsMinimised(); +} + +export function WindowIsNormal() { + return window.runtime.WindowIsNormal(); +} + +export function BrowserOpenURL(url) { + window.runtime.BrowserOpenURL(url); +} + +export function Environment() { + return window.runtime.Environment(); +} + +export function Quit() { + window.runtime.Quit(); +} + +export function Hide() { + window.runtime.Hide(); +} + +export function Show() { + window.runtime.Show(); +} + +export function ClipboardGetText() { + return window.runtime.ClipboardGetText(); +} + +export function ClipboardSetText(text) { + return window.runtime.ClipboardSetText(text); +} + +/** + * Callback for OnFileDrop returns a slice of file path strings when a drop is finished. + * + * @export + * @callback OnFileDropCallback + * @param {number} x - x coordinate of the drop + * @param {number} y - y coordinate of the drop + * @param {string[]} paths - A list of file paths. + */ + +/** + * OnFileDrop listens to drag and drop events and calls the callback with the coordinates of the drop and an array of path strings. + * + * @export + * @param {OnFileDropCallback} callback - Callback for OnFileDrop returns a slice of file path strings when a drop is finished. + * @param {boolean} [useDropTarget=true] - Only call the callback when the drop finished on an element that has the drop target style. (--wails-drop-target) + */ +export function OnFileDrop(callback, useDropTarget) { + return window.runtime.OnFileDrop(callback, useDropTarget); +} + +/** + * OnFileDropOff removes the drag and drop listeners and handlers. + */ +export function OnFileDropOff() { + return window.runtime.OnFileDropOff(); +} + +export function CanResolveFilePaths() { + return window.runtime.CanResolveFilePaths(); +} + +export function ResolveFilePaths(files) { + return window.runtime.ResolveFilePaths(files); +} + +export function InitializeNotifications() { + return window.runtime.InitializeNotifications(); +} + +export function CleanupNotifications() { + return window.runtime.CleanupNotifications(); +} + +export function IsNotificationAvailable() { + return window.runtime.IsNotificationAvailable(); +} + +export function RequestNotificationAuthorization() { + return window.runtime.RequestNotificationAuthorization(); +} + +export function CheckNotificationAuthorization() { + return window.runtime.CheckNotificationAuthorization(); +} + +export function SendNotification(options) { + return window.runtime.SendNotification(options); +} + +export function SendNotificationWithActions(options) { + return window.runtime.SendNotificationWithActions(options); +} + +export function RegisterNotificationCategory(category) { + return window.runtime.RegisterNotificationCategory(category); +} + +export function RemoveNotificationCategory(categoryId) { + return window.runtime.RemoveNotificationCategory(categoryId); +} + +export function RemoveAllPendingNotifications() { + return window.runtime.RemoveAllPendingNotifications(); +} + +export function RemovePendingNotification(identifier) { + return window.runtime.RemovePendingNotification(identifier); +} + +export function RemoveAllDeliveredNotifications() { + return window.runtime.RemoveAllDeliveredNotifications(); +} + +export function RemoveDeliveredNotification(identifier) { + return window.runtime.RemoveDeliveredNotification(identifier); +} + +export function RemoveNotification(identifier) { + return window.runtime.RemoveNotification(identifier); +} \ No newline at end of file diff --git a/desktop/go.mod b/desktop/go.mod new file mode 100644 index 0000000..2d3b2ae --- /dev/null +++ b/desktop/go.mod @@ -0,0 +1,40 @@ +module tefter/desktop + +go 1.25.0 + +require ( + fyne.io/systray v1.12.2 + github.com/wailsapp/wails/v2 v2.13.0 + golang.design/x/hotkey v0.6.1 +) + +require ( + git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3 // indirect + github.com/bep/debounce v1.2.1 // indirect + github.com/go-ole/go-ole v1.3.0 // indirect + github.com/godbus/dbus/v5 v5.1.0 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/gorilla/websocket v1.5.3 // indirect + github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e // indirect + github.com/labstack/echo/v4 v4.13.3 // indirect + github.com/labstack/gommon v0.4.2 // indirect + github.com/leaanthony/go-ansi-parser v1.6.1 // indirect + github.com/leaanthony/gosod v1.0.4 // indirect + github.com/leaanthony/slicer v1.6.0 // indirect + github.com/leaanthony/u v1.1.1 // indirect + github.com/mattn/go-colorable v0.1.13 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect + github.com/pkg/errors v0.9.1 // indirect + github.com/rivo/uniseg v0.4.7 // indirect + github.com/samber/lo v1.49.1 // indirect + github.com/tkrajina/go-reflector v0.5.8 // indirect + github.com/valyala/bytebufferpool v1.0.0 // indirect + github.com/valyala/fasttemplate v1.2.2 // indirect + github.com/wailsapp/go-webview2 v1.0.22 // indirect + github.com/wailsapp/mimetype v1.4.1 // indirect + golang.org/x/crypto v0.51.0 // indirect + golang.org/x/net v0.54.0 // indirect + golang.org/x/sys v0.44.0 // indirect + golang.org/x/text v0.37.0 // indirect +) diff --git a/desktop/go.sum b/desktop/go.sum new file mode 100644 index 0000000..f662837 --- /dev/null +++ b/desktop/go.sum @@ -0,0 +1,89 @@ +fyne.io/systray v1.12.2 h1:Y8DZxgLHsVQt6rY9Zrkkg+j67S7vv/1F2viOWKPpVeA= +fyne.io/systray v1.12.2/go.mod h1:RVwqP9nYMo7h5zViCBHri2FgjXF7H2cub7MAq4NSoLs= +git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3 h1:N3IGoHHp9pb6mj1cbXbuaSXV/UMKwmbKLf53nQmtqMA= +git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3/go.mod h1:QtOLZGz8olr4qH2vWK0QH0w0O4T9fEIjMuWpKUsH7nc= +github.com/bep/debounce v1.2.1 h1:v67fRdBA9UQu2NhLFXrSg0Brw7CexQekrBwDMM8bzeY= +github.com/bep/debounce v1.2.1/go.mod h1:H8yggRPQKLUhUoqrJC1bO2xNya7vanpDl7xR3ISbCJ0= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE= +github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78= +github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk= +github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= +github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e h1:Q3+PugElBCf4PFpxhErSzU3/PY5sFL5Z6rfv4AbGAck= +github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e/go.mod h1:alcuEEnZsY1WQsagKhZDsoPCRoOijYqhZvPwLG0kzVs= +github.com/labstack/echo/v4 v4.13.3 h1:pwhpCPrTl5qry5HRdM5FwdXnhXSLSY+WE+YQSeCaafY= +github.com/labstack/echo/v4 v4.13.3/go.mod h1:o90YNEeQWjDozo584l7AwhJMHN0bOC4tAfg+Xox9q5g= +github.com/labstack/gommon v0.4.2 h1:F8qTUNXgG1+6WQmqoUWnz8WiEU60mXVVw0P4ht1WRA0= +github.com/labstack/gommon v0.4.2/go.mod h1:QlUFxVM+SNXhDL/Z7YhocGIBYOiwB0mXm1+1bAPHPyU= +github.com/leaanthony/debme v1.2.1 h1:9Tgwf+kjcrbMQ4WnPcEIUcQuIZYqdWftzZkBr+i/oOc= +github.com/leaanthony/debme v1.2.1/go.mod h1:3V+sCm5tYAgQymvSOfYQ5Xx2JCr+OXiD9Jkw3otUjiA= +github.com/leaanthony/go-ansi-parser v1.6.1 h1:xd8bzARK3dErqkPFtoF9F3/HgN8UQk0ed1YDKpEz01A= +github.com/leaanthony/go-ansi-parser v1.6.1/go.mod h1:+vva/2y4alzVmmIEpk9QDhA7vLC5zKDTRwfZGOp3IWU= +github.com/leaanthony/gosod v1.0.4 h1:YLAbVyd591MRffDgxUOU1NwLhT9T1/YiwjKZpkNFeaI= +github.com/leaanthony/gosod v1.0.4/go.mod h1:GKuIL0zzPj3O1SdWQOdgURSuhkF+Urizzxh26t9f1cw= +github.com/leaanthony/slicer v1.6.0 h1:1RFP5uiPJvT93TAHi+ipd3NACobkW53yUiBqZheE/Js= +github.com/leaanthony/slicer v1.6.0/go.mod h1:o/Iz29g7LN0GqH3aMjWAe90381nyZlDNquK+mtH2Fj8= +github.com/leaanthony/u v1.1.1 h1:TUFjwDGlNX+WuwVEzDqQwC2lOv0P4uhTQw7CMFdiK7M= +github.com/leaanthony/u v1.1.1/go.mod h1:9+o6hejoRljvZ3BzdYlVL0JYCwtnAsVuN9pVTQcaRfI= +github.com/matryer/is v1.4.0/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU= +github.com/matryer/is v1.4.1 h1:55ehd8zaGABKLXQUe2awZ99BD/PTc2ls+KV/dXphgEQ= +github.com/matryer/is v1.4.1/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU= +github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= +github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= +github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ= +github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/samber/lo v1.49.1 h1:4BIFyVfuQSEpluc7Fua+j1NolZHiEHEpaSEKdsH0tew= +github.com/samber/lo v1.49.1/go.mod h1:dO6KHFzUKXgP8LDhU0oI8d2hekjXnGOu0DB8Jecxd6o= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/tkrajina/go-reflector v0.5.8 h1:yPADHrwmUbMq4RGEyaOUpz2H90sRsETNVpjzo3DLVQQ= +github.com/tkrajina/go-reflector v0.5.8/go.mod h1:ECbqLgccecY5kPmPmXg1MrHW585yMcDkVl6IvJe64T4= +github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= +github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= +github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo= +github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ= +github.com/wailsapp/go-webview2 v1.0.22 h1:YT61F5lj+GGaat5OB96Aa3b4QA+mybD0Ggq6NZijQ58= +github.com/wailsapp/go-webview2 v1.0.22/go.mod h1:qJmWAmAmaniuKGZPWwne+uor3AHMB5PFhqiK0Bbj8kc= +github.com/wailsapp/mimetype v1.4.1 h1:pQN9ycO7uo4vsUUuPeHEYoUkLVkaRntMnHJxVwYhwHs= +github.com/wailsapp/mimetype v1.4.1/go.mod h1:9aV5k31bBOv5z6u+QP8TltzvNGJPmNJD4XlAL3U+j3o= +github.com/wailsapp/wails/v2 v2.13.0 h1:S7OgXWpj72V91unF8iDWJKbcS9ZpwCT3R0QVru4v2Mg= +github.com/wailsapp/wails/v2 v2.13.0/go.mod h1:nVr/wSIEZ7xxKPkzK65mjpKpaOPQI2k4pvLwGR/i4kc= +golang.design/x/hotkey v0.6.1 h1:mR3kp6L7eykJnEH7jvYQ9kc00LTq8edeWRFDsXfWp6Q= +golang.design/x/hotkey v0.6.1/go.mod h1:+CUQy3N+t1b8HbhsDScVWWuUpXiRPNRIKugECCiW0Po= +golang.design/x/mainthread v0.3.0 h1:UwFus0lcPodNpMOGoQMe87jSFwbSsEY//CA7yVmu4j8= +golang.design/x/mainthread v0.3.0/go.mod h1:vYX7cF2b3pTJMGM/hc13NmN6kblKnf4/IyvHeu259L0= +golang.org/x/crypto v0.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI= +golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8= +golang.org/x/net v0.0.0-20210505024714-0287a6fb4125/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= +golang.org/x/net v0.54.0 h1:2zJIZAxAHV/OHCDTCOHAYehQzLfSXuf/5SoL/Dv6w/w= +golang.org/x/net v0.54.0/go.mod h1:Sj4oj8jK6XmHpBZU/zWHw3BV3abl4Kvi+Ut7cQcY+cQ= +golang.org/x/sys v0.0.0-20200810151505-1b9f1253b3ed/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ= +golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc= +golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/desktop/hotkey_darwin.go b/desktop/hotkey_darwin.go new file mode 100644 index 0000000..6bd1f42 --- /dev/null +++ b/desktop/hotkey_darwin.go @@ -0,0 +1,10 @@ +//go:build darwin + +package main + +import "golang.design/x/hotkey" + +const ( + modAlt = hotkey.ModOption + modCmd = hotkey.ModCmd +) diff --git a/desktop/hotkey_linux.go b/desktop/hotkey_linux.go new file mode 100644 index 0000000..5fc5ee7 --- /dev/null +++ b/desktop/hotkey_linux.go @@ -0,0 +1,10 @@ +//go:build linux + +package main + +import "golang.design/x/hotkey" + +const ( + modAlt = hotkey.Mod1 + modCmd = hotkey.Mod4 +) diff --git a/desktop/hotkey_windows.go b/desktop/hotkey_windows.go new file mode 100644 index 0000000..ffe18f0 --- /dev/null +++ b/desktop/hotkey_windows.go @@ -0,0 +1,10 @@ +//go:build windows + +package main + +import "golang.design/x/hotkey" + +const ( + modAlt = hotkey.ModAlt + modCmd = hotkey.ModWin +) diff --git a/desktop/main.go b/desktop/main.go new file mode 100644 index 0000000..54d758a --- /dev/null +++ b/desktop/main.go @@ -0,0 +1,196 @@ +// tefter-desktop — Wails v2 shell around the shared frontend bundle. +// Window + tray + global shortcut + single-instance lock; all app logic +// (storage, sync) lives in the frontend, so it works fully offline. +package main + +import ( + "context" + "embed" + "flag" + "log" + "os" + "strings" + + "github.com/wailsapp/wails/v2" + "github.com/wailsapp/wails/v2/pkg/options" + "github.com/wailsapp/wails/v2/pkg/options/assetserver" + "github.com/wailsapp/wails/v2/pkg/options/mac" + wruntime "github.com/wailsapp/wails/v2/pkg/runtime" + + "fyne.io/systray" + "golang.design/x/hotkey" +) + +// Version is stamped via -ldflags "-X main.Version=…". +var Version = "dev" + +//go:embed all:frontend/dist +var assets embed.FS + +//go:embed build/appicon.png +var trayIcon []byte + +type appShell struct { + ctx context.Context + quitOnClose bool + hotkeyCombo string + endTray func() + quitting bool +} + +func main() { + quitOnClose := flag.Bool("quit-on-close", envBool("TEFTER_QUIT_ON_CLOSE"), "quit when the window closes instead of hiding to tray") + combo := flag.String("hotkey", envOr("TEFTER_HOTKEY", "ctrl+shift+space"), "global show/hide shortcut, e.g. ctrl+shift+space") + flag.Parse() + + s := &appShell{quitOnClose: *quitOnClose, hotkeyCombo: *combo} + + err := wails.Run(&options.App{ + Title: "Tefter", + Width: 980, + Height: 700, + AssetServer: &assetserver.Options{ + Assets: assets, + }, + HideWindowOnClose: !*quitOnClose, + OnStartup: s.startup, + OnShutdown: s.shutdown, + SingleInstanceLock: &options.SingleInstanceLock{ + UniqueId: "life.uka.tefter", + OnSecondInstanceLaunch: s.onSecondInstance, + }, + Mac: &mac.Options{ + About: &mac.AboutInfo{ + Title: "Tefter " + Version, + Message: "Self-hosted Notational Velocity style notes", + }, + }, + }) + if err != nil { + log.Fatal(err) + } +} + +func (s *appShell) startup(ctx context.Context) { + s.ctx = ctx + + // Tray: open, sync now, quit (SPEC §7). RunWithExternalLoop coexists with + // the Wails main loop. + start, end := systray.RunWithExternalLoop(s.trayReady, nil) + s.endTray = end + start() + + // Global show/hide shortcut. Best effort: requires X11 on Linux. + go s.registerHotkey() +} + +func (s *appShell) shutdown(ctx context.Context) { + if s.endTray != nil { + s.endTray() + } +} + +func (s *appShell) onSecondInstance(data options.SecondInstanceData) { + s.showAndFocus() +} + +func (s *appShell) trayReady() { + systray.SetIcon(trayIcon) + systray.SetTitle("Tefter") + systray.SetTooltip("Tefter — notes") + mOpen := systray.AddMenuItem("Open", "Show the Tefter window") + mSync := systray.AddMenuItem("Sync now", "Trigger a sync") + systray.AddSeparator() + mQuit := systray.AddMenuItem("Quit", "Quit Tefter") + go func() { + for { + select { + case <-mOpen.ClickedCh: + s.showAndFocus() + case <-mSync.ClickedCh: + wruntime.EventsEmit(s.ctx, "tefter:sync") + case <-mQuit.ClickedCh: + s.quitting = true + wruntime.Quit(s.ctx) + return + } + } + }() +} + +func (s *appShell) registerHotkey() { + mods, key, ok := parseHotkey(s.hotkeyCombo) + if !ok { + log.Printf("hotkey: cannot parse %q, global shortcut disabled", s.hotkeyCombo) + return + } + hk := hotkey.New(mods, key) + if err := hk.Register(); err != nil { + log.Printf("hotkey: register %q: %v (global shortcut disabled)", s.hotkeyCombo, err) + return + } + log.Printf("hotkey: %s toggles the window", s.hotkeyCombo) + for range hk.Keydown() { + s.showAndFocus() + } +} + +func (s *appShell) showAndFocus() { + wruntime.WindowShow(s.ctx) + // The classic NV "always at hand" flow: land in the omnibar. + wruntime.EventsEmit(s.ctx, "tefter:focus-omnibar") +} + +func envOr(k, def string) string { + if v := os.Getenv(k); v != "" { + return v + } + return def +} + +func envBool(k string) bool { + v := strings.ToLower(os.Getenv(k)) + return v == "1" || v == "true" || v == "yes" +} + +// parseHotkey turns "ctrl+shift+space" into golang.design/x/hotkey values. +// modAlt/modCmd are defined per-OS in hotkey_*.go. +func parseHotkey(combo string) ([]hotkey.Modifier, hotkey.Key, bool) { + var mods []hotkey.Modifier + var key hotkey.Key + haveKey := false + for _, part := range strings.Split(strings.ToLower(combo), "+") { + switch p := strings.TrimSpace(part); p { + case "ctrl", "control": + mods = append(mods, hotkey.ModCtrl) + case "shift": + mods = append(mods, hotkey.ModShift) + case "alt", "option": + mods = append(mods, modAlt) + case "cmd", "meta", "super", "win": + mods = append(mods, modCmd) + case "space": + key, haveKey = hotkey.KeySpace, true + default: + if len(p) == 1 { + if k, found := charKeys[rune(p[0])]; found { + key, haveKey = k, true + } + } + } + } + return mods, key, haveKey +} + +var charKeys = map[rune]hotkey.Key{ + 'a': hotkey.KeyA, 'b': hotkey.KeyB, 'c': hotkey.KeyC, 'd': hotkey.KeyD, + 'e': hotkey.KeyE, 'f': hotkey.KeyF, 'g': hotkey.KeyG, 'h': hotkey.KeyH, + 'i': hotkey.KeyI, 'j': hotkey.KeyJ, 'k': hotkey.KeyK, 'l': hotkey.KeyL, + 'm': hotkey.KeyM, 'n': hotkey.KeyN, 'o': hotkey.KeyO, 'p': hotkey.KeyP, + 'q': hotkey.KeyQ, 'r': hotkey.KeyR, 's': hotkey.KeyS, 't': hotkey.KeyT, + 'u': hotkey.KeyU, 'v': hotkey.KeyV, 'w': hotkey.KeyW, 'x': hotkey.KeyX, + 'y': hotkey.KeyY, 'z': hotkey.KeyZ, + '0': hotkey.Key0, '1': hotkey.Key1, '2': hotkey.Key2, '3': hotkey.Key3, + '4': hotkey.Key4, '5': hotkey.Key5, '6': hotkey.Key6, '7': hotkey.Key7, + '8': hotkey.Key8, '9': hotkey.Key9, +} diff --git a/desktop/tefter.desktop b/desktop/tefter.desktop new file mode 100644 index 0000000..4b80a22 --- /dev/null +++ b/desktop/tefter.desktop @@ -0,0 +1,9 @@ +[Desktop Entry] +Type=Application +Name=Tefter +Comment=Self-hosted Notational Velocity style notes +Exec=tefter-desktop +Icon=tefter +Terminal=false +Categories=Utility;Office; +StartupWMClass=Tefter diff --git a/desktop/wails.json b/desktop/wails.json new file mode 100644 index 0000000..6000640 --- /dev/null +++ b/desktop/wails.json @@ -0,0 +1,16 @@ +{ + "$schema": "https://wails.io/schemas/config.v2.json", + "name": "Tefter", + "outputfilename": "tefter-desktop", + "frontend:install": "", + "frontend:build": "", + "frontend:dev:watcher": "", + "frontend:dev:serverUrl": "auto", + "author": { + "name": "senad" + }, + "info": { + "productName": "Tefter", + "comments": "Self-hosted Notational Velocity style notes" + } +} diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..edca623 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,17 @@ + + + + + + + + + + + Tefter + + +
+ + + diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 0000000..596bd7e --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,2136 @@ +{ + "name": "tefter-frontend", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "tefter-frontend", + "version": "0.1.0", + "dependencies": { + "@codemirror/commands": "^6.7.1", + "@codemirror/lang-markdown": "^6.3.1", + "@codemirror/language": "^6.10.6", + "@codemirror/state": "^6.5.0", + "@codemirror/view": "^6.35.3", + "@lezer/highlight": "^1.2.1", + "dompurify": "^3.2.3", + "marked": "^15.0.4" + }, + "devDependencies": { + "@sveltejs/vite-plugin-svelte": "^3.1.2", + "@types/node": "^26.1.1", + "svelte": "^4.2.19", + "svelte-check": "^3.8.6", + "tslib": "^2.8.1", + "typescript": "^5.6.3", + "vite": "^5.4.11" + } + }, + "node_modules/@ampproject/remapping": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz", + "integrity": "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + }, + "engines": { + "node": ">=6.0.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==", + "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.4", + "resolved": "https://registry.npmjs.org/@codemirror/commands/-/commands-6.10.4.tgz", + "integrity": "sha512-Ryk9y9T0FFVF0cUGhAknveAyUOl/A1qReTFi+qPKtOh2Z9F4AUBz3XOrYD4ZEgZirdugVzHvd/2/Wcwy5OliTg==", + "license": "MIT", + "dependencies": { + "@codemirror/language": "^6.0.0", + "@codemirror/state": "^6.7.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==", + "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==", + "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==", + "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==", + "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.4", + "resolved": "https://registry.npmjs.org/@codemirror/language/-/language-6.12.4.tgz", + "integrity": "sha512-1q4PaT+o6PbgpkJt4Q8Fv5XJxTy4FUZ4MWETtyiDw3J0Pyr9E2vqcKL+k9wcvjNTIsauxvE7OfmWj3FRPHQ76A==", + "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.7", + "resolved": "https://registry.npmjs.org/@codemirror/lint/-/lint-6.9.7.tgz", + "integrity": "sha512-28/+iWLYxKxsvGYhSYL7zaCZqLz5+FFFDq9tVsvGv9kv8RY4fFAchJ5WX9M3YrrRlTIsECjsXPqeNgnSmNP2dg==", + "license": "MIT", + "dependencies": { + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.42.0", + "crelt": "^1.0.5" + } + }, + "node_modules/@codemirror/state": { + "version": "6.7.1", + "resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.7.1.tgz", + "integrity": "sha512-9QzNDgE4EYDnAHfrTlR2lwiPciiOymLtwKK+8yHQzCc7GXhAP9xdEbEJFy2IWB1j9UGUl9BsgMmTo/ImA02T7A==", + "license": "MIT", + "dependencies": { + "@marijn/find-cluster-break": "^1.0.0" + } + }, + "node_modules/@codemirror/view": { + "version": "6.43.6", + "resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.43.6.tgz", + "integrity": "sha512-EVunGSYN1wz1p75WY1s3Xg7t3i8Yol0kGZGizNdX9BUFgMFILYVe8/u6EVpo7Ff5PwbZuILb4QAq7IZoKzIEQA==", + "license": "MIT", + "dependencies": { + "@codemirror/state": "^6.7.0", + "crelt": "^1.0.6", + "style-mod": "^4.1.0", + "w3c-keyname": "^2.2.4" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "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==", + "license": "MIT" + }, + "node_modules/@lezer/css": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@lezer/css/-/css-1.3.4.tgz", + "integrity": "sha512-N+tn9tej2hPvyKgHEApMOQfHczDJCwxrRFS3SPn9QjYN+uwHvEDnCgKRrb3mxDYxRS8sKMM8fhC3+lc04Abz5Q==", + "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==", + "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==", + "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==", + "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==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.0.0" + } + }, + "node_modules/@lezer/markdown": { + "version": "1.7.1", + "resolved": "https://registry.npmjs.org/@lezer/markdown/-/markdown-1.7.1.tgz", + "integrity": "sha512-MEBZeFSBxgteUjEC3Wxg2Dwld5/JxRKG267L3bMFdibm8KjqSdiJYBeFw1Nt1CM8+zKMpSIEHblY8FD9z38sJQ==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.5.0", + "@lezer/highlight": "^1.0.0" + } + }, + "node_modules/@marijn/find-cluster-break": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@marijn/find-cluster-break/-/find-cluster-break-1.0.3.tgz", + "integrity": "sha512-FY+MKLBoTsLNJF/eLWaOsXGdz6uh3Iu1axjPf6TUq92IYumcTcXWHoS747JARLkcdlJ/Waiaxc5wQfFO8jC6NA==", + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.2.tgz", + "integrity": "sha512-6o7ZLZK+BeenkZCFNDXqpbjw9bD6nuWonvS/lwQJp7NoVVxm6p3qE7qQ5jGuBjiFsgvqjD8mZAU5oWxTmbOeOg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.2.tgz", + "integrity": "sha512-BaH7BllCACHoH1LguOU56UItGfUWjujlO65kS9LAodViaN4bwIKd7oeW/ZHJ/4ljr/7MIiENnNy3HJ0zXv8Zkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.2.tgz", + "integrity": "sha512-v39RCCvj4He82I9sFmk+M1VZ0PLM9sfsLVikjfx2hYBNALhrrOR2D3JjQA6AhlaSOgcR+RzrKY7e1+bT6SUO/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.2.tgz", + "integrity": "sha512-yl0y2vq3S3lHeuXhEdss6TWfKW8vkujImO12tn4ZkG/4oghr09LvdYm2RElVjokTQiUvDUGXLGsYeLqUMCKpGA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.2.tgz", + "integrity": "sha512-tT4pvt4qXD+vEoezupCWi+a1F0vvDiksiHc+PxRlYTOH1I6/X4id9jPxTP+Fg+545euaFT1jJVs4CEdHZAU1vw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.2.tgz", + "integrity": "sha512-6nU5F2wCW+qvCBhTn1pdIU3bzsIoF7EUwsCDRxilWGprQR6yd508YnH9+OKFCwpfS8pjZqDUmnCAr7exax0XCg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.2.tgz", + "integrity": "sha512-n1GJHPOvpIfhi3TmrCeh6S6URt9BFCt0KQE3qvexyGCTAKpR4Lg+eWvNZEqu7epxwus/8ElT3hacYEucm49SZg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.2.tgz", + "integrity": "sha512-JqgflS8wEB+UXV/vS1RpRbifGBeN4D5lz8D8oOFbFZw4vedvdOgCFAjfBmIMdW3yL10XpQQ0Ambepw6MXrhOnA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.2.tgz", + "integrity": "sha512-wnFJkogWvN4jm/hQRF2UBaeUmk20j5+DmHvoyWii2b8HJDyvz1MF2OU/6ynXt2KR63rbZLWkFpoytpdc/yBuSA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.2.tgz", + "integrity": "sha512-HVu2bp0zhvJ8xHEV9+UUs7S90VadmBSY3LcIMvozbPo4AuMGDWlz3ymHLHZPX4hR67TKTt8Qp5PJ5RBg/i+RMQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.2.tgz", + "integrity": "sha512-mQqqAV8QaoSgr9I2fKDLY2BAVvmKjWoGiu/cSYQonsLvtqwEn1E4QYfnCOcp5zoEqNhsDYin1s6jx/VJmrxlZg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.2.tgz", + "integrity": "sha512-IxKLoxCQ2IWi6bT2akyDUBGsOImDKB+sPp4EsTmwFQ/fMwpCKm8uLSSgP/Kx/QYUgKis6SEZ5/Nlhup0DIA0PQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.2.tgz", + "integrity": "sha512-Mk5ha2RQSgyFfmYYLkBpPnUk8D8FriBxesO1u9O75X0mHgXL1UQcH5Itl2lurWL2tj0RxV9b9tJgipac0hRY9A==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.2.tgz", + "integrity": "sha512-CjvEnqJL/0/TQ3TXX3OPIJ/kmBellrWd4heXUmHeJlTnmwjKpSJzoehLaL6Xk0ZnMHBu9dZuFADNOrtjF4v+2w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.2.tgz", + "integrity": "sha512-1SiZbzwdkaDURsew/tSOrooKiYy7EQGT6m8ufavAi9NEyQb/6VuIxFXAL1fqa4iZe3g4NbNk4P7J32z2tw5Mgg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.2.tgz", + "integrity": "sha512-nQts12zJ3NQRoE6uYljOH89v7szzLDvG2JD/vsX+vGXU8w/At1GowTZ5/7qeFQ8m7L55rpR8Okugnuo5bgjy2Q==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.2.tgz", + "integrity": "sha512-E9/ll019jhPIJgpzfZoIkBGhcz+kKNgVWYRY0zr9srBdPPFVpvOKW8VaJKUbeK+eZXyQF9ltME+Kk6affeaPgg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.2.tgz", + "integrity": "sha512-5BqxR/pshjey51iliyzTD5Xi3EN0aLmQ2lZ3lvefVV9c82BvrLo2/6OT55iifpWBufs6kdwWbuOKS841DrmK9A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.2.tgz", + "integrity": "sha512-uNN83XxQrRAh/w0/pmAfibcwyb6YWt4gP+dpnQKPVJshAloQ785ii8CT8ZCIxkGg9opVsvAlGhFitSm6D1Jjpg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.2.tgz", + "integrity": "sha512-srjEIxSH3LRnJN6THczDHWQplqEMFiAJrTab0msUryh9kwNpkICf3Ea6q6MN/2cZwRFUNx5w+h6Hpi4QuHS6Zg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.2.tgz", + "integrity": "sha512-8hOJnxgbyObnCm5AlRA3A931xX19xq80RjVTKgJOvEKWqJruP/Uf12IbAOaDjjEXYRewwHLfmF0YRIdK3OwKWA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.2.tgz", + "integrity": "sha512-mmF4AY1i0hG/bLWUctUq59gtmgaSIRa3cu/A3JFRp/sCNEme2bgDEiDS22P9FbnJB8NJNF4jPJiSP5RHQpUTDg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.2.tgz", + "integrity": "sha512-DZgkknc6jhHrk46V25vbAM0zZkyP0nSDkJB8/dRkLTxv470dOmWDqGoEJl/9A0dFfS7yE3REOwNDxpHwSLSt0Q==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.2.tgz", + "integrity": "sha512-T6xr6ucWSFto+VGajA8YH26LdpHRuP4YLHEKAtCWvJDOlnmWcDZVCI2Jmjr+IFHDlt2zRaTAKE4tfjTaWLgJBg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.2.tgz", + "integrity": "sha512-BfzEnDJOt9T8M989/lA37EcJgat01wLRnoi5dQf3QzOH7jzpqTAzdDbVfRljVr5r+jzKqpbHeyOfAaXxAd0PAA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@sveltejs/vite-plugin-svelte": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte/-/vite-plugin-svelte-3.1.2.tgz", + "integrity": "sha512-Txsm1tJvtiYeLUVRNqxZGKR/mI+CzuIQuc2gn+YCs9rMTowpNZ2Nqt53JdL8KF9bLhAf2ruR/dr9eZCwdTriRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@sveltejs/vite-plugin-svelte-inspector": "^2.1.0", + "debug": "^4.3.4", + "deepmerge": "^4.3.1", + "kleur": "^4.1.5", + "magic-string": "^0.30.10", + "svelte-hmr": "^0.16.0", + "vitefu": "^0.2.5" + }, + "engines": { + "node": "^18.0.0 || >=20" + }, + "peerDependencies": { + "svelte": "^4.0.0 || ^5.0.0-next.0", + "vite": "^5.0.0" + } + }, + "node_modules/@sveltejs/vite-plugin-svelte-inspector": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte-inspector/-/vite-plugin-svelte-inspector-2.1.0.tgz", + "integrity": "sha512-9QX28IymvBlSCqsCll5t0kQVxipsfhFFL+L2t3nTWfXnddYwxBuAEtTtlaVQpRz9c37BhJjltSeY4AJSC03SSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.3.4" + }, + "engines": { + "node": "^18.0.0 || >=20" + }, + "peerDependencies": { + "@sveltejs/vite-plugin-svelte": "^3.0.0", + "svelte": "^4.0.0 || ^5.0.0-next.0", + "vite": "^5.0.0" + } + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "26.1.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-26.1.1.tgz", + "integrity": "sha512-nxAkRSVkN1Y0JC1W8ky/fTfkGsMmcrRsbx+3XoZE+rMOX71kLYTV7fLXpqud1GpbpP5TuffXFqfX7fH2GgZREw==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~8.3.0" + } + }, + "node_modules/@types/pug": { + "version": "2.0.10", + "resolved": "https://registry.npmjs.org/@types/pug/-/pug-2.0.10.tgz", + "integrity": "sha512-Sk/uYFOBAB7mb74XcpizmH0KOR2Pv3D2Hmrh1Dmy5BmK3MpdSa5kqZcg6EKBdklU0bFXX9gCfzvpnyUehrPIuA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/trusted-types": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", + "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", + "license": "MIT", + "optional": true + }, + "node_modules/acorn": { + "version": "8.17.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.17.0.tgz", + "integrity": "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, + "license": "ISC", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/aria-query": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.2.tgz", + "integrity": "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/axobject-query": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz", + "integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/binary-extensions": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", + "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/brace-expansion": { + "version": "1.1.16", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.16.tgz", + "integrity": "sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/buffer-crc32": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-1.0.0.tgz", + "integrity": "sha512-Db1SbgBS/fg/392AblrMJk97KggmvYhr4pB5ZIMTWtaivCPMWLkmb7m21cJvpvgK+J3nsU2CmmixNBZx4vFj/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/code-red": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/code-red/-/code-red-1.0.4.tgz", + "integrity": "sha512-7qJWqItLA8/VPVlKJlFXU+NBlo/qyfs39aJcuMT/2ere32ZqvF5OSxgdM5xOfJJ7O429gg2HM47y8v9P+9wrNw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.4.15", + "@types/estree": "^1.0.1", + "acorn": "^8.10.0", + "estree-walker": "^3.0.3", + "periscopic": "^3.1.0" + } + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "license": "MIT" + }, + "node_modules/crelt": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.7.tgz", + "integrity": "sha512-aK6BbWfhf4U/wCcLHKPJl/xa6VkVstRaPywWtMKGwuOLc/wZTyQYuoxgvZnNsBvv7Kg3YTBQYYBCggcviQczuA==", + "license": "MIT" + }, + "node_modules/css-tree": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-2.3.1.tgz", + "integrity": "sha512-6Fv1DV/TYw//QF5IzQdqsNDjx/wc8TrMBZsqjL9eW01tWb7R7k/mq+/VXfJCl7SoD5emsJop9cOByJZfs8hYIw==", + "dev": true, + "license": "MIT", + "dependencies": { + "mdn-data": "2.0.30", + "source-map-js": "^1.0.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deepmerge": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", + "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/detect-indent": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/detect-indent/-/detect-indent-6.1.0.tgz", + "integrity": "sha512-reYkTUJAZb9gUuZ2RvVCNhVHdg62RHnJ7WJl8ftMi4diZ6NWlciOzQN88pUhSELEwflJht4oQDv0F0BMlwaYtA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/dompurify": { + "version": "3.4.12", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.12.tgz", + "integrity": "sha512-zQvGet8Z2sWbQhCmfFz/T5QWH2oBmjnqK3qvOjaqaNLrLEF912WamU+ohnTp0TCep/MFVHpdJuCZEdFOdTnEFg==", + "license": "(MPL-2.0 OR Apache-2.0)", + "optionalDependencies": { + "@types/trusted-types": "^2.0.7" + } + }, + "node_modules/es6-promise": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/es6-promise/-/es6-promise-3.3.1.tgz", + "integrity": "sha512-SOp9Phqvqn7jtEUxPWdWfWoLmyt2VaJ6MpvP9Comy1MceMXqE6bxvaTu4iaxpYYPzhny28Lc+M87/c2cPK6lDg==", + "dev": true, + "license": "MIT" + }, + "node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", + "dev": true, + "license": "ISC" + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "dev": true, + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", + "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", + "dev": true, + "license": "ISC", + "dependencies": { + "once": "^1.3.0", + "wrappy": "1" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/is-reference": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/is-reference/-/is-reference-3.0.3.tgz", + "integrity": "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.6" + } + }, + "node_modules/kleur": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz", + "integrity": "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/locate-character": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/locate-character/-/locate-character-3.0.0.tgz", + "integrity": "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==", + "dev": true, + "license": "MIT" + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/marked": { + "version": "15.0.12", + "resolved": "https://registry.npmjs.org/marked/-/marked-15.0.12.tgz", + "integrity": "sha512-8dD6FusOQSrpv9Z1rdNMdlSgQOIP880DHqnohobOmYLElGEqAL/JvxvuxZO16r4HtjTlfPRDC1hbvxC9dPN2nA==", + "license": "MIT", + "bin": { + "marked": "bin/marked.js" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/mdn-data": { + "version": "2.0.30", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.0.30.tgz", + "integrity": "sha512-GaqWWShW4kv/G9IEucWScBx9G1/vsFZZJUO+tD26M8J8z3Kw5RDQjaoZe03YAClgeS/SWPOcb4nkFBTEi5DUEA==", + "dev": true, + "license": "CC0-1.0" + }, + "node_modules/min-indent": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", + "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/mkdirp": { + "version": "0.5.6", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz", + "integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "minimist": "^1.2.6" + }, + "bin": { + "mkdirp": "bin/cmd.js" + } + }, + "node_modules/mri": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz", + "integrity": "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.15", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.15.tgz", + "integrity": "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "dev": true, + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/periscopic": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/periscopic/-/periscopic-3.1.0.tgz", + "integrity": "sha512-vKiQ8RRtkl9P+r/+oefh25C3fhybptkHKCZSPlcXiJux2tJF55GnEj3BVn4A5gKfq9NWWXXrxkHBwVPUfH0opw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0", + "estree-walker": "^3.0.0", + "is-reference": "^3.0.0" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.17", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.17.tgz", + "integrity": "sha512-J7EF+8X+CzRPaJPOv9Ck2wNWJvGnnl3PcNPAdGg6GTLjyVpyQ0yATMSXRFRV01BviT/9Gwuc3rjEyJbDJG9a4w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.12", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/rimraf": { + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.7.1.tgz", + "integrity": "sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w==", + "deprecated": "Rimraf versions prior to v4 are no longer supported", + "dev": true, + "license": "ISC", + "dependencies": { + "glob": "^7.1.3" + }, + "bin": { + "rimraf": "bin.js" + } + }, + "node_modules/rollup": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.2.tgz", + "integrity": "sha512-RFnrW4lhXA3s3eqHDZvN654g8OTjzRfqpIRJYczCGB6HzphckVAi/Qh4tbPUbRuDi7s1Llv8g/NspLkttY3gTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.62.2", + "@rollup/rollup-android-arm64": "4.62.2", + "@rollup/rollup-darwin-arm64": "4.62.2", + "@rollup/rollup-darwin-x64": "4.62.2", + "@rollup/rollup-freebsd-arm64": "4.62.2", + "@rollup/rollup-freebsd-x64": "4.62.2", + "@rollup/rollup-linux-arm-gnueabihf": "4.62.2", + "@rollup/rollup-linux-arm-musleabihf": "4.62.2", + "@rollup/rollup-linux-arm64-gnu": "4.62.2", + "@rollup/rollup-linux-arm64-musl": "4.62.2", + "@rollup/rollup-linux-loong64-gnu": "4.62.2", + "@rollup/rollup-linux-loong64-musl": "4.62.2", + "@rollup/rollup-linux-ppc64-gnu": "4.62.2", + "@rollup/rollup-linux-ppc64-musl": "4.62.2", + "@rollup/rollup-linux-riscv64-gnu": "4.62.2", + "@rollup/rollup-linux-riscv64-musl": "4.62.2", + "@rollup/rollup-linux-s390x-gnu": "4.62.2", + "@rollup/rollup-linux-x64-gnu": "4.62.2", + "@rollup/rollup-linux-x64-musl": "4.62.2", + "@rollup/rollup-openbsd-x64": "4.62.2", + "@rollup/rollup-openharmony-arm64": "4.62.2", + "@rollup/rollup-win32-arm64-msvc": "4.62.2", + "@rollup/rollup-win32-ia32-msvc": "4.62.2", + "@rollup/rollup-win32-x64-gnu": "4.62.2", + "@rollup/rollup-win32-x64-msvc": "4.62.2", + "fsevents": "~2.3.2" + } + }, + "node_modules/sade": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/sade/-/sade-1.8.1.tgz", + "integrity": "sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A==", + "dev": true, + "license": "MIT", + "dependencies": { + "mri": "^1.1.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/sander": { + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/sander/-/sander-0.5.1.tgz", + "integrity": "sha512-3lVqBir7WuKDHGrKRDn/1Ye3kwpXaDOMsiRP1wd6wpZW56gJhsbp5RqQpA6JG/P+pkXizygnr1dKR8vzWaVsfA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es6-promise": "^3.1.2", + "graceful-fs": "^4.1.3", + "mkdirp": "^0.5.1", + "rimraf": "^2.5.2" + } + }, + "node_modules/sorcery": { + "version": "0.11.1", + "resolved": "https://registry.npmjs.org/sorcery/-/sorcery-0.11.1.tgz", + "integrity": "sha512-o7npfeJE6wi6J9l0/5LKshFzZ2rMatRiCDwYeDQaOzqdzRJwALhX7mk/A/ecg6wjMu7wdZbmXfD2S/vpOg0bdQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.4.14", + "buffer-crc32": "^1.0.0", + "minimist": "^1.2.0", + "sander": "^0.5.0" + }, + "bin": { + "sorcery": "bin/sorcery" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/strip-indent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz", + "integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "min-indent": "^1.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "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==", + "license": "MIT" + }, + "node_modules/svelte": { + "version": "4.2.20", + "resolved": "https://registry.npmjs.org/svelte/-/svelte-4.2.20.tgz", + "integrity": "sha512-eeEgGc2DtiUil5ANdtd8vPwt9AgaMdnuUFnPft9F5oMvU/FHu5IHFic+p1dR/UOB7XU2mX2yHW+NcTch4DCh5Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@ampproject/remapping": "^2.2.1", + "@jridgewell/sourcemap-codec": "^1.4.15", + "@jridgewell/trace-mapping": "^0.3.18", + "@types/estree": "^1.0.1", + "acorn": "^8.9.0", + "aria-query": "^5.3.0", + "axobject-query": "^4.0.0", + "code-red": "^1.0.3", + "css-tree": "^2.3.1", + "estree-walker": "^3.0.3", + "is-reference": "^3.0.1", + "locate-character": "^3.0.0", + "magic-string": "^0.30.4", + "periscopic": "^3.1.0" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/svelte-check": { + "version": "3.8.6", + "resolved": "https://registry.npmjs.org/svelte-check/-/svelte-check-3.8.6.tgz", + "integrity": "sha512-ij0u4Lw/sOTREP13BdWZjiXD/BlHE6/e2e34XzmVmsp5IN4kVa3PWP65NM32JAgwjZlwBg/+JtiNV1MM8khu0Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.17", + "chokidar": "^3.4.1", + "picocolors": "^1.0.0", + "sade": "^1.7.4", + "svelte-preprocess": "^5.1.3", + "typescript": "^5.0.3" + }, + "bin": { + "svelte-check": "bin/svelte-check" + }, + "peerDependencies": { + "svelte": "^3.55.0 || ^4.0.0-next.0 || ^4.0.0 || ^5.0.0-next.0" + } + }, + "node_modules/svelte-hmr": { + "version": "0.16.0", + "resolved": "https://registry.npmjs.org/svelte-hmr/-/svelte-hmr-0.16.0.tgz", + "integrity": "sha512-Gyc7cOS3VJzLlfj7wKS0ZnzDVdv3Pn2IuVeJPk9m2skfhcu5bq3wtIZyQGggr7/Iim5rH5cncyQft/kRLupcnA==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^12.20 || ^14.13.1 || >= 16" + }, + "peerDependencies": { + "svelte": "^3.19.0 || ^4.0.0" + } + }, + "node_modules/svelte-preprocess": { + "version": "5.1.4", + "resolved": "https://registry.npmjs.org/svelte-preprocess/-/svelte-preprocess-5.1.4.tgz", + "integrity": "sha512-IvnbQ6D6Ao3Gg6ftiM5tdbR6aAETwjhHV+UKGf5bHGYR69RQvF1ho0JKPcbUON4vy4R7zom13jPjgdOWCQ5hDA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "@types/pug": "^2.0.6", + "detect-indent": "^6.1.0", + "magic-string": "^0.30.5", + "sorcery": "^0.11.0", + "strip-indent": "^3.0.0" + }, + "engines": { + "node": ">= 16.0.0" + }, + "peerDependencies": { + "@babel/core": "^7.10.2", + "coffeescript": "^2.5.1", + "less": "^3.11.3 || ^4.0.0", + "postcss": "^7 || ^8", + "postcss-load-config": "^2.1.0 || ^3.0.0 || ^4.0.0 || ^5.0.0", + "pug": "^3.0.0", + "sass": "^1.26.8", + "stylus": "^0.55.0", + "sugarss": "^2.0.0 || ^3.0.0 || ^4.0.0", + "svelte": "^3.23.0 || ^4.0.0-next.0 || ^4.0.0 || ^5.0.0-next.0", + "typescript": ">=3.9.5 || ^4.0.0 || ^5.0.0" + }, + "peerDependenciesMeta": { + "@babel/core": { + "optional": true + }, + "coffeescript": { + "optional": true + }, + "less": { + "optional": true + }, + "postcss": { + "optional": true + }, + "postcss-load-config": { + "optional": true + }, + "pug": { + "optional": true + }, + "sass": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "typescript": { + "optional": true + } + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD" + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz", + "integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/vitefu": { + "version": "0.2.5", + "resolved": "https://registry.npmjs.org/vitefu/-/vitefu-0.2.5.tgz", + "integrity": "sha512-SgHtMLoqaeeGnd2evZ849ZbACbnwQCIwRH57t18FxcXoZop0uQu0uzlIhJBlF/eWVzuce0sHeqPcDo+evVcg8Q==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "vite": "^3.0.0 || ^4.0.0 || ^5.0.0" + }, + "peerDependenciesMeta": { + "vite": { + "optional": true + } + } + }, + "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==", + "license": "MIT" + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "dev": true, + "license": "ISC" + } + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..3a455cb --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,30 @@ +{ + "name": "tefter-frontend", + "private": true, + "version": "0.1.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "check": "svelte-check --tsconfig ./tsconfig.json" + }, + "devDependencies": { + "@sveltejs/vite-plugin-svelte": "^3.1.2", + "@types/node": "^26.1.1", + "svelte": "^4.2.19", + "svelte-check": "^3.8.6", + "tslib": "^2.8.1", + "typescript": "^5.6.3", + "vite": "^5.4.11" + }, + "dependencies": { + "@codemirror/commands": "^6.7.1", + "@codemirror/lang-markdown": "^6.3.1", + "@codemirror/language": "^6.10.6", + "@codemirror/state": "^6.5.0", + "@codemirror/view": "^6.35.3", + "@lezer/highlight": "^1.2.1", + "dompurify": "^3.2.3", + "marked": "^15.0.4" + } +} diff --git a/frontend/public/icon-192.png b/frontend/public/icon-192.png new file mode 100644 index 0000000..5a4942a Binary files /dev/null and b/frontend/public/icon-192.png differ diff --git a/frontend/public/icon-512-maskable.png b/frontend/public/icon-512-maskable.png new file mode 100644 index 0000000..596858a Binary files /dev/null and b/frontend/public/icon-512-maskable.png differ diff --git a/frontend/public/icon-512.png b/frontend/public/icon-512.png new file mode 100644 index 0000000..7d3ba56 Binary files /dev/null and b/frontend/public/icon-512.png differ diff --git a/frontend/public/icon.svg b/frontend/public/icon.svg new file mode 100644 index 0000000..4d10394 --- /dev/null +++ b/frontend/public/icon.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/frontend/public/manifest.webmanifest b/frontend/public/manifest.webmanifest new file mode 100644 index 0000000..c56decd --- /dev/null +++ b/frontend/public/manifest.webmanifest @@ -0,0 +1,15 @@ +{ + "name": "Tefter", + "short_name": "Tefter", + "description": "Self-hosted Notational Velocity style notes", + "start_url": "/", + "scope": "/", + "display": "standalone", + "background_color": "#1a1a1a", + "theme_color": "#2f6fde", + "icons": [ + { "src": "/icon-192.png", "sizes": "192x192", "type": "image/png", "purpose": "any" }, + { "src": "/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any" }, + { "src": "/icon-512-maskable.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" } + ] +} diff --git a/frontend/src/app.css b/frontend/src/app.css new file mode 100644 index 0000000..8102d07 --- /dev/null +++ b/frontend/src/app.css @@ -0,0 +1,134 @@ +:root { + --bg: #fafafa; + --bg-panel: #ffffff; + --fg: #1a1a1a; + --fg-dim: #767676; + --sep: #e2e2e2; + --accent: #2f6fde; + --accent-bg: #e8f0fe; + --sel-bg: #dce8fb; + --mark-bg: #ffe9a8; + --mark-fg: inherit; + --dot-green: #2e9e44; + --dot-yellow: #d9a400; + --dot-gray: #9a9a9a; + --dot-red: #cc3333; + --mono: ui-monospace, 'SF Mono', Menlo, Consolas, 'Liberation Mono', monospace; + --sans: system-ui, -apple-system, 'Segoe UI', Roboto, Ubuntu, Cantarell, sans-serif; +} + +@media (prefers-color-scheme: dark) { + :root { + --bg: #1a1a1a; + --bg-panel: #212121; + --fg: #e4e4e4; + --fg-dim: #8f8f8f; + --sep: #333333; + --accent: #6ba0f2; + --accent-bg: #263450; + --sel-bg: #2c3e5c; + --mark-bg: #6b5a1a; + --mark-fg: #ffe9a8; + } +} + +* { + box-sizing: border-box; +} + +html, +body { + margin: 0; + padding: 0; + height: 100%; + overflow: hidden; +} + +body { + font-family: var(--sans); + background: var(--bg); + color: var(--fg); +} + +#app { + height: 100%; +} + +mark { + background: var(--mark-bg); + color: var(--mark-fg); + border-radius: 2px; +} + +button { + font: inherit; + color: inherit; + background: none; + border: none; + cursor: pointer; + padding: 0; +} + +input, +select { + font: inherit; + color: var(--fg); + background: var(--bg-panel); + border: 1px solid var(--sep); + border-radius: 6px; +} + +/* CodeMirror chrome */ +.cm-editor { + height: 100%; + font-size: 14px; + background: var(--bg-panel); +} +.cm-editor .cm-content { + font-family: var(--mono); + caret-color: var(--fg); + padding: 12px 0; +} +.cm-editor .cm-line { + padding: 0 16px; +} +.cm-editor.cm-focused { + outline: none; +} +.cm-editor .cm-cursor { + border-left-color: var(--fg); +} +.cm-editor .cm-selectionBackground, +.cm-editor.cm-focused .cm-selectionBackground { + background: var(--sel-bg) !important; +} +.cm-search-hit { + background: var(--mark-bg); + color: var(--mark-fg); + border-radius: 2px; +} + +/* markdown token styling: bold headings, dim syntax marks */ +.tok-heading { + font-weight: 700; +} +.tok-meta, +.tok-processingInstruction { + color: var(--fg-dim); +} +.tok-emphasis { + font-style: italic; +} +.tok-strong { + font-weight: 700; +} +.tok-link, +.tok-url { + color: var(--accent); +} +.tok-strikethrough { + text-decoration: line-through; +} +.tok-monospace { + color: var(--fg-dim); +} diff --git a/frontend/src/db/idb.ts b/frontend/src/db/idb.ts new file mode 100644 index 0000000..97e4340 --- /dev/null +++ b/frontend/src/db/idb.ts @@ -0,0 +1,67 @@ +import type { Note } from '../types'; + +const DB_NAME = 'tefter'; +const DB_VERSION = 1; + +let dbp: Promise | null = null; + +function open(): Promise { + if (dbp) return dbp; + dbp = new Promise((resolve, reject) => { + const req = indexedDB.open(DB_NAME, DB_VERSION); + req.onupgradeneeded = () => { + const db = req.result; + if (!db.objectStoreNames.contains('notes')) db.createObjectStore('notes', { keyPath: 'id' }); + if (!db.objectStoreNames.contains('meta')) db.createObjectStore('meta'); + }; + req.onsuccess = () => resolve(req.result); + req.onerror = () => reject(req.error); + }); + return dbp; +} + +function tx(store: string, mode: IDBTransactionMode, fn: (s: IDBObjectStore) => IDBRequest): Promise { + return open().then( + (db) => + new Promise((resolve, reject) => { + const t = db.transaction(store, mode); + const req = fn(t.objectStore(store)); + t.oncomplete = () => resolve(req.result); + t.onerror = () => reject(t.error); + t.onabort = () => reject(t.error); + }) + ); +} + +export function idbGetAllNotes(): Promise { + return tx('notes', 'readonly', (s) => s.getAll()); +} + +export function idbPutNote(n: Note): Promise { + return tx('notes', 'readwrite', (s) => s.put(n)); +} + +export async function idbPutNotes(notes: Note[]): Promise { + if (!notes.length) return; + const db = await open(); + await new Promise((resolve, reject) => { + const t = db.transaction('notes', 'readwrite'); + const s = t.objectStore('notes'); + for (const n of notes) s.put(n); + t.oncomplete = () => resolve(); + t.onerror = () => reject(t.error); + t.onabort = () => reject(t.error); + }); +} + +export function idbDeleteNote(id: string): Promise { + return tx('notes', 'readwrite', (s) => s.delete(id)); +} + +export function idbGetMeta(key: string): Promise { + return tx('meta', 'readonly', (s) => s.get(key)) as Promise; +} + +export function idbSetMeta(key: string, value: unknown): Promise { + return tx('meta', 'readwrite', (s) => s.put(value, key)); +} diff --git a/frontend/src/db/store.ts b/frontend/src/db/store.ts new file mode 100644 index 0000000..53d7f93 --- /dev/null +++ b/frontend/src/db/store.ts @@ -0,0 +1,137 @@ +import { writable, derived, get } from 'svelte/store'; +import type { Note, ServerNote, Settings } from '../types'; +import { idbGetAllNotes, idbPutNote, idbPutNotes, idbGetMeta, idbSetMeta } from './idb'; + +// All note metadata + content lives in RAM (SPEC §5: filter in memory, <10ms @ 10k notes). +export const notes = writable>(new Map()); +export const settings = writable({ serverUrl: '', token: '', vertical: false }); +export const cursor = writable(0); + +export const pendingCount = derived(notes, ($n) => { + let c = 0; + for (const note of $n.values()) if (note.dirty) c++; + return c; +}); + +export function uuidv4(): string { + if (crypto.randomUUID) return crypto.randomUUID(); + const b = crypto.getRandomValues(new Uint8Array(16)); + b[6] = (b[6] & 0x0f) | 0x40; + b[8] = (b[8] & 0x3f) | 0x80; + const h = [...b].map((x) => x.toString(16).padStart(2, '0')).join(''); + return `${h.slice(0, 8)}-${h.slice(8, 12)}-${h.slice(12, 16)}-${h.slice(16, 20)}-${h.slice(20)}`; +} + +export async function loadAll(): Promise { + const all = await idbGetAllNotes(); + const map = new Map(); + for (const n of all) map.set(n.id, n); + notes.set(map); + cursor.set((await idbGetMeta('cursor')) ?? 0); + const s = await idbGetMeta>('settings'); + if (s) settings.set({ serverUrl: s.serverUrl ?? '', token: s.token ?? '', vertical: s.vertical ?? false }); +} + +export async function saveSettings(patch: Partial): Promise { + settings.update((s) => ({ ...s, ...patch })); + await idbSetMeta('settings', get(settings)); +} + +export async function setCursor(v: number): Promise { + cursor.set(v); + await idbSetMeta('cursor', v); +} + +function mutate(n: Note): void { + notes.update((m) => { + m.set(n.id, n); + return m; + }); +} + +/** Persist an edit: IndexedDB write happens before any network activity (SPEC §4.4). */ +export async function updateContent(id: string, content: string): Promise { + const n = get(notes).get(id); + if (!n || n.content === content) return; // no-op saves must not dirty the note + const upd: Note = { ...n, content, modified_at: Date.now(), dirty: true }; + mutate(upd); + await idbPutNote(upd); +} + +export async function createNote(firstLine: string): Promise { + const now = Date.now(); + const n: Note = { + id: uuidv4(), + content: firstLine ? firstLine + '\n' : '', + tags: [], + created_at: now, + modified_at: now, + deleted: false, + version: 0, + baseVersion: 0, + dirty: true, + }; + mutate(n); + await idbPutNote(n); + return n; +} + +export async function deleteNote(id: string): Promise { + const n = get(notes).get(id); + if (!n) return null; + const upd: Note = { ...n, deleted: true, modified_at: Date.now(), dirty: true }; + mutate(upd); + await idbPutNote(upd); + return n; // pre-delete copy, for undo +} + +export async function restoreNote(prev: Note): Promise { + const cur = get(notes).get(prev.id); + // Undo the tombstone but keep it dirty so the restore syncs too. + const upd: Note = { ...(cur ?? prev), content: prev.content, deleted: false, modified_at: Date.now(), dirty: true }; + mutate(upd); + await idbPutNote(upd); +} + +/** Apply a pulled server note. Local dirty copies win until pushed (SPEC §4 client loop). */ +export async function applyServerNote(sn: ServerNote): Promise { + await applyServerNotes([sn]); +} + +/** Batch variant: one store update + one IndexedDB transaction per pulled page. */ +export async function applyServerNotes(sns: ServerNote[]): Promise { + const m = get(notes); + const updates: Note[] = []; + for (const sn of sns) { + const local = m.get(sn.id); + if (local && local.dirty) continue; // push will resolve + updates.push({ ...sn, baseVersion: sn.version, dirty: false }); + } + if (!updates.length) return; + notes.update((map) => { + for (const u of updates) map.set(u.id, u); + return map; + }); + await idbPutNotes(updates); +} + +/** After an accepted push: clear dirty unless the note changed again mid-flight. */ +export async function markPushed(id: string, sentModifiedAt: number, version: number): Promise { + const n = get(notes).get(id); + if (!n) return; + const stillSame = n.modified_at === sentModifiedAt; + const upd: Note = { ...n, version, baseVersion: version, dirty: stillSame ? false : n.dirty }; + mutate(upd); + await idbPutNote(upd); +} + +/** After a conflict: server truth replaces local; conflict copy arrives via next pull. */ +export async function replaceWithServer(sn: ServerNote): Promise { + const upd: Note = { ...sn, baseVersion: sn.version, dirty: false }; + mutate(upd); + await idbPutNote(upd); +} + +export async function persistNotes(list: Note[]): Promise { + await idbPutNotes(list); +} diff --git a/frontend/src/globals.d.ts b/frontend/src/globals.d.ts new file mode 100644 index 0000000..87a77ee --- /dev/null +++ b/frontend/src/globals.d.ts @@ -0,0 +1,2 @@ +/** Injected by Vite `define` (see vite.config.ts). */ +declare const __TEFTER_VERSION__: string; diff --git a/frontend/src/main.ts b/frontend/src/main.ts new file mode 100644 index 0000000..0f55d7a --- /dev/null +++ b/frontend/src/main.ts @@ -0,0 +1,37 @@ +import './app.css'; +import App from './ui/App.svelte'; +import { loadAll } from './db/store'; +import { startSync } from './sync/engine'; + +async function boot() { + await loadAll(); + + const app = new App({ target: document.getElementById('app')! }); + + startSync(); + + // Desktop shell (Wails) integration: tray "Sync now" and the global shortcut. + const rt = (window as unknown as { + runtime?: { EventsOn(name: string, cb: () => void): void }; + }).runtime; + if (rt?.EventsOn) { + rt.EventsOn('tefter:focus-omnibar', () => app.externalFocusOmnibar()); + rt.EventsOn('tefter:sync', () => { + void import('./sync/engine').then((m) => m.syncNow()); + }); + } + + // IndexedDB persistence (SPEC §8) + if (navigator.storage?.persist) { + navigator.storage.persist().catch(() => {}); + } + + // Service worker only where it exists and makes sense (not in the Wails webview) + if (import.meta.env.PROD && 'serviceWorker' in navigator && location.protocol.startsWith('http')) { + navigator.serviceWorker.register('/sw.js').catch(() => {}); + } + + return app; +} + +void boot(); diff --git a/frontend/src/search.ts b/frontend/src/search.ts new file mode 100644 index 0000000..d47ddb8 --- /dev/null +++ b/frontend/src/search.ts @@ -0,0 +1,119 @@ +import type { Note } from './types'; + +// Diacritic folding: NFD decomposition strips combining marks (č→c, š→s, ž→z, ć→c…); +// đ/Đ don't decompose so they are mapped explicitly. +const EXTRA: Record = { đ: 'd', Đ: 'd', ø: 'o', Ø: 'o', ł: 'l', Ł: 'l', ß: 'ss' }; + +export function fold(s: string): string { + return s + .toLowerCase() + .replace(/[đĐøØłŁß]/g, (c) => EXTRA[c]) + .normalize('NFD') + .replace(/[\u0300-\u036f]/g, ''); +} + +/** First non-empty line, markdown heading markers stripped. */ +export function noteTitle(content: string): string { + for (const raw of content.split('\n')) { + const line = raw.replace(/^#{1,6}\s+/, '').trim(); + if (line) return line; + } + return 'Untitled'; +} + +export function noteBody(content: string): string { + const lines = content.split('\n'); + let i = 0; + while (i < lines.length && !lines[i].trim()) i++; + i++; // skip the title line + return lines + .slice(i) + .join(' ') + .replace(/\s+/g, ' ') + .trim(); +} + +// Folded title/content are cached per note object; notes are replaced (not +// mutated) on change, so WeakMap entries invalidate themselves. +const foldCache = new WeakMap(); + +function foldedOf(n: Note): { title: string; body: string } { + let c = foldCache.get(n); + if (!c) { + c = { title: fold(noteTitle(n.content)), body: fold(n.content) }; + foldCache.set(n, c); + } + return c; +} + +/** + * Rank categories: 0 exact title match, 1 title prefix, 2 all terms in title, + * 3 terms matched across title+body. Terms are AND-ed, case- and diacritic-insensitive. + * Returns matching notes sorted by (rank asc, modified_at desc). + */ +export function filterNotes(notes: Note[], query: string, tagFilter: string | null): Note[] { + let pool = notes.filter((n) => !n.deleted); + if (tagFilter) pool = pool.filter((n) => n.tags.includes(tagFilter)); + + const q = fold(query.trim()); + if (!q) return pool.sort((a, b) => b.modified_at - a.modified_at); + + const terms = q.split(/\s+/).filter(Boolean); + const ranked: { n: Note; rank: number }[] = []; + + for (const n of pool) { + const { title, body } = foldedOf(n); + let ok = true; + let allInTitle = true; + for (const t of terms) { + if (title.includes(t)) continue; + allInTitle = false; + if (!body.includes(t)) { + ok = false; + break; + } + } + if (!ok) continue; + let rank = 3; + if (title === q) rank = 0; + else if (title.startsWith(q)) rank = 1; + else if (allInTitle) rank = 2; + ranked.push({ n, rank }); + } + + ranked.sort((a, b) => a.rank - b.rank || b.n.modified_at - a.n.modified_at); + return ranked.map((r) => r.n); +} + +/** + * Build a case/diacritic-insensitive RegExp matching any of the query terms, + * for highlighting in the original (unfolded) text. + */ +export function highlightRegex(query: string): RegExp | null { + const terms = query.trim().split(/\s+/).filter(Boolean); + if (!terms.length) return null; + const classes: Record = { + a: '[aàáâãäåā]', c: '[cçćč]', d: '[dđď]', e: '[eèéêëē]', g: '[gğ]', + i: '[iìíîïī]', l: '[lł]', n: '[nñń]', o: '[oòóôõöøō]', s: '[sšś]', + u: '[uùúûüū]', y: '[yýÿ]', z: '[zžźż]', + }; + const pat = terms + .map((t) => + fold(t) + .split('') + .map((ch) => classes[ch] || ch.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')) + .join('') + ) + .join('|'); + try { + return new RegExp(pat, 'giu'); + } catch { + return null; + } +} + +export function allTags(notes: Note[]): string[] { + const set = new Set(); + for (const n of notes) if (!n.deleted) for (const t of n.tags) set.add(t); + return [...set].sort(); +} diff --git a/frontend/src/sync/engine.ts b/frontend/src/sync/engine.ts new file mode 100644 index 0000000..2b49f82 --- /dev/null +++ b/frontend/src/sync/engine.ts @@ -0,0 +1,113 @@ +import { writable, get } from 'svelte/store'; +import type { PushResult, ServerNote, SyncState } from '../types'; +import { notes, settings, cursor, setCursor, applyServerNotes, markPushed, replaceWithServer } from '../db/store'; + +export const syncState = writable('local'); +export const lastSyncAt = writable(null); +export const syncError = writable(''); + +let editTimer: ReturnType | null = null; +let interval: ReturnType | null = null; +let running = false; +let queued = false; + +function configured(): boolean { + const s = get(settings); + return !!s.serverUrl; +} + +function api(path: string, init: RequestInit = {}): Promise { + const s = get(settings); + const base = s.serverUrl.replace(/\/+$/, ''); + return fetch(base + path, { + ...init, + headers: { + ...(init.headers || {}), + Authorization: 'Bearer ' + s.token, + 'Content-Type': 'application/json', + }, + }); +} + +async function pull(): Promise { + for (;;) { + const since = get(cursor); + const res = await api(`/api/v1/changes?since=${since}&limit=500`); + if (!res.ok) throw new Error(`pull: HTTP ${res.status}`); + const body: { notes: ServerNote[]; cursor: number; more: boolean } = await res.json(); + await applyServerNotes(body.notes); + if (body.cursor > since) await setCursor(body.cursor); + if (!body.more) break; + } +} + +async function push(): Promise { + const dirty = [...get(notes).values()].filter((n) => n.dirty); + if (!dirty.length) return; + const payload = dirty.map((n) => ({ + id: n.id, + content: n.content, + tags: n.tags, + created_at: n.created_at, + modified_at: n.modified_at, + deleted: n.deleted, + baseVersion: n.baseVersion, + })); + const res = await api('/api/v1/notes/batch', { method: 'POST', body: JSON.stringify({ notes: payload }) }); + if (!res.ok) throw new Error(`push: HTTP ${res.status}`); + const body: { results: PushResult[] } = await res.json(); + const sentAt = new Map(dirty.map((n) => [n.id, n.modified_at])); + for (const r of body.results) { + if (r.status === 'accepted' && r.version) { + await markPushed(r.id, sentAt.get(r.id) ?? 0, r.version); + } else if (r.status === 'conflict' && r.serverNote) { + await replaceWithServer(r.serverNote); // conflict copy arrives on next pull + } + } +} + +/** Pull-then-push; safe to interrupt at any point (SPEC §4). */ +export async function syncNow(): Promise { + if (!configured()) { + syncState.set('local'); + return; + } + if (running) { + queued = true; + return; + } + running = true; + syncState.set('syncing'); + try { + await pull(); + await push(); + await pull(); // pick up conflict copies + versions created by our own push + syncState.set(navigator.onLine ? 'synced' : 'offline'); + lastSyncAt.set(Date.now()); + syncError.set(''); + } catch (e) { + syncState.set(navigator.onLine ? 'error' : 'offline'); + syncError.set(e instanceof Error ? e.message : String(e)); + } finally { + running = false; + if (queued) { + queued = false; + void syncNow(); + } + } +} + +/** Debounced trigger: sync 3 s after the last edit (SPEC §4 client loop). */ +export function notifyEdit(): void { + if (!configured()) return; + if (editTimer) clearTimeout(editTimer); + editTimer = setTimeout(() => void syncNow(), 3000); +} + +export function startSync(): void { + window.addEventListener('online', () => void syncNow()); + window.addEventListener('offline', () => syncState.set('offline')); + if (interval) clearInterval(interval); + interval = setInterval(() => void syncNow(), 60_000); + void syncNow(); +} diff --git a/frontend/src/types.ts b/frontend/src/types.ts new file mode 100644 index 0000000..7d4e6d6 --- /dev/null +++ b/frontend/src/types.ts @@ -0,0 +1,37 @@ +export interface Note { + id: string; + content: string; + tags: string[]; + created_at: number; // unix ms + modified_at: number; // unix ms + deleted: boolean; + version: number; // server-assigned version of the copy we derived from (0 = never synced) + baseVersion: number; // server version this local copy was derived from + dirty: boolean; // locally modified, not yet pushed +} + +export interface ServerNote { + id: string; + content: string; + tags: string[]; + created_at: number; + modified_at: number; + deleted: boolean; + version: number; +} + +export interface PushResult { + id: string; + status: 'accepted' | 'conflict'; + version?: number; + conflictCopyId?: string; + serverNote?: ServerNote; +} + +export type SyncState = 'synced' | 'syncing' | 'offline' | 'error' | 'local'; + +export interface Settings { + serverUrl: string; + token: string; + vertical: boolean; // vertical split (list left, editor right) +} diff --git a/frontend/src/ui/App.svelte b/frontend/src/ui/App.svelte new file mode 100644 index 0000000..f073aa1 --- /dev/null +++ b/frontend/src/ui/App.svelte @@ -0,0 +1,338 @@ + + + + +
+ {#if !narrow || mobileView === 'list'} +
+ moveSelection(-1)} + on:down={() => moveSelection(1)} + on:clear={clearOmnibar} + /> + {#if tagFilter} + + {/if} + + +
+ {/if} + +
+ {#if !narrow || mobileView === 'list'} +
+ openNote(e.detail)} /> +
+ {/if} + + {#if !narrow || mobileView === 'editor'} +
+ {#if narrow && mobileView === 'editor'} +
+ + {selected ? noteTitle(selected.content) : ''} + +
+ {/if} + {#if selected && preview} + void openWikiLink(e.detail)} /> + {:else if selected} + + {:else} +
+

Type to search — Enter creates a note when nothing matches.

+
+ {/if} +
+ {/if} +
+ + {#if menuOpen} + (menuOpen = false)} /> + {/if} + + {#if toast} + (toast = null)} /> + {/if} +
+ + diff --git a/frontend/src/ui/Editor.svelte b/frontend/src/ui/Editor.svelte new file mode 100644 index 0000000..11a4218 --- /dev/null +++ b/frontend/src/ui/Editor.svelte @@ -0,0 +1,170 @@ + + +
+ + diff --git a/frontend/src/ui/NoteList.svelte b/frontend/src/ui/NoteList.svelte new file mode 100644 index 0000000..07badcd --- /dev/null +++ b/frontend/src/ui/NoteList.svelte @@ -0,0 +1,138 @@ + + +
+ {#each visible as n (n.id)} +
dispatch('open', n.id)} + on:keydown={(e) => e.key === 'Enter' && dispatch('open', n.id)} + > + {@html hl(noteTitle(n.content))} + {@html hl(noteBody(n.content).slice(0, 120))} + + {#each n.tags as t}{t}{/each} + {when(n.modified_at)} + +
+ {:else} +
{query.trim() ? 'No matches — Enter creates this note' : 'No notes yet'}
+ {/each} + {#if filtered.length > MAX_ROWS} +
…and {filtered.length - MAX_ROWS} more — keep typing to narrow down
+ {/if} +
+ + diff --git a/frontend/src/ui/Omnibar.svelte b/frontend/src/ui/Omnibar.svelte new file mode 100644 index 0000000..cf9c867 --- /dev/null +++ b/frontend/src/ui/Omnibar.svelte @@ -0,0 +1,59 @@ + + + + + diff --git a/frontend/src/ui/Preview.svelte b/frontend/src/ui/Preview.svelte new file mode 100644 index 0000000..31fa0f2 --- /dev/null +++ b/frontend/src/ui/Preview.svelte @@ -0,0 +1,131 @@ + + + +
+ {@html html} +
+ + diff --git a/frontend/src/ui/SettingsMenu.svelte b/frontend/src/ui/SettingsMenu.svelte new file mode 100644 index 0000000..f182e9d --- /dev/null +++ b/frontend/src/ui/SettingsMenu.svelte @@ -0,0 +1,182 @@ + + + +
dispatch('close')}> +
+

Settings

+ +
+

Sync

+ + +
Status: {$syncState}{$lastSyncAt ? ` · last sync ${new Date($lastSyncAt).toLocaleTimeString()}` : ''}
+
+ +
+

Layout

+ +
+ +
+

Import

+ + + {#if importResult}
{importResult}
{/if} +
+ +
Tefter {version}
+ +
+ + +
+
+
+ + diff --git a/frontend/src/ui/StatusDot.svelte b/frontend/src/ui/StatusDot.svelte new file mode 100644 index 0000000..43aa2cc --- /dev/null +++ b/frontend/src/ui/StatusDot.svelte @@ -0,0 +1,78 @@ + + +
+ + {#if open} +
+
{labels[$syncState]}
+
Last sync: {fmt($lastSyncAt)}
+
Pending changes: {$pendingCount}
+ {#if $syncError}
{$syncError}
{/if} + +
+ {/if} +
+ + diff --git a/frontend/src/ui/Toast.svelte b/frontend/src/ui/Toast.svelte new file mode 100644 index 0000000..122257b --- /dev/null +++ b/frontend/src/ui/Toast.svelte @@ -0,0 +1,55 @@ + + +
+ {message} + {#if actionLabel} + + {/if} +
+ + diff --git a/frontend/svelte.config.js b/frontend/svelte.config.js new file mode 100644 index 0000000..4c6b24b --- /dev/null +++ b/frontend/svelte.config.js @@ -0,0 +1,5 @@ +import { vitePreprocess } from '@sveltejs/vite-plugin-svelte'; + +export default { + preprocess: vitePreprocess(), +}; diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json new file mode 100644 index 0000000..ea58e18 --- /dev/null +++ b/frontend/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "target": "ES2020", + "useDefineForClassFields": true, + "module": "ESNext", + "moduleResolution": "bundler", + "resolveJsonModule": true, + "allowJs": true, + "checkJs": false, + "isolatedModules": true, + "strict": true, + "noUnusedLocals": false, + "skipLibCheck": true, + "types": ["svelte", "vite/client", "node"] + }, + "include": ["src/**/*.ts", "src/**/*.svelte", "vite.config.ts"] +} diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts new file mode 100644 index 0000000..e2b25fe --- /dev/null +++ b/frontend/vite.config.ts @@ -0,0 +1,67 @@ +import { defineConfig, type Plugin } from 'vite'; +import { svelte } from '@sveltejs/vite-plugin-svelte'; +import { createHash } from 'node:crypto'; + +const version = process.env.TEFTER_VERSION || 'dev'; + +// Emits sw.js at build time with the precache list of all emitted assets, +// versioned by a hash of the file names (which themselves carry content hashes). +function serviceWorker(): Plugin { + return { + name: 'tefter-sw', + apply: 'build', + generateBundle(_opts, bundle) { + const assets = Object.keys(bundle) + .filter((f) => !f.endsWith('.map')) + .map((f) => '/' + f); + assets.push('/', '/manifest.webmanifest', '/icon-192.png', '/icon-512.png'); + const hash = createHash('sha256').update(assets.join(',') + version).digest('hex').slice(0, 12); + const sw = `// generated by vite.config.ts — do not edit +const CACHE = 'tefter-${'${'}HASH}'; +const ASSETS = ${'${'}ASSETS}; + +self.addEventListener('install', (e) => { + e.waitUntil(caches.open(CACHE).then((c) => c.addAll(ASSETS)).then(() => self.skipWaiting())); +}); + +self.addEventListener('activate', (e) => { + e.waitUntil( + caches.keys() + .then((keys) => Promise.all(keys.filter((k) => k !== CACHE).map((k) => caches.delete(k)))) + .then(() => self.clients.claim()) + ); +}); + +self.addEventListener('fetch', (e) => { + const url = new URL(e.request.url); + if (e.request.method !== 'GET' || url.origin !== location.origin) return; + if (url.pathname.startsWith('/api/')) return; // network-only; sync engine handles offline + e.respondWith( + caches.match(url.pathname === '/' || url.pathname === '/index.html' ? '/' : e.request).then( + (hit) => hit || fetch(e.request) + ) + ); +}); +` + .replace('${HASH}', hash) + .replace('${ASSETS}', JSON.stringify(assets)); + this.emitFile({ type: 'asset', fileName: 'sw.js', source: sw }); + }, + }; +} + +export default defineConfig({ + plugins: [svelte(), serviceWorker()], + define: { + __TEFTER_VERSION__: JSON.stringify(version), + }, + build: { + target: 'es2020', + sourcemap: false, + }, + server: { + proxy: { + '/api': 'http://127.0.0.1:8420', + }, + }, +}); diff --git a/server/api/api.go b/server/api/api.go new file mode 100644 index 0000000..8fc8727 --- /dev/null +++ b/server/api/api.go @@ -0,0 +1,153 @@ +// Package api exposes the /api/v1 sync endpoints and serves the embedded web app. +package api + +import ( + "bytes" + "encoding/json" + "io" + "io/fs" + "log" + "net/http" + "strconv" + "strings" + "time" + + "tefter/server/importer" + "tefter/server/store" +) + +type Server struct { + Store *store.Store + Version string + WebFS fs.FS // frontend dist root, may be nil (API-only) +} + +func (s *Server) Handler() http.Handler { + mux := http.NewServeMux() + + mux.HandleFunc("GET /api/v1/health", s.handleHealth) + mux.Handle("GET /api/v1/changes", s.auth(s.handleChanges)) + mux.Handle("POST /api/v1/notes/batch", s.auth(s.handleBatch)) + mux.Handle("POST /api/v1/import/simplenote", s.auth(s.handleImport)) + mux.HandleFunc("OPTIONS /api/v1/", func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNoContent) + }) + + if s.WebFS != nil { + mux.HandleFunc("/", s.handleStatic) + } + + return withCORS(mux) +} + +// withCORS allows the desktop (wails://) and dev-server origins to call the API. +func withCORS(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if strings.HasPrefix(r.URL.Path, "/api/") { + w.Header().Set("Access-Control-Allow-Origin", "*") + w.Header().Set("Access-Control-Allow-Methods", "GET, POST, OPTIONS") + w.Header().Set("Access-Control-Allow-Headers", "Authorization, Content-Type") + w.Header().Set("Access-Control-Max-Age", "86400") + } + next.ServeHTTP(w, r) + }) +} + +func (s *Server) auth(next http.HandlerFunc) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + tok, ok := strings.CutPrefix(r.Header.Get("Authorization"), "Bearer ") + if !ok || !s.Store.CheckToken(strings.TrimSpace(tok)) { + jsonError(w, http.StatusUnauthorized, "invalid token") + return + } + next(w, r) + }) +} + +func jsonError(w http.ResponseWriter, code int, msg string) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(code) + _ = json.NewEncoder(w).Encode(map[string]string{"error": msg}) +} + +func writeJSON(w http.ResponseWriter, v any) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(v) +} + +func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) { + writeJSON(w, map[string]any{"ok": true, "version": s.Version, "schema": store.SchemaVersion}) +} + +func (s *Server) handleChanges(w http.ResponseWriter, r *http.Request) { + since, _ := strconv.ParseInt(r.URL.Query().Get("since"), 10, 64) + limit, _ := strconv.Atoi(r.URL.Query().Get("limit")) + notes, cursor, more, err := s.Store.Changes(since, limit) + if err != nil { + log.Printf("changes: %v", err) + jsonError(w, http.StatusInternalServerError, "internal error") + return + } + writeJSON(w, map[string]any{"notes": notes, "cursor": cursor, "more": more}) +} + +func (s *Server) handleBatch(w http.ResponseWriter, r *http.Request) { + var body struct { + Notes []store.IncomingNote `json:"notes"` + } + if err := json.NewDecoder(io.LimitReader(r.Body, 64<<20)).Decode(&body); err != nil { + jsonError(w, http.StatusBadRequest, "bad json: "+err.Error()) + return + } + results, err := s.Store.ApplyBatch(body.Notes, time.Now()) + if err != nil { + log.Printf("batch: %v", err) + jsonError(w, http.StatusInternalServerError, "internal error") + return + } + writeJSON(w, map[string]any{"results": results}) +} + +func (s *Server) handleImport(w http.ResponseWriter, r *http.Request) { + if err := r.ParseMultipartForm(64 << 20); err != nil { + jsonError(w, http.StatusBadRequest, "bad multipart form: "+err.Error()) + return + } + f, _, err := r.FormFile("file") + if err != nil { + jsonError(w, http.StatusBadRequest, "missing 'file' field") + return + } + defer f.Close() + data, err := io.ReadAll(f) + if err != nil { + jsonError(w, http.StatusBadRequest, "read upload: "+err.Error()) + return + } + sum, err := importer.ImportZip(s.Store, bytes.NewReader(data), int64(len(data))) + if err != nil { + jsonError(w, http.StatusBadRequest, "import: "+err.Error()) + return + } + log.Printf("import via API: %s", sum) + writeJSON(w, sum) +} + +// handleStatic serves the embedded PWA: exact file if present, index.html otherwise. +func (s *Server) handleStatic(w http.ResponseWriter, r *http.Request) { + path := strings.TrimPrefix(r.URL.Path, "/") + if path == "" { + path = "index.html" + } + if f, err := s.WebFS.Open(path); err == nil { + f.Close() + if path == "sw.js" { + w.Header().Set("Cache-Control", "no-cache") + } + http.ServeFileFS(w, r, s.WebFS, path) + return + } + // SPA fallback + w.Header().Set("Cache-Control", "no-cache") + http.ServeFileFS(w, r, s.WebFS, "index.html") +} diff --git a/server/api/api_test.go b/server/api/api_test.go new file mode 100644 index 0000000..726a3bc --- /dev/null +++ b/server/api/api_test.go @@ -0,0 +1,256 @@ +package api + +import ( + "bytes" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "path/filepath" + "strings" + "testing" + + "tefter/server/store" +) + +// client is a minimal reimplementation of the frontend sync loop (pull-then-push, +// last-write-wins with conflict copies) used to prove two devices converge. +type client struct { + t *testing.T + base string + token string + cursor int64 + notes map[string]*clientNote +} + +type clientNote struct { + store.Note + BaseVersion int64 + Dirty bool +} + +func newClient(t *testing.T, base, token string) *client { + return &client{t: t, base: base, token: token, notes: map[string]*clientNote{}} +} + +func (c *client) req(method, path string, body any, out any) int { + var rdr *bytes.Reader + if body != nil { + b, _ := json.Marshal(body) + rdr = bytes.NewReader(b) + } else { + rdr = bytes.NewReader(nil) + } + req, _ := http.NewRequest(method, c.base+path, rdr) + req.Header.Set("Authorization", "Bearer "+c.token) + req.Header.Set("Content-Type", "application/json") + res, err := http.DefaultClient.Do(req) + if err != nil { + c.t.Fatal(err) + } + defer res.Body.Close() + if out != nil { + if err := json.NewDecoder(res.Body).Decode(out); err != nil { + c.t.Fatal(err) + } + } + return res.StatusCode +} + +func (c *client) edit(id, content string) { + n, ok := c.notes[id] + if !ok { + n = &clientNote{} + n.ID = id + c.notes[id] = n + } + n.Content = content + n.Dirty = true +} + +func (c *client) pull() { + for { + var body struct { + Notes []store.Note `json:"notes"` + Cursor int64 `json:"cursor"` + More bool `json:"more"` + } + if code := c.req("GET", fmt.Sprintf("/api/v1/changes?since=%d&limit=2", c.cursor), nil, &body); code != 200 { + c.t.Fatalf("pull HTTP %d", code) + } + for _, sn := range body.Notes { + if local, ok := c.notes[sn.ID]; ok && local.Dirty { + continue // dirty local copy wins until pushed + } + c.notes[sn.ID] = &clientNote{Note: sn, BaseVersion: sn.Version} + } + c.cursor = body.Cursor + if !body.More { + return + } + } +} + +func (c *client) push() { + var dirty []map[string]any + for _, n := range c.notes { + if n.Dirty { + dirty = append(dirty, map[string]any{ + "id": n.ID, "content": n.Content, "tags": n.Tags, + "created_at": n.CreatedAt, "modified_at": n.ModifiedAt, + "deleted": n.Deleted, "baseVersion": n.BaseVersion, + }) + } + } + if dirty == nil { + return + } + var body struct { + Results []store.PushResult `json:"results"` + } + if code := c.req("POST", "/api/v1/notes/batch", map[string]any{"notes": dirty}, &body); code != 200 { + c.t.Fatalf("push HTTP %d", code) + } + for _, r := range body.Results { + n := c.notes[r.ID] + switch r.Status { + case "accepted": + n.Dirty = false + n.BaseVersion = r.Version + n.Version = r.Version + case "conflict": + if r.ServerNote != nil { + c.notes[r.ID] = &clientNote{Note: *r.ServerNote, BaseVersion: r.ServerNote.Version} + } + } + } +} + +func (c *client) sync() { c.pull(); c.push(); c.pull() } + +func (c *client) visible() map[string]string { + out := map[string]string{} + for id, n := range c.notes { + if !n.Deleted { + out[id] = n.Content + } + } + return out +} + +func newTestServer(t *testing.T) (*httptest.Server, string) { + st, err := store.Open(filepath.Join(t.TempDir(), "api.db")) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { st.Close() }) + tok, err := st.EnsureToken() + if err != nil { + t.Fatal(err) + } + srv := httptest.NewServer((&Server{Store: st, Version: "test"}).Handler()) + t.Cleanup(srv.Close) + return srv, tok +} + +func TestAuthRequired(t *testing.T) { + srv, _ := newTestServer(t) + res, err := http.Get(srv.URL + "/api/v1/changes?since=0") + if err != nil { + t.Fatal(err) + } + res.Body.Close() + if res.StatusCode != http.StatusUnauthorized { + t.Fatalf("want 401, got %d", res.StatusCode) + } + res, err = http.Get(srv.URL + "/api/v1/health") + if err != nil { + t.Fatal(err) + } + res.Body.Close() + if res.StatusCode != http.StatusOK { + t.Fatalf("health must be open, got %d", res.StatusCode) + } +} + +func TestTwoClientsConverge(t *testing.T) { + srv, tok := newTestServer(t) + a := newClient(t, srv.URL, tok) + b := newClient(t, srv.URL, tok) + + // A creates three notes, B pulls them. + a.edit("n1", "groceries\nmilk") + a.edit("n2", "ideas") + a.edit("n3", "scratch") + a.sync() + b.sync() + if len(b.visible()) != 3 { + t.Fatalf("B should see 3 notes, sees %d", len(b.visible())) + } + + // Independent edits on different notes converge cleanly. + a.edit("n1", "groceries\nmilk, bread") + b.edit("n2", "ideas\nbuild tefter") + a.sync() + b.sync() + a.sync() + if fmt.Sprint(a.visible()) != fmt.Sprint(b.visible()) { + t.Fatalf("divergence:\nA=%v\nB=%v", a.visible(), b.visible()) + } + + // Concurrent edit of the SAME note → conflict copy, never silent overwrite. + a.edit("n3", "scratch from A") + b.edit("n3", "scratch from B") + a.sync() + b.sync() // B's push conflicts; server truth restored + copy created + a.sync() + b.sync() + + av, bv := a.visible(), b.visible() + if fmt.Sprint(av) != fmt.Sprint(bv) { + t.Fatalf("divergence after conflict:\nA=%v\nB=%v", av, bv) + } + if len(av) != 4 { + t.Fatalf("want 4 notes (3 + conflict copy), got %d: %v", len(av), av) + } + foundCopy := false + for _, content := range av { + if strings.Contains(content, "conflicted copy") && strings.Contains(content, "scratch from B") { + foundCopy = true + } + } + if !foundCopy { + t.Fatalf("conflict copy missing: %v", av) + } + // Both edits survived somewhere — no data loss. + joined := fmt.Sprint(av) + if !strings.Contains(joined, "scratch from A") || !strings.Contains(joined, "scratch from B") { + t.Fatalf("an edit was silently lost: %v", av) + } +} + +func TestDeleteEditConflictAcrossClients(t *testing.T) { + srv, tok := newTestServer(t) + a := newClient(t, srv.URL, tok) + b := newClient(t, srv.URL, tok) + + a.edit("n1", "keep me") + a.sync() + b.sync() + + // A deletes while B edits concurrently; edit must win. + an := a.notes["n1"] + an.Deleted = true + an.Dirty = true + b.edit("n1", "keep me — edited") + b.sync() // B's edit lands first + a.sync() // A's stale delete is dropped, server truth restored + b.sync() + + if fmt.Sprint(a.visible()) != fmt.Sprint(b.visible()) { + t.Fatalf("divergence:\nA=%v\nB=%v", a.visible(), b.visible()) + } + if a.visible()["n1"] != "keep me — edited" { + t.Fatalf("edit did not win: %v", a.visible()) + } +} diff --git a/server/go.mod b/server/go.mod new file mode 100644 index 0000000..e0c0ccc --- /dev/null +++ b/server/go.mod @@ -0,0 +1,17 @@ +module tefter/server + +go 1.25.0 + +require modernc.org/sqlite v1.53.0 + +require ( + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/ncruces/go-strftime v1.0.0 // indirect + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect + golang.org/x/sys v0.44.0 // indirect + modernc.org/libc v1.73.4 // indirect + modernc.org/mathutil v1.7.1 // indirect + modernc.org/memory v1.11.0 // indirect +) diff --git a/server/go.sum b/server/go.sum new file mode 100644 index 0000000..b054032 --- /dev/null +++ b/server/go.sum @@ -0,0 +1,51 @@ +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs= +github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= +github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= +github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +golang.org/x/mod v0.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4= +golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ= +golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= +golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ= +golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/tools v0.45.0 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8= +golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0= +modernc.org/cc/v4 v4.28.4 h1:Hd/4Es+MBj+/7hSdZaisNyu6bv3V0Dp2MdllyfqaH+c= +modernc.org/cc/v4 v4.28.4/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI= +modernc.org/ccgo/v4 v4.34.4 h1:OVnSOWQjVKOYkFxoHYB+qQmSHK5gqMqARM+K9DpR/Ws= +modernc.org/ccgo/v4 v4.34.4/go.mod h1:qdKqE8FNIYyysougB1RX9MxCzp5oJOcQXSobANJ4TuE= +modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM= +modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU= +modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI= +modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito= +modernc.org/gc/v3 v3.1.3 h1:6QAplYyVO+KdPW3pGnqmJDUxtkec8ooEWvks/hhU3lc= +modernc.org/gc/v3 v3.1.3/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY= +modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks= +modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI= +modernc.org/libc v1.73.4 h1:+ra4Ui8ngyt8HDcO1FTDPWlkAh6yOdaO2yAoh8MddQA= +modernc.org/libc v1.73.4/go.mod h1:DXZ3eO8qMCNn2SnmTNCiC71nJ9Rcq3PsnpU6Vc4rWK8= +modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= +modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= +modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= +modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= +modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg= +modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= +modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w= +modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= +modernc.org/sqlite v1.53.0 h1:20WG8N9q4ji/dEqGk4uiI0c6OPjSeLTNYGFCc3+7c1M= +modernc.org/sqlite v1.53.0/go.mod h1:xoEpOIpGrgT48H5iiyt/YXPCZPEzlfmfFwtk8Lklw8s= +modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= +modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= +modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= +modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= diff --git a/server/importer/simplenote.go b/server/importer/simplenote.go new file mode 100644 index 0000000..fdb0798 --- /dev/null +++ b/server/importer/simplenote.go @@ -0,0 +1,126 @@ +// Package importer reads Simplenote export archives into the store. +package importer + +import ( + "archive/zip" + "crypto/sha256" + "encoding/json" + "fmt" + "io" + "strings" + "time" + + "tefter/server/store" +) + +// Verified against a real export (2026): the zip contains source/notes.json with +// {activeNotes: [...], trashedNotes: [...]}; each note has id, content, +// creationDate, lastModified (ISO 8601) and optionally tags. There is no +// `markdown` field in current exports. +type snNote struct { + ID string `json:"id"` + Content string `json:"content"` + CreationDate string `json:"creationDate"` + LastModified string `json:"lastModified"` + Tags []string `json:"tags"` +} + +type snExport struct { + ActiveNotes []snNote `json:"activeNotes"` + TrashedNotes []snNote `json:"trashedNotes"` +} + +type Summary struct { + Imported int `json:"imported"` + Skipped int `json:"skipped"` + Trashed int `json:"trashed"` +} + +func (s Summary) String() string { + return fmt.Sprintf("imported %d, skipped %d duplicates, %d trashed", s.Imported, s.Skipped, s.Trashed) +} + +func parseTime(iso string, fallback time.Time) int64 { + if t, err := time.Parse(time.RFC3339, iso); err == nil { + return t.UnixMilli() + } + return fallback.UnixMilli() +} + +// ImportZip imports a Simplenote export zip (as a reader) into st. Idempotent: +// notes are deduped by Simplenote id kept in the source_id column. +func ImportZip(st *store.Store, r io.ReaderAt, size int64) (Summary, error) { + var sum Summary + zr, err := zip.NewReader(r, size) + if err != nil { + return sum, fmt.Errorf("open zip: %w", err) + } + + var data []byte + for _, f := range zr.File { + if f.Name == "source/notes.json" || strings.HasSuffix(f.Name, "/notes.json") || f.Name == "notes.json" { + rc, err := f.Open() + if err != nil { + return sum, err + } + data, err = io.ReadAll(rc) + rc.Close() + if err != nil { + return sum, err + } + break + } + } + if data == nil { + return sum, fmt.Errorf("notes.json not found in archive (expected source/notes.json)") + } + + var exp snExport + if err := json.Unmarshal(data, &exp); err != nil { + return sum, fmt.Errorf("parse notes.json: %w", err) + } + + now := time.Now() + doImport := func(notes []snNote, deleted bool) error { + for _, sn := range notes { + if sn.ID == "" { + // No id to dedupe on — synthesize one from content+creation date. + sn.ID = fmt.Sprintf("synth-%x", contentHash(sn)) + } + n := store.Note{ + Content: strings.ReplaceAll(sn.Content, "\r\n", "\n"), + Tags: sn.Tags, + CreatedAt: parseTime(sn.CreationDate, now), + ModifiedAt: parseTime(sn.LastModified, now), + Deleted: deleted, + } + inserted, err := st.UpsertImported("simplenote:"+sn.ID, n) + if err != nil { + return err + } + if inserted { + if deleted { + sum.Trashed++ + } else { + sum.Imported++ + } + } else { + sum.Skipped++ + } + } + return nil + } + + if err := doImport(exp.ActiveNotes, false); err != nil { + return sum, err + } + if err := doImport(exp.TrashedNotes, true); err != nil { + return sum, err + } + return sum, nil +} + +func contentHash(sn snNote) []byte { + h := sha256.Sum256([]byte(sn.CreationDate + "\x00" + sn.Content)) + return h[:16] +} diff --git a/server/importer/simplenote_test.go b/server/importer/simplenote_test.go new file mode 100644 index 0000000..37abc2b --- /dev/null +++ b/server/importer/simplenote_test.go @@ -0,0 +1,124 @@ +package importer + +import ( + "archive/zip" + "bytes" + "os" + "path/filepath" + "testing" + + "tefter/server/store" +) + +func makeZip(t *testing.T, notesJSON string) *bytes.Reader { + t.Helper() + var buf bytes.Buffer + zw := zip.NewWriter(&buf) + w, err := zw.Create("source/notes.json") + if err != nil { + t.Fatal(err) + } + if _, err := w.Write([]byte(notesJSON)); err != nil { + t.Fatal(err) + } + if err := zw.Close(); err != nil { + t.Fatal(err) + } + return bytes.NewReader(buf.Bytes()) +} + +const sample = `{ + "activeNotes": [ + {"id": "n1", "content": "Title one\r\nbody", "creationDate": "2019-03-06T04:32:52.000Z", "lastModified": "2026-07-11T19:42:01.000Z", "tags": ["work"]}, + {"id": "n2", "content": "Second", "creationDate": "2020-01-01T00:00:00.000Z", "lastModified": "2020-01-02T00:00:00.000Z"} + ], + "trashedNotes": [ + {"id": "n3", "content": "", "creationDate": "2025-12-12T07:27:05.000Z", "lastModified": "2026-02-11T12:53:42.000Z"} + ] +}` + +func TestImportAndIdempotency(t *testing.T) { + st, err := store.Open(filepath.Join(t.TempDir(), "t.db")) + if err != nil { + t.Fatal(err) + } + defer st.Close() + + r := makeZip(t, sample) + sum, err := ImportZip(st, r, r.Size()) + if err != nil { + t.Fatal(err) + } + if sum.Imported != 2 || sum.Trashed != 1 || sum.Skipped != 0 { + t.Fatalf("first import: %+v", sum) + } + + // Re-import must not duplicate (SPEC §6). + r2 := makeZip(t, sample) + sum2, err := ImportZip(st, r2, r2.Size()) + if err != nil { + t.Fatal(err) + } + if sum2.Imported != 0 || sum2.Trashed != 0 || sum2.Skipped != 3 { + t.Fatalf("re-import: %+v", sum2) + } + + notes, _, _, err := st.Changes(0, 500) + if err != nil { + t.Fatal(err) + } + if len(notes) != 3 { + t.Fatalf("want 3 notes, got %d", len(notes)) + } + byContent := map[string]store.Note{} + for _, n := range notes { + byContent[n.Content] = n + } + one, ok := byContent["Title one\nbody"] // CRLF normalized + if !ok { + t.Fatalf("CRLF not normalized: %+v", notes) + } + if one.CreatedAt != 1551846772000 || one.ModifiedAt != 1783798921000 { + t.Fatalf("timestamps: %d %d", one.CreatedAt, one.ModifiedAt) + } + if len(one.Tags) != 1 || one.Tags[0] != "work" { + t.Fatalf("tags: %v", one.Tags) + } +} + +// TestImportRealExport runs against the actual Simplenote export when present. +func TestImportRealExport(t *testing.T) { + path := os.Getenv("TEFTER_REAL_EXPORT") + if path == "" { + t.Skip("set TEFTER_REAL_EXPORT=/path/to/notes.zip to run") + } + st, err := store.Open(filepath.Join(t.TempDir(), "t.db")) + if err != nil { + t.Fatal(err) + } + defer st.Close() + + f, err := os.Open(path) + if err != nil { + t.Fatal(err) + } + defer f.Close() + info, _ := f.Stat() + + sum, err := ImportZip(st, f, info.Size()) + if err != nil { + t.Fatal(err) + } + t.Logf("real export: %s", sum) + if sum.Imported == 0 { + t.Fatal("expected notes from real export") + } + + sum2, err := ImportZip(st, f, info.Size()) + if err != nil { + t.Fatal(err) + } + if sum2.Imported != 0 || sum2.Trashed != 0 { + t.Fatalf("real re-import not idempotent: %+v", sum2) + } +} diff --git a/server/main.go b/server/main.go new file mode 100644 index 0000000..0c8a73a --- /dev/null +++ b/server/main.go @@ -0,0 +1,211 @@ +// tefterd — single-binary server for Tefter (see SPEC.md). +package main + +import ( + "flag" + "fmt" + "log" + "net/http" + "os" + + "tefter/server/api" + "tefter/server/importer" + "tefter/server/store" + "tefter/server/webdist" +) + +// Version is stamped via -ldflags "-X main.Version=…". +var Version = "dev" + +func usage() { + fmt.Fprintf(os.Stderr, `tefterd %s — self-hosted Notational Velocity style notes server + +Usage: + tefterd [serve] [--listen :8420] [--db tefter.db] run the server (default) + tefterd init [--db tefter.db] create the database, print the auth token + tefterd token rotate [--db tefter.db] generate and print a new auth token + tefterd import simplenote [--db ...] import a Simplenote export archive + tefterd compact [--days 90] [--db ...] purge tombstones older than N days + tefterd backup [--db ...] consistent snapshot via VACUUM INTO + tefterd version print version +`, Version) +} + +func main() { + log.SetFlags(log.LstdFlags) + + args := os.Args[1:] + cmd := "serve" + if len(args) > 0 && !isFlag(args[0]) { + cmd = args[0] + args = args[1:] + } + + switch cmd { + case "serve": + cmdServe(args) + case "init": + cmdInit(args) + case "token": + if len(args) < 1 || args[0] != "rotate" { + usage() + os.Exit(2) + } + cmdTokenRotate(args[1:]) + case "import": + if len(args) < 2 || args[0] != "simplenote" { + usage() + os.Exit(2) + } + cmdImport(args[1], args[2:]) + case "compact": + cmdCompact(args) + case "backup": + if len(args) < 1 || isFlag(args[0]) { + usage() + os.Exit(2) + } + cmdBackup(args[0], args[1:]) + case "version": + fmt.Println(Version) + case "help", "-h", "--help": + usage() + default: + usage() + os.Exit(2) + } +} + +func isFlag(s string) bool { return len(s) > 0 && s[0] == '-' } + +func dbFlag(fs *flag.FlagSet) *string { + def := os.Getenv("TEFTER_DB") + if def == "" { + def = "tefter.db" + } + return fs.String("db", def, "path to SQLite database") +} + +func openStore(path string) *store.Store { + st, err := store.Open(path) + if err != nil { + log.Fatalf("open database %s: %v", path, err) + } + return st +} + +func cmdServe(args []string) { + fs := flag.NewFlagSet("serve", flag.ExitOnError) + db := dbFlag(fs) + listenDef := os.Getenv("TEFTER_LISTEN") + if listenDef == "" { + listenDef = ":8420" + } + listen := fs.String("listen", listenDef, "listen address") + _ = fs.Parse(args) + + st := openStore(*db) + defer st.Close() + + if tok, err := st.EnsureToken(); err != nil { + log.Fatalf("token: %v", err) + } else if tok != "" { + fmt.Printf("First start: generated auth token (store it safely, it is shown only once):\n\n %s\n\n", tok) + } + + web, err := webdist.FS() + if err != nil { + log.Fatalf("embedded frontend: %v", err) + } + srv := &api.Server{Store: st, Version: Version, WebFS: web} + + total, deleted, _ := st.NoteCount() + log.Printf("tefterd %s listening on %s (db %s, %d notes, %d tombstones)", Version, *listen, *db, total, deleted) + if err := http.ListenAndServe(*listen, srv.Handler()); err != nil { + log.Fatal(err) + } +} + +func cmdInit(args []string) { + fs := flag.NewFlagSet("init", flag.ExitOnError) + db := dbFlag(fs) + _ = fs.Parse(args) + + st := openStore(*db) + defer st.Close() + tok, err := st.EnsureToken() + if err != nil { + log.Fatalf("token: %v", err) + } + if tok == "" { + fmt.Println("Database already initialized. Use `tefterd token rotate` for a new token.") + return + } + fmt.Printf("Initialized %s\nAuth token (store it safely, it is shown only once):\n\n %s\n\n", *db, tok) +} + +func cmdTokenRotate(args []string) { + fs := flag.NewFlagSet("token rotate", flag.ExitOnError) + db := dbFlag(fs) + _ = fs.Parse(args) + + st := openStore(*db) + defer st.Close() + tok, err := st.RotateToken() + if err != nil { + log.Fatalf("rotate: %v", err) + } + fmt.Printf("New auth token (old token is now invalid):\n\n %s\n\n", tok) +} + +func cmdImport(zipPath string, args []string) { + fs := flag.NewFlagSet("import", flag.ExitOnError) + db := dbFlag(fs) + _ = fs.Parse(args) + + st := openStore(*db) + defer st.Close() + + f, err := os.Open(zipPath) + if err != nil { + log.Fatalf("open %s: %v", zipPath, err) + } + defer f.Close() + info, err := f.Stat() + if err != nil { + log.Fatal(err) + } + sum, err := importer.ImportZip(st, f, info.Size()) + if err != nil { + log.Fatalf("import: %v", err) + } + fmt.Println(sum) +} + +func cmdCompact(args []string) { + fs := flag.NewFlagSet("compact", flag.ExitOnError) + db := dbFlag(fs) + days := fs.Int("days", 90, "purge tombstones older than this many days") + _ = fs.Parse(args) + + st := openStore(*db) + defer st.Close() + n, err := st.Compact(*days) + if err != nil { + log.Fatalf("compact: %v", err) + } + fmt.Printf("purged %d tombstones older than %d days\n", n, *days) +} + +func cmdBackup(dest string, args []string) { + fs := flag.NewFlagSet("backup", flag.ExitOnError) + db := dbFlag(fs) + _ = fs.Parse(args) + + st := openStore(*db) + defer st.Close() + if err := st.Backup(dest); err != nil { + log.Fatalf("backup: %v", err) + } + fmt.Printf("backup written to %s\n", dest) +} diff --git a/server/store/store.go b/server/store/store.go new file mode 100644 index 0000000..bd789b4 --- /dev/null +++ b/server/store/store.go @@ -0,0 +1,408 @@ +// Package store is the SQLite access layer for tefterd. +package store + +import ( + "crypto/rand" + "crypto/sha256" + "crypto/subtle" + "database/sql" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "strconv" + "strings" + "time" + + _ "modernc.org/sqlite" +) + +const SchemaVersion = 1 + +type Note struct { + ID string `json:"id"` + Content string `json:"content"` + Tags []string `json:"tags"` + CreatedAt int64 `json:"created_at"` + ModifiedAt int64 `json:"modified_at"` + Deleted bool `json:"deleted"` + Version int64 `json:"version"` +} + +type IncomingNote struct { + ID string `json:"id"` + Content string `json:"content"` + Tags []string `json:"tags"` + CreatedAt int64 `json:"created_at"` + ModifiedAt int64 `json:"modified_at"` + Deleted bool `json:"deleted"` + BaseVersion int64 `json:"baseVersion"` +} + +type PushResult struct { + ID string `json:"id"` + Status string `json:"status"` // accepted | conflict + Version int64 `json:"version,omitempty"` + ConflictCopyID string `json:"conflictCopyId,omitempty"` + ServerNote *Note `json:"serverNote,omitempty"` +} + +type Store struct { + db *sql.DB +} + +func Open(path string) (*Store, error) { + // _pragma via DSN keeps every pool connection configured identically. + dsn := fmt.Sprintf("file:%s?_pragma=journal_mode(WAL)&_pragma=busy_timeout(5000)&_pragma=synchronous(NORMAL)&_pragma=foreign_keys(ON)", path) + db, err := sql.Open("sqlite", dsn) + if err != nil { + return nil, err + } + db.SetMaxOpenConns(1) // single writer; personal scale + s := &Store{db: db} + if err := s.migrate(); err != nil { + db.Close() + return nil, err + } + return s, nil +} + +func (s *Store) Close() error { return s.db.Close() } + +func (s *Store) migrate() error { + _, err := s.db.Exec(` +CREATE TABLE IF NOT EXISTS notes ( + id TEXT PRIMARY KEY, + content TEXT NOT NULL DEFAULT '', + tags TEXT NOT NULL DEFAULT '[]', + created_at INTEGER NOT NULL, + modified_at INTEGER NOT NULL, + deleted INTEGER NOT NULL DEFAULT 0, + version INTEGER NOT NULL, + source_id TEXT +); +CREATE INDEX IF NOT EXISTS idx_notes_version ON notes(version); +CREATE UNIQUE INDEX IF NOT EXISTS idx_notes_source ON notes(source_id) WHERE source_id IS NOT NULL; +CREATE TABLE IF NOT EXISTS meta (k TEXT PRIMARY KEY, v TEXT); +INSERT OR IGNORE INTO meta (k, v) VALUES ('schema_version', '1'); +INSERT OR IGNORE INTO meta (k, v) VALUES ('next_version', '1'); +`) + return err +} + +// --- meta helpers --- + +func (s *Store) getMeta(k string) (string, error) { + var v string + err := s.db.QueryRow(`SELECT v FROM meta WHERE k = ?`, k).Scan(&v) + if errors.Is(err, sql.ErrNoRows) { + return "", nil + } + return v, err +} + +func (s *Store) setMeta(k, v string) error { + _, err := s.db.Exec(`INSERT INTO meta (k, v) VALUES (?, ?) ON CONFLICT(k) DO UPDATE SET v = excluded.v`, k, v) + return err +} + +func nextVersionTx(tx *sql.Tx) (int64, error) { + var cur string + if err := tx.QueryRow(`SELECT v FROM meta WHERE k = 'next_version'`).Scan(&cur); err != nil { + return 0, err + } + n, err := strconv.ParseInt(cur, 10, 64) + if err != nil { + return 0, err + } + if _, err := tx.Exec(`UPDATE meta SET v = ? WHERE k = 'next_version'`, strconv.FormatInt(n+1, 10)); err != nil { + return 0, err + } + return n, nil +} + +// --- token auth --- + +// EnsureToken returns a freshly generated token if none exists yet ("" otherwise). +func (s *Store) EnsureToken() (string, error) { + h, err := s.getMeta("token_hash") + if err != nil { + return "", err + } + if h != "" { + return "", nil + } + return s.RotateToken() +} + +// RotateToken generates a new bearer token, stores its SHA-256, returns the plaintext. +func (s *Store) RotateToken() (string, error) { + raw := make([]byte, 32) + if _, err := rand.Read(raw); err != nil { + return "", err + } + tok := hex.EncodeToString(raw) + sum := sha256.Sum256([]byte(tok)) + if err := s.setMeta("token_hash", hex.EncodeToString(sum[:])); err != nil { + return "", err + } + return tok, nil +} + +func (s *Store) CheckToken(tok string) bool { + h, err := s.getMeta("token_hash") + if err != nil || h == "" { + return false + } + sum := sha256.Sum256([]byte(tok)) + return subtle.ConstantTimeCompare([]byte(hex.EncodeToString(sum[:])), []byte(h)) == 1 +} + +// --- notes --- + +func tagsJSON(tags []string) string { + if tags == nil { + tags = []string{} + } + b, _ := json.Marshal(tags) + return string(b) +} + +func scanNote(scan func(dest ...any) error) (Note, error) { + var n Note + var tags string + var deleted int + if err := scan(&n.ID, &n.Content, &tags, &n.CreatedAt, &n.ModifiedAt, &deleted, &n.Version); err != nil { + return n, err + } + n.Deleted = deleted != 0 + if err := json.Unmarshal([]byte(tags), &n.Tags); err != nil || n.Tags == nil { + n.Tags = []string{} + } + return n, nil +} + +// Changes returns notes with version > since, ordered by version, up to limit. +func (s *Store) Changes(since int64, limit int) (notes []Note, cursor int64, more bool, err error) { + if limit <= 0 || limit > 500 { + limit = 500 + } + rows, err := s.db.Query( + `SELECT id, content, tags, created_at, modified_at, deleted, version + FROM notes WHERE version > ? ORDER BY version LIMIT ?`, since, limit+1) + if err != nil { + return nil, 0, false, err + } + defer rows.Close() + notes = []Note{} + for rows.Next() { + n, err := scanNote(rows.Scan) + if err != nil { + return nil, 0, false, err + } + notes = append(notes, n) + } + if err := rows.Err(); err != nil { + return nil, 0, false, err + } + if len(notes) > limit { + notes = notes[:limit] + more = true + } + cursor = since + if len(notes) > 0 { + cursor = notes[len(notes)-1].Version + } + return notes, cursor, more, nil +} + +func getNoteTx(tx *sql.Tx, id string) (*Note, error) { + row := tx.QueryRow(`SELECT id, content, tags, created_at, modified_at, deleted, version FROM notes WHERE id = ?`, id) + n, err := scanNote(row.Scan) + if errors.Is(err, sql.ErrNoRows) { + return nil, nil + } + if err != nil { + return nil, err + } + return &n, nil +} + +// conflictTitle appends the marker to the first non-empty line of content. +func conflictTitle(content string, at time.Time) string { + marker := at.Format(" (conflicted copy 2006-01-02 15:04)") + lines := strings.Split(content, "\n") + for i, l := range lines { + if strings.TrimSpace(l) != "" { + lines[i] = l + marker + return strings.Join(lines, "\n") + } + } + return strings.TrimSpace(marker) +} + +func newUUID() string { + b := make([]byte, 16) + _, _ = rand.Read(b) + b[6] = (b[6] & 0x0f) | 0x40 + b[8] = (b[8] & 0x3f) | 0x80 + h := hex.EncodeToString(b) + return h[0:8] + "-" + h[8:12] + "-" + h[12:16] + "-" + h[16:20] + "-" + h[20:] +} + +// ApplyBatch applies pushed notes under the SPEC §4 conflict rules, in one transaction. +func (s *Store) ApplyBatch(in []IncomingNote, now time.Time) ([]PushResult, error) { + tx, err := s.db.Begin() + if err != nil { + return nil, err + } + defer tx.Rollback() + + results := make([]PushResult, 0, len(in)) + for _, inc := range in { + cur, err := getNoteTx(tx, inc.ID) + if err != nil { + return nil, err + } + + switch { + case cur == nil: + // Rule 1: unknown id → insert. + v, err := nextVersionTx(tx) + if err != nil { + return nil, err + } + if _, err := tx.Exec( + `INSERT INTO notes (id, content, tags, created_at, modified_at, deleted, version) VALUES (?,?,?,?,?,?,?)`, + inc.ID, inc.Content, tagsJSON(inc.Tags), inc.CreatedAt, inc.ModifiedAt, b2i(inc.Deleted), v); err != nil { + return nil, err + } + results = append(results, PushResult{ID: inc.ID, Status: "accepted", Version: v}) + + case inc.BaseVersion == cur.Version: + // Rule 2: clean fast-forward. + v, err := nextVersionTx(tx) + if err != nil { + return nil, err + } + if _, err := tx.Exec( + `UPDATE notes SET content=?, tags=?, modified_at=?, deleted=?, version=? WHERE id=?`, + inc.Content, tagsJSON(inc.Tags), inc.ModifiedAt, b2i(inc.Deleted), v, inc.ID); err != nil { + return nil, err + } + results = append(results, PushResult{ID: inc.ID, Status: "accepted", Version: v}) + + case inc.Deleted: + // Rule 4a: stale delete vs newer server change → edit wins, delete dropped. + sn := *cur + results = append(results, PushResult{ID: inc.ID, Status: "conflict", ServerNote: &sn}) + + case cur.Deleted: + // Rule 4b: stale edit vs server tombstone → edit wins, overwrites the tombstone. + v, err := nextVersionTx(tx) + if err != nil { + return nil, err + } + if _, err := tx.Exec( + `UPDATE notes SET content=?, tags=?, modified_at=?, deleted=0, version=? WHERE id=?`, + inc.Content, tagsJSON(inc.Tags), inc.ModifiedAt, v, inc.ID); err != nil { + return nil, err + } + results = append(results, PushResult{ID: inc.ID, Status: "accepted", Version: v}) + + default: + // Rule 3: true edit/edit conflict → server content stays; incoming becomes a + // conflict copy with a new id, `conflict` tag and a marker on its first line. + copyID := newUUID() + v, err := nextVersionTx(tx) + if err != nil { + return nil, err + } + tags := inc.Tags + if !contains(tags, "conflict") { + tags = append(append([]string{}, tags...), "conflict") + } + if _, err := tx.Exec( + `INSERT INTO notes (id, content, tags, created_at, modified_at, deleted, version) VALUES (?,?,?,?,?,?,?)`, + copyID, conflictTitle(inc.Content, now), tagsJSON(tags), inc.ModifiedAt, inc.ModifiedAt, 0, v); err != nil { + return nil, err + } + sn := *cur + results = append(results, PushResult{ID: inc.ID, Status: "conflict", ConflictCopyID: copyID, ServerNote: &sn}) + } + } + + if err := tx.Commit(); err != nil { + return nil, err + } + return results, nil +} + +// UpsertImported inserts an imported note keyed by source_id; returns false if it +// already exists (dedupe — SPEC §6 idempotent import). +func (s *Store) UpsertImported(sourceID string, n Note) (bool, error) { + tx, err := s.db.Begin() + if err != nil { + return false, err + } + defer tx.Rollback() + + var existing string + err = tx.QueryRow(`SELECT id FROM notes WHERE source_id = ?`, sourceID).Scan(&existing) + if err == nil { + return false, tx.Commit() // already imported + } + if !errors.Is(err, sql.ErrNoRows) { + return false, err + } + v, err := nextVersionTx(tx) + if err != nil { + return false, err + } + if n.ID == "" { + n.ID = newUUID() + } + if _, err := tx.Exec( + `INSERT INTO notes (id, content, tags, created_at, modified_at, deleted, version, source_id) VALUES (?,?,?,?,?,?,?,?)`, + n.ID, n.Content, tagsJSON(n.Tags), n.CreatedAt, n.ModifiedAt, b2i(n.Deleted), v, sourceID); err != nil { + return false, err + } + return true, tx.Commit() +} + +// Compact purges tombstones older than the given number of days. +func (s *Store) Compact(olderThanDays int) (int64, error) { + cutoff := time.Now().AddDate(0, 0, -olderThanDays).UnixMilli() + res, err := s.db.Exec(`DELETE FROM notes WHERE deleted = 1 AND modified_at < ?`, cutoff) + if err != nil { + return 0, err + } + return res.RowsAffected() +} + +// Backup writes a consistent snapshot via VACUUM INTO. +func (s *Store) Backup(path string) error { + _, err := s.db.Exec(`VACUUM INTO ?`, path) + return err +} + +func (s *Store) NoteCount() (total, deleted int64, err error) { + err = s.db.QueryRow(`SELECT COUNT(*), COALESCE(SUM(deleted), 0) FROM notes`).Scan(&total, &deleted) + return +} + +func b2i(b bool) int { + if b { + return 1 + } + return 0 +} + +func contains(ss []string, s string) bool { + for _, x := range ss { + if x == s { + return true + } + } + return false +} diff --git a/server/store/store_test.go b/server/store/store_test.go new file mode 100644 index 0000000..a71974c --- /dev/null +++ b/server/store/store_test.go @@ -0,0 +1,213 @@ +package store + +import ( + "path/filepath" + "strings" + "testing" + "time" +) + +func newTestStore(t *testing.T) *Store { + t.Helper() + s, err := Open(filepath.Join(t.TempDir(), "test.db")) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { s.Close() }) + return s +} + +func push(t *testing.T, s *Store, n IncomingNote) PushResult { + t.Helper() + res, err := s.ApplyBatch([]IncomingNote{n}, time.Date(2026, 7, 12, 10, 30, 0, 0, time.UTC)) + if err != nil { + t.Fatal(err) + } + if len(res) != 1 { + t.Fatalf("want 1 result, got %d", len(res)) + } + return res[0] +} + +func TestInsertAndFastForward(t *testing.T) { + s := newTestStore(t) + + r := push(t, s, IncomingNote{ID: "a", Content: "hello\nworld", CreatedAt: 1, ModifiedAt: 1}) + if r.Status != "accepted" || r.Version != 1 { + t.Fatalf("insert: %+v", r) + } + + r = push(t, s, IncomingNote{ID: "a", Content: "hello v2", ModifiedAt: 2, BaseVersion: r.Version}) + if r.Status != "accepted" || r.Version != 2 { + t.Fatalf("fast-forward: %+v", r) + } + + notes, cursor, more, err := s.Changes(0, 500) + if err != nil { + t.Fatal(err) + } + if len(notes) != 1 || notes[0].Content != "hello v2" || cursor != 2 || more { + t.Fatalf("changes: %+v cursor=%d more=%v", notes, cursor, more) + } +} + +func TestEditEditConflictCreatesCopy(t *testing.T) { + s := newTestStore(t) + + r1 := push(t, s, IncomingNote{ID: "a", Content: "base note", ModifiedAt: 1}) + // device B fast-forwards + push(t, s, IncomingNote{ID: "a", Content: "B's edit", ModifiedAt: 2, BaseVersion: r1.Version}) + // device A pushes a stale edit + r := push(t, s, IncomingNote{ID: "a", Content: "A's edit", Tags: []string{"x"}, ModifiedAt: 3, BaseVersion: r1.Version}) + + if r.Status != "conflict" || r.ConflictCopyID == "" { + t.Fatalf("want conflict with copy, got %+v", r) + } + if r.ServerNote == nil || r.ServerNote.Content != "B's edit" { + t.Fatalf("server truth missing: %+v", r.ServerNote) + } + + notes, _, _, err := s.Changes(0, 500) + if err != nil { + t.Fatal(err) + } + if len(notes) != 2 { + t.Fatalf("want original + conflict copy, got %d notes", len(notes)) + } + var copyNote *Note + for i := range notes { + if notes[i].ID == r.ConflictCopyID { + copyNote = ¬es[i] + } + } + if copyNote == nil { + t.Fatal("conflict copy not in changes feed") + } + if !strings.Contains(copyNote.Content, "A's edit (conflicted copy 2026-07-12 10:30)") { + t.Fatalf("conflict marker missing: %q", copyNote.Content) + } + if !containsStr(copyNote.Tags, "conflict") || !containsStr(copyNote.Tags, "x") { + t.Fatalf("conflict tags wrong: %v", copyNote.Tags) + } +} + +func TestStaleDeleteLosesToEdit(t *testing.T) { + s := newTestStore(t) + + r1 := push(t, s, IncomingNote{ID: "a", Content: "v1", ModifiedAt: 1}) + push(t, s, IncomingNote{ID: "a", Content: "v2 edited", ModifiedAt: 2, BaseVersion: r1.Version}) + + r := push(t, s, IncomingNote{ID: "a", Deleted: true, ModifiedAt: 3, BaseVersion: r1.Version}) + if r.Status != "conflict" || r.ConflictCopyID != "" { + t.Fatalf("stale delete must be dropped without a copy: %+v", r) + } + notes, _, _, _ := s.Changes(0, 500) + if len(notes) != 1 || notes[0].Deleted || notes[0].Content != "v2 edited" { + t.Fatalf("edit must survive: %+v", notes) + } +} + +func TestStaleEditBeatsTombstone(t *testing.T) { + s := newTestStore(t) + + r1 := push(t, s, IncomingNote{ID: "a", Content: "v1", ModifiedAt: 1}) + push(t, s, IncomingNote{ID: "a", Deleted: true, ModifiedAt: 2, BaseVersion: r1.Version}) + + r := push(t, s, IncomingNote{ID: "a", Content: "resurrected", ModifiedAt: 3, BaseVersion: r1.Version}) + if r.Status != "accepted" { + t.Fatalf("edit must overwrite tombstone: %+v", r) + } + notes, _, _, _ := s.Changes(0, 500) + if len(notes) != 1 || notes[0].Deleted || notes[0].Content != "resurrected" { + t.Fatalf("tombstone must be overwritten: %+v", notes) + } +} + +func TestCleanDeleteAccepted(t *testing.T) { + s := newTestStore(t) + r1 := push(t, s, IncomingNote{ID: "a", Content: "v1", ModifiedAt: 1}) + r := push(t, s, IncomingNote{ID: "a", Content: "v1", Deleted: true, ModifiedAt: 2, BaseVersion: r1.Version}) + if r.Status != "accepted" { + t.Fatalf("clean delete: %+v", r) + } + notes, _, _, _ := s.Changes(0, 500) + if len(notes) != 1 || !notes[0].Deleted { + t.Fatalf("tombstone expected: %+v", notes) + } +} + +func TestChangesPaging(t *testing.T) { + s := newTestStore(t) + for i := 0; i < 7; i++ { + push(t, s, IncomingNote{ID: string(rune('a' + i)), Content: "n", ModifiedAt: int64(i)}) + } + var got int + var cursor int64 + for { + notes, c, more, err := s.Changes(cursor, 3) + if err != nil { + t.Fatal(err) + } + got += len(notes) + cursor = c + if !more { + break + } + } + if got != 7 { + t.Fatalf("paged total = %d, want 7", got) + } +} + +func TestTokenRoundtrip(t *testing.T) { + s := newTestStore(t) + tok, err := s.EnsureToken() + if err != nil || tok == "" { + t.Fatalf("EnsureToken: %q %v", tok, err) + } + if again, _ := s.EnsureToken(); again != "" { + t.Fatal("EnsureToken must not regenerate") + } + if !s.CheckToken(tok) { + t.Fatal("valid token rejected") + } + if s.CheckToken("nope") { + t.Fatal("invalid token accepted") + } + tok2, err := s.RotateToken() + if err != nil { + t.Fatal(err) + } + if s.CheckToken(tok) || !s.CheckToken(tok2) { + t.Fatal("rotation did not invalidate old token") + } +} + +func TestCompact(t *testing.T) { + s := newTestStore(t) + old := time.Now().AddDate(0, 0, -200).UnixMilli() + r1 := push(t, s, IncomingNote{ID: "a", Content: "x", ModifiedAt: old}) + push(t, s, IncomingNote{ID: "a", Deleted: true, ModifiedAt: old, BaseVersion: r1.Version}) + push(t, s, IncomingNote{ID: "b", Content: "keep", ModifiedAt: time.Now().UnixMilli()}) + + n, err := s.Compact(90) + if err != nil { + t.Fatal(err) + } + if n != 1 { + t.Fatalf("purged %d, want 1", n) + } + total, deleted, _ := s.NoteCount() + if total != 1 || deleted != 0 { + t.Fatalf("after compact: total=%d deleted=%d", total, deleted) + } +} + +func containsStr(ss []string, s string) bool { + for _, x := range ss { + if x == s { + return true + } + } + return false +} diff --git a/server/webdist/dist/.gitkeep b/server/webdist/dist/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/server/webdist/webdist.go b/server/webdist/webdist.go new file mode 100644 index 0000000..6902566 --- /dev/null +++ b/server/webdist/webdist.go @@ -0,0 +1,17 @@ +// Package webdist embeds the built frontend bundle. The Makefile copies +// ../frontend/dist into this directory before `go build` (go:embed cannot +// reach outside the module directory). +package webdist + +import ( + "embed" + "io/fs" +) + +//go:embed all:dist +var dist embed.FS + +// FS returns the frontend bundle rooted at its dist directory. +func FS() (fs.FS, error) { + return fs.Sub(dist, "dist") +}