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 <noreply@anthropic.com>
This commit is contained in:
113
.github/workflows/release.yml
vendored
Normal file
113
.github/workflows/release.yml
vendored
Normal file
@@ -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
|
||||
12
.gitignore
vendored
Normal file
12
.gitignore
vendored
Normal file
@@ -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
|
||||
43
Makefile
Normal file
43
Makefile
Normal file
@@ -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
|
||||
110
README.md
Normal file
110
README.md
Normal file
@@ -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).
|
||||
251
SPEC.md
Normal file
251
SPEC.md
Normal file
@@ -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 <token>`)
|
||||
|
||||
| Method | Path | Purpose |
|
||||
|---|---|---|
|
||||
| GET | `/changes?since=<cursor>&limit=500` | Pull notes with `version > cursor`, ordered by version. Returns `{notes: [...], cursor: <max version returned>, 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.
|
||||
24
desktop/frontend/wailsjs/runtime/package.json
Normal file
24
desktop/frontend/wailsjs/runtime/package.json
Normal file
@@ -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 <lea.anthony@gmail.com>",
|
||||
"license": "MIT",
|
||||
"bugs": {
|
||||
"url": "https://github.com/wailsapp/wails/issues"
|
||||
},
|
||||
"homepage": "https://github.com/wailsapp/wails#readme"
|
||||
}
|
||||
330
desktop/frontend/wailsjs/runtime/runtime.d.ts
vendored
Normal file
330
desktop/frontend/wailsjs/runtime/runtime.d.ts
vendored
Normal file
@@ -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<boolean>;
|
||||
|
||||
// [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<Size>;
|
||||
|
||||
// [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<Position>;
|
||||
|
||||
// [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<boolean>;
|
||||
|
||||
// [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<boolean>;
|
||||
|
||||
// [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<boolean>;
|
||||
|
||||
// [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<Screen[]>;
|
||||
|
||||
// [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<EnvironmentInfo>;
|
||||
|
||||
// [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<string>;
|
||||
|
||||
// [ClipboardSetText](https://wails.io/docs/reference/runtime/clipboard#clipboardsettext)
|
||||
// Sets a text on the clipboard
|
||||
export function ClipboardSetText(text: string): Promise<boolean>;
|
||||
|
||||
// [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<void>;
|
||||
|
||||
// [CleanupNotifications](https://wails.io/docs/reference/runtime/notification#cleanupnotifications)
|
||||
// Cleans up notification resources and releases any held connections.
|
||||
export function CleanupNotifications(): Promise<void>;
|
||||
|
||||
// [IsNotificationAvailable](https://wails.io/docs/reference/runtime/notification#isnotificationavailable)
|
||||
// Checks if notifications are available on the current platform.
|
||||
export function IsNotificationAvailable(): Promise<boolean>;
|
||||
|
||||
// [RequestNotificationAuthorization](https://wails.io/docs/reference/runtime/notification#requestnotificationauthorization)
|
||||
// Requests notification authorization from the user (macOS only).
|
||||
export function RequestNotificationAuthorization(): Promise<boolean>;
|
||||
|
||||
// [CheckNotificationAuthorization](https://wails.io/docs/reference/runtime/notification#checknotificationauthorization)
|
||||
// Checks the current notification authorization status (macOS only).
|
||||
export function CheckNotificationAuthorization(): Promise<boolean>;
|
||||
|
||||
// [SendNotification](https://wails.io/docs/reference/runtime/notification#sendnotification)
|
||||
// Sends a basic notification with the given options.
|
||||
export function SendNotification(options: NotificationOptions): Promise<void>;
|
||||
|
||||
// [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<void>;
|
||||
|
||||
// [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<void>;
|
||||
|
||||
// [RemoveNotificationCategory](https://wails.io/docs/reference/runtime/notification#removenotificationcategory)
|
||||
// Removes a previously registered notification category.
|
||||
export function RemoveNotificationCategory(categoryId: string): Promise<void>;
|
||||
|
||||
// [RemoveAllPendingNotifications](https://wails.io/docs/reference/runtime/notification#removeallpendingnotifications)
|
||||
// Removes all pending notifications from the notification center.
|
||||
export function RemoveAllPendingNotifications(): Promise<void>;
|
||||
|
||||
// [RemovePendingNotification](https://wails.io/docs/reference/runtime/notification#removependingnotification)
|
||||
// Removes a specific pending notification by its identifier.
|
||||
export function RemovePendingNotification(identifier: string): Promise<void>;
|
||||
|
||||
// [RemoveAllDeliveredNotifications](https://wails.io/docs/reference/runtime/notification#removealldeliverednotifications)
|
||||
// Removes all delivered notifications from the notification center.
|
||||
export function RemoveAllDeliveredNotifications(): Promise<void>;
|
||||
|
||||
// [RemoveDeliveredNotification](https://wails.io/docs/reference/runtime/notification#removedeliverednotification)
|
||||
// Removes a specific delivered notification by its identifier.
|
||||
export function RemoveDeliveredNotification(identifier: string): Promise<void>;
|
||||
|
||||
// [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<void>;
|
||||
298
desktop/frontend/wailsjs/runtime/runtime.js
Normal file
298
desktop/frontend/wailsjs/runtime/runtime.js
Normal file
@@ -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);
|
||||
}
|
||||
40
desktop/go.mod
Normal file
40
desktop/go.mod
Normal file
@@ -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
|
||||
)
|
||||
89
desktop/go.sum
Normal file
89
desktop/go.sum
Normal file
@@ -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=
|
||||
10
desktop/hotkey_darwin.go
Normal file
10
desktop/hotkey_darwin.go
Normal file
@@ -0,0 +1,10 @@
|
||||
//go:build darwin
|
||||
|
||||
package main
|
||||
|
||||
import "golang.design/x/hotkey"
|
||||
|
||||
const (
|
||||
modAlt = hotkey.ModOption
|
||||
modCmd = hotkey.ModCmd
|
||||
)
|
||||
10
desktop/hotkey_linux.go
Normal file
10
desktop/hotkey_linux.go
Normal file
@@ -0,0 +1,10 @@
|
||||
//go:build linux
|
||||
|
||||
package main
|
||||
|
||||
import "golang.design/x/hotkey"
|
||||
|
||||
const (
|
||||
modAlt = hotkey.Mod1
|
||||
modCmd = hotkey.Mod4
|
||||
)
|
||||
10
desktop/hotkey_windows.go
Normal file
10
desktop/hotkey_windows.go
Normal file
@@ -0,0 +1,10 @@
|
||||
//go:build windows
|
||||
|
||||
package main
|
||||
|
||||
import "golang.design/x/hotkey"
|
||||
|
||||
const (
|
||||
modAlt = hotkey.ModAlt
|
||||
modCmd = hotkey.ModWin
|
||||
)
|
||||
196
desktop/main.go
Normal file
196
desktop/main.go
Normal file
@@ -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,
|
||||
}
|
||||
9
desktop/tefter.desktop
Normal file
9
desktop/tefter.desktop
Normal file
@@ -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
|
||||
16
desktop/wails.json
Normal file
16
desktop/wails.json
Normal file
@@ -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"
|
||||
}
|
||||
}
|
||||
17
frontend/index.html
Normal file
17
frontend/index.html
Normal file
@@ -0,0 +1,17 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
||||
<meta name="theme-color" content="#1a1a1a" media="(prefers-color-scheme: dark)" />
|
||||
<meta name="theme-color" content="#fafafa" media="(prefers-color-scheme: light)" />
|
||||
<link rel="manifest" href="/manifest.webmanifest" />
|
||||
<link rel="icon" href="/icon-192.png" />
|
||||
<link rel="apple-touch-icon" href="/icon-192.png" />
|
||||
<title>Tefter</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
2136
frontend/package-lock.json
generated
Normal file
2136
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
30
frontend/package.json
Normal file
30
frontend/package.json
Normal file
@@ -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"
|
||||
}
|
||||
}
|
||||
BIN
frontend/public/icon-192.png
Normal file
BIN
frontend/public/icon-192.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3.1 KiB |
BIN
frontend/public/icon-512-maskable.png
Normal file
BIN
frontend/public/icon-512-maskable.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3.4 KiB |
BIN
frontend/public/icon-512.png
Normal file
BIN
frontend/public/icon-512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 9.0 KiB |
8
frontend/public/icon.svg
Normal file
8
frontend/public/icon.svg
Normal file
@@ -0,0 +1,8 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
|
||||
<rect width="512" height="512" rx="96" fill="#2f6fde"/>
|
||||
<rect x="96" y="120" width="320" height="52" rx="26" fill="#ffffff" opacity="0.95"/>
|
||||
<rect x="96" y="216" width="320" height="14" rx="7" fill="#ffffff" opacity="0.7"/>
|
||||
<rect x="96" y="258" width="250" height="14" rx="7" fill="#ffffff" opacity="0.55"/>
|
||||
<rect x="96" y="300" width="290" height="14" rx="7" fill="#ffffff" opacity="0.4"/>
|
||||
<rect x="96" y="342" width="200" height="14" rx="7" fill="#ffffff" opacity="0.3"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 556 B |
15
frontend/public/manifest.webmanifest
Normal file
15
frontend/public/manifest.webmanifest
Normal file
@@ -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" }
|
||||
]
|
||||
}
|
||||
134
frontend/src/app.css
Normal file
134
frontend/src/app.css
Normal file
@@ -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);
|
||||
}
|
||||
67
frontend/src/db/idb.ts
Normal file
67
frontend/src/db/idb.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import type { Note } from '../types';
|
||||
|
||||
const DB_NAME = 'tefter';
|
||||
const DB_VERSION = 1;
|
||||
|
||||
let dbp: Promise<IDBDatabase> | null = null;
|
||||
|
||||
function open(): Promise<IDBDatabase> {
|
||||
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<T>(store: string, mode: IDBTransactionMode, fn: (s: IDBObjectStore) => IDBRequest<T>): Promise<T> {
|
||||
return open().then(
|
||||
(db) =>
|
||||
new Promise<T>((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<Note[]> {
|
||||
return tx('notes', 'readonly', (s) => s.getAll());
|
||||
}
|
||||
|
||||
export function idbPutNote(n: Note): Promise<unknown> {
|
||||
return tx('notes', 'readwrite', (s) => s.put(n));
|
||||
}
|
||||
|
||||
export async function idbPutNotes(notes: Note[]): Promise<void> {
|
||||
if (!notes.length) return;
|
||||
const db = await open();
|
||||
await new Promise<void>((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<unknown> {
|
||||
return tx('notes', 'readwrite', (s) => s.delete(id));
|
||||
}
|
||||
|
||||
export function idbGetMeta<T>(key: string): Promise<T | undefined> {
|
||||
return tx('meta', 'readonly', (s) => s.get(key)) as Promise<T | undefined>;
|
||||
}
|
||||
|
||||
export function idbSetMeta(key: string, value: unknown): Promise<unknown> {
|
||||
return tx('meta', 'readwrite', (s) => s.put(value, key));
|
||||
}
|
||||
137
frontend/src/db/store.ts
Normal file
137
frontend/src/db/store.ts
Normal file
@@ -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<Map<string, Note>>(new Map());
|
||||
export const settings = writable<Settings>({ serverUrl: '', token: '', vertical: false });
|
||||
export const cursor = writable<number>(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<void> {
|
||||
const all = await idbGetAllNotes();
|
||||
const map = new Map<string, Note>();
|
||||
for (const n of all) map.set(n.id, n);
|
||||
notes.set(map);
|
||||
cursor.set((await idbGetMeta<number>('cursor')) ?? 0);
|
||||
const s = await idbGetMeta<Partial<Settings>>('settings');
|
||||
if (s) settings.set({ serverUrl: s.serverUrl ?? '', token: s.token ?? '', vertical: s.vertical ?? false });
|
||||
}
|
||||
|
||||
export async function saveSettings(patch: Partial<Settings>): Promise<void> {
|
||||
settings.update((s) => ({ ...s, ...patch }));
|
||||
await idbSetMeta('settings', get(settings));
|
||||
}
|
||||
|
||||
export async function setCursor(v: number): Promise<void> {
|
||||
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<void> {
|
||||
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<Note> {
|
||||
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<Note | null> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
await applyServerNotes([sn]);
|
||||
}
|
||||
|
||||
/** Batch variant: one store update + one IndexedDB transaction per pulled page. */
|
||||
export async function applyServerNotes(sns: ServerNote[]): Promise<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
const upd: Note = { ...sn, baseVersion: sn.version, dirty: false };
|
||||
mutate(upd);
|
||||
await idbPutNote(upd);
|
||||
}
|
||||
|
||||
export async function persistNotes(list: Note[]): Promise<void> {
|
||||
await idbPutNotes(list);
|
||||
}
|
||||
2
frontend/src/globals.d.ts
vendored
Normal file
2
frontend/src/globals.d.ts
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
/** Injected by Vite `define` (see vite.config.ts). */
|
||||
declare const __TEFTER_VERSION__: string;
|
||||
37
frontend/src/main.ts
Normal file
37
frontend/src/main.ts
Normal file
@@ -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();
|
||||
119
frontend/src/search.ts
Normal file
119
frontend/src/search.ts
Normal file
@@ -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<string, string> = { đ: '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<Note, { title: string; body: string }>();
|
||||
|
||||
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<string, string> = {
|
||||
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<string>();
|
||||
for (const n of notes) if (!n.deleted) for (const t of n.tags) set.add(t);
|
||||
return [...set].sort();
|
||||
}
|
||||
113
frontend/src/sync/engine.ts
Normal file
113
frontend/src/sync/engine.ts
Normal file
@@ -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<SyncState>('local');
|
||||
export const lastSyncAt = writable<number | null>(null);
|
||||
export const syncError = writable<string>('');
|
||||
|
||||
let editTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let interval: ReturnType<typeof setInterval> | 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<Response> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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();
|
||||
}
|
||||
37
frontend/src/types.ts
Normal file
37
frontend/src/types.ts
Normal file
@@ -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)
|
||||
}
|
||||
338
frontend/src/ui/App.svelte
Normal file
338
frontend/src/ui/App.svelte
Normal file
@@ -0,0 +1,338 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { notes, settings, createNote, deleteNote, restoreNote } from '../db/store';
|
||||
import { filterNotes, noteTitle, fold, allTags } from '../search';
|
||||
import { notifyEdit } from '../sync/engine';
|
||||
import type { Note } from '../types';
|
||||
import Omnibar from './Omnibar.svelte';
|
||||
import NoteList from './NoteList.svelte';
|
||||
import Editor from './Editor.svelte';
|
||||
import Preview from './Preview.svelte';
|
||||
import StatusDot from './StatusDot.svelte';
|
||||
import SettingsMenu from './SettingsMenu.svelte';
|
||||
import Toast from './Toast.svelte';
|
||||
|
||||
let query = '';
|
||||
let selectedId: string | null = null;
|
||||
let tagFilter: string | null = null;
|
||||
let preview = false;
|
||||
let menuOpen = false;
|
||||
let narrow = false;
|
||||
let mobileView: 'list' | 'editor' = 'list';
|
||||
let toast: { message: string; onAction: () => void } | null = null;
|
||||
let toastTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
let omnibar: Omnibar;
|
||||
let editor: Editor;
|
||||
|
||||
$: notesArr = [...$notes.values()];
|
||||
$: filtered = filterNotes(notesArr, query, tagFilter);
|
||||
$: tags = allTags(notesArr);
|
||||
$: selected = selectedId ? $notes.get(selectedId) ?? null : null;
|
||||
$: vertical = $settings.vertical;
|
||||
|
||||
// Auto-select the best match while typing (NV behavior); empty query deselects.
|
||||
let lastQuery = '';
|
||||
$: if (query !== lastQuery) {
|
||||
lastQuery = query;
|
||||
selectedId = query.trim() ? (filtered[0]?.id ?? null) : null;
|
||||
}
|
||||
|
||||
// Drop selection if the note vanished (deleted / filtered out by sync).
|
||||
$: if (selectedId) {
|
||||
const cur = $notes.get(selectedId);
|
||||
if (!cur || cur.deleted) selectedId = null;
|
||||
}
|
||||
|
||||
function openNote(id: string, focusEditor = true) {
|
||||
selectedId = id;
|
||||
preview = false;
|
||||
if (narrow) mobileView = 'editor';
|
||||
if (focusEditor) queueMicrotask(() => editor?.focusEnd());
|
||||
}
|
||||
|
||||
async function onEnter() {
|
||||
if (selectedId && filtered.some((n) => n.id === selectedId)) {
|
||||
openNote(selectedId);
|
||||
} else if (query.trim()) {
|
||||
const n = await createNote(query.trim());
|
||||
notifyEdit();
|
||||
openNote(n.id);
|
||||
}
|
||||
}
|
||||
|
||||
function moveSelection(delta: number) {
|
||||
if (!filtered.length) return;
|
||||
const idx = filtered.findIndex((n) => n.id === selectedId);
|
||||
const next = idx < 0 ? (delta > 0 ? 0 : filtered.length - 1) : Math.min(filtered.length - 1, Math.max(0, idx + delta));
|
||||
selectedId = filtered[next].id;
|
||||
}
|
||||
|
||||
function clearOmnibar() {
|
||||
query = '';
|
||||
selectedId = null;
|
||||
omnibar?.focus();
|
||||
}
|
||||
|
||||
function backToOmnibar() {
|
||||
if (narrow) mobileView = 'list';
|
||||
omnibar?.focus();
|
||||
}
|
||||
|
||||
async function doDelete() {
|
||||
if (!selectedId) return;
|
||||
const idx = filtered.findIndex((n) => n.id === selectedId);
|
||||
const prev = await deleteNote(selectedId);
|
||||
if (!prev) return;
|
||||
notifyEdit();
|
||||
const rest = filtered.filter((n) => n.id !== prev.id);
|
||||
selectedId = rest[Math.min(idx, rest.length - 1)]?.id ?? null;
|
||||
showToast(`Deleted “${noteTitle(prev.content)}”`, () => {
|
||||
void restoreNote(prev).then(() => notifyEdit());
|
||||
});
|
||||
}
|
||||
|
||||
function showToast(message: string, onAction: () => void) {
|
||||
if (toastTimer) clearTimeout(toastTimer);
|
||||
toast = { message, onAction };
|
||||
toastTimer = setTimeout(() => (toast = null), 5000);
|
||||
}
|
||||
|
||||
function cycleTag() {
|
||||
if (!tags.length) return;
|
||||
if (tagFilter === null) tagFilter = tags[0];
|
||||
else {
|
||||
const i = tags.indexOf(tagFilter);
|
||||
tagFilter = i < 0 || i === tags.length - 1 ? null : tags[i + 1];
|
||||
}
|
||||
}
|
||||
|
||||
/** Open a [[wiki-link]] target: existing note by title, or create it. */
|
||||
async function openWikiLink(title: string) {
|
||||
const want = fold(title.trim());
|
||||
const hit = notesArr.find((n) => !n.deleted && fold(noteTitle(n.content)) === want);
|
||||
if (hit) {
|
||||
openNote(hit.id);
|
||||
} else {
|
||||
const n = await createNote(title.trim());
|
||||
notifyEdit();
|
||||
openNote(n.id);
|
||||
}
|
||||
}
|
||||
|
||||
/** Called from the desktop shell (tray / global shortcut) via Wails events. */
|
||||
export function externalFocusOmnibar() {
|
||||
if (narrow) mobileView = 'list';
|
||||
omnibar?.focusSelect();
|
||||
}
|
||||
|
||||
function inEditor(): boolean {
|
||||
return !!document.activeElement?.closest('.cm-editor');
|
||||
}
|
||||
|
||||
function onKeydown(e: KeyboardEvent) {
|
||||
const mod = e.metaKey || e.ctrlKey;
|
||||
if (mod && e.key.toLowerCase() === 'l') {
|
||||
e.preventDefault();
|
||||
if (narrow) mobileView = 'list';
|
||||
omnibar?.focusSelect();
|
||||
} else if (mod && (e.key === 'Delete' || e.key === 'Backspace')) {
|
||||
e.preventDefault();
|
||||
void doDelete();
|
||||
} else if (mod && e.shiftKey && e.key.toLowerCase() === 'p') {
|
||||
e.preventDefault();
|
||||
if (selected) preview = !preview;
|
||||
} else if (mod && !e.shiftKey && e.key.toLowerCase() === 'k') {
|
||||
e.preventDefault();
|
||||
cycleTag();
|
||||
} else if (mod && e.key.toLowerCase() === 'j') {
|
||||
e.preventDefault();
|
||||
const wasInEditor = inEditor();
|
||||
moveSelection(e.shiftKey ? -1 : 1);
|
||||
if (wasInEditor) queueMicrotask(() => editor?.focusEnd());
|
||||
} else if (e.key === 'Escape' && inEditor()) {
|
||||
e.preventDefault();
|
||||
backToOmnibar(); // query preserved (SPEC §5.7)
|
||||
}
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
const mq = window.matchMedia('(max-width: 639px)');
|
||||
narrow = mq.matches;
|
||||
const onMq = () => (narrow = mq.matches);
|
||||
mq.addEventListener('change', onMq);
|
||||
omnibar?.focus();
|
||||
// First run: offer server config; Cancel = work locally only (SPEC §7).
|
||||
if (!localStorage.getItem('tefter-first-run-done')) {
|
||||
localStorage.setItem('tefter-first-run-done', '1');
|
||||
if (!notesArr.length && !$settings.serverUrl) menuOpen = true;
|
||||
}
|
||||
return () => mq.removeEventListener('change', onMq);
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:window on:keydown={onKeydown} />
|
||||
|
||||
<div class="app" class:vertical class:narrow>
|
||||
{#if !narrow || mobileView === 'list'}
|
||||
<header>
|
||||
<Omnibar
|
||||
bind:this={omnibar}
|
||||
bind:value={query}
|
||||
on:enter={onEnter}
|
||||
on:up={() => moveSelection(-1)}
|
||||
on:down={() => moveSelection(1)}
|
||||
on:clear={clearOmnibar}
|
||||
/>
|
||||
{#if tagFilter}
|
||||
<button class="tagchip" title="Clear tag filter (Ctrl+K cycles)" on:click={() => (tagFilter = null)}>
|
||||
#{tagFilter} ✕
|
||||
</button>
|
||||
{/if}
|
||||
<StatusDot />
|
||||
<button class="menu-btn" title="Settings" on:click={() => (menuOpen = !menuOpen)}>⋯</button>
|
||||
</header>
|
||||
{/if}
|
||||
|
||||
<div class="panes">
|
||||
{#if !narrow || mobileView === 'list'}
|
||||
<div class="list-pane">
|
||||
<NoteList {filtered} {selectedId} {query} on:open={(e) => openNote(e.detail)} />
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if !narrow || mobileView === 'editor'}
|
||||
<div class="editor-pane">
|
||||
{#if narrow && mobileView === 'editor'}
|
||||
<div class="mobile-bar">
|
||||
<button on:click={backToOmnibar}>‹ Notes</button>
|
||||
<span class="mobile-title">{selected ? noteTitle(selected.content) : ''}</span>
|
||||
<button on:click={() => (preview = !preview)}>{preview ? 'Edit' : 'MD'}</button>
|
||||
</div>
|
||||
{/if}
|
||||
{#if selected && preview}
|
||||
<Preview content={selected.content} on:wikilink={(e) => void openWikiLink(e.detail)} />
|
||||
{:else if selected}
|
||||
<Editor bind:this={editor} note={selected} {query} on:escape={backToOmnibar} />
|
||||
{:else}
|
||||
<div class="empty">
|
||||
<p>Type to search — <kbd>Enter</kbd> creates a note when nothing matches.</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if menuOpen}
|
||||
<SettingsMenu on:close={() => (menuOpen = false)} />
|
||||
{/if}
|
||||
|
||||
{#if toast}
|
||||
<Toast message={toast.message} actionLabel="Undo" onAction={toast.onAction} on:done={() => (toast = null)} />
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.app {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 10px;
|
||||
border-bottom: 1px solid var(--sep);
|
||||
background: var(--bg);
|
||||
}
|
||||
.menu-btn {
|
||||
font-size: 18px;
|
||||
line-height: 1;
|
||||
padding: 4px 8px;
|
||||
border-radius: 6px;
|
||||
color: var(--fg-dim);
|
||||
}
|
||||
.menu-btn:hover {
|
||||
background: var(--sel-bg);
|
||||
}
|
||||
.tagchip {
|
||||
white-space: nowrap;
|
||||
background: var(--accent-bg);
|
||||
color: var(--accent);
|
||||
padding: 3px 8px;
|
||||
border-radius: 10px;
|
||||
font-size: 12px;
|
||||
}
|
||||
.panes {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
}
|
||||
.list-pane {
|
||||
height: 34%;
|
||||
min-height: 90px;
|
||||
overflow-y: auto;
|
||||
border-bottom: 1px solid var(--sep);
|
||||
background: var(--bg);
|
||||
}
|
||||
.editor-pane {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--bg-panel);
|
||||
}
|
||||
.app.vertical .panes {
|
||||
flex-direction: row;
|
||||
}
|
||||
.app.vertical .list-pane {
|
||||
height: auto;
|
||||
width: 320px;
|
||||
min-width: 220px;
|
||||
border-bottom: none;
|
||||
border-right: 1px solid var(--sep);
|
||||
}
|
||||
.app.narrow .list-pane {
|
||||
flex: 1;
|
||||
height: auto;
|
||||
width: auto;
|
||||
border: none;
|
||||
}
|
||||
.mobile-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 10px;
|
||||
border-bottom: 1px solid var(--sep);
|
||||
}
|
||||
.mobile-bar button {
|
||||
color: var(--accent);
|
||||
font-size: 15px;
|
||||
}
|
||||
.mobile-title {
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
font-weight: 600;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.empty {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--fg-dim);
|
||||
padding: 20px;
|
||||
text-align: center;
|
||||
}
|
||||
kbd {
|
||||
border: 1px solid var(--sep);
|
||||
border-radius: 4px;
|
||||
padding: 1px 5px;
|
||||
font-family: var(--mono);
|
||||
font-size: 12px;
|
||||
}
|
||||
</style>
|
||||
170
frontend/src/ui/Editor.svelte
Normal file
170
frontend/src/ui/Editor.svelte
Normal file
@@ -0,0 +1,170 @@
|
||||
<script lang="ts">
|
||||
import { onMount, onDestroy, createEventDispatcher } from 'svelte';
|
||||
import { EditorState, Compartment } from '@codemirror/state';
|
||||
import {
|
||||
EditorView,
|
||||
keymap,
|
||||
highlightSpecialChars,
|
||||
drawSelection,
|
||||
MatchDecorator,
|
||||
Decoration,
|
||||
ViewPlugin,
|
||||
type DecorationSet,
|
||||
type ViewUpdate,
|
||||
} from '@codemirror/view';
|
||||
import { defaultKeymap, history, historyKeymap } from '@codemirror/commands';
|
||||
import { syntaxHighlighting, defaultHighlightStyle } from '@codemirror/language';
|
||||
import { markdown, markdownLanguage } from '@codemirror/lang-markdown';
|
||||
import { updateContent } from '../db/store';
|
||||
import { notifyEdit } from '../sync/engine';
|
||||
import { highlightRegex } from '../search';
|
||||
import type { Note } from '../types';
|
||||
|
||||
export let note: Note;
|
||||
export let query = '';
|
||||
|
||||
const dispatch = createEventDispatcher<{ escape: void }>();
|
||||
|
||||
let host: HTMLElement;
|
||||
let view: EditorView | null = null;
|
||||
let currentId = '';
|
||||
let saveTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
// The last content this editor and the store agreed on. A store value equal to
|
||||
// it is an echo of our own save; anything else is an external (sync) change.
|
||||
let lastKnown = '';
|
||||
const searchMark = new Compartment();
|
||||
|
||||
function searchExtension(q: string) {
|
||||
const rx = highlightRegex(q);
|
||||
if (!rx) return [];
|
||||
const deco = new MatchDecorator({
|
||||
regexp: new RegExp(rx.source, 'gi'),
|
||||
decoration: Decoration.mark({ class: 'cm-search-hit' }),
|
||||
});
|
||||
return ViewPlugin.fromClass(
|
||||
class {
|
||||
decorations: DecorationSet;
|
||||
constructor(v: EditorView) {
|
||||
this.decorations = deco.createDeco(v);
|
||||
}
|
||||
update(u: ViewUpdate) {
|
||||
this.decorations = deco.updateDeco(u, this.decorations);
|
||||
}
|
||||
},
|
||||
{ decorations: (v) => v.decorations }
|
||||
);
|
||||
}
|
||||
|
||||
function flushSave(id: string, content: string) {
|
||||
if (id === currentId) lastKnown = content;
|
||||
void updateContent(id, content).then(() => notifyEdit());
|
||||
}
|
||||
|
||||
function makeState(content: string): EditorState {
|
||||
return EditorState.create({
|
||||
doc: content,
|
||||
extensions: [
|
||||
history(),
|
||||
highlightSpecialChars(),
|
||||
drawSelection(),
|
||||
EditorView.lineWrapping,
|
||||
syntaxHighlighting(defaultHighlightStyle, { fallback: true }),
|
||||
markdown({ base: markdownLanguage }),
|
||||
searchMark.of(searchExtension(query)),
|
||||
keymap.of([
|
||||
{
|
||||
key: 'Escape',
|
||||
run: () => {
|
||||
dispatch('escape');
|
||||
return true;
|
||||
},
|
||||
},
|
||||
...defaultKeymap,
|
||||
...historyKeymap,
|
||||
]),
|
||||
EditorView.updateListener.of((u) => {
|
||||
if (u.docChanged) {
|
||||
const id = currentId;
|
||||
const text = u.state.doc.toString();
|
||||
if (saveTimer) clearTimeout(saveTimer);
|
||||
// Autosave: 400 ms after last keystroke → IndexedDB, mark dirty (SPEC §5).
|
||||
saveTimer = setTimeout(() => {
|
||||
saveTimer = null;
|
||||
if (id === currentId) flushSave(id, text);
|
||||
}, 400);
|
||||
}
|
||||
}),
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
export function focusEnd() {
|
||||
if (!view) return;
|
||||
view.focus();
|
||||
view.dispatch({ selection: { anchor: view.state.doc.length } });
|
||||
}
|
||||
|
||||
// Swap document when a different note is opened; apply external (sync) changes
|
||||
// without clobbering in-flight typing.
|
||||
$: if (view && note) {
|
||||
if (note.id !== currentId) {
|
||||
if (saveTimer) {
|
||||
clearTimeout(saveTimer);
|
||||
saveTimer = null;
|
||||
flushSave(currentId, view.state.doc.toString());
|
||||
}
|
||||
currentId = note.id;
|
||||
lastKnown = note.content;
|
||||
view.setState(makeState(note.content));
|
||||
} else if (note.content !== lastKnown) {
|
||||
// Store changed underneath us (sync pull / conflict replacement) — not an
|
||||
// echo of our own autosave. Replace the doc.
|
||||
lastKnown = note.content;
|
||||
if (saveTimer) {
|
||||
clearTimeout(saveTimer);
|
||||
saveTimer = null;
|
||||
}
|
||||
if (note.content !== view.state.doc.toString()) {
|
||||
view.dispatch({ changes: { from: 0, to: view.state.doc.length, insert: note.content } });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let lastQuery = '';
|
||||
$: if (view && query !== lastQuery) {
|
||||
lastQuery = query;
|
||||
view.dispatch({ effects: searchMark.reconfigure(searchExtension(query)) });
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
currentId = note.id;
|
||||
lastKnown = note.content;
|
||||
view = new EditorView({ state: makeState(note.content), parent: host });
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
if (saveTimer && view) {
|
||||
clearTimeout(saveTimer);
|
||||
saveTimer = null;
|
||||
flushSave(currentId, view.state.doc.toString());
|
||||
}
|
||||
view?.destroy();
|
||||
view = null;
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="editor-host" bind:this={host}></div>
|
||||
|
||||
<style>
|
||||
.editor-host {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
.editor-host :global(.cm-editor) {
|
||||
height: 100%;
|
||||
}
|
||||
.editor-host :global(.cm-scroller) {
|
||||
overflow: auto;
|
||||
}
|
||||
</style>
|
||||
138
frontend/src/ui/NoteList.svelte
Normal file
138
frontend/src/ui/NoteList.svelte
Normal file
@@ -0,0 +1,138 @@
|
||||
<script lang="ts">
|
||||
import { createEventDispatcher, tick } from 'svelte';
|
||||
import { noteTitle, noteBody, highlightRegex } from '../search';
|
||||
import type { Note } from '../types';
|
||||
|
||||
export let filtered: Note[] = [];
|
||||
export let selectedId: string | null = null;
|
||||
export let query = '';
|
||||
|
||||
// DOM cap: filtering is in-memory over everything, but rendering thousands of
|
||||
// rows would blow the <10 ms keystroke budget. Selection stays within the cap.
|
||||
const MAX_ROWS = 100;
|
||||
|
||||
const dispatch = createEventDispatcher<{ open: string }>();
|
||||
|
||||
let listEl: HTMLElement;
|
||||
|
||||
$: visible = filtered.length > MAX_ROWS ? filtered.slice(0, MAX_ROWS) : filtered;
|
||||
$: rx = highlightRegex(query);
|
||||
|
||||
$: if (selectedId) {
|
||||
void tick().then(() => {
|
||||
listEl?.querySelector('.row.selected')?.scrollIntoView({ block: 'nearest' });
|
||||
});
|
||||
}
|
||||
|
||||
function esc(s: string): string {
|
||||
return s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||
}
|
||||
|
||||
function hl(s: string): string {
|
||||
if (!rx) return esc(s);
|
||||
rx.lastIndex = 0;
|
||||
let out = '';
|
||||
let last = 0;
|
||||
let m: RegExpExecArray | null;
|
||||
while ((m = rx.exec(s))) {
|
||||
out += esc(s.slice(last, m.index)) + '<mark>' + esc(m[0]) + '</mark>';
|
||||
last = m.index + m[0].length;
|
||||
if (m.index === rx.lastIndex) rx.lastIndex++;
|
||||
}
|
||||
return out + esc(s.slice(last));
|
||||
}
|
||||
|
||||
function when(ms: number): string {
|
||||
const d = new Date(ms);
|
||||
const now = new Date();
|
||||
if (d.toDateString() === now.toDateString())
|
||||
return d.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' });
|
||||
if (d.getFullYear() === now.getFullYear())
|
||||
return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric' });
|
||||
return d.toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' });
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="list" bind:this={listEl} role="listbox" aria-label="Notes">
|
||||
{#each visible as n (n.id)}
|
||||
<div
|
||||
class="row"
|
||||
class:selected={n.id === selectedId}
|
||||
role="option"
|
||||
aria-selected={n.id === selectedId}
|
||||
tabindex="-1"
|
||||
on:click={() => dispatch('open', n.id)}
|
||||
on:keydown={(e) => e.key === 'Enter' && dispatch('open', n.id)}
|
||||
>
|
||||
<span class="title">{@html hl(noteTitle(n.content))}</span>
|
||||
<span class="snippet">{@html hl(noteBody(n.content).slice(0, 120))}</span>
|
||||
<span class="meta">
|
||||
{#each n.tags as t}<span class="tag">{t}</span>{/each}
|
||||
<span class="date">{when(n.modified_at)}</span>
|
||||
</span>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="none">{query.trim() ? 'No matches — Enter creates this note' : 'No notes yet'}</div>
|
||||
{/each}
|
||||
{#if filtered.length > MAX_ROWS}
|
||||
<div class="none">…and {filtered.length - MAX_ROWS} more — keep typing to narrow down</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.list {
|
||||
min-height: 100%;
|
||||
}
|
||||
.row {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 10px;
|
||||
padding: 6px 12px;
|
||||
line-height: 1.7;
|
||||
border-bottom: 1px solid var(--sep);
|
||||
cursor: default;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
}
|
||||
.row.selected {
|
||||
background: var(--sel-bg);
|
||||
}
|
||||
.title {
|
||||
font-weight: 600;
|
||||
flex-shrink: 0;
|
||||
max-width: 55%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.snippet {
|
||||
flex: 1;
|
||||
color: var(--fg-dim);
|
||||
font-size: 12.5px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.meta {
|
||||
margin-left: auto;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
.tag {
|
||||
background: var(--accent-bg);
|
||||
color: var(--accent);
|
||||
font-size: 11px;
|
||||
padding: 1px 7px;
|
||||
border-radius: 9px;
|
||||
}
|
||||
.date {
|
||||
color: var(--fg-dim);
|
||||
font-size: 12px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.none {
|
||||
padding: 16px;
|
||||
color: var(--fg-dim);
|
||||
text-align: center;
|
||||
}
|
||||
</style>
|
||||
59
frontend/src/ui/Omnibar.svelte
Normal file
59
frontend/src/ui/Omnibar.svelte
Normal file
@@ -0,0 +1,59 @@
|
||||
<script lang="ts">
|
||||
import { createEventDispatcher } from 'svelte';
|
||||
|
||||
export let value = '';
|
||||
const dispatch = createEventDispatcher<{ enter: void; up: void; down: void; clear: void }>();
|
||||
|
||||
let input: HTMLInputElement;
|
||||
|
||||
export function focus() {
|
||||
input?.focus();
|
||||
}
|
||||
export function focusSelect() {
|
||||
input?.focus();
|
||||
input?.select();
|
||||
}
|
||||
|
||||
function onKeydown(e: KeyboardEvent) {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
dispatch('enter');
|
||||
} else if (e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
dispatch('down');
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
dispatch('up');
|
||||
} else if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
dispatch('clear');
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<input
|
||||
bind:this={input}
|
||||
bind:value
|
||||
type="text"
|
||||
class="omnibar"
|
||||
placeholder="Search or Create"
|
||||
spellcheck="false"
|
||||
autocomplete="off"
|
||||
autocapitalize="off"
|
||||
enterkeyhint="go"
|
||||
on:keydown={onKeydown}
|
||||
/>
|
||||
|
||||
<style>
|
||||
.omnibar {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
font-size: 16px; /* ≥16px: no zoom-on-focus on Android (SPEC §8) */
|
||||
padding: 7px 12px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
.omnibar:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent);
|
||||
}
|
||||
</style>
|
||||
131
frontend/src/ui/Preview.svelte
Normal file
131
frontend/src/ui/Preview.svelte
Normal file
@@ -0,0 +1,131 @@
|
||||
<script lang="ts">
|
||||
import { createEventDispatcher } from 'svelte';
|
||||
import { Marked } from 'marked';
|
||||
import DOMPurify from 'dompurify';
|
||||
|
||||
export let content = '';
|
||||
|
||||
const dispatch = createEventDispatcher<{ wikilink: string }>();
|
||||
|
||||
function escAttr(s: string): string {
|
||||
return s.replace(/&/g, '&').replace(/"/g, '"').replace(/</g, '<').replace(/>/g, '>');
|
||||
}
|
||||
|
||||
// GFM + [[wiki-link]] inline extension (SPEC §5 editor: NV Alt behavior)
|
||||
const marked = new Marked({
|
||||
gfm: true,
|
||||
breaks: true,
|
||||
extensions: [
|
||||
{
|
||||
name: 'wikilink',
|
||||
level: 'inline',
|
||||
start(src: string) {
|
||||
const i = src.indexOf('[[');
|
||||
return i < 0 ? undefined : i;
|
||||
},
|
||||
tokenizer(src: string) {
|
||||
const m = /^\[\[([^\]\n]+)\]\]/.exec(src);
|
||||
if (m) return { type: 'wikilink', raw: m[0], title: m[1].trim() };
|
||||
return undefined;
|
||||
},
|
||||
renderer(tok) {
|
||||
const t = escAttr(String((tok as { title?: string }).title ?? ''));
|
||||
return `<a href="#" class="wikilink" data-title="${t}">${t}</a>`;
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
DOMPurify.addHook('afterSanitizeAttributes', (node) => {
|
||||
if (node.tagName === 'A' && !node.classList.contains('wikilink')) {
|
||||
node.setAttribute('target', '_blank');
|
||||
node.setAttribute('rel', 'noopener noreferrer');
|
||||
}
|
||||
});
|
||||
|
||||
$: html = DOMPurify.sanitize(marked.parse(content, { async: false }) as string, {
|
||||
ADD_ATTR: ['data-title', 'target'],
|
||||
});
|
||||
|
||||
function onClick(e: MouseEvent) {
|
||||
const a = (e.target as HTMLElement).closest('a');
|
||||
if (!a) return;
|
||||
if (a.classList.contains('wikilink')) {
|
||||
e.preventDefault();
|
||||
const title = a.getAttribute('data-title');
|
||||
if (title) dispatch('wikilink', title);
|
||||
return;
|
||||
}
|
||||
const href = a.getAttribute('href') || '';
|
||||
// In the Wails webview, open external links in the system browser.
|
||||
const rt = (window as unknown as { runtime?: { BrowserOpenURL(u: string): void } }).runtime;
|
||||
if (rt?.BrowserOpenURL && /^https?:/i.test(href)) {
|
||||
e.preventDefault();
|
||||
rt.BrowserOpenURL(href);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- svelte-ignore a11y-click-events-have-key-events a11y-no-static-element-interactions -->
|
||||
<div class="preview" on:click={onClick}>
|
||||
{@html html}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.preview {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 16px 20px;
|
||||
line-height: 1.55;
|
||||
font-size: 15px;
|
||||
}
|
||||
.preview :global(h1),
|
||||
.preview :global(h2),
|
||||
.preview :global(h3) {
|
||||
line-height: 1.25;
|
||||
}
|
||||
.preview :global(pre) {
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--sep);
|
||||
border-radius: 6px;
|
||||
padding: 10px 12px;
|
||||
overflow-x: auto;
|
||||
font-family: var(--mono);
|
||||
font-size: 13px;
|
||||
}
|
||||
.preview :global(code) {
|
||||
font-family: var(--mono);
|
||||
font-size: 0.9em;
|
||||
background: var(--bg);
|
||||
border-radius: 4px;
|
||||
padding: 1px 4px;
|
||||
}
|
||||
.preview :global(pre code) {
|
||||
background: none;
|
||||
padding: 0;
|
||||
}
|
||||
.preview :global(blockquote) {
|
||||
margin: 0.6em 0;
|
||||
padding: 0 1em;
|
||||
border-left: 3px solid var(--sep);
|
||||
color: var(--fg-dim);
|
||||
}
|
||||
.preview :global(table) {
|
||||
border-collapse: collapse;
|
||||
}
|
||||
.preview :global(th),
|
||||
.preview :global(td) {
|
||||
border: 1px solid var(--sep);
|
||||
padding: 4px 10px;
|
||||
}
|
||||
.preview :global(a) {
|
||||
color: var(--accent);
|
||||
}
|
||||
.preview :global(input[type='checkbox']) {
|
||||
margin-right: 6px;
|
||||
}
|
||||
.preview :global(.wikilink) {
|
||||
text-decoration: none;
|
||||
border-bottom: 1px dashed var(--accent);
|
||||
}
|
||||
</style>
|
||||
182
frontend/src/ui/SettingsMenu.svelte
Normal file
182
frontend/src/ui/SettingsMenu.svelte
Normal file
@@ -0,0 +1,182 @@
|
||||
<script lang="ts">
|
||||
import { createEventDispatcher } from 'svelte';
|
||||
import { settings, saveSettings } from '../db/store';
|
||||
import { syncNow, syncState, lastSyncAt } from '../sync/engine';
|
||||
import { get } from 'svelte/store';
|
||||
|
||||
const dispatch = createEventDispatcher<{ close: void }>();
|
||||
|
||||
const version: string = __TEFTER_VERSION__;
|
||||
|
||||
let serverUrl = get(settings).serverUrl;
|
||||
let token = get(settings).token;
|
||||
let vertical = get(settings).vertical;
|
||||
let importing = false;
|
||||
let importResult = '';
|
||||
let fileInput: HTMLInputElement;
|
||||
|
||||
async function save() {
|
||||
await saveSettings({ serverUrl: serverUrl.trim(), token: token.trim(), vertical });
|
||||
void syncNow();
|
||||
dispatch('close');
|
||||
}
|
||||
|
||||
async function importZip() {
|
||||
const f = fileInput?.files?.[0];
|
||||
if (!f) return;
|
||||
const s = get(settings);
|
||||
const base = (serverUrl || s.serverUrl).replace(/\/+$/, '');
|
||||
if (!base) {
|
||||
importResult = 'Set a server URL first.';
|
||||
return;
|
||||
}
|
||||
importing = true;
|
||||
importResult = '';
|
||||
try {
|
||||
const fd = new FormData();
|
||||
fd.append('file', f);
|
||||
const res = await fetch(base + '/api/v1/import/simplenote', {
|
||||
method: 'POST',
|
||||
headers: { Authorization: 'Bearer ' + (token || s.token) },
|
||||
body: fd,
|
||||
});
|
||||
if (!res.ok) throw new Error('HTTP ' + res.status);
|
||||
const r = await res.json();
|
||||
importResult = `Imported ${r.imported}, skipped ${r.skipped} duplicates, ${r.trashed} trashed.`;
|
||||
void syncNow();
|
||||
} catch (e) {
|
||||
importResult = 'Import failed: ' + (e instanceof Error ? e.message : String(e));
|
||||
} finally {
|
||||
importing = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- svelte-ignore a11y-click-events-have-key-events a11y-no-static-element-interactions -->
|
||||
<div class="backdrop" on:click|self={() => dispatch('close')}>
|
||||
<div class="panel">
|
||||
<h2>Settings</h2>
|
||||
|
||||
<section>
|
||||
<h3>Sync</h3>
|
||||
<label>
|
||||
Server URL
|
||||
<input type="url" bind:value={serverUrl} placeholder="https://notes.example.com" />
|
||||
</label>
|
||||
<label>
|
||||
Token
|
||||
<input type="password" bind:value={token} placeholder="paste token from tefterd init" />
|
||||
</label>
|
||||
<div class="hint">Status: {$syncState}{$lastSyncAt ? ` · last sync ${new Date($lastSyncAt).toLocaleTimeString()}` : ''}</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h3>Layout</h3>
|
||||
<label class="row">
|
||||
<input type="checkbox" bind:checked={vertical} />
|
||||
Vertical split (list left, editor right)
|
||||
</label>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h3>Import</h3>
|
||||
<input type="file" accept=".zip" bind:this={fileInput} />
|
||||
<button class="btn" disabled={importing} on:click={importZip}>
|
||||
{importing ? 'Importing…' : 'Import Simplenote zip'}
|
||||
</button>
|
||||
{#if importResult}<div class="hint">{importResult}</div>{/if}
|
||||
</section>
|
||||
|
||||
<section class="about">Tefter {version}</section>
|
||||
|
||||
<div class="actions">
|
||||
<button class="btn" on:click={() => dispatch('close')}>Cancel</button>
|
||||
<button class="btn primary" on:click={save}>Save</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 40;
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: center;
|
||||
padding-top: 8vh;
|
||||
}
|
||||
.panel {
|
||||
background: var(--bg-panel);
|
||||
border: 1px solid var(--sep);
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
|
||||
width: min(440px, 92vw);
|
||||
max-height: 84vh;
|
||||
overflow-y: auto;
|
||||
padding: 18px 22px;
|
||||
}
|
||||
h2 {
|
||||
margin: 0 0 6px;
|
||||
font-size: 17px;
|
||||
}
|
||||
h3 {
|
||||
margin: 14px 0 6px;
|
||||
font-size: 13px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--fg-dim);
|
||||
}
|
||||
label {
|
||||
display: block;
|
||||
font-size: 13px;
|
||||
color: var(--fg-dim);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
label input[type='url'],
|
||||
label input[type='password'] {
|
||||
display: block;
|
||||
width: 100%;
|
||||
margin-top: 3px;
|
||||
padding: 6px 10px;
|
||||
font-size: 16px;
|
||||
}
|
||||
.row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
color: var(--fg);
|
||||
}
|
||||
.hint {
|
||||
font-size: 12.5px;
|
||||
color: var(--fg-dim);
|
||||
margin-top: 6px;
|
||||
}
|
||||
.btn {
|
||||
border: 1px solid var(--sep);
|
||||
border-radius: 6px;
|
||||
padding: 5px 14px;
|
||||
margin-top: 8px;
|
||||
background: var(--bg);
|
||||
}
|
||||
.btn.primary {
|
||||
background: var(--accent);
|
||||
border-color: var(--accent);
|
||||
color: #fff;
|
||||
}
|
||||
.btn:disabled {
|
||||
opacity: 0.5;
|
||||
}
|
||||
.about {
|
||||
margin-top: 14px;
|
||||
font-size: 12px;
|
||||
color: var(--fg-dim);
|
||||
}
|
||||
.actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
</style>
|
||||
78
frontend/src/ui/StatusDot.svelte
Normal file
78
frontend/src/ui/StatusDot.svelte
Normal file
@@ -0,0 +1,78 @@
|
||||
<script lang="ts">
|
||||
import { syncState, lastSyncAt, syncError, syncNow } from '../sync/engine';
|
||||
import { pendingCount } from '../db/store';
|
||||
|
||||
let open = false;
|
||||
|
||||
const labels: Record<string, string> = {
|
||||
synced: 'Synced',
|
||||
syncing: 'Syncing…',
|
||||
offline: 'Offline',
|
||||
error: 'Sync error',
|
||||
local: 'Local only (no server configured)',
|
||||
};
|
||||
|
||||
$: color =
|
||||
$syncState === 'synced'
|
||||
? 'var(--dot-green)'
|
||||
: $syncState === 'syncing'
|
||||
? 'var(--dot-yellow)'
|
||||
: $syncState === 'error'
|
||||
? 'var(--dot-red)'
|
||||
: 'var(--dot-gray)';
|
||||
|
||||
function fmt(ms: number | null): string {
|
||||
if (!ms) return 'never';
|
||||
return new Date(ms).toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit', second: '2-digit' });
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="wrap">
|
||||
<button class="dot" style="background:{color}" title={labels[$syncState]} aria-label="Sync status" on:click={() => (open = !open)}></button>
|
||||
{#if open}
|
||||
<div class="pop">
|
||||
<div><strong>{labels[$syncState]}</strong></div>
|
||||
<div>Last sync: {fmt($lastSyncAt)}</div>
|
||||
<div>Pending changes: {$pendingCount}</div>
|
||||
{#if $syncError}<div class="err">{$syncError}</div>{/if}
|
||||
<button class="now" on:click={() => void syncNow()}>Sync now</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.wrap {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
.dot {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.pop {
|
||||
position: absolute;
|
||||
top: 22px;
|
||||
right: 0;
|
||||
z-index: 30;
|
||||
background: var(--bg-panel);
|
||||
border: 1px solid var(--sep);
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.15);
|
||||
padding: 10px 14px;
|
||||
font-size: 13px;
|
||||
line-height: 1.7;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.err {
|
||||
color: var(--dot-red);
|
||||
max-width: 240px;
|
||||
white-space: normal;
|
||||
}
|
||||
.now {
|
||||
margin-top: 4px;
|
||||
color: var(--accent);
|
||||
}
|
||||
</style>
|
||||
55
frontend/src/ui/Toast.svelte
Normal file
55
frontend/src/ui/Toast.svelte
Normal file
@@ -0,0 +1,55 @@
|
||||
<script lang="ts">
|
||||
import { createEventDispatcher } from 'svelte';
|
||||
|
||||
export let message = '';
|
||||
export let actionLabel = '';
|
||||
export let onAction: () => void = () => {};
|
||||
|
||||
const dispatch = createEventDispatcher<{ done: void }>();
|
||||
|
||||
function act() {
|
||||
onAction();
|
||||
dispatch('done');
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="toast" role="status">
|
||||
<span>{message}</span>
|
||||
{#if actionLabel}
|
||||
<button on:click={act}>{actionLabel}</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.toast {
|
||||
position: fixed;
|
||||
bottom: 18px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
z-index: 50;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
background: var(--fg);
|
||||
color: var(--bg);
|
||||
padding: 9px 16px;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.25);
|
||||
animation: rise 0.15s ease-out;
|
||||
}
|
||||
.toast button {
|
||||
color: var(--mark-bg);
|
||||
font-weight: 600;
|
||||
}
|
||||
@keyframes rise {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateX(-50%) translateY(8px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateX(-50%) translateY(0);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
5
frontend/svelte.config.js
Normal file
5
frontend/svelte.config.js
Normal file
@@ -0,0 +1,5 @@
|
||||
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
|
||||
|
||||
export default {
|
||||
preprocess: vitePreprocess(),
|
||||
};
|
||||
17
frontend/tsconfig.json
Normal file
17
frontend/tsconfig.json
Normal file
@@ -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"]
|
||||
}
|
||||
67
frontend/vite.config.ts
Normal file
67
frontend/vite.config.ts
Normal file
@@ -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',
|
||||
},
|
||||
},
|
||||
});
|
||||
153
server/api/api.go
Normal file
153
server/api/api.go
Normal file
@@ -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")
|
||||
}
|
||||
256
server/api/api_test.go
Normal file
256
server/api/api_test.go
Normal file
@@ -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())
|
||||
}
|
||||
}
|
||||
17
server/go.mod
Normal file
17
server/go.mod
Normal file
@@ -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
|
||||
)
|
||||
51
server/go.sum
Normal file
51
server/go.sum
Normal file
@@ -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=
|
||||
126
server/importer/simplenote.go
Normal file
126
server/importer/simplenote.go
Normal file
@@ -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]
|
||||
}
|
||||
124
server/importer/simplenote_test.go
Normal file
124
server/importer/simplenote_test.go
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
211
server/main.go
Normal file
211
server/main.go
Normal file
@@ -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 <export.zip> [--db ...] import a Simplenote export archive
|
||||
tefterd compact [--days 90] [--db ...] purge tombstones older than N days
|
||||
tefterd backup <dest.db> [--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)
|
||||
}
|
||||
408
server/store/store.go
Normal file
408
server/store/store.go
Normal file
@@ -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
|
||||
}
|
||||
213
server/store/store_test.go
Normal file
213
server/store/store_test.go
Normal file
@@ -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
|
||||
}
|
||||
0
server/webdist/dist/.gitkeep
vendored
Normal file
0
server/webdist/dist/.gitkeep
vendored
Normal file
17
server/webdist/webdist.go
Normal file
17
server/webdist/webdist.go
Normal file
@@ -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")
|
||||
}
|
||||
Reference in New Issue
Block a user