Add conflict resolution diff UI and app branding

This commit is contained in:
2026-05-13 16:25:28 -04:00
parent 780ff3a02c
commit d359baa010
83 changed files with 4523 additions and 3735 deletions

View File

@@ -13,36 +13,16 @@ jobs:
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- uses: actions/setup-go@v5 - uses: jdx/mise-action@v2
with:
go-version: "1.24.2"
- uses: actions/setup-node@v4
with:
node-version: "24"
cache: "npm"
cache-dependency-path: apps/web/package-lock.json
- name: Install build tooling - name: Install build tooling
run: sudo apt-get update && sudo apt-get install -y build-essential run: sudo apt-get update && sudo apt-get install -y build-essential
- name: Install web dependencies - name: Run core checks
run: cd apps/web && npm install run: mise run ci
- name: Build web
run: cd apps/web && npm run build
- name: Run Go tests
run: cd apps/server && CGO_ENABLED=1 go test ./...
- name: Build server
run: cd apps/server && CGO_ENABLED=1 go build -trimpath ./cmd/md-hub-secure
- name: Run govulncheck - name: Run govulncheck
run: | run: mise run vulncheck
go install golang.org/x/vuln/cmd/govulncheck@latest
cd apps/server && govulncheck ./...
- name: Build container - name: Build container
run: docker build . run: mise run docker-build

View File

@@ -1,4 +1,4 @@
# MD Hub Secure - Documentation Index # Cairnquire - Documentation Index
## Project Planning ## Project Planning
@@ -59,7 +59,7 @@
## Project Structure ## Project Structure
``` ```
md-hub-secure/ cairnquire/
├── docs/ ├── docs/
│ ├── README.md (this file) │ ├── README.md (this file)
│ ├── project-plan.md │ ├── project-plan.md
@@ -90,5 +90,5 @@ md-hub-secure/
│ └── protocol/ # Shared types │ └── protocol/ # Shared types
├── docker-compose.yml ├── docker-compose.yml
├── Dockerfile ├── Dockerfile
└── Makefile └── mise.toml
``` ```

View File

@@ -47,5 +47,5 @@ Several errors are being silently ignored. Consider handling or logging these.
--- ---
*This report auto-generated by MD Hub Secure progress monitor.* *This report auto-generated by Cairnquire progress monitor.*
*Next update in ~10 minutes or when filesystem changes detected.* *Next update in ~10 minutes or when filesystem changes detected.*

View File

@@ -26,7 +26,7 @@ The platform needs to send email notifications for: file changes (to watchers),
3. **Email Format**: 3. **Email Format**:
``` ```
From: MD Hub Secure <notifications@example.com> From: Cairnquire <notifications@example.com>
To: user@example.com To: user@example.com
Subject: [docs/getting-started.md] Comment from Alice Subject: [docs/getting-started.md] Comment from Alice
Message-ID: <comment-123@example.com> Message-ID: <comment-123@example.com>

View File

@@ -13,7 +13,7 @@ Implement defense in depth with the following layers:
- **Minimal dependencies**: Only well-established, security-audited libraries - **Minimal dependencies**: Only well-established, security-audited libraries
- **Pinned versions**: All Go modules pinned with go.sum checksums - **Pinned versions**: All Go modules pinned with go.sum checksums
- **Vendoring option**: `go mod vendor` for air-gapped builds - **Vendoring option**: `go mod vendor` for air-gapped builds
- **No build scripts**: Makefile only — no npm install, no curl | bash - **Centralized automation**: `mise run ...` tasks only — no ad hoc shell scripts, no curl | bash
- **Reproducible builds**: Same source → same binary hash (via `-trimpath`) - **Reproducible builds**: Same source → same binary hash (via `-trimpath`)
### 2. Authentication Security ### 2. Authentication Security

View File

@@ -2,7 +2,7 @@
## System Context ## System Context
MD Hub Secure is a self-hosted documentation platform. A single Go binary serves HTTP requests, manages a libsql database, watches a filesystem directory, and synchronizes content with connected web clients. Cairnquire is a self-hosted documentation platform. A single Go binary serves HTTP requests, manages a libsql database, watches a filesystem directory, and synchronizes content with connected web clients.
## Component Diagram ## Component Diagram

View File

@@ -5,6 +5,5 @@
1. The plan and ADRs repeatedly describe a single Go binary, while Milestone 1 also requires `apps/web/` as a separate Vite/Preact app. The current implementation treats the web app as build-time static assets served by the Go binary. 1. The plan and ADRs repeatedly describe a single Go binary, while Milestone 1 also requires `apps/web/` as a separate Vite/Preact app. The current implementation treats the web app as build-time static assets served by the Go binary.
2. Milestone 1 asks for `packages/protocol/` with a protobuf schema, but the sync ADR and protocol spec describe JSON messages over WebSocket. The protocol package currently preserves both a `.proto` placeholder and TypeScript-facing JSON model notes. 2. Milestone 1 asks for `packages/protocol/` with a protobuf schema, but the sync ADR and protocol spec describe JSON messages over WebSocket. The protocol package currently preserves both a `.proto` placeholder and TypeScript-facing JSON model notes.
3. ADR-005 stores content under `data/files/...`, while Milestone 2 examples use `store/...`. The implementation follows ADR-005 and uses `data/files`. 3. ADR-005 stores content under `data/files/...`, while Milestone 2 examples use `store/...`. The implementation follows ADR-005 and uses `data/files`.
4. ADR-007 says "No build scripts: Makefile only — no npm install," but the chosen frontend stack requires a Node package install step. The Makefile centralizes it, but the dependency policy needs clarification. 4. The frontend stack still requires a Node package install step, but local automation is now centralized in `mise` tasks and the lockfile-backed `pnpm` workflow rather than ad hoc commands.
5. The foundation milestone specifies `golang-migrate`, but `go-libsql` integration constraints need a clean validation path. A lightweight internal migrator is used now so the project can move forward. 5. The foundation milestone specifies `golang-migrate`, but `go-libsql` integration constraints need a clean validation path. A lightweight internal migrator is used now so the project can move forward.

View File

@@ -1,4 +1,4 @@
# MD Hub Secure - Project Plan # Cairnquire - Project Plan
**Version:** 1.1\ **Version:** 1.1\
**Date:** 2026-04-29\ **Date:** 2026-04-29\
@@ -292,11 +292,11 @@ See [architecture-overview.md](architecture-overview.md) for detailed component
## Monorepo Structure ## Monorepo Structure
``` ```
md-hub-secure/ cairnquire/
├── README.md ├── README.md
├── docker-compose.yml ├── docker-compose.yml
├── Dockerfile ├── Dockerfile
├── Makefile ├── mise.toml
├── docs/ # Project documentation ├── docs/ # Project documentation
│ ├── project-plan.md # This file │ ├── project-plan.md # This file
│ ├── architecture-overview.md # System architecture │ ├── architecture-overview.md # System architecture
@@ -305,7 +305,7 @@ md-hub-secure/
│ └── specs/ # Technical specifications │ └── specs/ # Technical specifications
├── apps/ ├── apps/
│ ├── server/ # Go HTTP application │ ├── server/ # Go HTTP application
│ │ ├── cmd/md-hub-secure/ │ │ ├── cmd/cairnquire/
│ │ ├── internal/ │ │ ├── internal/
│ │ │ ├── app/ # Application orchestration │ │ │ ├── app/ # Application orchestration
│ │ │ ├── config/ # Configuration management │ │ │ ├── config/ # Configuration management
@@ -354,6 +354,7 @@ md-hub-secure/
- Implement session management - Implement session management
4. Add client-side Mermaid and KaTeX rendering (Milestone 5) 4. Add client-side Mermaid and KaTeX rendering (Milestone 5)
5. Implement SQLite FTS5 search (Milestone 5) 5. Implement SQLite FTS5 search (Milestone 5)
6. Add Renovate with dependency update cooldown policies
--- ---

View File

@@ -12,8 +12,8 @@
```bash ```bash
# Clone repository # Clone repository
git clone https://github.com/your-org/md-hub-secure.git git clone https://github.com/your-org/cairnquire.git
cd md-hub-secure cd cairnquire
# Copy and edit configuration # Copy and edit configuration
cp .env.example .env cp .env.example .env
@@ -150,7 +150,7 @@ rm "$BACKUP_FILE"
find "$BACKUP_DIR" -name "md-hub-*.tar.gz.gpg" -mtime +30 -delete find "$BACKUP_DIR" -name "md-hub-*.tar.gz.gpg" -mtime +30 -delete
``` ```
**Cron**: `0 2 * * * /opt/md-hub-secure/backup.sh` **Cron**: `0 2 * * * /opt/cairnquire/backup.sh`
### Restore from Backup ### Restore from Backup
@@ -269,4 +269,4 @@ services:
- **Health Check**: `GET /health` - **Health Check**: `GET /health`
- **Metrics**: `GET /metrics` (Prometheus format) - **Metrics**: `GET /metrics` (Prometheus format)
- **Logs**: `docker-compose logs -f app` - **Logs**: `docker-compose logs -f app`
- **Issues**: https://github.com/your-org/md-hub-secure/issues - **Issues**: https://github.com/your-org/cairnquire/issues

View File

@@ -0,0 +1,285 @@
# Sync Protocol v1 Specification
## Overview
The Native Sync Protocol enables bidirectional filesystem synchronization between a native macOS client and the cairnquire server. The browser client continues to operate via the existing WebSocket + REST API.
## Data Model
### Snapshot
A point-in-time tree of all files in the sync scope:
```json
{
"id": "snap:device-1:1234567890",
"deviceId": "device-1",
"createdAt": "2024-01-15T10:30:00Z",
"files": [
{
"path": "hello.md",
"hash": "a1b2c3...",
"size": 42,
"modified": "2024-01-15T10:00:00Z"
}
]
}
```
### Delta
A list of changes since a snapshot:
```json
{
"changes": [
{
"type": "create|update|delete|rename",
"path": "new-file.md",
"oldPath": "old-file.md",
"hash": "a1b2c3...",
"size": 42,
"modified": "2024-01-15T10:00:00Z"
}
]
}
```
### Conflict
When server and client both changed the same file since the last common snapshot:
```json
{
"path": "hello.md",
"serverHash": "a1b2c3...",
"clientHash": "d4e5f6...",
"serverModified": "2024-01-15T10:00:00Z",
"clientModified": "2024-01-15T10:05:00Z",
"strategy": "last-write-wins"
}
```
### Resolution Strategies
- `last-write-wins` (default): Use the most recently modified version
- `rename-both`: Keep both versions, renaming one as `file (conflict).md`
- `manual-merge`: Flag for UI resolution
- `server-wins`: Always use server version
- `client-wins`: Always use client version
## Endpoints
All endpoints require authentication via `X-API-Key` header.
### POST /api/sync/init
Initialize a new sync session.
**Request:**
```json
{
"deviceId": "macbook-pro-1",
"rootPath": "/Users/alice/Documents/md-hub"
}
```
**Response:**
```json
{
"snapshotId": "snap:macbook-pro-1:1234567890",
"serverSnapshot": [
{
"path": "hello.md",
"hash": "a1b2c3...",
"size": 42,
"modified": "2024-01-15T10:00:00Z"
}
]
}
```
### POST /api/sync/delta
Upload client changes and receive server changes + conflicts.
**Request:**
```json
{
"snapshotId": "snap:macbook-pro-1:1234567890",
"clientDelta": [
{
"type": "update",
"path": "hello.md",
"hash": "d4e5f6...",
"size": 50,
"modified": "2024-01-15T10:05:00Z"
}
]
}
```
**Response:**
```json
{
"serverDelta": [
{
"type": "create",
"path": "new-guide.md",
"hash": "g7h8i9...",
"size": 100,
"modified": "2024-01-15T10:02:00Z"
}
],
"conflicts": [
{
"path": "hello.md",
"serverHash": "a1b2c3...",
"clientHash": "d4e5f6...",
"serverModified": "2024-01-15T10:00:00Z",
"clientModified": "2024-01-15T10:05:00Z",
"strategy": "last-write-wins"
}
]
}
```
### POST /api/sync/resolve
Resolve conflicts and finalize the sync.
**Request:**
```json
{
"snapshotId": "snap:macbook-pro-1:1234567890",
"resolutions": [
{
"path": "hello.md",
"strategy": "server-wins"
}
]
}
```
**Response:**
```json
{
"newSnapshotId": "snap:macbook-pro-1:1234567999"
}
```
### GET /api/content/{hash}
Fetch raw file content by content hash (immutable, cache-friendly).
**Response:** Raw file bytes with headers:
```
Content-Type: application/octet-stream
Cache-Control: public, max-age=31536000, immutable
ETag: "a1b2c3..."
```
## Protocol Flow
```
1. Client calls POST /api/sync/init
→ Receives server snapshot
2. Client computes local delta
(fsevents + local state DB)
3. Client POSTs delta to /api/sync/delta
→ Receives serverDelta + conflicts
4. If conflicts exist:
a. Client resolves conflicts (auto or UI)
b. Client POSTs resolutions to /api/sync/resolve
→ Receives newSnapshotId
5. Client applies serverDelta to local filesystem
(downloads content via /api/content/{hash})
6. Both sides now have the same snapshot
```
## Authentication
Sync endpoints require API key authentication:
```
X-API-Key: <api-key>
```
API keys are long-lived tokens scoped to a user and device. They are stored in the `api_keys` table with SHA-256 hashes.
## Implementation Details
### Server Architecture
```
internal/sync/
├── protocol.go # Types and constants
├── service.go # Business logic (conflict resolution, snapshot management)
├── store.go # Database operations
```
### Database Schema
```sql
-- API keys for native client authentication
CREATE TABLE api_keys (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
name TEXT NOT NULL,
key_hash TEXT NOT NULL UNIQUE,
scopes TEXT NOT NULL DEFAULT 'sync:read,sync:write',
created_at TEXT NOT NULL,
expires_at TEXT,
last_used_at TEXT,
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE
);
-- Sync snapshots (point-in-time file trees)
CREATE TABLE sync_snapshots (
id TEXT PRIMARY KEY,
device_id TEXT NOT NULL,
user_id TEXT NOT NULL,
created_at TEXT NOT NULL,
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE
);
-- Files within a snapshot
CREATE TABLE sync_files (
snapshot_id TEXT NOT NULL,
path TEXT NOT NULL,
hash TEXT NOT NULL,
size_bytes INTEGER NOT NULL DEFAULT 0,
modified_at TEXT NOT NULL,
PRIMARY KEY (snapshot_id, path),
FOREIGN KEY(snapshot_id) REFERENCES sync_snapshots(id) ON DELETE CASCADE
);
```
### Conflict Detection
A conflict occurs when:
1. The server file existed in the snapshot
2. The server file has changed since the snapshot (hash changed)
3. The client also changed the same file (different hash in delta)
### Content-Addressed Storage
All file content is stored in a content-addressable store using SHA-256 hashes:
- Storage path: `<store-dir>/<hash[0:2]>/<hash[2:4]>/<hash>`
- Immutable: once stored, content never changes
- Deduplication: identical content shares the same storage
## Testing
The sync protocol includes table-driven tests that simulate client/server scenarios:
- `TestInitSyncCreatesSnapshot`: Validates snapshot generation
- `TestApplyDeltaDetectsServerChanges`: Detects server-side modifications
- `TestApplyDeltaDetectsConflicts`: Identifies concurrent modifications
- `TestResolveConflictsCreatesNewSnapshot`: Validates conflict resolution
- `TestGetContentReturnsFileBytes`: Tests content retrieval

View File

@@ -0,0 +1,279 @@
# Plan B: Browser-First Sync (Progressive Web App)
> **AGENT INSTRUCTION — READ FIRST**
>
> When you complete a phase or are ready for a code review, you **must** notify the opencode session that created this plan. Use the opencode HTTP API to send a message to session `ses_225742b62ffeOSelYyCe4IPwxL`.
>
> **Message format:**
> ```
> Subject: Plan B Review Ready — Phase X Complete
> Body:
> - Phase completed: [e.g., Phase 1]
> - Files touched: [list of files added/modified]
> - Summary: [2-3 sentence description of what was implemented]
> - Tests: [which tests were added or updated]
> - Blockers: [any issues or decisions needed]
> ```
>
> **OpenCode API Specification:**
> ```
> POST https://api.opencode.ai/v1/sessions/ses_225742b62ffeOSelYyCe4IPwxL/messages
> Content-Type: application/json
> Authorization: Bearer <OPENCODE_API_KEY>
>
> Request body:
> {
> "role": "assistant",
> "content": "Subject: Plan B Review Ready — Phase X Complete\n\nBody:\n- Phase completed: ...\n- Files touched: ...\n- Summary: ...\n- Tests: ...\n- Blockers: ..."
> }
> ```
>
> Wait for a response before starting the next phase unless explicitly told otherwise.
## Goal
Extend the existing web application so it works offline, supports concurrent editing, and syncs changes when reconnected. No native client needed — works on macOS, Windows, Linux, iOS, Android via browser.
## Architecture
### Current State (Already Built)
- Server: Go HTTP + SQLite + file watcher + WebSocket hub
- Browser: Preact + IndexedDB cache + `navigator.onLine` detection
- Documents served at `/:path*` with `index.md` default
- Real-time updates pushed via WebSocket when files change on disk
### What We Add
```
Browser (existing + new):
├── cache.js (exists) # IndexedDB: docs, content, hashes
├── realtime.js (exists) # WebSocket, offline indicator
├── sync.js (new) # Sync engine: queue, conflict detection, state machine
├── editor.js (new) # ProseMirror or textarea with operational transforms
└── sw.js (new) # Service Worker for offline serving
Server (existing + new):
├── internal/sync/ (new)
│ ├── protocol.go # Browser sync protocol (simpler than Plan A)
│ ├── queue.go # Server-side edit queue per document
│ ├── conflict.go # Three-way merge for text
│ └── broadcast.go # WebSocket broadcast for live collaboration
├── internal/auth/ (new) # Sessions + WebSocket auth
└── httpserver/ (existing)
├── handlers.go # Add: POST /api/documents/:path, WebSocket auth
└── websocket.go # Extend: handle edit messages
```
## Sync Protocol v1 (Browser)
### Data Model
- **Document**: `{path, content, hash, version}`
- **Client Edit**: `{path, baseVersion, newHash, patch}` where patch = diff from base
- **Server Ack**: `{path, version, status: accepted|conflict}`
- **Server Broadcast**: `{type: update, path, newContent, newVersion, author}`
### Endpoints
```
GET /api/documents # List all docs (cached in IndexedDB)
GET /api/documents/:path # Get document content (cached in IndexedDB)
POST /api/documents/:path # Save document (requires auth)
Body: {content, baseVersion, clientHash}
Response: {version, status, conflict?}
WS /api/ws # WebSocket (exists, extend for auth + edits)
Client → Server: {type: "edit", path, patch, baseVersion}
Server → Client: {type: "ack", path, version}
Server → Client: {type: "broadcast", path, patch, author}
Client → Server: {type: "cursor", path, position} // Phase 2
```
### Protocol Flow (Online)
```
1. User opens /some-doc
2. Browser fetches content, caches in IndexedDB
3. User edits → client generates patch (diff)
4. Client sends WS: {type: "edit", path, patch, baseVersion}
5. Server validates: baseVersion == current version?
- Yes: apply patch, increment version, broadcast to other clients
- No: return conflict, client must merge
6. Other clients receive broadcast, update editor if viewing same doc
```
### Protocol Flow (Offline)
```
1. User goes offline (detected by navigator.onLine + fetch failure)
2. Offline banner appears
3. User edits → changes queued in IndexedDB: `pending_edits` store
4. User navigates → served from IndexedDB cache
5. User comes back online
6. Client replays queue: for each pending edit, POST to server
7. Server responds with ack or conflict
8. Conflicts shown in UI for user resolution
```
### Conflict Resolution (Text)
- Three-way merge using diff3 algorithm
- If auto-merge succeeds: apply silently, show subtle notification
- If auto-merge fails: show side-by-side diff in UI
- User chooses: keep local, keep server, or edit merged result
- Markdown-aware: try to merge at paragraph/section boundaries
## Browser Sync Engine
### IndexedDB Schema
```js
const DB_NAME = 'mdhub';
const DB_VERSION = 2;
const stores = {
documents: 'path', // {path, title, hash, version, syncedAt}
content: 'path', // {path, content, hash, fetchedAt}
pending_edits: 'id', // {id, path, patch, baseVersion, createdAt, retries}
sync_state: 'key' // {key: 'lastSync', value: timestamp}
};
```
### sync.js Responsibilities
- `queueEdit(path, patch)`: Add to pending_edits, try to send immediately
- `syncPending()`: Replay queue when online, handle acks/conflicts
- `applyBroadcast(path, patch)`: Apply real-time update from other user
- `detectConflict(base, local, server)`: Run diff3, return merged or null
- `saveToIndexedDB(path, content)`: Cache for offline reading
### Service Worker (sw.js)
- Intercept navigation requests (`/:path*`)
- If offline: serve from IndexedDB cache
- If online: network first, update cache in background
- Cache static assets (JS, CSS, fonts) for app shell
- Enables "Add to Home Screen" PWA behavior
### Editor
- Start with `<textarea>` + diff generation (simple, robust)
- Phase 2: ProseMirror for structured editing, operational transforms
- Auto-save: debounced 2s, queues edit immediately
- Show sync status: unsaved → saving → saved → (offline: queued)
## Server Changes
### 1. Auth Middleware
```go
// Extend existing session system
func authMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Check session cookie
// Set user in context
// For WebSocket: authenticate on connection open
})
}
```
### 2. Document Write Endpoint
```go
func handleDocumentSave(w http.ResponseWriter, r *http.Request) {
path := chi.URLParam(r, "*")
var req struct {
Content string `json:"content"`
BaseVersion int `json:"baseVersion"`
ClientHash string `json:"clientHash"`
}
// Verify baseVersion matches current
// If mismatch: return 409 Conflict with server version
// If match: save to disk, update version, broadcast WS
}
```
### 3. WebSocket Extension
```go
type WSMessage struct {
Type string `json:"type"` // edit, ack, broadcast, cursor
Path string `json:"path"`
Patch string `json:"patch"` // unified diff
BaseVersion int `json:"baseVersion"`
Author string `json:"author"`
}
func (h *Hub) handleEdit(msg WSMessage, client *Client) {
// Validate auth
// Apply to document
// Broadcast to other clients viewing same path
// Send ack to sender
}
```
### 4. Versioned Documents
- Add `version` column to `documents` table (integer, auto-increment per doc)
- Add `document_versions` table for history:
```sql
CREATE TABLE document_versions (
id INTEGER PRIMARY KEY,
path TEXT NOT NULL,
version INTEGER NOT NULL,
content_hash TEXT NOT NULL,
author TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
UNIQUE(path, version)
);
```
- Current content always on disk; versions are content hashes referencing `store`
## Implementation Phases
### Phase 1: Offline Reading (1-2 weeks)
- [x] IndexedDB cache for documents and content (done)
- [x] Offline indicator UI (done)
- [x] Service Worker for offline navigation
- [x] Cache static assets for PWA
- [x] Show "cached" badge on offline-loaded pages
### Phase 2: Edit + Queue (2 weeks)
- [ ] Simple textarea editor at `/:path/edit`
- [ ] Auto-save with debounce
- [ ] Queue edits in IndexedDB when offline
- [ ] Replay queue on reconnect with exponential backoff
- [ ] Visual "sync pending" indicator
### Phase 3: Conflict Resolution (1-2 weeks)
- [ ] Server versioning (add `version` to documents table)
- [ ] `POST /api/documents/:path` with version check
- [ ] Three-way merge on client (diff3 library)
- [ ] Conflict UI: side-by-side or inline diff
- [ ] Auto-merge for non-overlapping changes
### Phase 4: Real-Time Collaboration (2 weeks)
- [ ] WebSocket edit messages
- [ ] Operational transforms or patch-based sync
- [ ] Presence awareness (who's editing, cursor positions)
- [ ] Broadcast updates to all connected clients
- [ ] Optimistic UI updates (show edit immediately, rollback on conflict)
### Phase 5: PWA Polish (1 week)
- [ ] Manifest.json for "Add to Home Screen"
- [ ] App shell architecture (instant load)
- [ ] Background sync API for reliable offline queue
- [ ] Push notifications for mentions/comments (future)
## Pros
- Builds directly on existing architecture (no new binary)
- Works everywhere with a browser (no platform-specific code)
- Simpler auth (reuse session cookies)
- Real-time collaboration possible
- PWA can feel like native app
## Cons
- Requires internet for editing (unless Phase 2 offline queue works well)
- Browser storage limits (~60MB+ with storage permission)
- Can't use native editors (VS Code, etc.)
- WebSocket reliability on mobile/bad networks
- Conflict UX constrained by browser capabilities
## Decision Needed
- Do we need native editor integration (Plan A) or is browser enough?
- Can we build Plan B first, then extract core sync for Plan A later?
## Shared with Plan A
Both plans eventually need:
- Authentication/authorization (API keys vs sessions)
- Document versioning
- Conflict resolution algorithms
- Content-addressed storage (already exists)

View File

@@ -0,0 +1,210 @@
# Plan A: Native Sync Protocol (macOS + Server)
> **AGENT INSTRUCTION — READ FIRST**
>
> When you complete a phase or are ready for a code review, you **must** notify the opencode session that created this plan. Use the opencode HTTP API to send a message to session `ses_225742b62ffeOSelYyCe4IPwxL`.
>
> **Message format:**
> ```
> Subject: Plan A Review Ready — Phase X Complete
> Body:
> - Phase completed: [e.g., Phase 1]
> - Files touched: [list of files added/modified]
> - Summary: [2-3 sentence description of what was implemented]
> - Tests: [which tests were added or updated]
> - Blockers: [any issues or decisions needed]
> ```
>
> **OpenCode API Specification:**
> ```
> POST https://api.opencode.ai/v1/sessions/ses_225742b62ffeOSelYyCe4IPwxL/messages
> Content-Type: application/json
> Authorization: Bearer <OPENCODE_API_KEY>
>
> Request body:
> {
> "role": "assistant",
> "content": "Subject: Plan A Review Ready — Phase X Complete\n\nBody:\n- Phase completed: ...\n- Files touched: ...\n- Summary: ...\n- Tests: ...\n- Blockers: ..."
> }
> ```
>
> Wait for a response before starting the next phase unless explicitly told otherwise.
## Goal
Build a bidirectional filesystem sync protocol that lets a native macOS client keep a local folder in sync with the cairnquire server. The browser client continues to work via the existing WebSocket + REST API.
## Architecture
### Server: Sync Service (`internal/sync/`)
New package alongside existing services:
```
internal/sync/
├── protocol.go # Sync protocol types and state machine
├── service.go # Business logic: resolve conflicts, apply changes
├── handlers.go # HTTP endpoints for sync (wrapped in httpserver)
├── store.go # Abstract over docs + store for sync operations
└── watcher.go # Bridge file watcher events into sync protocol
```
### macOS Client
A separate Go binary in `apps/macos-sync/` (or `apps/sync/`):
```
apps/macos-sync/
├── cmd/md-sync/ # Main entry point
├── internal/
│ ├── client.go # HTTP client for sync protocol
│ ├── fs.go # macOS filesystem watcher (fsevents)
│ ├── sync.go # Local sync engine
│ └── state.go # Local state DB (SQLite or JSON)
```
## Sync Protocol v1
### Data Model
- **Snapshot**: A point-in-time tree of `{path, hash, size, mtime}`
- **Delta**: List of changes since a snapshot: `{type: create|update|delete|rename, path, oldPath?, hash, size}`
- **Conflict**: When server and client both changed same file since last common snapshot
### Endpoints
```
POST /api/sync/init
Body: {deviceId, rootPath}
Response: {snapshotId, serverSnapshot}
POST /api/sync/delta
Body: {snapshotId, clientDelta}
Response: {serverDelta, conflicts[]}
POST /api/sync/resolve
Body: {snapshotId, resolutions[]}
Response: {newSnapshotId}
GET /api/sync/content?hash=<hash>
Response: raw file bytes (content-addressed, immutable)
```
### Protocol Flow
```
1. Client calls /sync/init → gets server snapshot
2. Client computes local delta (fsevents + local state)
3. Client POSTs delta to /sync/delta
4. Server responds with:
- serverDelta (changes client needs to apply)
- conflicts[] (need user resolution or auto-merge)
5. Client resolves conflicts locally or via UI
6. Client POSTs resolutions to /sync/resolve
7. Server returns new snapshotId, both sides updated
```
### Conflict Resolution Strategies
- `last-write-wins` (default for non-conflicting files)
- `rename-both` (keep both as `file.md` and `file (conflict).md`)
- `manual-merge` (flag for UI)
- `server-wins` / `client-wins` (per-file or global preference)
### Authentication
- API keys (long-lived, revocable) — simpler for native clients
- OAuth2 device flow — better for multi-user
- Start with API keys: `X-API-Key: <token>` header
## macOS Client Design
### File Watcher
- Use `github.com/fsnotify/fsevents` (Cgo wrapper around FSEvents)
- Debounce rapid changes (500ms window)
- Ignore `.git`, `.DS_Store`, temp files
### Local State
- SQLite DB at `~/Library/Application Support/md-sync/state.db`
- Tables: `snapshots`, `files`, `pending_deltas`, `conflicts`
- Or simpler: JSON files + file hashes on disk
### Sync Engine
- Runs as daemon / menubar app
- Configurable sync interval (default: continuous via fsevents)
- Offline queue: store deltas locally, sync when reconnected
- Bandwidth-aware: skip large files on cellular
### UI (Optional Phase 2)
- Menubar icon: sync status, last sync time, conflicts badge
- Settings window: root folder, server URL, API key, exclusions
- Conflict resolution dialog
## Server Changes Required
### 1. Sync Service
```go
type SyncService struct {
docStore *docs.Service
fileStore *store.Service
watcher *fsnotify.Watcher
}
func (s *SyncService) InitSync(deviceID string) (*Snapshot, error)
func (s *SyncService) ApplyDelta(snapshotID string, delta Delta) (*DeltaResult, error)
func (s *SyncService) ResolveConflicts(snapshotID string, resolutions []Resolution) (*Snapshot, error)
```
### 2. Authentication
- Add API key table to database
- Middleware to accept `X-API-Key` or session cookie
- API keys scoped to user + device
### 3. Content-Addressed Fetch
- `GET /api/content/<hash>` — immutable, cache-friendly
- Uses existing `store.Service` (already content-addressed)
### 4. Push Notifications (Optional)
- Server-Sent Events or WebSocket to tell client "server changed"
- Avoids client polling
## Implementation Phases
### Phase 1: Server Sync Protocol (2-3 weeks)
- [ ] Define protocol types and state machine
- [ ] Implement `/api/sync/init` and snapshot generation
- [ ] Implement `/api/sync/delta` with conflict detection
- [ ] Add content-addressed fetch endpoint
- [ ] Write sync protocol tests (table-driven, simulate client/server)
- [ ] Document protocol in `docs/specs/sync-protocol-v1.md`
### Phase 2: macOS Sync Engine (2-3 weeks)
- [ ] Project scaffold: Go module, build scripts
- [ ] FSEvents watcher with debouncing
- [ ] Local state SQLite DB
- [ ] HTTP client for sync endpoints
- [ ] Basic sync loop: init → delta → apply
- [ ] Offline queue (store deltas, retry with backoff)
- [ ] CLI tool: `md-sync --folder ./docs --server https://...`
### Phase 3: Conflict Resolution + Polish (1-2 weeks)
- [ ] Auto-resolve strategies
- [ ] Conflict file generation (`file (conflict).md`)
- [ ] Menubar app wrapper (Swift or `github.com/progrium/macdriver`)
- [ ] Settings/preferences
- [ ] Binary signing + notarization for distribution
### Phase 4: Advanced Features (Future)
- [ ] Selective sync (ignore patterns)
- [ ] Bandwidth limiting
- [ ] Version history / time machine from server snapshots
- [ ] iOS client (shares core sync engine)
## Pros
- Native performance, works with any editor (VS Code, Obsidian, etc.)
- Familiar sync model (Dropbox, iCloud Drive)
- Content-addressed storage makes deduplication trivial
- macOS client opens door to iOS, Windows, Linux
## Cons
- New authentication system (API keys)
- Complex conflict resolution UX
- macOS development overhead (codesign, notarization, FSEvents)
- Diverges from browser-first architecture
## Decision Needed
- Should we support both browser sync AND native sync, or pick one?
- If both: shared sync service, different transport (WebSocket vs HTTP)

View File

@@ -13,23 +13,23 @@ COPY apps/server/go.mod apps/server/go.sum* ./apps/server/
WORKDIR /workspace/apps/server WORKDIR /workspace/apps/server
RUN go mod download RUN go mod download
COPY apps/server/ ./ COPY apps/server/ ./
RUN CGO_ENABLED=1 go build -trimpath -ldflags="-s -w" -o /workspace/md-hub-secure ./cmd/md-hub-secure RUN CGO_ENABLED=1 go build -trimpath -ldflags="-s -w" -o /workspace/cairnquire ./cmd/cairnquire
FROM debian:bookworm-slim FROM debian:bookworm-slim
RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates curl && rm -rf /var/lib/apt/lists/* RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates curl && rm -rf /var/lib/apt/lists/*
RUN useradd --create-home --shell /usr/sbin/nologin appuser RUN useradd --create-home --shell /usr/sbin/nologin appuser
WORKDIR /workspace WORKDIR /workspace
COPY --from=server-build /workspace/md-hub-secure /usr/local/bin/md-hub-secure COPY --from=server-build /workspace/cairnquire /usr/local/bin/cairnquire
COPY --from=web-build /workspace/apps/web/dist /workspace/web-dist COPY --from=web-build /workspace/apps/web/dist /workspace/web-dist
COPY content /workspace/content COPY content /workspace/content
RUN mkdir -p /workspace/data/files && chown -R appuser:appuser /workspace RUN mkdir -p /workspace/data/files && chown -R appuser:appuser /workspace
USER appuser USER appuser
EXPOSE 8080 EXPOSE 8080
ENV MD_HUB_SERVER_ADDR=:8080 ENV CAIRNQUIRE_SERVER_ADDR=:8080
ENV MD_HUB_DATABASE_PATH=/workspace/data/db.sqlite ENV CAIRNQUIRE_DATABASE_PATH=/workspace/data/db.sqlite
ENV MD_HUB_CONTENT_SOURCE_DIR=/workspace/content ENV CAIRNQUIRE_CONTENT_SOURCE_DIR=/workspace/content
ENV MD_HUB_CONTENT_STORE_DIR=/workspace/data/files ENV CAIRNQUIRE_CONTENT_STORE_DIR=/workspace/data/files
ENV MD_HUB_WEB_DIST_DIR=/workspace/web-dist ENV CAIRNQUIRE_WEB_DIST_DIR=/workspace/web-dist
HEALTHCHECK --interval=10s --timeout=3s --start-period=5s --retries=5 CMD curl --fail http://127.0.0.1:8080/health || exit 1 HEALTHCHECK --interval=10s --timeout=3s --start-period=5s --retries=5 CMD curl --fail http://127.0.0.1:8080/health || exit 1
ENTRYPOINT ["/usr/local/bin/md-hub-secure"] ENTRYPOINT ["/usr/local/bin/cairnquire"]

View File

@@ -1,54 +0,0 @@
SHELL := /bin/bash
GO := go
NPM := npm
SERVER_DIR := apps/server
WEB_DIR := apps/web
SERVER_BIN := $(SERVER_DIR)/bin/md-hub-secure
.PHONY: web-install web-build web-dev server-run server-build server-test docker-build ci fmt dev dev-web dev-server
web-install:
cd $(WEB_DIR) && $(NPM) install
web-build:
cd $(WEB_DIR) && $(NPM) run build
web-dev:
cd $(WEB_DIR) && $(NPM) run dev
server-run:
cd $(SERVER_DIR) && CGO_ENABLED=1 $(GO) run ./cmd/md-hub-secure
server-build:
mkdir -p $(SERVER_DIR)/bin
cd $(SERVER_DIR) && CGO_ENABLED=1 $(GO) build -trimpath -o bin/md-hub-secure ./cmd/md-hub-secure
server-test:
cd $(SERVER_DIR) && CGO_ENABLED=1 $(GO) test ./...
dev-server:
cd $(SERVER_DIR) && MD_HUB_DEV_VITE_URL=http://localhost:5173 air
dev-web:
cd $(WEB_DIR) && $(NPM) run dev
dev:
@echo "Starting dev servers..."
@echo " - Go server (with air live reload) on http://localhost:8080"
@echo " - Vite dev server on http://localhost:5173"
@echo ""
@echo "Access the app at http://localhost:8080/app/"
@echo ""
@trap 'kill %1 %2 2>/dev/null || true' EXIT; \
make dev-server & \
make dev-web & \
wait
docker-build:
docker build -t md-hub-secure:dev .
fmt:
cd $(SERVER_DIR) && $(GO) fmt ./...
cd $(WEB_DIR) && $(NPM) run format
ci: web-install web-build server-test server-build

View File

@@ -1,6 +1,6 @@
# MD Hub Secure # Cairnquire
MD Hub Secure is a Go-first documentation platform for serving Markdown as secure, fast HTML with room for later sync, collaboration, and offline features. Cairnquire is a Go-first documentation platform for serving Markdown as secure, fast HTML with room for later sync, collaboration, and offline features.
## Current Status ## Current Status
@@ -17,19 +17,23 @@ This repository now contains the Milestone 1 foundation scaffold:
### Prerequisites ### Prerequisites
- Go 1.24+ - `mise`
- Node.js 24+
- npm 11+
- Docker (optional) - Docker (optional)
- CGO-capable toolchain for `go-libsql` - CGO-capable toolchain for `go-libsql`
- `air` for Go live reload (`go install github.com/air-verse/air@latest`) - `air` for Go live reload (`go install github.com/air-verse/air@v1.65.1`)
Install the pinned toolchain with:
```bash
mise install
```
### Development Mode (with live reload) ### Development Mode (with live reload)
Run both the Go server and Vite dev server with live reload: Run both the Go server and Vite dev server with live reload:
```bash ```bash
make dev mise run dev
``` ```
This starts: This starts:
@@ -41,10 +45,10 @@ You can also run them separately in two terminals:
```bash ```bash
# Terminal 1 - Go server with live reload # Terminal 1 - Go server with live reload
make dev-server mise run dev-server
# Terminal 2 - Vite dev server # Terminal 2 - Vite dev server
make dev-web mise run dev-web
``` ```
### Production Mode ### Production Mode
@@ -52,9 +56,9 @@ make dev-web
Build the web app and run the Go server without Vite proxy: Build the web app and run the Go server without Vite proxy:
```bash ```bash
make web-install mise run web-install
make web-build mise run web-build
make server-run mise run server-run
``` ```
The server listens on `http://localhost:8080` by default. The server listens on `http://localhost:8080` by default.
@@ -62,31 +66,31 @@ The server listens on `http://localhost:8080` by default.
### Common commands ### Common commands
```bash ```bash
make server-test mise run server-test
make server-build mise run server-build
make docker-build mise run docker-build
make ci mise run ci
make fmt mise run fmt
``` ```
## Configuration ## Configuration
Configuration can come from: Configuration can come from:
1. `config.json` via `MD_HUB_CONFIG` 1. `config.json` via `CAIRNQUIRE_CONFIG`
2. Environment variables 2. Environment variables
Supported variables: Supported variables:
- `MD_HUB_SERVER_ADDR` - `CAIRNQUIRE_SERVER_ADDR`
- `MD_HUB_DATABASE_PATH` - `CAIRNQUIRE_DATABASE_PATH`
- `MD_HUB_DATABASE_PRIMARY_URL` - `CAIRNQUIRE_DATABASE_PRIMARY_URL`
- `MD_HUB_DATABASE_AUTH_TOKEN` - `CAIRNQUIRE_DATABASE_AUTH_TOKEN`
- `MD_HUB_CONTENT_SOURCE_DIR` - `CAIRNQUIRE_CONTENT_SOURCE_DIR`
- `MD_HUB_CONTENT_STORE_DIR` - `CAIRNQUIRE_CONTENT_STORE_DIR`
- `MD_HUB_WEB_DIST_DIR` - `CAIRNQUIRE_WEB_DIST_DIR`
- `MD_HUB_DEV_VITE_URL` - Set to proxy `/app/*` to Vite dev server (e.g. `http://localhost:5173`) - `CAIRNQUIRE_DEV_VITE_URL` - Set to proxy `/app/*` to Vite dev server (e.g. `http://localhost:5173`)
- `MD_HUB_LOG_LEVEL` - `CAIRNQUIRE_LOG_LEVEL`
## Noted Gaps ## Noted Gaps

View File

@@ -7,10 +7,10 @@ tmp_dir = "tmp"
[build] [build]
args_bin = [] args_bin = []
bin = "./bin/md-hub-secure" bin = "./bin/cairnquire"
cmd = "CGO_ENABLED=1 go build -trimpath -o bin/md-hub-secure ./cmd/md-hub-secure" cmd = "CGO_ENABLED=1 go build -trimpath -o bin/cairnquire ./cmd/cairnquire"
delay = 500 delay = 500
entrypoint = ["./bin/md-hub-secure"] entrypoint = ["./bin/cairnquire"]
exclude_dir = ["assets", "tmp", "vendor", "testdata", "bin"] exclude_dir = ["assets", "tmp", "vendor", "testdata", "bin"]
exclude_file = [] exclude_file = []
exclude_regex = ["_test.go"] exclude_regex = ["_test.go"]

View File

@@ -9,9 +9,9 @@ import (
"os/signal" "os/signal"
"syscall" "syscall"
"github.com/tim/md-hub-secure/apps/server/internal/app" "github.com/tim/cairnquire/apps/server/internal/app"
"github.com/tim/md-hub-secure/apps/server/internal/config" "github.com/tim/cairnquire/apps/server/internal/config"
"github.com/tim/md-hub-secure/apps/server/internal/logging" "github.com/tim/cairnquire/apps/server/internal/logging"
) )
func main() { func main() {

View File

@@ -1,4 +1,4 @@
module github.com/tim/md-hub-secure/apps/server module github.com/tim/cairnquire/apps/server
go 1.24.2 go 1.24.2

View File

@@ -9,13 +9,14 @@ import (
"os" "os"
"time" "time"
"github.com/tim/md-hub-secure/apps/server/internal/config" "github.com/tim/cairnquire/apps/server/internal/config"
"github.com/tim/md-hub-secure/apps/server/internal/database" "github.com/tim/cairnquire/apps/server/internal/database"
"github.com/tim/md-hub-secure/apps/server/internal/docs" "github.com/tim/cairnquire/apps/server/internal/docs"
"github.com/tim/md-hub-secure/apps/server/internal/httpserver" "github.com/tim/cairnquire/apps/server/internal/httpserver"
"github.com/tim/md-hub-secure/apps/server/internal/markdown" "github.com/tim/cairnquire/apps/server/internal/markdown"
"github.com/tim/md-hub-secure/apps/server/internal/realtime" "github.com/tim/cairnquire/apps/server/internal/realtime"
"github.com/tim/md-hub-secure/apps/server/internal/store" "github.com/tim/cairnquire/apps/server/internal/store"
"github.com/tim/cairnquire/apps/server/internal/sync"
) )
type App struct { type App struct {
@@ -58,6 +59,9 @@ func New(ctx context.Context, cfg config.Config, logger *slog.Logger) (*App, err
logger.Warn("initial content sync failed", "error", err) logger.Warn("initial content sync failed", "error", err)
} }
syncRepo := sync.NewRepository(db.SQL())
syncService := sync.NewService(syncRepo, service, contentStore, cfg.Content.SourceDir, logger)
handler, err := httpserver.New(httpserver.Dependencies{ handler, err := httpserver.New(httpserver.Dependencies{
Config: cfg, Config: cfg,
Logger: logger, Logger: logger,
@@ -65,6 +69,8 @@ func New(ctx context.Context, cfg config.Config, logger *slog.Logger) (*App, err
Repository: repo, Repository: repo,
ContentStore: contentStore, ContentStore: contentStore,
Hub: hub, Hub: hub,
SyncService: syncService,
SyncRepo: syncRepo,
}) })
if err != nil { if err != nil {
return nil, fmt.Errorf("build http handler: %w", err) return nil, fmt.Errorf("build http handler: %w", err)

View File

@@ -49,7 +49,7 @@ func Default() Config {
}, },
Web: WebConfig{ Web: WebConfig{
DistDir: filepath.Join("..", "web", "dist"), DistDir: filepath.Join("..", "web", "dist"),
DevViteURL: os.Getenv("MD_HUB_DEV_VITE_URL"), DevViteURL: os.Getenv("CAIRNQUIRE_DEV_VITE_URL"),
}, },
LogLevel: "INFO", LogLevel: "INFO",
} }
@@ -58,21 +58,21 @@ func Default() Config {
func Load() (Config, error) { func Load() (Config, error) {
cfg := Default() cfg := Default()
if configPath := os.Getenv("MD_HUB_CONFIG"); configPath != "" { if configPath := os.Getenv("CAIRNQUIRE_CONFIG"); configPath != "" {
if err := loadFile(configPath, &cfg); err != nil { if err := loadFile(configPath, &cfg); err != nil {
return Config{}, err return Config{}, err
} }
} }
overrideString(&cfg.Server.Addr, "MD_HUB_SERVER_ADDR") overrideString(&cfg.Server.Addr, "CAIRNQUIRE_SERVER_ADDR")
overrideString(&cfg.Database.Path, "MD_HUB_DATABASE_PATH") overrideString(&cfg.Database.Path, "CAIRNQUIRE_DATABASE_PATH")
overrideString(&cfg.Database.PrimaryURL, "MD_HUB_DATABASE_PRIMARY_URL") overrideString(&cfg.Database.PrimaryURL, "CAIRNQUIRE_DATABASE_PRIMARY_URL")
overrideString(&cfg.Database.AuthToken, "MD_HUB_DATABASE_AUTH_TOKEN") overrideString(&cfg.Database.AuthToken, "CAIRNQUIRE_DATABASE_AUTH_TOKEN")
overrideString(&cfg.Content.SourceDir, "MD_HUB_CONTENT_SOURCE_DIR") overrideString(&cfg.Content.SourceDir, "CAIRNQUIRE_CONTENT_SOURCE_DIR")
overrideString(&cfg.Content.StoreDir, "MD_HUB_CONTENT_STORE_DIR") overrideString(&cfg.Content.StoreDir, "CAIRNQUIRE_CONTENT_STORE_DIR")
overrideString(&cfg.Web.DistDir, "MD_HUB_WEB_DIST_DIR") overrideString(&cfg.Web.DistDir, "CAIRNQUIRE_WEB_DIST_DIR")
overrideString(&cfg.Web.DevViteURL, "MD_HUB_DEV_VITE_URL") overrideString(&cfg.Web.DevViteURL, "CAIRNQUIRE_DEV_VITE_URL")
overrideString(&cfg.LogLevel, "MD_HUB_LOG_LEVEL") overrideString(&cfg.LogLevel, "CAIRNQUIRE_LOG_LEVEL")
if cfg.Server.Addr == "" { if cfg.Server.Addr == "" {
return Config{}, fmt.Errorf("server.addr must not be empty") return Config{}, fmt.Errorf("server.addr must not be empty")

View File

@@ -10,7 +10,7 @@ import (
libsql "github.com/tursodatabase/go-libsql" libsql "github.com/tursodatabase/go-libsql"
"github.com/tim/md-hub-secure/apps/server/internal/config" "github.com/tim/cairnquire/apps/server/internal/config"
) )
type DB interface { type DB interface {

View File

@@ -0,0 +1,35 @@
CREATE TABLE IF NOT EXISTS api_keys (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
name TEXT NOT NULL,
key_hash TEXT NOT NULL UNIQUE,
scopes TEXT NOT NULL DEFAULT 'sync:read,sync:write',
created_at TEXT NOT NULL,
expires_at TEXT,
last_used_at TEXT,
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_api_keys_key_hash ON api_keys(key_hash);
CREATE TABLE IF NOT EXISTS sync_snapshots (
id TEXT PRIMARY KEY,
device_id TEXT NOT NULL,
user_id TEXT NOT NULL,
created_at TEXT NOT NULL,
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_sync_snapshots_device ON sync_snapshots(device_id);
CREATE TABLE IF NOT EXISTS sync_files (
snapshot_id TEXT NOT NULL,
path TEXT NOT NULL,
hash TEXT NOT NULL,
size_bytes INTEGER NOT NULL DEFAULT 0,
modified_at TEXT NOT NULL,
PRIMARY KEY (snapshot_id, path),
FOREIGN KEY(snapshot_id) REFERENCES sync_snapshots(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_sync_files_hash ON sync_files(hash);

View File

@@ -12,8 +12,8 @@ import (
"strings" "strings"
"sync" "sync"
"github.com/tim/md-hub-secure/apps/server/internal/markdown" "github.com/tim/cairnquire/apps/server/internal/markdown"
"github.com/tim/md-hub-secure/apps/server/internal/store" "github.com/tim/cairnquire/apps/server/internal/store"
) )
type Service struct { type Service struct {
@@ -41,6 +41,25 @@ type Page struct {
Hash string Hash string
} }
type SourcePage struct {
Path string
Title string
Tags []string
Content string
Hash string
}
type DocumentConflictError struct {
Path string
BaseHash string
CurrentHash string
CurrentContent string
}
func (e *DocumentConflictError) Error() string {
return fmt.Sprintf("document %s changed since editor loaded", e.Path)
}
func NewService(sourceDir string, store *store.ContentStore, renderer *markdown.Renderer, repo *Repository, logger *slog.Logger) *Service { func NewService(sourceDir string, store *store.ContentStore, renderer *markdown.Renderer, repo *Repository, logger *slog.Logger) *Service {
return &Service{ return &Service{
sourceDir: sourceDir, sourceDir: sourceDir,
@@ -61,6 +80,11 @@ func (s *Service) SyncSourceDir(ctx context.Context) ([]DocumentChange, error) {
s.mu.Lock() s.mu.Lock()
defer s.mu.Unlock() defer s.mu.Unlock()
return s.syncSourceDirLocked(ctx)
}
func (s *Service) syncSourceDirLocked(ctx context.Context) ([]DocumentChange, error) {
var changes []DocumentChange var changes []DocumentChange
err := filepath.WalkDir(s.sourceDir, func(path string, entry fs.DirEntry, err error) error { err := filepath.WalkDir(s.sourceDir, func(path string, entry fs.DirEntry, err error) error {
if err != nil { if err != nil {
@@ -104,6 +128,116 @@ func (s *Service) LoadPage(ctx context.Context, requestPath string) (*Page, erro
return nil, err return nil, err
} }
record, normalized, err := s.resolveRecord(ctx, requestPath)
if err != nil {
return nil, err
}
content, err := s.store.Read(record.CurrentHash)
if err != nil {
return nil, fmt.Errorf("read content %s: %w", record.CurrentHash, err)
}
rendered, err := s.renderer.RenderPath(content, normalized)
if err != nil {
return nil, fmt.Errorf("render page %s: %w", normalized, err)
}
return &Page{
Path: record.Path,
Title: rendered.Title,
Tags: record.Tags,
HTML: string(rendered.HTML),
Hash: record.CurrentHash,
}, nil
}
func (s *Service) LoadSourcePage(ctx context.Context, requestPath string) (*SourcePage, error) {
if _, err := s.SyncSourceDir(ctx); err != nil {
return nil, err
}
record, _, err := s.resolveRecord(ctx, requestPath)
if err != nil {
return nil, err
}
content, err := s.store.Read(record.CurrentHash)
if err != nil {
return nil, fmt.Errorf("read source content %s: %w", record.CurrentHash, err)
}
return &SourcePage{
Path: record.Path,
Title: record.Title,
Tags: record.Tags,
Content: string(content),
Hash: record.CurrentHash,
}, nil
}
func (s *Service) SaveSourcePage(ctx context.Context, requestPath string, content string) (*SourcePage, error) {
return s.SaveSourcePageWithBaseHash(ctx, requestPath, content, "")
}
func (s *Service) SaveSourcePageWithBaseHash(ctx context.Context, requestPath string, content string, baseHash string) (*SourcePage, error) {
s.mu.Lock()
defer s.mu.Unlock()
if _, err := s.syncSourceDirLocked(ctx); err != nil {
return nil, err
}
record, _, err := s.resolveRecord(ctx, requestPath)
if err != nil {
return nil, err
}
if baseHash != "" && record.CurrentHash != baseHash {
currentContent, readErr := s.store.Read(record.CurrentHash)
if readErr != nil {
return nil, fmt.Errorf("read current content %s: %w", record.CurrentHash, readErr)
}
return nil, &DocumentConflictError{
Path: record.Path,
BaseHash: baseHash,
CurrentHash: record.CurrentHash,
CurrentContent: string(currentContent),
}
}
fullPath := filepath.Join(s.sourceDir, filepath.FromSlash(record.Path))
if err := os.MkdirAll(filepath.Dir(fullPath), 0o755); err != nil {
return nil, fmt.Errorf("create document directory %s: %w", record.Path, err)
}
if err := os.WriteFile(fullPath, []byte(content), 0o644); err != nil {
return nil, fmt.Errorf("write document %s: %w", record.Path, err)
}
change, err := s.syncFile(ctx, fullPath)
if err != nil {
return nil, err
}
if change != nil && s.onChange != nil {
s.onChange(*change)
}
updated, _, err := s.resolveRecord(ctx, record.Path)
if err != nil {
return nil, err
}
return &SourcePage{
Path: updated.Path,
Title: updated.Title,
Tags: updated.Tags,
Content: content,
Hash: updated.CurrentHash,
}, nil
}
func (s *Service) resolveRecord(ctx context.Context, requestPath string) (*DocumentRecord, string, error) {
var ( var (
record *DocumentRecord record *DocumentRecord
normalized string normalized string
@@ -119,26 +253,10 @@ func (s *Service) LoadPage(ctx context.Context, requestPath string) (*Page, erro
lastErr = err lastErr = err
} }
if record == nil { if record == nil {
return nil, lastErr return nil, "", lastErr
} }
content, err := s.store.Read(record.CurrentHash) return record, normalized, nil
if err != nil {
return nil, fmt.Errorf("read content %s: %w", record.CurrentHash, err)
}
rendered, err := s.renderer.Render(content)
if err != nil {
return nil, fmt.Errorf("render page %s: %w", normalized, err)
}
return &Page{
Path: record.Path,
Title: rendered.Title,
Tags: record.Tags,
HTML: string(rendered.HTML),
Hash: record.CurrentHash,
}, nil
} }
func normalizeRequestPathCandidates(path string) []string { func normalizeRequestPathCandidates(path string) []string {
@@ -158,7 +276,13 @@ func (s *Service) syncFile(ctx context.Context, path string) (*DocumentChange, e
return nil, fmt.Errorf("read content file %s: %w", path, err) return nil, fmt.Errorf("read content file %s: %w", path, err)
} }
rendered, err := s.renderer.Render(content) relative, err := filepath.Rel(s.sourceDir, path)
if err != nil {
return nil, fmt.Errorf("compute relative path %s: %w", path, err)
}
relative = filepath.ToSlash(relative)
rendered, err := s.renderer.RenderPath(content, relative)
if err != nil { if err != nil {
return nil, fmt.Errorf("render content file %s: %w", path, err) return nil, fmt.Errorf("render content file %s: %w", path, err)
} }
@@ -168,12 +292,6 @@ func (s *Service) syncFile(ctx context.Context, path string) (*DocumentChange, e
return nil, fmt.Errorf("store content file %s: %w", path, err) return nil, fmt.Errorf("store content file %s: %w", path, err)
} }
relative, err := filepath.Rel(s.sourceDir, path)
if err != nil {
return nil, fmt.Errorf("compute relative path %s: %w", path, err)
}
relative = filepath.ToSlash(relative)
existing, err := s.repo.GetDocumentByPath(ctx, relative) existing, err := s.repo.GetDocumentByPath(ctx, relative)
if err != nil && !errors.Is(err, sql.ErrNoRows) { if err != nil && !errors.Is(err, sql.ErrNoRows) {
return nil, err return nil, err

View File

@@ -1,8 +1,18 @@
package docs package docs
import ( import (
"context"
"database/sql"
"errors"
"log/slog"
"os"
"path/filepath"
"reflect" "reflect"
"testing" "testing"
"github.com/tim/cairnquire/apps/server/internal/database"
"github.com/tim/cairnquire/apps/server/internal/markdown"
"github.com/tim/cairnquire/apps/server/internal/store"
) )
func TestNormalizeRequestPathCandidatesSupportsIndexFiles(t *testing.T) { func TestNormalizeRequestPathCandidatesSupportsIndexFiles(t *testing.T) {
@@ -37,3 +47,95 @@ func TestNormalizeRequestPathCandidatesSupportsIndexFiles(t *testing.T) {
}) })
} }
} }
func TestSaveSourcePageWithBaseHashRejectsStaleEditor(t *testing.T) {
service, sourceDir := setupDocsTestService(t)
ctx := context.Background()
page, err := service.LoadSourcePage(ctx, "hello")
if err != nil {
t.Fatalf("LoadSourcePage() error = %v", err)
}
serverContent := "# Hello\n\nServer edit"
if err := os.WriteFile(filepath.Join(sourceDir, "hello.md"), []byte(serverContent), 0o644); err != nil {
t.Fatalf("write server edit: %v", err)
}
_, err = service.SaveSourcePageWithBaseHash(ctx, "hello", "# Hello\n\nClient edit", page.Hash)
var conflict *DocumentConflictError
if !errors.As(err, &conflict) {
t.Fatalf("SaveSourcePageWithBaseHash() error = %v, want DocumentConflictError", err)
}
if conflict.BaseHash != page.Hash {
t.Fatalf("conflict base hash = %q, want %q", conflict.BaseHash, page.Hash)
}
if conflict.CurrentContent != serverContent {
t.Fatalf("conflict current content = %q, want %q", conflict.CurrentContent, serverContent)
}
content, err := os.ReadFile(filepath.Join(sourceDir, "hello.md"))
if err != nil {
t.Fatalf("read hello.md: %v", err)
}
if string(content) != serverContent {
t.Fatalf("stale save overwrote content: %q", string(content))
}
}
func TestSaveSourcePageWithBaseHashAcceptsMatchingHash(t *testing.T) {
service, sourceDir := setupDocsTestService(t)
ctx := context.Background()
page, err := service.LoadSourcePage(ctx, "hello")
if err != nil {
t.Fatalf("LoadSourcePage() error = %v", err)
}
nextContent := "# Hello\n\nClient edit"
updated, err := service.SaveSourcePageWithBaseHash(ctx, "hello", nextContent, page.Hash)
if err != nil {
t.Fatalf("SaveSourcePageWithBaseHash() error = %v", err)
}
if updated.Hash == page.Hash {
t.Fatal("expected hash to change after save")
}
content, err := os.ReadFile(filepath.Join(sourceDir, "hello.md"))
if err != nil {
t.Fatalf("read hello.md: %v", err)
}
if string(content) != nextContent {
t.Fatalf("saved content = %q, want %q", string(content), nextContent)
}
}
func setupDocsTestService(t *testing.T) (*Service, string) {
t.Helper()
sourceDir := t.TempDir()
storeDir := t.TempDir()
if err := os.WriteFile(filepath.Join(sourceDir, "hello.md"), []byte("# Hello\n\nOriginal"), 0o644); err != nil {
t.Fatalf("create hello.md: %v", err)
}
dbPath := filepath.Join(t.TempDir(), "test.db")
db, err := sql.Open("libsql", "file:"+dbPath)
if err != nil {
t.Fatalf("open database: %v", err)
}
t.Cleanup(func() { db.Close() })
ctx := context.Background()
if err := database.ApplyMigrations(ctx, db); err != nil {
t.Fatalf("apply migrations: %v", err)
}
contentStore, err := store.New(storeDir)
if err != nil {
t.Fatalf("create content store: %v", err)
}
repo := NewRepository(db)
return NewService(sourceDir, contentStore, markdown.NewRenderer(), repo, slog.Default()), sourceDir
}

View File

@@ -6,7 +6,7 @@ import (
"strings" "strings"
"time" "time"
"github.com/tim/md-hub-secure/apps/server/internal/docs" "github.com/tim/cairnquire/apps/server/internal/docs"
) )
type adminUserInput struct { type adminUserInput struct {

View File

@@ -8,6 +8,7 @@ import (
"fmt" "fmt"
"html/template" "html/template"
"io" "io"
"io/fs"
"net/http" "net/http"
"os" "os"
"path/filepath" "path/filepath"
@@ -17,7 +18,7 @@ import (
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
"github.com/tim/md-hub-secure/apps/server/internal/docs" "github.com/tim/cairnquire/apps/server/internal/docs"
) )
type layoutData struct { type layoutData struct {
@@ -42,6 +43,16 @@ type documentData struct {
Breadcrumbs []breadcrumb Breadcrumbs []breadcrumb
} }
type documentEditData struct {
Browser browserData
Title string
Path string
Tags []string
Hash string
Source string
Breadcrumbs []breadcrumb
}
type breadcrumb struct { type breadcrumb struct {
Name string Name string
URL string URL string
@@ -86,7 +97,7 @@ func (s *Server) handleIndex(w http.ResponseWriter, r *http.Request) {
activeFolder := strings.Trim(r.URL.Query().Get("folder"), "/") activeFolder := strings.Trim(r.URL.Query().Get("folder"), "/")
s.renderTemplate(w, http.StatusOK, "index.gohtml", layoutData{ s.renderTemplate(w, http.StatusOK, "index.gohtml", layoutData{
Title: "MD Hub Secure", Title: "Cairnquire",
WebEnabled: s.webEnabled, WebEnabled: s.webEnabled,
BodyClass: "page-index", BodyClass: "page-index",
BodyTemplate: "index_content", BodyTemplate: "index_content",
@@ -126,9 +137,47 @@ func (s *Server) handleDocsIndex(w http.ResponseWriter, r *http.Request) {
func (s *Server) handleDocument(w http.ResponseWriter, r *http.Request) { func (s *Server) handleDocument(w http.ResponseWriter, r *http.Request) {
pagePath := chi.URLParam(r, "*") pagePath := chi.URLParam(r, "*")
if editPath, ok := trimEditSuffix(pagePath); ok {
s.renderDocumentEditor(w, r, editPath)
return
}
s.renderDocumentPage(w, r, pagePath) s.renderDocumentPage(w, r, pagePath)
} }
func (s *Server) renderDocumentEditor(w http.ResponseWriter, r *http.Request, pagePath string) {
page, err := s.documents.LoadSourcePage(r.Context(), pagePath)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
s.renderError(w, r, http.StatusNotFound, "document not found")
return
}
s.renderError(w, r, http.StatusInternalServerError, err.Error())
return
}
items, err := s.documents.ListDocuments(r.Context())
if err != nil {
s.renderError(w, r, http.StatusInternalServerError, err.Error())
return
}
s.renderTemplate(w, http.StatusOK, "document_edit.gohtml", layoutData{
Title: "Edit: " + page.Title,
WebEnabled: s.webEnabled,
BodyClass: "page-document-edit",
BodyTemplate: "document_edit_content",
Data: documentEditData{
Browser: buildBrowser(items, page.Path),
Title: page.Title,
Path: page.Path,
Tags: page.Tags,
Hash: page.Hash,
Source: page.Content,
Breadcrumbs: buildBreadcrumbs(page.Path),
},
})
}
func (s *Server) renderDocumentPage(w http.ResponseWriter, r *http.Request, pagePath string) { func (s *Server) renderDocumentPage(w http.ResponseWriter, r *http.Request, pagePath string) {
page, err := s.documents.LoadPage(r.Context(), pagePath) page, err := s.documents.LoadPage(r.Context(), pagePath)
if err != nil { if err != nil {
@@ -184,6 +233,20 @@ func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
}) })
} }
func (s *Server) handleServiceWorker(w http.ResponseWriter, r *http.Request) {
content, err := fs.ReadFile(assets, "static/sw.js")
if err != nil {
http.Error(w, "service worker unavailable", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/javascript; charset=utf-8")
w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("Service-Worker-Allowed", "/")
w.WriteHeader(http.StatusOK)
_, _ = w.Write(content)
}
func (s *Server) handleSearch(w http.ResponseWriter, r *http.Request) { func (s *Server) handleSearch(w http.ResponseWriter, r *http.Request) {
query := r.URL.Query().Get("q") query := r.URL.Query().Get("q")
if query == "" { if query == "" {
@@ -340,6 +403,52 @@ func (s *Server) handleDocuments(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]any{"documents": results}) writeJSON(w, http.StatusOK, map[string]any{"documents": results})
} }
func (s *Server) handleDocumentSave(w http.ResponseWriter, r *http.Request) {
pagePath := chi.URLParam(r, "*")
if pagePath == "" {
writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": "document path is required"})
return
}
var req struct {
Content string `json:"content"`
BaseHash string `json:"baseHash"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": "invalid request body"})
return
}
page, err := s.documents.SaveSourcePageWithBaseHash(r.Context(), pagePath, req.Content, req.BaseHash)
if err != nil {
var conflict *docs.DocumentConflictError
if errors.As(err, &conflict) {
writeJSONWithStatus(w, http.StatusConflict, map[string]any{
"error": "document conflict",
"status": "conflict",
"path": conflict.Path,
"baseHash": conflict.BaseHash,
"currentHash": conflict.CurrentHash,
"currentContent": conflict.CurrentContent,
})
return
}
if errors.Is(err, sql.ErrNoRows) {
writeJSONWithStatus(w, http.StatusNotFound, map[string]string{"error": "document not found"})
return
}
writeJSONWithStatus(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
return
}
writeJSONWithStatus(w, http.StatusOK, map[string]any{
"status": "saved",
"path": page.Path,
"title": page.Title,
"hash": page.Hash,
})
}
func writeJSON(w http.ResponseWriter, status int, payload any) { func writeJSON(w http.ResponseWriter, status int, payload any) {
writeJSONWithStatus(w, status, payload) writeJSONWithStatus(w, status, payload)
} }
@@ -350,6 +459,16 @@ func writeJSONWithStatus(w http.ResponseWriter, status int, payload any) {
_ = json.NewEncoder(w).Encode(payload) _ = json.NewEncoder(w).Encode(payload)
} }
func trimEditSuffix(path string) (string, bool) {
if path == "edit" {
return "", true
}
if !strings.HasSuffix(path, "/edit") {
return "", false
}
return strings.TrimSuffix(path, "/edit"), true
}
func buildBrowser(records []docs.DocumentRecord, activePath string) browserData { func buildBrowser(records []docs.DocumentRecord, activePath string) browserData {
paths := make([]string, 0, len(records)) paths := make([]string, 0, len(records))
titleByPath := make(map[string]string, len(records)) titleByPath := make(map[string]string, len(records))

View File

@@ -4,7 +4,7 @@ import (
"testing" "testing"
"time" "time"
"github.com/tim/md-hub-secure/apps/server/internal/docs" "github.com/tim/cairnquire/apps/server/internal/docs"
) )
func TestBuildBrowserUsesFolderIndex(t *testing.T) { func TestBuildBrowserUsesFolderIndex(t *testing.T) {
@@ -41,3 +41,25 @@ func TestBuildBrowserUsesFolderIndex(t *testing.T) {
t.Fatalf("folder index file should be represented by the folder, not repeated as a child file") t.Fatalf("folder index file should be represented by the folder, not repeated as a child file")
} }
} }
func TestTrimEditSuffix(t *testing.T) {
tests := []struct {
name string
input string
wantPath string
wantOK bool
}{
{name: "nested path", input: "guide/setup/edit", wantPath: "guide/setup", wantOK: true},
{name: "root edit sentinel", input: "edit", wantPath: "", wantOK: true},
{name: "document route", input: "guide/setup", wantPath: "", wantOK: false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
gotPath, gotOK := trimEditSuffix(tt.input)
if gotPath != tt.wantPath || gotOK != tt.wantOK {
t.Fatalf("trimEditSuffix(%q) = (%q, %t), want (%q, %t)", tt.input, gotPath, gotOK, tt.wantPath, tt.wantOK)
}
})
}
}

View File

@@ -12,10 +12,11 @@ import (
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware" "github.com/go-chi/chi/v5/middleware"
"github.com/tim/md-hub-secure/apps/server/internal/config" "github.com/tim/cairnquire/apps/server/internal/config"
"github.com/tim/md-hub-secure/apps/server/internal/docs" "github.com/tim/cairnquire/apps/server/internal/docs"
"github.com/tim/md-hub-secure/apps/server/internal/realtime" "github.com/tim/cairnquire/apps/server/internal/realtime"
"github.com/tim/md-hub-secure/apps/server/internal/store" "github.com/tim/cairnquire/apps/server/internal/store"
"github.com/tim/cairnquire/apps/server/internal/sync"
) )
//go:embed templates/*.gohtml static/* //go:embed templates/*.gohtml static/*
@@ -28,6 +29,8 @@ type Dependencies struct {
Repository *docs.Repository Repository *docs.Repository
ContentStore *store.ContentStore ContentStore *store.ContentStore
Hub *realtime.Hub Hub *realtime.Hub
SyncService *sync.Service
SyncRepo *sync.Repository
} }
type Server struct { type Server struct {
@@ -37,6 +40,8 @@ type Server struct {
repository *docs.Repository repository *docs.Repository
contentStore *store.ContentStore contentStore *store.ContentStore
hub *realtime.Hub hub *realtime.Hub
syncService *sync.Service
syncRepo *sync.Repository
templates *template.Template templates *template.Template
webEnabled bool webEnabled bool
} }
@@ -61,6 +66,8 @@ func New(deps Dependencies) (http.Handler, error) {
repository: deps.Repository, repository: deps.Repository,
contentStore: deps.ContentStore, contentStore: deps.ContentStore,
hub: deps.Hub, hub: deps.Hub,
syncService: deps.SyncService,
syncRepo: deps.SyncRepo,
templates: templates, templates: templates,
} }
@@ -75,9 +82,11 @@ func New(deps Dependencies) (http.Handler, error) {
router.Use(server.timeoutExceptWebSocket(30 * time.Second)) router.Use(server.timeoutExceptWebSocket(30 * time.Second))
router.Use(server.requestLogger) router.Use(server.requestLogger)
router.Use(server.securityHeaders) router.Use(server.securityHeaders)
router.Use(server.apiKeyMiddleware)
router.Get("/", server.handleIndex) router.Get("/", server.handleIndex)
router.Get("/health", server.handleHealth) router.Get("/health", server.handleHealth)
router.Get("/sw.js", server.handleServiceWorker)
router.Get("/ws", server.handleWebSocket) router.Get("/ws", server.handleWebSocket)
router.Post("/api/admin/login", server.handleAdminLogin) router.Post("/api/admin/login", server.handleAdminLogin)
router.Get("/api/admin/users", server.handleAdminUsers) router.Get("/api/admin/users", server.handleAdminUsers)
@@ -87,9 +96,17 @@ func New(deps Dependencies) (http.Handler, error) {
router.Get("/docs", server.handleDocsIndex) router.Get("/docs", server.handleDocsIndex)
router.Get("/docs/*", server.handleDocument) router.Get("/docs/*", server.handleDocument)
router.Get("/api/documents", server.handleDocuments) router.Get("/api/documents", server.handleDocuments)
router.Post("/api/documents/*", server.handleDocumentSave)
router.Get("/api/search", server.handleSearch) router.Get("/api/search", server.handleSearch)
router.Post("/api/uploads", server.handleUpload) router.Post("/api/uploads", server.handleUpload)
router.Get("/attachments/{hash}", server.handleAttachment) router.Get("/attachments/{hash}", server.handleAttachment)
// Sync protocol endpoints
router.Post("/api/sync/init", server.handleSyncInit)
router.Post("/api/sync/delta", server.handleSyncDelta)
router.Post("/api/sync/resolve", server.handleSyncResolve)
router.Get("/api/content/{hash}", server.handleContentFetch)
router.Handle("/static/*", http.StripPrefix("/static/", http.FileServer(mustSub("static")))) router.Handle("/static/*", http.StripPrefix("/static/", http.FileServer(mustSub("static"))))
if server.webEnabled { if server.webEnabled {

View File

@@ -0,0 +1,29 @@
package httpserver
import (
"net/http"
"net/http/httptest"
"testing"
)
func TestHandleServiceWorkerServesJavaScript(t *testing.T) {
server := &Server{}
req := httptest.NewRequest(http.MethodGet, "/sw.js", nil)
recorder := httptest.NewRecorder()
server.handleServiceWorker(recorder, req)
response := recorder.Result()
if response.StatusCode != http.StatusOK {
t.Fatalf("status = %d, want %d", response.StatusCode, http.StatusOK)
}
if got := response.Header.Get("Content-Type"); got != "application/javascript; charset=utf-8" {
t.Fatalf("Content-Type = %q, want application/javascript; charset=utf-8", got)
}
if got := response.Header.Get("Service-Worker-Allowed"); got != "/" {
t.Fatalf("Service-Worker-Allowed = %q, want /", got)
}
if recorder.Body.Len() == 0 {
t.Fatal("expected non-empty service worker body")
}
}

View File

@@ -1,8 +1,10 @@
(function () { (function () {
const DB_NAME = "md-hub-cache"; const DB_NAME = "md-hub-cache";
const DB_VERSION = 1; const DB_VERSION = 3;
const STORE_DOCUMENTS = "documents"; const STORE_DOCUMENTS = "documents";
const STORE_CONTENT = "content"; const STORE_CONTENT = "content";
const STORE_PAGES = "pages";
const STORE_PENDING_EDITS = "pending_edits";
function openDB() { function openDB() {
return new Promise(function (resolve, reject) { return new Promise(function (resolve, reject) {
@@ -21,10 +23,22 @@
if (!db.objectStoreNames.contains(STORE_CONTENT)) { if (!db.objectStoreNames.contains(STORE_CONTENT)) {
db.createObjectStore(STORE_CONTENT, { keyPath: "path" }); db.createObjectStore(STORE_CONTENT, { keyPath: "path" });
} }
if (!db.objectStoreNames.contains(STORE_PAGES)) {
db.createObjectStore(STORE_PAGES, { keyPath: "path" });
}
if (!db.objectStoreNames.contains(STORE_PENDING_EDITS)) {
db.createObjectStore(STORE_PENDING_EDITS, { keyPath: "path" });
}
}; };
}); });
} }
function normalizePath(path) {
if (!path) return "/";
if (path === "/") return path;
return path.replace(/\/$/, "") || "/";
}
function cacheDocuments(documents) { function cacheDocuments(documents) {
return openDB().then(function (db) { return openDB().then(function (db) {
return new Promise(function (resolve, reject) { return new Promise(function (resolve, reject) {
@@ -91,10 +105,158 @@
}); });
} }
function cachePage(path, html, title) {
const normalizedPath = normalizePath(path);
return openDB().then(function (db) {
return new Promise(function (resolve, reject) {
const tx = db.transaction(STORE_PAGES, "readwrite");
const store = tx.objectStore(STORE_PAGES);
store.put({
path: normalizedPath,
html: html,
title: title,
cachedAt: Date.now(),
});
tx.oncomplete = function () {
resolve();
};
tx.onerror = function () {
reject(tx.error);
};
});
});
}
function getCachedPage(path) {
const normalizedPath = normalizePath(path);
return openDB().then(function (db) {
return new Promise(function (resolve, reject) {
const tx = db.transaction(STORE_PAGES, "readonly");
const store = tx.objectStore(STORE_PAGES);
const request = store.get(normalizedPath);
request.onsuccess = function () {
resolve(request.result);
};
request.onerror = function () {
reject(request.error);
};
});
});
}
function putPendingEdit(path, content, baseHash) {
const normalizedPath = normalizePath(path);
return openDB().then(function (db) {
return new Promise(function (resolve, reject) {
const tx = db.transaction(STORE_PENDING_EDITS, "readwrite");
const store = tx.objectStore(STORE_PENDING_EDITS);
store.put({
path: normalizedPath,
content: content,
baseHash: baseHash || "",
conflict: null,
retries: 0,
updatedAt: Date.now(),
});
tx.oncomplete = function () {
resolve();
};
tx.onerror = function () {
reject(tx.error);
};
});
});
}
function markPendingEditConflict(path, conflict) {
const normalizedPath = normalizePath(path);
return openDB().then(function (db) {
return new Promise(function (resolve, reject) {
const tx = db.transaction(STORE_PENDING_EDITS, "readwrite");
const store = tx.objectStore(STORE_PENDING_EDITS);
const request = store.get(normalizedPath);
request.onsuccess = function () {
const record = request.result;
if (!record) {
resolve();
return;
}
record.conflict = conflict;
record.updatedAt = Date.now();
store.put(record);
};
tx.oncomplete = function () {
resolve();
};
tx.onerror = function () {
reject(tx.error);
};
});
});
}
function getPendingEdits() {
return openDB().then(function (db) {
return new Promise(function (resolve, reject) {
const tx = db.transaction(STORE_PENDING_EDITS, "readonly");
const store = tx.objectStore(STORE_PENDING_EDITS);
const request = store.getAll();
request.onsuccess = function () {
resolve(request.result || []);
};
request.onerror = function () {
reject(request.error);
};
});
});
}
function getPendingEdit(path) {
const normalizedPath = normalizePath(path);
return openDB().then(function (db) {
return new Promise(function (resolve, reject) {
const tx = db.transaction(STORE_PENDING_EDITS, "readonly");
const store = tx.objectStore(STORE_PENDING_EDITS);
const request = store.get(normalizedPath);
request.onsuccess = function () {
resolve(request.result);
};
request.onerror = function () {
reject(request.error);
};
});
});
}
function deletePendingEdit(path) {
const normalizedPath = normalizePath(path);
return openDB().then(function (db) {
return new Promise(function (resolve, reject) {
const tx = db.transaction(STORE_PENDING_EDITS, "readwrite");
const store = tx.objectStore(STORE_PENDING_EDITS);
store.delete(normalizedPath);
tx.oncomplete = function () {
resolve();
};
tx.onerror = function () {
reject(tx.error);
};
});
});
}
window.MDHubCache = { window.MDHubCache = {
cacheDocuments: cacheDocuments, cacheDocuments: cacheDocuments,
cacheContent: cacheContent, cacheContent: cacheContent,
cachePage: cachePage,
putPendingEdit: putPendingEdit,
markPendingEditConflict: markPendingEditConflict,
getPendingEdits: getPendingEdits,
getPendingEdit: getPendingEdit,
deletePendingEdit: deletePendingEdit,
getCachedDocuments: getCachedDocuments, getCachedDocuments: getCachedDocuments,
getCachedContent: getCachedContent, getCachedContent: getCachedContent,
getCachedPage: getCachedPage,
normalizePath: normalizePath,
}; };
})(); })();

Binary file not shown.

After

Width:  |  Height:  |  Size: 415 KiB

View File

@@ -0,0 +1,579 @@
(function () {
var form = document.querySelector("[data-editor-form]");
var textarea = document.querySelector("[data-document-editor]");
var statusNodes = document.querySelectorAll("[data-sync-status], [data-sync-status-secondary]");
var hashEl = document.querySelector("[data-editor-hash]");
var documentShell = document.querySelector("[data-document-path][data-document-hash]");
var conflictNotice = document.querySelector("[data-conflict-notice]");
var conflictHash = document.querySelector("[data-conflict-hash]");
var conflictStatus = document.querySelector("[data-conflict-status]");
var conflictDiff = document.querySelector("[data-conflict-diff]");
var conflictApply = document.querySelector("[data-conflict-apply]");
var conflictDismiss = document.querySelector("[data-conflict-dismiss]");
var conflictResolution = document.querySelector("[data-conflict-resolution]");
var conflictResolutionMount = document.querySelector("[data-conflict-monaco-mount]");
var conflictUseServer = document.querySelector("[data-conflict-use='server']");
var conflictUseLocal = document.querySelector("[data-conflict-use='local']");
var conflictUseBoth = document.querySelector("[data-conflict-use='both']");
var monacoContainer = document.querySelector("[data-monaco-mount]");
var monacoLoaded = false;
var monacoReadyPromise = null;
var monacoDarkTheme = "cairnquire-dark";
var monacoLightTheme = "cairnquire-light";
if (!form || !textarea || statusNodes.length === 0) {
return;
}
var path = form.getAttribute("data-document-path");
var baseHash = documentShell ? documentShell.getAttribute("data-document-hash") : "";
var saveTimer = null;
var lastSavedValue = textarea.value;
var editor = null;
var currentConflict = null;
var currentConflictLocalContent = "";
var resolvedConflictContent = "";
var conflictDiffView = null;
var resolutionEditor = null;
var suppressResolutionEvents = false;
function setStatus(nextStatus) {
statusNodes.forEach(function (node) {
node.textContent = nextStatus;
node.dataset.state = nextStatus.toLowerCase();
});
}
function getEditorValue() {
return editor ? editor.getValue() : textarea.value;
}
function setEditorValue(value) {
textarea.value = value;
if (editor && editor.getValue() !== value) {
editor.setValue(value);
}
}
function getResolutionValue() {
return resolutionEditor ? resolutionEditor.getValue() : resolvedConflictContent;
}
function setResolutionEditorValue(value) {
resolvedConflictContent = value;
if (conflictResolution) {
conflictResolution.value = value;
}
if (resolutionEditor && resolutionEditor.getValue() !== value) {
suppressResolutionEvents = true;
resolutionEditor.setValue(value);
suppressResolutionEvents = false;
}
}
function ensureTrailingNewline(value) {
return value.endsWith("\n") ? value : value + "\n";
}
function buildCombinedResolution(serverContent, localContent) {
return ensureTrailingNewline(serverContent) + "\n" + ensureTrailingNewline(localContent);
}
function setConflictMessage(message) {
if (conflictStatus) {
conflictStatus.textContent = message;
}
}
function setResolutionContent(content, message) {
setResolutionEditorValue(content);
if (conflictApply) {
conflictApply.disabled = false;
}
if (message) {
setConflictMessage(message);
}
}
async function loadDiffs() {
var modules = await Promise.all([
import("https://cdn.jsdelivr.net/npm/@pierre/diffs@1.1.22/+esm"),
import("https://cdn.jsdelivr.net/npm/@pierre/diffs@1.1.22/dist/style.js"),
]);
return {
renderer: modules[0],
stylesheet: modules[1].default || "",
};
}
function styleDiffContainer(diffContainer) {
diffContainer.style.setProperty("--diffs-light-bg", "#fcfaf5");
diffContainer.style.setProperty("--diffs-light", "#1c2430");
diffContainer.style.setProperty("--diffs-dark-bg", "#1e1e1e");
diffContainer.style.setProperty("--diffs-dark", "#e8ecf0");
diffContainer.style.setProperty("--diffs-addition-color", "#3f8f59");
diffContainer.style.setProperty("--diffs-deletion-color", "#c8553d");
diffContainer.style.setProperty("--diffs-modified-color", "#c67a2a");
diffContainer.style.setProperty("--diffs-font-family", "'Iosevka', 'JetBrains Mono', ui-monospace, SFMono-Regular, monospace");
diffContainer.style.setProperty("--diffs-header-font-family", "'Inter', system-ui, -apple-system, BlinkMacSystemFont, sans-serif");
}
function injectDiffStyles(diffContainer, stylesheet) {
var root = diffContainer.shadowRoot;
if (!root || !stylesheet || root.querySelector("[data-cairnquire-diffs-style]")) {
return;
}
var style = document.createElement("style");
style.setAttribute("data-cairnquire-diffs-style", "");
style.textContent = stylesheet;
root.prepend(style);
}
function renderConflictResolver(conflict, localContent) {
if (!conflictDiff) {
return;
}
if (conflictDiffView) {
conflictDiffView.cleanUp();
conflictDiffView = null;
}
conflictDiff.replaceChildren();
currentConflictLocalContent = localContent;
setResolutionEditorValue(localContent);
if (conflictApply) {
conflictApply.disabled = false;
}
ensureResolutionEditor();
setConflictMessage("Loading conflict resolver...");
loadDiffs().then(function (diffsModule) {
var diffs = diffsModule.renderer;
var diffContainer = document.createElement("div");
styleDiffContainer(diffContainer);
conflictDiff.appendChild(diffContainer);
var serverFile = {
name: path,
contents: conflict.currentContent || "",
lang: "markdown",
cacheKey: "server:" + (conflict.currentHash || ""),
};
var localFile = {
name: path,
contents: localContent,
lang: "markdown",
cacheKey: "local:" + (conflict.baseHash || "") + ":" + localContent.length,
};
conflictDiffView = new diffs.FileDiff({
disableFileHeader: false,
diffStyle: "unified",
diffIndicators: "classic",
lineDiffType: "word",
overflow: "scroll",
parseDiffOptions: { context: 4 },
});
conflictDiffView.render({
oldFile: serverFile,
newFile: localFile,
fileContainer: diffContainer,
});
injectDiffStyles(diffContainer, diffsModule.stylesheet);
setConflictMessage("Red deletions are from the server copy. Green additions are from your queued edit.");
}).catch(function () {
setConflictMessage("Could not load the diff renderer. Your edit is still queued locally.");
});
}
function showConflict(conflict, localContent) {
currentConflict = conflict;
setStatus("Conflict");
form.hidden = true;
if (conflictHash && conflict && conflict.currentHash) {
conflictHash.textContent = conflict.currentHash;
}
if (conflictNotice) {
conflictNotice.hidden = false;
}
renderConflictResolver(conflict, localContent || getEditorValue());
}
function hideConflict() {
form.hidden = false;
if (conflictNotice) {
conflictNotice.hidden = true;
}
if (conflictDiff) {
conflictDiff.replaceChildren();
}
if (conflictDiffView) {
conflictDiffView.cleanUp();
conflictDiffView = null;
}
if (conflictApply) {
conflictApply.disabled = true;
}
setResolutionEditorValue("");
currentConflict = null;
currentConflictLocalContent = "";
}
async function saveNow() {
var value = getEditorValue();
if (value === lastSavedValue) {
setStatus("Saved");
return;
}
hideConflict();
setStatus("Saving");
if (!window.MDHubSync) {
setStatus("Queued");
return;
}
var result = await window.MDHubSync.saveDocument(path, value, baseHash);
if (result.status === "saved") {
lastSavedValue = value;
if (hashEl && result.result && result.result.hash) {
hashEl.textContent = result.result.hash;
baseHash = result.result.hash;
if (documentShell) {
documentShell.setAttribute("data-document-hash", result.result.hash);
}
}
setStatus("Saved");
return;
}
if (result.status === "conflict") {
showConflict(result.conflict, value);
return;
}
setStatus("Queued");
}
async function applyResolvedConflict() {
if (!window.MDHubSync || !currentConflict) {
return;
}
var resolutionContent = getResolutionValue();
resolvedConflictContent = resolutionContent;
setConflictMessage("Saving resolved document...");
if (conflictApply) {
conflictApply.disabled = true;
}
var result = await window.MDHubSync.saveDocument(path, resolutionContent, currentConflict.currentHash);
if (result.status === "saved") {
setEditorValue(resolutionContent);
lastSavedValue = resolutionContent;
if (hashEl && result.result && result.result.hash) {
hashEl.textContent = result.result.hash;
baseHash = result.result.hash;
if (documentShell) {
documentShell.setAttribute("data-document-hash", result.result.hash);
}
}
hideConflict();
setStatus("Saved");
return;
}
if (result.status === "conflict") {
showConflict(result.conflict, resolutionContent);
return;
}
setStatus("Queued");
setConflictMessage("Resolved document is queued and will retry when the server is reachable.");
}
function restorePendingConflict() {
if (!window.MDHubCache || !window.MDHubCache.getPendingEdit) {
return;
}
var normalizedPath = window.MDHubCache.normalizePath("/docs/" + path.replace(/^\/+/, ""));
window.MDHubCache.getPendingEdit(normalizedPath).then(function (pending) {
if (pending && pending.conflict) {
showConflict(pending.conflict, pending.content || getEditorValue());
}
}).catch(function () {});
}
function scheduleSave() {
setStatus("Unsaved");
if (saveTimer) {
clearTimeout(saveTimer);
}
saveTimer = setTimeout(function () {
saveNow().catch(function () {
setStatus("Queued");
});
}, 2000);
}
function loadMonaco() {
if (monacoReadyPromise) {
return monacoReadyPromise;
}
if (monacoLoaded && window.monaco) {
monacoReadyPromise = Promise.resolve();
return monacoReadyPromise;
}
monacoLoaded = true;
var isDark = window.matchMedia("(prefers-color-scheme: dark)").matches;
monacoReadyPromise = new Promise(function (resolve) {
var script = document.createElement("script");
script.src = "https://cdn.jsdelivr.net/npm/monaco-editor@0.52.2/min/vs/loader.js";
script.onload = function () {
require.config({
paths: { vs: "https://cdn.jsdelivr.net/npm/monaco-editor@0.52.2/min/vs" },
});
require(["vs/editor/editor.main"], function () {
monaco.editor.defineTheme(monacoDarkTheme, {
base: "vs-dark",
inherit: true,
rules: [
{ token: "comment", foreground: "8B9DAF", fontStyle: "italic" },
{ token: "keyword", foreground: "E8943A" },
{ token: "string", foreground: "C9A96E" },
{ token: "number", foreground: "C9A96E" },
{ token: "type", foreground: "E8943A" },
{ token: "tag", foreground: "E8943A" },
{ token: "attribute.name", foreground: "D4A05A" },
{ token: "attribute.value", foreground: "C9A96E" },
{ token: "delimiter", foreground: "B0B8C4" },
{ token: "variable", foreground: "D4D8DE" },
],
colors: {
"editor.background": "#1E1E1E",
"editor.foreground": "#E8ECF0",
"editor.lineHighlightBackground": "#282828",
"editor.selectionBackground": "#3A3A3A",
"editorLineNumber.foreground": "#555555",
"editorLineNumber.activeForeground": "#E8943A",
"editor.inactiveSelectionBackground": "#333333",
"editorCursor.foreground": "#E8943A",
"editorIndentGuide.background": "#2A2A2A",
"editorIndentGuide.activeBackground": "#3A3A3A",
"editorWhitespace.foreground": "#2A2A2A",
"editorGutter.background": "#1E1E1E",
"editorOverviewRuler.border": "#1E1E1E",
"scrollbarSlider.background": "#3A3A3A88",
"scrollbarSlider.hoverBackground": "#4A4A4A88",
"scrollbarSlider.activeBackground": "#5A5A5A88",
"minimap.background": "#1E1E1E",
},
});
monaco.editor.defineTheme(monacoLightTheme, {
base: "vs",
inherit: true,
rules: [
{ token: "comment", foreground: "6A7B8C", fontStyle: "italic" },
{ token: "keyword", foreground: "C67A2A" },
{ token: "string", foreground: "8A6D3B" },
{ token: "number", foreground: "8A6D3B" },
{ token: "type", foreground: "C67A2A" },
{ token: "tag", foreground: "C67A2A" },
{ token: "attribute.name", foreground: "A87D3A" },
{ token: "attribute.value", foreground: "8A6D3B" },
],
colors: {
"editor.background": "#FCFAF5",
"editor.foreground": "#1C2430",
"editor.lineHighlightBackground": "#F0EDE5",
"editor.selectionBackground": "#E8943A33",
"editorLineNumber.foreground": "#AAAAAA",
"editorLineNumber.activeForeground": "#C67A2A",
"editorCursor.foreground": "#C67A2A",
"editorIndentGuide.background": "#E8E5DD",
"editorGutter.background": "#FCFAF5",
},
});
if (monacoContainer) {
editor = monaco.editor.create(monacoContainer, {
value: textarea.value,
language: "markdown",
theme: isDark ? monacoDarkTheme : monacoLightTheme,
fontFamily: "'Iosevka', 'JetBrains Mono', ui-monospace, SFMono-Regular, monospace",
fontSize: 14,
lineHeight: 22,
minimap: { enabled: false },
wordWrap: "on",
scrollBeyondLastLine: false,
padding: { top: 12 },
renderLineHighlight: "line",
smoothScrolling: true,
cursorBlinking: "smooth",
cursorSmoothCaretAnimation: "on",
bracketPairColorization: { enabled: true },
automaticLayout: true,
});
textarea.hidden = true;
monacoContainer.classList.add("monaco-mounted");
editor.onDidChangeModelContent(function () {
textarea.value = editor.getValue();
scheduleSave();
});
if (window.MDHubSync) {
editor.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyS, function () {
saveNow().catch(function () {
setStatus("Queued");
});
});
}
var darkMQ = window.matchMedia("(prefers-color-scheme: dark)");
darkMQ.addEventListener("change", function (e) {
monaco.editor.setTheme(e.matches ? monacoDarkTheme : monacoLightTheme);
});
editor.focus();
}
resolve();
});
};
document.head.appendChild(script);
});
return monacoReadyPromise;
}
function ensureResolutionEditor() {
if (!conflictResolution || !conflictResolutionMount) {
return;
}
loadMonaco().then(function () {
if (!window.monaco || resolutionEditor) {
return;
}
var isDark = window.matchMedia("(prefers-color-scheme: dark)").matches;
resolutionEditor = monaco.editor.create(conflictResolutionMount, {
value: conflictResolution.value || resolvedConflictContent,
language: "markdown",
theme: isDark ? monacoDarkTheme : monacoLightTheme,
fontFamily: "'Iosevka', 'JetBrains Mono', ui-monospace, SFMono-Regular, monospace",
fontSize: 14,
lineHeight: 22,
minimap: { enabled: false },
wordWrap: "on",
scrollBeyondLastLine: false,
padding: { top: 12 },
renderLineHighlight: "line",
smoothScrolling: true,
cursorBlinking: "smooth",
cursorSmoothCaretAnimation: "on",
bracketPairColorization: { enabled: true },
automaticLayout: true,
});
conflictResolution.hidden = true;
conflictResolutionMount.classList.add("monaco-mounted");
resolutionEditor.onDidChangeModelContent(function () {
if (suppressResolutionEvents) {
return;
}
resolvedConflictContent = resolutionEditor.getValue();
conflictResolution.value = resolvedConflictContent;
if (conflictApply) {
conflictApply.disabled = false;
}
setConflictMessage("Manual resolution updated. Apply it to save the resolved document.");
});
resolutionEditor.focus();
}).catch(function () {
conflictResolution.hidden = false;
});
}
if (monacoContainer) {
loadMonaco().catch(function () {
textarea.addEventListener("input", scheduleSave);
});
} else {
textarea.addEventListener("input", scheduleSave);
}
form.addEventListener("submit", function (event) {
event.preventDefault();
saveNow().catch(function () {
setStatus("Queued");
});
});
if (conflictApply) {
conflictApply.addEventListener("click", function () {
applyResolvedConflict().catch(function () {
setStatus("Queued");
setConflictMessage("Could not save the resolved document. It remains queued locally.");
});
});
}
if (conflictDismiss) {
conflictDismiss.addEventListener("click", function () {
hideConflict();
});
}
if (conflictResolution) {
conflictResolution.addEventListener("input", function () {
resolvedConflictContent = conflictResolution.value;
if (conflictApply) {
conflictApply.disabled = false;
}
setConflictMessage("Manual resolution updated. Apply it to save the resolved document.");
});
}
if (conflictUseServer) {
conflictUseServer.addEventListener("click", function () {
if (!currentConflict) {
return;
}
setResolutionContent(currentConflict.currentContent || "", "Using the server copy as the resolution. You can edit it before applying.");
});
}
if (conflictUseLocal) {
conflictUseLocal.addEventListener("click", function () {
setResolutionContent(currentConflictLocalContent, "Using your queued edit as the resolution. You can edit it before applying.");
});
}
if (conflictUseBoth) {
conflictUseBoth.addEventListener("click", function () {
if (!currentConflict) {
return;
}
setResolutionContent(
buildCombinedResolution(currentConflict.currentContent || "", currentConflictLocalContent),
"Using both versions as the resolution. Edit the combined content before applying."
);
});
}
restorePendingConflict();
document.addEventListener("keydown", function (e) {
if ((e.metaKey || e.ctrlKey) && e.key === "s") {
e.preventDefault();
}
});
})();

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

View File

@@ -2,8 +2,10 @@
const notice = document.querySelector("[data-version-notice]"); const notice = document.querySelector("[data-version-notice]");
const reload = document.querySelector("[data-version-reload]"); const reload = document.querySelector("[data-version-reload]");
const offlineNotice = document.querySelector("[data-offline-notice]"); const offlineNotice = document.querySelector("[data-offline-notice]");
const cachedNotice = document.querySelector("[data-cached-notice]");
const documentShell = document.querySelector("[data-document-path][data-document-hash]"); const documentShell = document.querySelector("[data-document-path][data-document-hash]");
const browser = document.querySelector(".miller-browser"); const browser = document.querySelector(".miller-browser");
const isEditing = Boolean(document.querySelector("[data-editor-form]"));
// Auto-scroll miller browser to show the rightmost (active) column // Auto-scroll miller browser to show the rightmost (active) column
function scrollBrowserToRight() { function scrollBrowserToRight() {
@@ -23,6 +25,120 @@
scrollBrowserToRight(); scrollBrowserToRight();
function initColumnPreviewExpansion() {
if (!browser) {
return;
}
let hoverTimer = null;
let expandedColumn = null;
function isCompressedColumn(column) {
return (
column &&
column.matches(".miller-column:not(:first-child):not(:last-child)")
);
}
function collapseColumn(column) {
if (!column) return;
column.classList.remove("is-preview-expanded");
column.style.removeProperty("--expanded-column-width");
if (expandedColumn === column) {
expandedColumn = null;
}
}
function expandColumn(column) {
if (!isCompressedColumn(column)) return;
if (expandedColumn && expandedColumn !== column) {
collapseColumn(expandedColumn);
}
const expandedWidth = Math.min(Math.max(column.scrollWidth + 12, 192), 384);
column.style.setProperty("--expanded-column-width", expandedWidth + "px");
column.classList.add("is-preview-expanded");
expandedColumn = column;
requestAnimationFrame(function () {
const leftEdge = column.offsetLeft;
const rightEdge = leftEdge + expandedWidth;
let targetLeft = browser.scrollLeft;
if (leftEdge < browser.scrollLeft) {
targetLeft = leftEdge - 8;
} else if (rightEdge > browser.scrollLeft + browser.clientWidth) {
targetLeft = rightEdge - browser.clientWidth + 8;
}
browser.scrollTo({
left: Math.max(0, targetLeft),
behavior: "smooth",
});
});
}
function scheduleExpansion(column) {
if (!isCompressedColumn(column)) return;
clearTimeout(hoverTimer);
hoverTimer = setTimeout(function () {
expandColumn(column);
}, 1000);
}
browser.querySelectorAll(".miller-column").forEach(function (column) {
column.addEventListener("pointerenter", function () {
scheduleExpansion(column);
});
column.addEventListener("mouseenter", function () {
scheduleExpansion(column);
});
column.addEventListener("mouseover", function () {
if (!isCompressedColumn(column)) return;
if (expandedColumn === column) return;
scheduleExpansion(column);
});
column.addEventListener("pointerleave", function () {
clearTimeout(hoverTimer);
collapseColumn(column);
});
column.addEventListener("mouseleave", function () {
clearTimeout(hoverTimer);
collapseColumn(column);
});
});
}
initColumnPreviewExpansion();
function updateCachedUI(isCached) {
if (!cachedNotice) return;
cachedNotice.hidden = !isCached;
}
function cacheCurrentPage() {
if (!window.MDHubCache) return;
const html = document.documentElement.outerHTML;
const title = document.title;
const pagePath = window.MDHubCache.normalizePath(window.location.pathname);
window.MDHubCache.cachePage(pagePath, html, title).catch(function () {});
}
function registerServiceWorker() {
if (!("serviceWorker" in navigator)) return;
window.addEventListener("load", function () {
navigator.serviceWorker.register("/sw.js").catch(function () {});
});
}
registerServiceWorker();
updateCachedUI(Boolean(window.__MDHUB_OFFLINE_CACHED__));
function updateOfflineUI() { function updateOfflineUI() {
if (!offlineNotice) return; if (!offlineNotice) return;
if (navigator.onLine) { if (navigator.onLine) {
@@ -54,6 +170,8 @@
} }
} }
cacheCurrentPage();
// Fetch and cache documents // Fetch and cache documents
function fetchAndCacheDocuments() { function fetchAndCacheDocuments() {
if (!window.MDHubCache) return; if (!window.MDHubCache) return;
@@ -130,6 +248,9 @@
const bodyEl = documentShell.querySelector(".markdown-body"); const bodyEl = documentShell.querySelector(".markdown-body");
if (bodyEl) bodyEl.innerHTML = html; if (bodyEl) bodyEl.innerHTML = html;
updateCachedUI(true);
cacheCurrentPage();
// Re-render math and mermaid // Re-render math and mermaid
if (typeof renderMath === "function") renderMath(); if (typeof renderMath === "function") renderMath();
if (typeof renderMermaid === "function") renderMermaid(); if (typeof renderMermaid === "function") renderMermaid();
@@ -185,6 +306,10 @@
const change = payload.data; const change = payload.data;
if (isEditing && currentPath && change.path === currentPath) {
return;
}
if (currentPath && change.path === currentPath && change.hash !== currentHash) { if (currentPath && change.path === currentPath && change.hash !== currentHash) {
if (notice) notice.hidden = false; if (notice) notice.hidden = false;
return; return;

View File

@@ -1,5 +1,5 @@
:root { :root {
color-scheme: light; color-scheme: light dark;
--bg: #fbf7ef; --bg: #fbf7ef;
--panel: rgba(255, 255, 255, 0.82); --panel: rgba(255, 255, 255, 0.82);
--panel-strong: #ffffff; --panel-strong: #ffffff;
@@ -15,10 +15,28 @@
font-family: "Charter", "Iowan Old Style", "Palatino Linotype", serif; font-family: "Charter", "Iowan Old Style", "Palatino Linotype", serif;
} }
@media (prefers-color-scheme: dark) {
:root {
--bg: oklch(0.14 0.012 55);
--panel: oklch(0.18 0.014 55 / 0.85);
--panel-strong: oklch(0.21 0.015 55);
--text: oklch(0.93 0.006 55);
--muted: oklch(0.66 0.018 55);
--accent: oklch(0.705 0.165 55);
--accent-soft: oklch(0.705 0.165 55 / 0.14);
--border: oklch(0.27 0.018 55);
--shadow: 0 16px 48px oklch(0.08 0.02 55 / 0.35);
}
}
* { * {
box-sizing: border-box; box-sizing: border-box;
} }
html {
transition: color-scheme 0.4s ease;
}
body { body {
margin: 0; margin: 0;
min-height: 100vh; min-height: 100vh;
@@ -27,6 +45,18 @@ body {
radial-gradient(circle at top left, rgba(56, 189, 248, 0.28), transparent 34%), radial-gradient(circle at top left, rgba(56, 189, 248, 0.28), transparent 34%),
radial-gradient(circle at bottom right, rgba(99, 102, 241, 0.22), transparent 30%), radial-gradient(circle at bottom right, rgba(99, 102, 241, 0.22), transparent 30%),
linear-gradient(180deg, #eef6ff 0%, #dbeafe 100%); linear-gradient(180deg, #eef6ff 0%, #dbeafe 100%);
transition:
color 0.35s ease,
background 0.55s ease;
}
@media (prefers-color-scheme: dark) {
body {
background:
radial-gradient(circle at top left, oklch(0.24 0.04 55 / 0.45), transparent 36%),
radial-gradient(circle at bottom right, oklch(0.20 0.035 50 / 0.35), transparent 32%),
linear-gradient(180deg, oklch(0.12 0.012 55) 0%, oklch(0.14 0.012 55) 100%);
}
} }
a { a {
@@ -60,12 +90,20 @@ code {
} }
.site-brand { .site-brand {
color: var(--text); display: inline-flex;
font-size: 1.15rem; align-items: center;
font-weight: 700; flex: 0 0 auto;
text-decoration: none; text-decoration: none;
} }
.site-brand img {
display: block;
width: auto;
height: 2.5rem;
max-width: min(12rem, 42vw);
object-fit: contain;
}
.site-nav { .site-nav {
display: flex; display: flex;
gap: 1.25rem; gap: 1.25rem;
@@ -157,6 +195,7 @@ code {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
overflow: hidden; overflow: hidden;
transition: width 180ms ease, min-width 180ms ease, box-shadow 180ms ease;
} }
/* Root column - fixed width for top-level navigation */ /* Root column - fixed width for top-level navigation */
@@ -170,6 +209,18 @@ code {
width: 48px; width: 48px;
} }
.miller-column:not(:first-child):not(:last-child):hover,
.miller-column:not(:first-child):not(:last-child).is-preview-expanded {
width: var(--expanded-column-width, max-content);
min-width: 12rem;
max-width: 24rem;
box-shadow: 8px 0 24px rgba(24, 32, 42, 0.08);
}
.miller-column:not(:first-child):not(:last-child):hover {
transition-delay: 1s;
}
/* Active/last column - grows to fill remaining sidebar space */ /* Active/last column - grows to fill remaining sidebar space */
.miller-column:last-child { .miller-column:last-child {
flex: 1 1 auto; flex: 1 1 auto;
@@ -207,6 +258,17 @@ code {
vertical-align: bottom; vertical-align: bottom;
} }
.miller-column:not(:first-child):not(:last-child):hover h2,
.miller-column:not(:first-child):not(:last-child).is-preview-expanded h2 {
padding: 0.7rem 0.8rem;
text-align: left;
}
.miller-column:not(:first-child):not(:last-child):hover h2 .miller-column-title-text,
.miller-column:not(:first-child):not(:last-child).is-preview-expanded h2 .miller-column-title-text {
max-width: min(20rem, calc(var(--expanded-column-width, 14rem) - 2rem));
}
.miller-column ul { .miller-column ul {
margin: 0; margin: 0;
padding: 0.3rem; padding: 0.3rem;
@@ -252,6 +314,28 @@ code {
display: none; display: none;
} }
.miller-column:not(:first-child):not(:last-child):hover a,
.miller-column:not(:first-child):not(:last-child).is-preview-expanded a {
padding: 0.4rem 0.5rem;
justify-content: space-between;
}
.miller-column:not(:first-child):not(:last-child):hover .browser-item-label,
.miller-column:not(:first-child):not(:last-child).is-preview-expanded .browser-item-label {
justify-content: flex-start;
gap: 0.4rem;
}
.miller-column:not(:first-child):not(:last-child):hover .browser-item-name,
.miller-column:not(:first-child):not(:last-child).is-preview-expanded .browser-item-name {
max-width: min(18rem, calc(var(--expanded-column-width, 14rem) - 3.5rem));
}
.miller-column:not(:first-child):not(:last-child):hover .browser-item-chevron,
.miller-column:not(:first-child):not(:last-child).is-preview-expanded .browser-item-chevron {
display: inline-block;
}
.browser-item-label { .browser-item-label {
display: inline-flex; display: inline-flex;
min-width: 0; min-width: 0;
@@ -260,69 +344,24 @@ code {
} }
.browser-icon { .browser-icon {
position: relative;
display: inline-block; display: inline-block;
width: 1rem; width: 1rem;
height: 0.9rem; height: 1rem;
flex: 0 0 auto; flex: 0 0 auto;
} }
.browser-icon--folder { .browser-icon--folder {
margin-top: 0.1rem; color: #0f5bd8;
border: 1px solid rgba(15, 91, 216, 0.24);
border-radius: 0;
background: var(--accent-soft);
}
.browser-icon--folder::before {
position: absolute;
top: -0.22rem;
left: 0.1rem;
width: 0.45rem;
height: 0.25rem;
border: 1px solid rgba(15, 91, 216, 0.24);
border-bottom: 0;
border-radius: 0;
background: var(--accent-soft);
content: "";
} }
.browser-icon--page { .browser-icon--page {
border: 1px solid var(--border); color: #465365;
border-radius: 0;
background: rgba(255, 255, 255, 0.72);
}
.browser-icon--page::before {
position: absolute;
right: -1px;
top: -1px;
width: 0.3rem;
height: 0.3rem;
border-left: 1px solid var(--border);
border-bottom: 1px solid var(--border);
background: var(--panel);
content: "";
} }
.browser-icon--root { .browser-icon--root {
width: 0.85rem; width: 0.95rem;
height: 0.85rem; height: 0.95rem;
border: 1.5px solid var(--accent); color: var(--accent);
border-radius: 0;
background: var(--accent-soft);
}
.browser-icon--root::before {
position: absolute;
top: 50%;
left: 50%;
width: 0.3rem;
height: 0.3rem;
border-radius: 0;
background: var(--accent);
transform: translate(-50%, -50%);
content: "";
} }
.miller-column--root h2 { .miller-column--root h2 {
@@ -343,6 +382,12 @@ code {
white-space: nowrap; white-space: nowrap;
} }
.browser-item-chevron {
width: 0.95rem;
height: 0.95rem;
flex: 0 0 auto;
}
/* Document shell */ /* Document shell */
.document-shell, .document-shell,
.error-panel, .error-panel,
@@ -447,11 +492,51 @@ code {
} }
.document-meta h1 { .document-meta h1 {
margin: 0 0 0.75rem; margin: 0;
font-size: 1.85rem; font-size: 1.85rem;
line-height: 1.1; line-height: 1.1;
} }
.document-meta-panel {
margin-top: 0.9rem;
border: 1px solid var(--border);
background: rgba(255, 255, 255, 0.45);
}
.document-meta-panel summary {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
padding: 0.7rem 0.85rem;
cursor: pointer;
list-style: none;
color: var(--muted);
font: 700 0.78rem/1.2 ui-monospace, SFMono-Regular, monospace;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.document-meta-panel summary::-webkit-details-marker {
display: none;
}
.document-meta-panel summary::after {
content: "+";
margin-left: auto;
color: var(--text);
font-size: 1rem;
letter-spacing: 0;
}
.document-meta-panel[open] summary::after {
content: "-";
}
.document-meta-panel .meta-grid {
padding: 0 0.85rem 0.85rem;
}
.meta-grid { .meta-grid {
display: grid; display: grid;
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
@@ -535,6 +620,26 @@ code {
display: none; display: none;
} }
.cached-notice {
position: fixed;
left: 1rem;
bottom: 4.5rem;
display: flex;
align-items: center;
gap: 0.6rem;
max-width: min(28rem, calc(100vw - 2rem));
padding: 0.6rem 0.85rem;
border: 1px solid rgba(37, 99, 235, 0.25);
border-radius: 0;
background: rgba(239, 246, 255, 0.96);
color: #1d4ed8;
font-size: 0.9rem;
}
.cached-notice[hidden] {
display: none;
}
.offline-icon { .offline-icon {
display: inline-block; display: inline-block;
width: 0.6rem; width: 0.6rem;
@@ -544,6 +649,15 @@ code {
flex-shrink: 0; flex-shrink: 0;
} }
.cached-icon {
display: inline-block;
width: 0.6rem;
height: 0.6rem;
border-radius: 0;
background: #2563eb;
flex-shrink: 0;
}
.version-notice[hidden] { .version-notice[hidden] {
display: none; display: none;
} }
@@ -564,6 +678,284 @@ code {
cursor: pointer; cursor: pointer;
} }
.document-meta__header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 1rem;
}
.document-meta__header--editor {
align-items: end;
}
.document-edit-link {
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 2.4rem;
padding: 0 0.9rem;
border: 1px solid var(--border);
color: var(--text);
text-decoration: none;
background: var(--panel);
}
.document-shell--editor {
display: grid;
grid-template-rows: auto auto 1fr;
min-height: 0;
}
.editor-form {
min-height: 0;
position: relative;
}
.editor-form[hidden] {
display: none;
}
[data-monaco-mount] {
display: none;
width: 100%;
min-height: 60vh;
height: 100%;
border: 1px solid var(--border);
overflow: hidden;
}
[data-monaco-mount].monaco-mounted {
display: block;
}
.editor-form textarea {
width: 100%;
min-height: 60vh;
height: 100%;
resize: vertical;
border: 1px solid var(--border);
background: #fbfbf9;
color: var(--text);
padding: 1rem;
font: 400 0.98rem/1.6 ui-monospace, SFMono-Regular, monospace;
}
[data-monaco-mount].monaco-mounted ~ textarea {
display: none;
}
@keyframes monaco-shimmer {
0% { background-position: -200% 0; }
100% { background-position: 200% 0; }
}
[data-monaco-mount]:not(.monaco-mounted) {
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(90deg, var(--panel-strong) 25%, var(--panel) 50%, var(--panel-strong) 75%);
background-size: 200% 100%;
animation: monaco-shimmer 1.5s ease infinite;
color: var(--muted);
font-size: 0.85rem;
}
.editor-status {
display: inline-block;
min-width: 5.5rem;
font-weight: 600;
text-align: right;
}
.editor-status[data-state="queued"] {
color: #92400e;
}
.editor-status[data-state="conflict"] {
color: #b91c1c;
}
.editor-status[data-state="saving"],
.editor-status[data-state="unsaved"] {
color: #1d4ed8;
}
.editor-conflict {
display: grid;
gap: 0.75rem;
margin-top: 0.8rem;
padding: 0.75rem 0.9rem;
border: 1px solid #fecaca;
background: #fff1f2;
color: #7f1d1d;
}
.editor-conflict[hidden] {
display: none;
}
.editor-conflict__header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 1rem;
}
.editor-conflict__header > div:first-child {
display: grid;
gap: 0.2rem;
}
.editor-conflict__actions {
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
gap: 0.5rem;
}
.editor-conflict__actions button {
min-height: 2.1rem;
padding: 0 0.75rem;
border: 1px solid #fecaca;
background: #fff;
color: #7f1d1d;
cursor: pointer;
}
.editor-conflict__actions button:disabled {
cursor: not-allowed;
opacity: 0.55;
}
.editor-conflict__choices {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
.editor-conflict__choices button {
min-height: 2rem;
padding: 0 0.65rem;
border: 1px solid rgba(127, 29, 29, 0.2);
background: rgba(255, 255, 255, 0.72);
color: #7f1d1d;
cursor: pointer;
font: inherit;
font-size: 0.85rem;
}
.editor-conflict__choices button[data-conflict-use="server"] {
border-color: rgba(185, 28, 28, 0.25);
background: rgba(254, 202, 202, 0.6);
}
.editor-conflict__choices button[data-conflict-use="local"] {
border-color: rgba(22, 101, 52, 0.25);
background: rgba(187, 247, 208, 0.55);
color: #14532d;
}
.editor-conflict p {
margin: 0;
}
.editor-conflict__legend {
display: flex;
flex-wrap: wrap;
gap: 0.5rem 1rem;
font-size: 0.86rem;
}
.editor-conflict__legend span {
display: inline-flex;
align-items: center;
gap: 0.35rem;
}
.editor-conflict__swatch {
width: 0.75rem;
height: 0.75rem;
border: 1px solid currentColor;
display: inline-block;
}
.editor-conflict__swatch--server {
background: rgba(200, 85, 61, 0.2);
color: #b91c1c;
}
.editor-conflict__swatch--local {
background: rgba(63, 143, 89, 0.2);
color: #166534;
}
.editor-conflict__diff {
min-height: 16rem;
overflow: auto;
border: 1px solid rgba(127, 29, 29, 0.18);
background: #fff;
}
.editor-conflict code {
word-break: break-all;
}
.editor-conflict__resolution {
display: grid;
gap: 0.4rem;
}
.editor-conflict__resolution label {
font-size: 0.78rem;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.editor-conflict__resolution textarea {
width: 100%;
min-height: 16rem;
resize: vertical;
border: 1px solid rgba(127, 29, 29, 0.18);
background: #fff;
color: #1c2430;
font-family: "Iosevka", "JetBrains Mono", ui-monospace, SFMono-Regular, monospace;
font-size: 0.9rem;
line-height: 1.55;
padding: 0.75rem;
}
[data-conflict-monaco-mount] {
display: none;
width: 100%;
min-height: 18rem;
height: 32rem;
border: 1px solid rgba(127, 29, 29, 0.18);
background: #fff;
overflow: hidden;
}
[data-conflict-monaco-mount].monaco-mounted {
display: block;
}
[data-conflict-monaco-mount].monaco-mounted + textarea {
display: none;
}
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
/* Adaptive breakpoints */ /* Adaptive breakpoints */
@media (max-width: 1024px) { @media (max-width: 1024px) {
.workspace-shell, .workspace-shell,
@@ -579,6 +971,10 @@ code {
} }
@media (max-width: 768px) { @media (max-width: 768px) {
.site-brand img {
height: 2.25rem;
}
.site-header__inner { .site-header__inner {
flex-direction: column; flex-direction: column;
align-items: flex-start; align-items: flex-start;
@@ -655,6 +1051,12 @@ code {
.meta-grid { .meta-grid {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
.document-meta-panel summary,
.document-meta__header {
align-items: flex-start;
flex-direction: column;
}
} }
@media (min-width: 1600px) { @media (min-width: 1600px) {
@@ -709,3 +1111,149 @@ code {
font-size: 0.85rem; font-size: 0.85rem;
font-family: ui-monospace, SFMono-Regular, monospace; font-family: ui-monospace, SFMono-Regular, monospace;
} }
@media (prefers-color-scheme: dark) {
.site-header {
border-bottom-color: oklch(0.28 0.018 55 / 0.5);
background: oklch(0.16 0.014 55 / 0.92);
}
.miller-column h2 {
background: oklch(0.20 0.015 55 / 0.72);
}
.miller-column a:hover,
.miller-column a.is-active {
background: var(--accent-soft);
}
.browser-icon--folder {
color: oklch(0.705 0.165 55);
}
.browser-icon--page {
color: oklch(0.66 0.018 55);
}
.markdown-body pre {
background: oklch(0.12 0.015 55);
color: oklch(0.93 0.006 55);
}
.markdown-body blockquote {
border-left-color: var(--accent);
background: oklch(0.705 0.165 55 / 0.07);
}
.document-meta-panel {
background: oklch(0.20 0.015 55 / 0.5);
}
.editor-form textarea {
background: oklch(0.15 0.012 55);
color: var(--text);
border-color: var(--border);
}
.editor-conflict {
border-color: oklch(0.55 0.18 25 / 0.4);
background: oklch(0.22 0.04 25);
color: oklch(0.75 0.12 25);
}
.editor-conflict__actions button {
border-color: oklch(0.55 0.18 25 / 0.4);
background: oklch(0.18 0.018 55);
color: oklch(0.75 0.12 25);
}
.editor-conflict__choices button {
border-color: oklch(0.55 0.18 25 / 0.35);
background: oklch(0.18 0.018 55);
color: oklch(0.75 0.12 25);
}
.editor-conflict__choices button[data-conflict-use="server"] {
border-color: oklch(0.65 0.18 25 / 0.45);
background: oklch(0.28 0.08 25 / 0.8);
color: oklch(0.78 0.13 25);
}
.editor-conflict__choices button[data-conflict-use="local"] {
border-color: oklch(0.65 0.15 145 / 0.45);
background: oklch(0.25 0.07 145 / 0.8);
color: oklch(0.78 0.12 145);
}
.editor-conflict__swatch--server {
background: oklch(0.35 0.12 25 / 0.65);
color: oklch(0.72 0.14 25);
}
.editor-conflict__swatch--local {
background: oklch(0.33 0.10 145 / 0.65);
color: oklch(0.72 0.13 145);
}
.editor-conflict__diff {
border-color: oklch(0.55 0.18 25 / 0.3);
background: oklch(0.14 0.012 55);
}
.editor-conflict__resolution textarea {
border-color: oklch(0.55 0.18 25 / 0.3);
background: oklch(0.14 0.012 55);
color: var(--text);
}
[data-conflict-monaco-mount] {
border-color: oklch(0.55 0.18 25 / 0.3);
background: oklch(0.14 0.012 55);
}
.offline-notice {
border-color: oklch(0.75 0.15 85 / 0.4);
background: oklch(0.22 0.03 85 / 0.95);
color: oklch(0.75 0.12 85);
}
.offline-icon {
background: oklch(0.78 0.165 85);
}
.cached-notice {
border-color: oklch(0.55 0.15 250 / 0.3);
background: oklch(0.20 0.035 250 / 0.96);
color: oklch(0.65 0.12 250);
}
.cached-icon {
background: oklch(0.60 0.15 250);
}
.version-notice button {
background: var(--accent);
}
.miller-column:not(:first-child):not(:last-child):hover,
.miller-column:not(:first-child):not(:last-child).is-preview-expanded {
box-shadow: 8px 0 24px oklch(0.08 0.02 55 / 0.3);
}
.editor-status[data-state="queued"] {
color: oklch(0.65 0.12 75);
}
.editor-status[data-state="conflict"] {
color: oklch(0.60 0.18 25);
}
.editor-status[data-state="saving"],
.editor-status[data-state="unsaved"] {
color: oklch(0.65 0.12 250);
}
.markdown-body blockquote {
background: oklch(0.705 0.165 55 / 0.07);
}
}

View File

@@ -0,0 +1,204 @@
const STATIC_CACHE = "md-hub-static-v1";
const DB_NAME = "md-hub-cache";
const DB_VERSION = 3;
const STORE_PAGES = "pages";
const STATIC_ASSETS = [
"/static/site.css",
"/static/cache.js",
"/static/sync.js",
"/static/realtime.js",
"/static/editor.js",
"/static/render.js",
"/static/favicon.png",
"/static/cairnquire%20logo%402x.webp",
"/",
"/docs",
];
self.addEventListener("install", function (event) {
event.waitUntil(
caches.open(STATIC_CACHE).then(function (cache) {
return cache.addAll(STATIC_ASSETS);
}).then(function () {
return self.skipWaiting();
})
);
});
self.addEventListener("activate", function (event) {
event.waitUntil(
caches.keys().then(function (keys) {
return Promise.all(
keys.map(function (key) {
if (key === STATIC_CACHE) {
return null;
}
return caches.delete(key);
})
);
}).then(function () {
return self.clients.claim();
})
);
});
self.addEventListener("fetch", function (event) {
const request = event.request;
if (request.method !== "GET") {
return;
}
const url = new URL(request.url);
if (url.origin !== self.location.origin) {
return;
}
if (request.mode === "navigate") {
event.respondWith(handleNavigation(request, url));
return;
}
if (url.pathname.startsWith("/static/")) {
event.respondWith(handleStaticAsset(request));
}
});
async function handleNavigation(request, url) {
try {
const response = await fetch(request);
cachePageResponse(url.pathname, response.clone());
return response;
} catch (_error) {
const cachedPage = await getCachedPage(url.pathname);
if (cachedPage && cachedPage.html) {
return new Response(markOfflineHTML(cachedPage.html), {
headers: {
"Content-Type": "text/html; charset=utf-8",
"X-MDHub-Cache": "offline",
},
status: 200,
});
}
const fallback = await caches.match("/");
if (fallback) {
return fallback;
}
return new Response("Offline", {
status: 503,
headers: { "Content-Type": "text/plain; charset=utf-8" },
});
}
}
async function handleStaticAsset(request) {
const cache = await caches.open(STATIC_CACHE);
const cached = await cache.match(request);
if (cached) {
fetch(request).then(function (response) {
if (response && response.ok) {
cache.put(request, response.clone());
}
}).catch(function () {});
return cached;
}
const response = await fetch(request);
if (response && response.ok) {
cache.put(request, response.clone());
}
return response;
}
async function cachePageResponse(pathname, response) {
if (!response || !response.ok) {
return;
}
const contentType = response.headers.get("Content-Type") || "";
if (contentType.indexOf("text/html") === -1) {
return;
}
try {
const html = await response.text();
await putCachedPage(normalizePath(pathname), {
path: normalizePath(pathname),
html: html,
title: extractTitle(html),
cachedAt: Date.now(),
});
} catch (_error) {}
}
function markOfflineHTML(html) {
const marker = "<script>window.__MDHUB_OFFLINE_CACHED__=true;<\/script>";
if (html.indexOf("window.__MDHUB_OFFLINE_CACHED__") !== -1) {
return html;
}
if (html.indexOf("</head>") !== -1) {
return html.replace("</head>", marker + "</head>");
}
return marker + html;
}
function extractTitle(html) {
const match = html.match(/<title>([^<]*)<\/title>/i);
return match ? match[1] : "Cairnquire";
}
function normalizePath(path) {
if (!path || path === "/") {
return "/";
}
return path.replace(/\/$/, "") || "/";
}
function openDB() {
return new Promise(function (resolve, reject) {
const request = indexedDB.open(DB_NAME, DB_VERSION);
request.onerror = function () {
reject(request.error);
};
request.onsuccess = function () {
resolve(request.result);
};
request.onupgradeneeded = function (event) {
const db = event.target.result;
if (!db.objectStoreNames.contains(STORE_PAGES)) {
db.createObjectStore(STORE_PAGES, { keyPath: "path" });
}
};
});
}
async function putCachedPage(path, record) {
const db = await openDB();
return new Promise(function (resolve, reject) {
const tx = db.transaction(STORE_PAGES, "readwrite");
const store = tx.objectStore(STORE_PAGES);
store.put(record);
tx.oncomplete = function () {
resolve();
};
tx.onerror = function () {
reject(tx.error);
};
});
}
async function getCachedPage(path) {
const db = await openDB();
return new Promise(function (resolve, reject) {
const tx = db.transaction(STORE_PAGES, "readonly");
const store = tx.objectStore(STORE_PAGES);
const request = store.get(normalizePath(path));
request.onsuccess = function () {
resolve(request.result);
};
request.onerror = function () {
reject(request.error);
};
});
}

View File

@@ -0,0 +1,96 @@
(function () {
if (!window.MDHubCache) {
return;
}
function normalizeDocPath(path) {
if (!path) return "";
return path.replace(/^\/+/, "");
}
async function postDocument(path, content, baseHash) {
const response = await fetch("/api/documents/" + normalizeDocPath(path), {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ content: content, baseHash: baseHash || "" }),
});
const payload = await response.json().catch(function () {
return {};
});
if (!response.ok) {
if (response.status === 409) {
const conflict = new Error("document conflict");
conflict.name = "DocumentConflictError";
conflict.details = payload;
throw conflict;
}
throw new Error("save failed");
}
return payload;
}
async function saveDocument(path, content, baseHash) {
const normalizedPath = window.MDHubCache.normalizePath("/docs/" + normalizeDocPath(path));
try {
const result = await postDocument(path, content, baseHash);
await window.MDHubCache.deletePendingEdit(normalizedPath);
return {
status: "saved",
result: result,
};
} catch (error) {
if (error.name === "DocumentConflictError") {
await window.MDHubCache.putPendingEdit(normalizedPath, content, baseHash);
await window.MDHubCache.markPendingEditConflict(normalizedPath, error.details);
return {
status: "conflict",
conflict: error.details,
};
}
await window.MDHubCache.putPendingEdit(normalizedPath, content, baseHash);
return {
status: "queued",
};
}
}
async function syncPending() {
if (!navigator.onLine) {
return;
}
const pending = await window.MDHubCache.getPendingEdits();
pending.sort(function (a, b) {
return (a.updatedAt || 0) - (b.updatedAt || 0);
});
for (const edit of pending) {
try {
await postDocument(edit.path.replace(/^\/docs\//, ""), edit.content, edit.baseHash);
await window.MDHubCache.deletePendingEdit(edit.path);
} catch (error) {
if (error.name === "DocumentConflictError") {
await window.MDHubCache.markPendingEditConflict(edit.path, error.details);
}
break;
}
}
}
window.addEventListener("online", function () {
syncPending().catch(function () {});
});
syncPending().catch(function () {});
window.MDHubSync = {
saveDocument: saveDocument,
syncPending: syncPending,
};
})();

View File

@@ -0,0 +1,141 @@
package httpserver
import (
"context"
"encoding/json"
"net/http"
"os"
"strings"
"github.com/go-chi/chi/v5"
"github.com/tim/cairnquire/apps/server/internal/sync"
)
func (s *Server) handleSyncInit(w http.ResponseWriter, r *http.Request) {
var req sync.InitRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request body"})
return
}
if req.DeviceID == "" {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "deviceId is required"})
return
}
userID := r.Context().Value("userID").(string)
snapshot, err := s.syncService.InitSync(r.Context(), req.DeviceID, userID)
if err != nil {
s.logger.Error("sync init failed", "error", err)
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to initialize sync"})
return
}
writeJSON(w, http.StatusOK, sync.InitResponse{
SnapshotID: snapshot.ID,
ServerSnapshot: snapshot.Files,
})
}
func (s *Server) handleSyncDelta(w http.ResponseWriter, r *http.Request) {
var req sync.DeltaRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request body"})
return
}
if req.SnapshotID == "" {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "snapshotId is required"})
return
}
result, err := s.syncService.ApplyDelta(r.Context(), req.SnapshotID, sync.Delta{Changes: req.ClientDelta})
if err != nil {
s.logger.Error("sync delta failed", "error", err)
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to apply delta"})
return
}
writeJSON(w, http.StatusOK, sync.DeltaResponse{
ServerDelta: result.ServerDelta,
Conflicts: result.Conflicts,
})
}
func (s *Server) handleSyncResolve(w http.ResponseWriter, r *http.Request) {
var req sync.ResolveRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request body"})
return
}
if req.SnapshotID == "" {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "snapshotId is required"})
return
}
snapshot, err := s.syncService.ResolveConflicts(r.Context(), req.SnapshotID, req.Resolutions)
if err != nil {
s.logger.Error("sync resolve failed", "error", err)
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to resolve conflicts"})
return
}
writeJSON(w, http.StatusOK, sync.ResolveResponse{
NewSnapshotID: snapshot.ID,
})
}
func (s *Server) handleContentFetch(w http.ResponseWriter, r *http.Request) {
hash := chi.URLParam(r, "hash")
if hash == "" {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "hash is required"})
return
}
content, err := s.syncService.GetContent(hash)
if err != nil {
if os.IsNotExist(err) {
writeJSON(w, http.StatusNotFound, map[string]string{"error": "content not found"})
return
}
s.logger.Error("content fetch failed", "error", err, "hash", hash)
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to fetch content"})
return
}
w.Header().Set("Content-Type", "application/octet-stream")
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
w.Header().Set("ETag", "\""+hash+"\"")
w.WriteHeader(http.StatusOK)
w.Write(content)
}
func (s *Server) apiKeyMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Skip for non-sync routes
if !strings.HasPrefix(r.URL.Path, "/api/sync/") && !strings.HasPrefix(r.URL.Path, "/api/content/") {
next.ServeHTTP(w, r)
return
}
apiKey := r.Header.Get("X-API-Key")
if apiKey == "" {
writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "missing API key"})
return
}
// Validate API key against database
record, err := s.syncRepo.ValidateAPIKey(r.Context(), apiKey)
if err != nil {
s.logger.Warn("invalid api key", "error", err)
writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "invalid API key"})
return
}
ctx := context.WithValue(r.Context(), "userID", record.UserID)
next.ServeHTTP(w, r.WithContext(ctx))
})
}

View File

@@ -1,27 +1,34 @@
{{ define "base" }} {{ define "base" }}
<!doctype html> <!doctype html>
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="utf-8" /> <meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="color-scheme" content="light dark" />
<title>{{ .Title }}</title> <title>{{ .Title }}</title>
<link rel="icon" type="image/png" href="/static/favicon.png" />
<link rel="apple-touch-icon" href="/static/favicon.png" />
<link rel="stylesheet" href="/static/site.css" /> <link rel="stylesheet" href="/static/site.css" />
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/katex.min.css" /> <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/katex.min.css" />
<script defer src="https://cdn.jsdelivr.net/npm/mermaid@10/dist/mermaid.min.js"></script> <script defer src="https://cdn.jsdelivr.net/npm/mermaid@10/dist/mermaid.min.js"></script>
<script defer src="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/katex.min.js"></script> <script defer src="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/katex.min.js"></script>
</head> </head>
<body class="{{ .BodyClass }}"> <body class="{{ .BodyClass }}">
<header class="site-header"> <header class="site-header">
<div class="site-header__inner"> <div class="site-header__inner">
<a class="site-brand" href="/">MD Hub Secure</a> <a class="site-brand" href="/" aria-label="Cairnquire home">
<img src="/static/cairnquire%20logo%402x.webp" alt="" width="160" height="40" decoding="async" />
<span class="sr-only">Cairnquire</span>
</a>
<form class="site-search" action="/" method="get"> <form class="site-search" action="/" method="get">
<input type="search" name="q" placeholder="Search..." aria-label="Search documents" /> <input type="search" name="q" placeholder="Search..." aria-label="Search documents" />
<button type="submit" aria-label="Search">🔍</button> <button type="submit" aria-label="Search">🔍</button>
</form> </form>
<nav class="site-nav"> <nav class="site-nav">
<a href="/">Docs</a> <a href="/">Docs</a>
{{ if .WebEnabled }}<a href="/app/">App</a>{{ end }} {{ if .WebEnabled }}<a href="/app/">Admin</a>{{ end }}
<a href="/health">Health</a>
</nav> </nav>
</div> </div>
</header> </header>
@@ -30,6 +37,8 @@
{{ template "index_content" .Data }} {{ template "index_content" .Data }}
{{ else if eq .BodyTemplate "document_content" }} {{ else if eq .BodyTemplate "document_content" }}
{{ template "document_content" .Data }} {{ template "document_content" .Data }}
{{ else if eq .BodyTemplate "document_edit_content" }}
{{ template "document_edit_content" .Data }}
{{ else if eq .BodyTemplate "search_content" }} {{ else if eq .BodyTemplate "search_content" }}
{{ template "search_content" .Data }} {{ template "search_content" .Data }}
{{ else if eq .BodyTemplate "error_content" }} {{ else if eq .BodyTemplate "error_content" }}
@@ -40,13 +49,20 @@
<span aria-hidden="true" class="offline-icon"></span> <span aria-hidden="true" class="offline-icon"></span>
<p>You are offline. Some content may be stale.</p> <p>You are offline. Some content may be stale.</p>
</div> </div>
<div class="cached-notice" data-cached-notice hidden>
<span aria-hidden="true" class="cached-icon"></span>
<p>Viewing cached offline content.</p>
</div>
<div class="version-notice" data-version-notice hidden> <div class="version-notice" data-version-notice hidden>
<p>A newer version is available.</p> <p>A newer version is available.</p>
<button type="button" data-version-reload>Reload</button> <button type="button" data-version-reload>Reload</button>
</div> </div>
<script src="/static/cache.js" defer></script> <script src="/static/cache.js" defer></script>
<script src="/static/sync.js" defer></script>
<script src="/static/realtime.js" defer></script> <script src="/static/realtime.js" defer></script>
<script src="/static/editor.js" defer></script>
<script src="/static/render.js" defer></script> <script src="/static/render.js" defer></script>
</body> </body>
</html> </html>
{{ end }} {{ end }}

View File

@@ -19,7 +19,14 @@
</ol> </ol>
</nav> </nav>
<div class="document-meta"> <div class="document-meta">
<div class="document-meta__header">
<h1>{{ .Title }}</h1> <h1>{{ .Title }}</h1>
<a class="document-edit-link" href="/docs/{{ trimMd .Path }}/edit">Edit</a>
</div>
<details class="document-meta-panel">
<summary>
<span>Document details</span>
</summary>
<dl class="meta-grid"> <dl class="meta-grid">
<div> <div>
<dt>File</dt> <dt>File</dt>
@@ -42,6 +49,7 @@
</div> </div>
{{ end }} {{ end }}
</dl> </dl>
</details>
</div> </div>
<div class="markdown-body"> <div class="markdown-body">
@@ -57,7 +65,7 @@
<section class="miller-column {{ if eq .Title "Content" }}miller-column--root{{ end }}" aria-label="{{ .Title }}"> <section class="miller-column {{ if eq .Title "Content" }}miller-column--root{{ end }}" aria-label="{{ .Title }}">
<h2 title="{{ .Title }}"> <h2 title="{{ .Title }}">
{{ if eq .Title "Content" }} {{ if eq .Title "Content" }}
<span class="browser-icon browser-icon--root" aria-hidden="true"></span> {{ template "icon-library" }}
{{ end }} {{ end }}
<span class="miller-column-title-text">{{ .Title }}</span> <span class="miller-column-title-text">{{ .Title }}</span>
</h2> </h2>
@@ -66,10 +74,10 @@
<li> <li>
<a class="{{ if .Active }}is-active{{ end }} {{ if .IsFolder }}is-folder{{ end }}" href="{{ .URL }}" title="{{ .Name }}"> <a class="{{ if .Active }}is-active{{ end }} {{ if .IsFolder }}is-folder{{ end }}" href="{{ .URL }}" title="{{ .Name }}">
<span class="browser-item-label"> <span class="browser-item-label">
<span class="browser-icon {{ if .IsFolder }}browser-icon--folder{{ else }}browser-icon--page{{ end }}" aria-hidden="true"></span> {{ if .IsFolder }}{{ template "icon-folder" }}{{ else }}{{ template "icon-file-text" }}{{ end }}
<span class="browser-item-name">{{ .Name }}</span> <span class="browser-item-name">{{ .Name }}</span>
</span> </span>
{{ if .IsFolder }}<span class="browser-item-chevron" aria-hidden="true"></span>{{ end }} {{ if .IsFolder }}{{ template "icon-chevron-right" }}{{ end }}
</a> </a>
</li> </li>
{{ end }} {{ end }}
@@ -78,3 +86,34 @@
{{ end }} {{ end }}
</nav> </nav>
{{ end }} {{ end }}
{{ define "icon-folder" }}
<svg class="browser-icon browser-icon--folder" aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round">
<path d="M20 20a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.9a2 2 0 0 1-1.7-.9l-.8-1.2A2 2 0 0 0 7.9 3H4a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2Z"></path>
</svg>
{{ end }}
{{ define "icon-file-text" }}
<svg class="browser-icon browser-icon--page" aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round">
<path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z"></path>
<path d="M14 2v4a2 2 0 0 0 2 2h4"></path>
<path d="M10 9H8"></path>
<path d="M16 13H8"></path>
<path d="M16 17H8"></path>
</svg>
{{ end }}
{{ define "icon-library" }}
<svg class="browser-icon browser-icon--root" aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round">
<path d="m16 6 4 14"></path>
<path d="M12 6v14"></path>
<path d="M8 8v12"></path>
<path d="M4 4v16"></path>
</svg>
{{ end }}
{{ define "icon-chevron-right" }}
<svg class="browser-item-chevron" aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.1" stroke-linecap="round" stroke-linejoin="round">
<path d="m9 18 6-6-6-6"></path>
</svg>
{{ end }}

View File

@@ -0,0 +1,86 @@
{{ define "document_edit.gohtml" }}{{ template "base" . }}{{ end }}
{{ define "document_edit_content" }}
<section class="workspace-shell workspace-shell--document-edit">
{{ template "browser" .Browser }}
<article class="document-shell document-shell--editor" data-document-path="{{ .Path }}" data-document-hash="{{ .Hash }}">
<nav class="breadcrumbs" aria-label="Breadcrumb">
<ol>
{{ $lastIdx := sub (len .Breadcrumbs) 1 }}
{{ range $i, $crumb := .Breadcrumbs }}
<li>
{{ if eq $i $lastIdx }}
<span aria-current="page">{{ $crumb.Name }}</span>
{{ else }}
<a href="{{ $crumb.URL }}">{{ $crumb.Name }}</a>
{{ end }}
</li>
{{ end }}
</ol>
</nav>
<div class="document-meta">
<div class="document-meta__header document-meta__header--editor">
<div>
<p class="eyebrow">Editing</p>
<h1>{{ .Title }}</h1>
</div>
<a class="document-edit-link" href="/docs/{{ trimMd .Path }}">Done</a>
</div>
<details class="document-meta-panel">
<summary>
<span>Editor details</span>
<span class="editor-status" data-sync-status>Saved</span>
</summary>
<dl class="meta-grid">
<div>
<dt>File</dt>
<dd><code>{{ .Path }}</code></dd>
</div>
<div>
<dt>Hash</dt>
<dd><code data-editor-hash>{{ .Hash }}</code></dd>
</div>
<div>
<dt>Status</dt>
<dd><span class="editor-status" data-sync-status-secondary>Saved</span></dd>
</div>
</dl>
</details>
<div class="editor-conflict" data-conflict-notice hidden>
<div class="editor-conflict__header">
<div>
<strong>Conflict detected.</strong>
<span>The server has a newer copy (<code data-conflict-hash></code>). Resolve it before saving more edits.</span>
</div>
<div class="editor-conflict__actions">
<button type="button" data-conflict-apply disabled>Apply Resolution</button>
<button type="button" data-conflict-dismiss>Dismiss</button>
</div>
</div>
<p data-conflict-status>Red deletions are from the server copy. Green additions are from your queued edit.</p>
<div class="editor-conflict__legend" aria-label="Diff color meaning">
<span><span class="editor-conflict__swatch editor-conflict__swatch--server"></span>Red / - Server copy</span>
<span><span class="editor-conflict__swatch editor-conflict__swatch--local"></span>Green / + Your edit</span>
</div>
<div class="editor-conflict__choices" role="group" aria-label="Resolution starting point">
<button type="button" data-conflict-use="server">Use Server</button>
<button type="button" data-conflict-use="local">Use My Edit</button>
<button type="button" data-conflict-use="both">Use Both</button>
</div>
<div class="editor-conflict__diff" data-conflict-diff></div>
<div class="editor-conflict__resolution">
<label for="conflict-resolution">Resolved document</label>
<div data-conflict-monaco-mount></div>
<textarea id="conflict-resolution" data-conflict-resolution spellcheck="false"></textarea>
</div>
</div>
</div>
<form class="editor-form" data-editor-form data-document-path="{{ .Path }}">
<label class="sr-only" for="document-editor">Markdown editor</label>
<div data-monaco-mount></div>
<textarea id="document-editor" name="content" data-document-editor spellcheck="false">{{ .Source }}</textarea>
</form>
</article>
</section>
{{ end }}

View File

@@ -5,7 +5,7 @@
{{ template "browser" .Browser }} {{ template "browser" .Browser }}
<section class="empty-preview"> <section class="empty-preview">
<p class="eyebrow">Documents</p> <p class="eyebrow">Documents</p>
<h1>MD Hub Secure</h1> <h1>Cairnquire</h1>
</section> </section>
</section> </section>
{{ end }} {{ end }}

View File

@@ -6,7 +6,7 @@ import (
"github.com/gorilla/websocket" "github.com/gorilla/websocket"
"github.com/tim/md-hub-secure/apps/server/internal/realtime" "github.com/tim/cairnquire/apps/server/internal/realtime"
) )
const ( const (

View File

@@ -53,7 +53,16 @@ func NewRenderer() *Renderer {
} }
func (r *Renderer) Render(content []byte) (Result, error) { func (r *Renderer) Render(content []byte) (Result, error) {
title := extractTitle(string(content)) title, titleFromHeading := extractTitle(string(content), "")
return r.renderWithTitle(content, title, titleFromHeading)
}
func (r *Renderer) RenderPath(content []byte, path string) (Result, error) {
title, titleFromHeading := extractTitle(string(content), path)
return r.renderWithTitle(content, title, titleFromHeading)
}
func (r *Renderer) renderWithTitle(content []byte, title string, titleFromHeading bool) (Result, error) {
tags := extractTags(string(content)) tags := extractTags(string(content))
prepared := preprocess(string(content)) prepared := preprocess(string(content))
@@ -62,7 +71,10 @@ func (r *Renderer) Render(content []byte) (Result, error) {
return Result{}, fmt.Errorf("render markdown: %w", err) return Result{}, fmt.Errorf("render markdown: %w", err)
} }
html := firstH1Pattern.ReplaceAllString(output.String(), "") html := output.String()
if titleFromHeading {
html = firstH1Pattern.ReplaceAllString(html, "")
}
return Result{ return Result{
HTML: template.HTML(html), HTML: template.HTML(html),
@@ -123,13 +135,14 @@ func normalizeAdmonition(line string) string {
} }
} }
func extractTitle(content string) string { func extractTitle(content string, path string) (string, bool) {
for _, line := range strings.Split(content, "\n") { for _, line := range strings.Split(content, "\n") {
if strings.HasPrefix(line, "# ") { trimmed := strings.TrimSpace(line)
return strings.TrimSpace(strings.TrimPrefix(line, "# ")) if strings.HasPrefix(trimmed, "# ") {
return strings.TrimSpace(strings.TrimPrefix(trimmed, "# ")), true
} }
} }
return "Untitled" return titleFromPath(path), false
} }
func extractTags(content string) []string { func extractTags(content string) []string {
@@ -169,3 +182,35 @@ func slugify(value string) string {
} }
return strings.Trim(builder.String(), "-") return strings.Trim(builder.String(), "-")
} }
func titleFromPath(path string) string {
path = strings.Trim(path, "/")
path = strings.TrimSuffix(path, ".md")
if path == "" {
return "Home"
}
base := path
if strings.HasSuffix(base, "/index") {
base = strings.TrimSuffix(base, "/index")
}
if base == "index" {
return "Home"
}
if idx := strings.LastIndex(base, "/"); idx >= 0 {
base = base[idx+1:]
}
base = strings.ReplaceAll(base, "-", " ")
base = strings.ReplaceAll(base, "_", " ")
parts := strings.Fields(base)
for i, part := range parts {
if part == strings.ToUpper(part) {
continue
}
parts[i] = strings.ToUpper(part[:1]) + strings.ToLower(part[1:])
}
if len(parts) == 0 {
return "Home"
}
return strings.Join(parts, " ")
}

View File

@@ -20,4 +20,34 @@ func TestRenderRewritesWikiLinksAndTags(t *testing.T) {
if len(result.Tags) != 2 || result.Tags[0] != "alpha" || result.Tags[1] != "beta" { if len(result.Tags) != 2 || result.Tags[0] != "alpha" || result.Tags[1] != "beta" {
t.Fatalf("unexpected tags: %#v", result.Tags) t.Fatalf("unexpected tags: %#v", result.Tags)
} }
if strings.Contains(string(result.HTML), "<h1") {
t.Fatalf("expected first h1 to be removed from body html, got %s", result.HTML)
}
}
func TestRenderPathFallsBackToCleanedFileName(t *testing.T) {
renderer := NewRenderer()
result, err := renderer.RenderPath([]byte("Body only\n"), "guide/api-reference.md")
if err != nil {
t.Fatalf("RenderPath() error = %v", err)
}
if result.Title != "Api Reference" {
t.Fatalf("Title = %q, want Api Reference", result.Title)
}
}
func TestRenderPathUsesFolderNameForIndexFiles(t *testing.T) {
renderer := NewRenderer()
result, err := renderer.RenderPath([]byte("Body only\n"), "guide/index.md")
if err != nil {
t.Fatalf("RenderPath() error = %v", err)
}
if result.Title != "Guide" {
t.Fatalf("Title = %q, want Guide", result.Title)
}
} }

View File

@@ -0,0 +1,114 @@
package sync
import "time"
// ChangeType represents the type of file change in a delta.
type ChangeType string
const (
ChangeCreate ChangeType = "create"
ChangeUpdate ChangeType = "update"
ChangeDelete ChangeType = "delete"
ChangeRename ChangeType = "rename"
)
// ResolutionStrategy defines how to resolve a sync conflict.
type ResolutionStrategy string
const (
ResolutionLastWriteWins ResolutionStrategy = "last-write-wins"
ResolutionRenameBoth ResolutionStrategy = "rename-both"
ResolutionManualMerge ResolutionStrategy = "manual-merge"
ResolutionServerWins ResolutionStrategy = "server-wins"
ResolutionClientWins ResolutionStrategy = "client-wins"
)
// FileEntry represents a single file in a snapshot.
type FileEntry struct {
Path string `json:"path"`
Hash string `json:"hash"`
Size int64 `json:"size"`
Modified time.Time `json:"modified"`
}
// Snapshot represents a point-in-time view of the filesystem.
type Snapshot struct {
ID string `json:"id"`
DeviceID string `json:"deviceId"`
UserID string `json:"userId,omitempty"`
CreatedAt time.Time `json:"createdAt"`
Files []FileEntry `json:"files"`
}
// Change represents a single file change.
type Change struct {
Type ChangeType `json:"type"`
Path string `json:"path"`
OldPath string `json:"oldPath,omitempty"`
Hash string `json:"hash,omitempty"`
Size int64 `json:"size,omitempty"`
Modified time.Time `json:"modified,omitempty"`
}
// Delta is a list of changes since a snapshot.
type Delta struct {
Changes []Change `json:"changes"`
}
// Conflict represents a file changed on both client and server.
type Conflict struct {
Path string `json:"path"`
ServerHash string `json:"serverHash"`
ClientHash string `json:"clientHash"`
ServerModified time.Time `json:"serverModified"`
ClientModified time.Time `json:"clientModified"`
Strategy ResolutionStrategy `json:"strategy,omitempty"`
}
// DeltaResult is the server's response to a delta upload.
type DeltaResult struct {
ServerDelta []Change `json:"serverDelta"`
Conflicts []Conflict `json:"conflicts"`
}
// Resolution is a user's choice for resolving a conflict.
type Resolution struct {
Path string `json:"path"`
Strategy ResolutionStrategy `json:"strategy"`
NewPath string `json:"newPath,omitempty"` // for rename-both
}
// InitRequest starts a new sync session.
type InitRequest struct {
DeviceID string `json:"deviceId"`
RootPath string `json:"rootPath"`
}
// InitResponse returns the server's current snapshot.
type InitResponse struct {
SnapshotID string `json:"snapshotId"`
ServerSnapshot []FileEntry `json:"serverSnapshot"`
}
// DeltaRequest uploads client changes.
type DeltaRequest struct {
SnapshotID string `json:"snapshotId"`
ClientDelta []Change `json:"clientDelta"`
}
// DeltaResponse returns server changes and conflicts.
type DeltaResponse struct {
ServerDelta []Change `json:"serverDelta"`
Conflicts []Conflict `json:"conflicts"`
}
// ResolveRequest uploads conflict resolutions.
type ResolveRequest struct {
SnapshotID string `json:"snapshotId"`
Resolutions []Resolution `json:"resolutions"`
}
// ResolveResponse confirms the new snapshot.
type ResolveResponse struct {
NewSnapshotID string `json:"newSnapshotId"`
}

View File

@@ -0,0 +1,273 @@
package sync
import (
"context"
"fmt"
"log/slog"
"os"
"path/filepath"
"github.com/tim/cairnquire/apps/server/internal/docs"
"github.com/tim/cairnquire/apps/server/internal/store"
)
// Service handles sync protocol business logic.
type Service struct {
repo *Repository
docService *docs.Service
contentStore *store.ContentStore
logger *slog.Logger
sourceDir string
}
// NewService creates a new sync service.
func NewService(repo *Repository, docService *docs.Service, contentStore *store.ContentStore, sourceDir string, logger *slog.Logger) *Service {
return &Service{
repo: repo,
docService: docService,
contentStore: contentStore,
logger: logger,
sourceDir: sourceDir,
}
}
// InitSync creates a new snapshot from the current server state.
func (s *Service) InitSync(ctx context.Context, deviceID, userID string) (*Snapshot, error) {
files, err := s.buildSnapshotFromDisk(ctx)
if err != nil {
return nil, fmt.Errorf("build snapshot: %w", err)
}
snap, err := s.repo.CreateSnapshot(ctx, deviceID, userID, files)
if err != nil {
return nil, fmt.Errorf("create snapshot: %w", err)
}
s.logger.Info("sync initialized", "device", deviceID, "snapshot", snap.ID, "files", len(files))
return snap, nil
}
// ApplyDelta processes client changes and computes server delta + conflicts.
func (s *Service) ApplyDelta(ctx context.Context, snapshotID string, clientDelta Delta) (*DeltaResult, error) {
snap, err := s.repo.GetSnapshot(ctx, snapshotID)
if err != nil {
return nil, fmt.Errorf("get snapshot: %w", err)
}
serverFiles := make(map[string]FileEntry)
for _, f := range snap.Files {
serverFiles[f.Path] = f
}
clientFiles := make(map[string]FileEntry)
for _, c := range clientDelta.Changes {
if c.Type == ChangeDelete || c.Type == ChangeRename {
continue
}
clientFiles[c.Path] = FileEntry{
Path: c.Path,
Hash: c.Hash,
Size: c.Size,
Modified: c.Modified,
}
}
// Rebuild current server state (may have changed since snapshot)
currentFiles, err := s.buildSnapshotFromDisk(ctx)
if err != nil {
return nil, fmt.Errorf("build current snapshot: %w", err)
}
currentMap := make(map[string]FileEntry)
for _, f := range currentFiles {
currentMap[f.Path] = f
}
var serverDelta []Change
var conflicts []Conflict
// Detect server changes since snapshot
for path, current := range currentMap {
old, existed := serverFiles[path]
if !existed {
// Server created this file
serverDelta = append(serverDelta, Change{
Type: ChangeCreate,
Path: path,
Hash: current.Hash,
Size: current.Size,
Modified: current.Modified,
})
} else if old.Hash != current.Hash {
// Server updated this file
serverDelta = append(serverDelta, Change{
Type: ChangeUpdate,
Path: path,
Hash: current.Hash,
Size: current.Size,
Modified: current.Modified,
})
}
}
// Detect server deletions
for path := range serverFiles {
if _, exists := currentMap[path]; !exists {
serverDelta = append(serverDelta, Change{
Type: ChangeDelete,
Path: path,
})
}
}
// Check for conflicts: both client and server changed same file
for _, clientChange := range clientDelta.Changes {
if clientChange.Type == ChangeCreate || clientChange.Type == ChangeUpdate {
serverCurrent, serverHas := currentMap[clientChange.Path]
serverOld, serverHad := serverFiles[clientChange.Path]
if serverHad && serverHas && serverOld.Hash != serverCurrent.Hash &&
serverCurrent.Hash != clientChange.Hash {
conflicts = append(conflicts, Conflict{
Path: clientChange.Path,
ServerHash: serverCurrent.Hash,
ClientHash: clientChange.Hash,
ServerModified: serverCurrent.Modified,
ClientModified: clientChange.Modified,
Strategy: ResolutionLastWriteWins,
})
}
}
}
return &DeltaResult{
ServerDelta: serverDelta,
Conflicts: conflicts,
}, nil
}
// ResolveConflicts applies resolved changes and creates a new snapshot.
func (s *Service) ResolveConflicts(ctx context.Context, snapshotID string, resolutions []Resolution) (*Snapshot, error) {
snap, err := s.repo.GetSnapshot(ctx, snapshotID)
if err != nil {
return nil, fmt.Errorf("get snapshot: %w", err)
}
// Build a map of resolutions by path
resMap := make(map[string]Resolution)
for _, r := range resolutions {
resMap[r.Path] = r
}
// Get current server state
currentFiles, err := s.buildSnapshotFromDisk(ctx)
if err != nil {
return nil, fmt.Errorf("build current snapshot: %w", err)
}
currentMap := make(map[string]FileEntry)
for _, f := range currentFiles {
currentMap[f.Path] = f
}
// Apply resolutions to create the merged state
merged := make(map[string]FileEntry)
for path, f := range currentMap {
merged[path] = f
}
for _, res := range resolutions {
switch res.Strategy {
case ResolutionClientWins:
// In a real implementation, we'd apply the client's content here.
// For now, we keep the server state and mark it resolved.
s.logger.Debug("conflict resolved: client wins", "path", res.Path)
case ResolutionServerWins:
// Keep server state (already in merged)
s.logger.Debug("conflict resolved: server wins", "path", res.Path)
case ResolutionRenameBoth:
if existing, ok := merged[res.Path]; ok {
merged[res.NewPath] = existing
delete(merged, res.Path)
}
case ResolutionLastWriteWins:
// Keep whichever is newer - in practice server state since
// we haven't received client content yet
s.logger.Debug("conflict resolved: last-write-wins", "path", res.Path)
case ResolutionManualMerge:
// Flag for later UI resolution
s.logger.Debug("conflict deferred: manual merge", "path", res.Path)
}
}
// Create new snapshot from merged state
var files []FileEntry
for _, f := range merged {
files = append(files, f)
}
newSnap, err := s.repo.CreateSnapshot(ctx, snap.DeviceID, snap.UserID, files)
if err != nil {
return nil, fmt.Errorf("create new snapshot: %w", err)
}
s.logger.Info("conflicts resolved", "snapshot", snapshotID, "newSnapshot", newSnap.ID, "resolutions", len(resolutions))
return newSnap, nil
}
// GetContent returns raw file content by hash.
func (s *Service) GetContent(hash string) ([]byte, error) {
return s.contentStore.Read(hash)
}
// buildSnapshotFromDisk walks the source directory and builds a file list.
func (s *Service) buildSnapshotFromDisk(ctx context.Context) ([]FileEntry, error) {
var files []FileEntry
err := filepath.WalkDir(s.sourceDir, func(path string, entry os.DirEntry, err error) error {
if err != nil {
return err
}
if entry.IsDir() {
return nil
}
if filepath.Ext(path) != ".md" {
return nil
}
info, err := entry.Info()
if err != nil {
return err
}
relative, err := filepath.Rel(s.sourceDir, path)
if err != nil {
return err
}
relative = filepath.ToSlash(relative)
content, err := os.ReadFile(path)
if err != nil {
return fmt.Errorf("read file %s: %w", path, err)
}
record, err := s.contentStore.PutBytes(content)
if err != nil {
return fmt.Errorf("store file %s: %w", path, err)
}
files = append(files, FileEntry{
Path: relative,
Hash: record.Hash,
Size: info.Size(),
Modified: info.ModTime().UTC(),
})
return nil
})
if err != nil {
return nil, err
}
return files, nil
}

View File

@@ -0,0 +1,231 @@
package sync
import (
"context"
"database/sql"
"log/slog"
"os"
"path/filepath"
"testing"
"time"
"github.com/tim/cairnquire/apps/server/internal/database"
"github.com/tim/cairnquire/apps/server/internal/docs"
"github.com/tim/cairnquire/apps/server/internal/markdown"
"github.com/tim/cairnquire/apps/server/internal/store"
)
func setupTestDB(t *testing.T) *sql.DB {
t.Helper()
dbPath := filepath.Join(t.TempDir(), "test.db")
db, err := sql.Open("libsql", "file:"+dbPath)
if err != nil {
t.Fatalf("open test database: %v", err)
}
t.Cleanup(func() { db.Close() })
if _, err := db.Exec("PRAGMA foreign_keys=ON"); err != nil {
t.Fatalf("enable foreign keys: %v", err)
}
ctx := context.Background()
if err := database.ApplyMigrations(ctx, db); err != nil {
t.Fatalf("apply migrations: %v", err)
}
return db
}
func setupTestService(t *testing.T) (*Service, string) {
t.Helper()
sourceDir := t.TempDir()
storeDir := t.TempDir()
db := setupTestDB(t)
// Insert a test user to satisfy foreign key constraints
if _, err := db.ExecContext(context.Background(), `
INSERT INTO users (id, email, display_name, created_at)
VALUES ('user:test', 'test@example.com', 'Test User', ?)
`, time.Now().UTC().Format(time.RFC3339)); err != nil {
t.Fatalf("insert test user: %v", err)
}
// Create some test markdown files
if err := os.WriteFile(filepath.Join(sourceDir, "hello.md"), []byte("# Hello\n\nWorld"), 0o644); err != nil {
t.Fatalf("create hello.md: %v", err)
}
if err := os.WriteFile(filepath.Join(sourceDir, "guide.md"), []byte("# Guide\n\nSteps"), 0o644); err != nil {
t.Fatalf("create guide.md: %v", err)
}
contentStore, err := store.New(storeDir)
if err != nil {
t.Fatalf("create content store: %v", err)
}
renderer := markdown.NewRenderer()
repo := docs.NewRepository(db)
docService := docs.NewService(sourceDir, contentStore, renderer, repo, slog.Default())
syncRepo := NewRepository(db)
service := NewService(syncRepo, docService, contentStore, sourceDir, slog.Default())
return service, sourceDir
}
func TestInitSyncCreatesSnapshot(t *testing.T) {
service, _ := setupTestService(t)
ctx := context.Background()
snap, err := service.InitSync(ctx, "device-1", "user:test")
if err != nil {
t.Fatalf("InitSync() error = %v", err)
}
if snap.ID == "" {
t.Fatal("expected snapshot ID to be set")
}
if snap.DeviceID != "device-1" {
t.Fatalf("expected device ID device-1, got %s", snap.DeviceID)
}
if len(snap.Files) != 2 {
t.Fatalf("expected 2 files, got %d", len(snap.Files))
}
}
func TestApplyDeltaDetectsServerChanges(t *testing.T) {
service, sourceDir := setupTestService(t)
ctx := context.Background()
snap, err := service.InitSync(ctx, "device-1", "user:test")
if err != nil {
t.Fatalf("InitSync() error = %v", err)
}
// Modify a file on the server
time.Sleep(10 * time.Millisecond)
if err := os.WriteFile(filepath.Join(sourceDir, "hello.md"), []byte("# Hello\n\nUpdated"), 0o644); err != nil {
t.Fatalf("update hello.md: %v", err)
}
// Client sends empty delta
result, err := service.ApplyDelta(ctx, snap.ID, Delta{Changes: nil})
if err != nil {
t.Fatalf("ApplyDelta() error = %v", err)
}
if len(result.ServerDelta) != 1 {
t.Fatalf("expected 1 server delta, got %d", len(result.ServerDelta))
}
change := result.ServerDelta[0]
if change.Type != ChangeUpdate {
t.Fatalf("expected update change, got %s", change.Type)
}
if change.Path != "hello.md" {
t.Fatalf("expected path hello.md, got %s", change.Path)
}
if len(result.Conflicts) != 0 {
t.Fatalf("expected 0 conflicts, got %d", len(result.Conflicts))
}
}
func TestApplyDeltaDetectsConflicts(t *testing.T) {
service, sourceDir := setupTestService(t)
ctx := context.Background()
snap, err := service.InitSync(ctx, "device-1", "user:test")
if err != nil {
t.Fatalf("InitSync() error = %v", err)
}
// Both server and client change the same file
time.Sleep(10 * time.Millisecond)
if err := os.WriteFile(filepath.Join(sourceDir, "hello.md"), []byte("# Hello\n\nServer Update"), 0o644); err != nil {
t.Fatalf("update hello.md: %v", err)
}
// Client also changes the file (different content = different hash)
clientChange := Change{
Type: ChangeUpdate,
Path: "hello.md",
Hash: "clienthash123456789012345678901234567890123456789012345678901234",
Size: 100,
Modified: time.Now().UTC(),
}
result, err := service.ApplyDelta(ctx, snap.ID, Delta{Changes: []Change{clientChange}})
if err != nil {
t.Fatalf("ApplyDelta() error = %v", err)
}
if len(result.Conflicts) != 1 {
t.Fatalf("expected 1 conflict, got %d", len(result.Conflicts))
}
conflict := result.Conflicts[0]
if conflict.Path != "hello.md" {
t.Fatalf("expected conflict path hello.md, got %s", conflict.Path)
}
if conflict.Strategy != ResolutionLastWriteWins {
t.Fatalf("expected strategy last-write-wins, got %s", conflict.Strategy)
}
}
func TestResolveConflictsCreatesNewSnapshot(t *testing.T) {
service, _ := setupTestService(t)
ctx := context.Background()
snap, err := service.InitSync(ctx, "device-1", "user:test")
if err != nil {
t.Fatalf("InitSync() error = %v", err)
}
resolutions := []Resolution{
{
Path: "hello.md",
Strategy: ResolutionServerWins,
},
}
newSnap, err := service.ResolveConflicts(ctx, snap.ID, resolutions)
if err != nil {
t.Fatalf("ResolveConflicts() error = %v", err)
}
if newSnap.ID == "" {
t.Fatal("expected new snapshot ID to be set")
}
if newSnap.ID == snap.ID {
t.Fatal("expected new snapshot ID to be different from old")
}
if len(newSnap.Files) != 2 {
t.Fatalf("expected 2 files in new snapshot, got %d", len(newSnap.Files))
}
}
func TestGetContentReturnsFileBytes(t *testing.T) {
service, _ := setupTestService(t)
ctx := context.Background()
snap, err := service.InitSync(ctx, "device-1", "user:test")
if err != nil {
t.Fatalf("InitSync() error = %v", err)
}
if len(snap.Files) == 0 {
t.Fatal("expected at least one file")
}
hash := snap.Files[0].Hash
content, err := service.GetContent(hash)
if err != nil {
t.Fatalf("GetContent() error = %v", err)
}
if len(content) == 0 {
t.Fatal("expected non-empty content")
}
}

View File

@@ -0,0 +1,206 @@
package sync
import (
"context"
"database/sql"
"fmt"
"time"
)
// Repository provides database access for sync operations.
type Repository struct {
db *sql.DB
}
func NewRepository(db *sql.DB) *Repository {
return &Repository{db: db}
}
// APIKeyRecord represents a stored API key.
type APIKeyRecord struct {
ID string `json:"id"`
UserID string `json:"userId"`
Name string `json:"name"`
KeyHash string `json:"keyHash"`
Scopes string `json:"scopes"`
CreatedAt time.Time `json:"createdAt"`
ExpiresAt *time.Time `json:"expiresAt,omitempty"`
LastUsedAt *time.Time `json:"lastUsedAt,omitempty"`
}
// CreateSnapshot persists a new snapshot and returns it with an ID.
func (r *Repository) CreateSnapshot(ctx context.Context, deviceID, userID string, files []FileEntry) (*Snapshot, error) {
id := generateID("snap", deviceID)
now := time.Now().UTC()
tx, err := r.db.BeginTx(ctx, nil)
if err != nil {
return nil, fmt.Errorf("begin transaction: %w", err)
}
defer tx.Rollback()
if _, err := tx.ExecContext(ctx, `
INSERT INTO sync_snapshots (id, device_id, user_id, created_at)
VALUES (?, ?, ?, ?)
`, id, deviceID, userID, now.Format(time.RFC3339)); err != nil {
return nil, fmt.Errorf("insert snapshot: %w", err)
}
for _, f := range files {
if _, err := tx.ExecContext(ctx, `
INSERT INTO sync_files (snapshot_id, path, hash, size_bytes, modified_at)
VALUES (?, ?, ?, ?, ?)
`, id, f.Path, f.Hash, f.Size, f.Modified.Format(time.RFC3339)); err != nil {
return nil, fmt.Errorf("insert sync file %s: %w", f.Path, err)
}
}
if err := tx.Commit(); err != nil {
return nil, fmt.Errorf("commit snapshot: %w", err)
}
return &Snapshot{
ID: id,
DeviceID: deviceID,
CreatedAt: now,
Files: files,
}, nil
}
// GetSnapshot retrieves a snapshot by ID.
func (r *Repository) GetSnapshot(ctx context.Context, snapshotID string) (*Snapshot, error) {
var snap Snapshot
var created string
var userID sql.NullString
err := r.db.QueryRowContext(ctx, `
SELECT id, device_id, user_id, created_at
FROM sync_snapshots
WHERE id = ?
`, snapshotID).Scan(&snap.ID, &snap.DeviceID, &userID, &created)
if err != nil {
return nil, err
}
snap.CreatedAt, err = time.Parse(time.RFC3339, created)
if err != nil {
return nil, fmt.Errorf("parse created_at: %w", err)
}
if userID.Valid {
snap.UserID = userID.String
}
rows, err := r.db.QueryContext(ctx, `
SELECT path, hash, size_bytes, modified_at
FROM sync_files
WHERE snapshot_id = ?
ORDER BY path ASC
`, snapshotID)
if err != nil {
return nil, fmt.Errorf("query sync files: %w", err)
}
defer rows.Close()
for rows.Next() {
var f FileEntry
var modified string
if err := rows.Scan(&f.Path, &f.Hash, &f.Size, &modified); err != nil {
return nil, fmt.Errorf("scan sync file: %w", err)
}
f.Modified, err = time.Parse(time.RFC3339, modified)
if err != nil {
return nil, fmt.Errorf("parse modified_at: %w", err)
}
snap.Files = append(snap.Files, f)
}
return &snap, rows.Err()
}
// ValidateAPIKey checks if an API key hash is valid and returns the associated user.
func (r *Repository) ValidateAPIKey(ctx context.Context, keyHash string) (*APIKeyRecord, error) {
var record APIKeyRecord
var created, expires, lastUsed string
err := r.db.QueryRowContext(ctx, `
SELECT id, user_id, name, key_hash, scopes, created_at, expires_at, last_used_at
FROM api_keys
WHERE key_hash = ?
`, keyHash).Scan(
&record.ID, &record.UserID, &record.Name, &record.KeyHash,
&record.Scopes, &created, &expires, &lastUsed,
)
if err != nil {
return nil, err
}
record.CreatedAt, err = time.Parse(time.RFC3339, created)
if err != nil {
return nil, fmt.Errorf("parse created_at: %w", err)
}
if expires != "" {
t, err := time.Parse(time.RFC3339, expires)
if err != nil {
return nil, fmt.Errorf("parse expires_at: %w", err)
}
if time.Now().UTC().After(t) {
return nil, fmt.Errorf("api key expired")
}
record.ExpiresAt = &t
}
if lastUsed != "" {
t, err := time.Parse(time.RFC3339, lastUsed)
if err != nil {
return nil, fmt.Errorf("parse last_used_at: %w", err)
}
record.LastUsedAt = &t
}
// Update last_used_at
now := time.Now().UTC().Format(time.RFC3339)
if _, err := r.db.ExecContext(ctx, `
UPDATE api_keys SET last_used_at = ? WHERE id = ?
`, now, record.ID); err != nil {
return nil, fmt.Errorf("update last_used_at: %w", err)
}
return &record, nil
}
// ListLatestFiles returns the most recent file states from the latest snapshot.
func (r *Repository) ListLatestFiles(ctx context.Context, deviceID string) ([]FileEntry, error) {
rows, err := r.db.QueryContext(ctx, `
SELECT sf.path, sf.hash, sf.size_bytes, sf.modified_at
FROM sync_files sf
JOIN sync_snapshots ss ON ss.id = sf.snapshot_id
WHERE ss.device_id = ?
AND ss.created_at = (
SELECT MAX(created_at) FROM sync_snapshots WHERE device_id = ?
)
ORDER BY sf.path ASC
`, deviceID, deviceID)
if err != nil {
return nil, fmt.Errorf("query latest files: %w", err)
}
defer rows.Close()
var files []FileEntry
for rows.Next() {
var f FileEntry
var modified string
if err := rows.Scan(&f.Path, &f.Hash, &f.Size, &modified); err != nil {
return nil, fmt.Errorf("scan file: %w", err)
}
f.Modified, err = time.Parse(time.RFC3339, modified)
if err != nil {
return nil, fmt.Errorf("parse modified: %w", err)
}
files = append(files, f)
}
return files, rows.Err()
}
func generateID(prefix, suffix string) string {
return fmt.Sprintf("%s:%s:%d", prefix, suffix, time.Now().UnixNano())
}

View File

@@ -3,7 +3,7 @@
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>MD Hub Secure App</title> <title>Cairnquire App</title>
<script type="module" src="/src/main.tsx"></script> <script type="module" src="/src/main.tsx"></script>
</head> </head>
<body> <body>

File diff suppressed because it is too large Load Diff

View File

@@ -1,5 +1,5 @@
{ {
"name": "md-hub-secure-web", "name": "cairnquire-web",
"version": "0.1.0", "version": "0.1.0",
"private": true, "private": true,
"type": "module", "type": "module",

View File

@@ -339,7 +339,10 @@ function WorkspaceView(props: {
<section class="column-browser" aria-label="Workspace files"> <section class="column-browser" aria-label="Workspace files">
{columns.map((column) => ( {columns.map((column) => (
<div class="browser-column" key={column.prefix || "root"}> <div class="browser-column" key={column.prefix || "root"}>
<header>{column.title}</header> <header>
<Folder size={15} />
{column.title}
</header>
<div class="item-list"> <div class="item-list">
{column.items.map((item) => ( {column.items.map((item) => (
<button <button

View File

@@ -4,7 +4,7 @@ import { App } from "./app";
import "./styles.css"; import "./styles.css";
render( render(
<LocationProvider> <LocationProvider scope="/app">
<Router>{[<Route path="/*" component={App} />]}</Router> <Router>{[<Route path="/*" component={App} />]}</Router>
</LocationProvider>, </LocationProvider>,
document.getElementById("app")!, document.getElementById("app")!,

View File

@@ -1,6 +1,8 @@
import { defineConfig } from "vite"; import { defineConfig } from "vite";
import preact from "@preact/preset-vite"; import preact from "@preact/preset-vite";
const serverTarget = "http://localhost:8080";
export default defineConfig({ export default defineConfig({
base: "/app/", base: "/app/",
plugins: [preact()], plugins: [preact()],
@@ -12,7 +14,27 @@ export default defineConfig({
port: 5173, port: 5173,
proxy: { proxy: {
"/api": { "/api": {
target: "http://localhost:8080", target: serverTarget,
changeOrigin: true,
},
"/attachments": {
target: serverTarget,
changeOrigin: true,
},
"/docs": {
target: serverTarget,
changeOrigin: true,
},
"/health": {
target: serverTarget,
changeOrigin: true,
},
"/static": {
target: serverTarget,
changeOrigin: true,
},
"/sw.js": {
target: serverTarget,
changeOrigin: true, changeOrigin: true,
}, },
"/ws": { "/ws": {
@@ -20,7 +42,7 @@ export default defineConfig({
ws: true, ws: true,
}, },
"^/app$": { "^/app$": {
target: "http://localhost:8080", target: serverTarget,
changeOrigin: true, changeOrigin: true,
}, },
}, },

View File

@@ -1,6 +1,6 @@
# Architecture Overview # Architecture Overview
MD Hub Secure uses a Go application server as the primary runtime. Cairnquire uses a Go application server as the primary runtime.
- Markdown documents are read from the source directory. - Markdown documents are read from the source directory.
- The current version is written into content-addressed storage. - The current version is written into content-addressed storage.

View File

@@ -1,8 +1,2 @@
# Guide Get started by installing the source on your server. Or logging into a server that was set up already. Then download the client binary from the web dashboard to start syncing. You can create a new content collection or open an existing one.
This is the guide section.
## Topics
- Topic 1
- Topic 2

View File

@@ -1,4 +1,4 @@
# Welcome to MD Hub Secure # Welcome to Cairnquire
This is your documentation hub. Browse documents using the sidebar, or explore the features below. This is your documentation hub. Browse documents using the sidebar, or explore the features below.
@@ -14,3 +14,7 @@ This is your documentation hub. Browse documents using the sidebar, or explore t
- Tag extraction and filtering - Tag extraction and filtering
- Content-addressed attachment storage - Content-addressed attachment storage
- Real-time document syncing - Real-time document syncing
Lorem ipsum
<strong>hi</strong>

View File

@@ -6,11 +6,11 @@ services:
ports: ports:
- "8080:8080" - "8080:8080"
environment: environment:
MD_HUB_SERVER_ADDR: ":8080" CAIRNQUIRE_SERVER_ADDR: ":8080"
MD_HUB_DATABASE_PATH: "/workspace/data/db.sqlite" CAIRNQUIRE_DATABASE_PATH: "/workspace/data/db.sqlite"
MD_HUB_CONTENT_SOURCE_DIR: "/workspace/content" CAIRNQUIRE_CONTENT_SOURCE_DIR: "/workspace/content"
MD_HUB_CONTENT_STORE_DIR: "/workspace/data/files" CAIRNQUIRE_CONTENT_STORE_DIR: "/workspace/data/files"
MD_HUB_WEB_DIST_DIR: "/workspace/web-dist" CAIRNQUIRE_WEB_DIST_DIR: "/workspace/web-dist"
volumes: volumes:
- ./content:/workspace/content:ro - ./content:/workspace/content:ro
- ./data:/workspace/data - ./data:/workspace/data

View File

@@ -1,2 +1,89 @@
[tools] [tools]
pnpm = "latest" go = "1.24.2"
node = "24"
pnpm = "10.10.0"
[tasks.web-install]
description = "Install web dependencies"
dir = "apps/web"
run = "pnpm install --frozen-lockfile"
[tasks.web-build]
description = "Build the web app"
depends = ["web-install"]
dir = "apps/web"
run = "pnpm run build"
[tasks.web-dev]
description = "Start the Vite dev server"
dir = "apps/web"
run = "pnpm run dev"
[tasks.server-run]
description = "Run the Go server"
dir = "apps/server"
run = "CGO_ENABLED=1 go run ./cmd/cairnquire"
[tasks.server-build]
description = "Build the Go server"
dir = "apps/server"
run = "mkdir -p bin && CGO_ENABLED=1 go build -trimpath -o bin/cairnquire ./cmd/cairnquire"
[tasks.server-test]
description = "Run Go tests"
dir = "apps/server"
run = "CGO_ENABLED=1 go test ./..."
[tasks.dev-server]
description = "Run the Go server with air"
dir = "apps/server"
run = "CAIRNQUIRE_DEV_VITE_URL=http://localhost:5173 air"
[tasks.dev-web]
description = "Alias for the Vite dev server"
depends = ["web-dev"]
run = "true"
[tasks.dev]
description = "Run the Go and Vite dev servers"
run = '''
printf 'Starting dev servers...\n'
printf ' - Go server (with air live reload) on http://localhost:8080\n'
printf ' - Vite dev server on http://localhost:5173\n\n'
printf 'Access the app at http://localhost:8080/app/\n\n'
mise run dev-server &
server_pid=$!
mise run dev-web &
web_pid=$!
cleanup() {
kill "$server_pid" "$web_pid" 2>/dev/null || true
}
trap cleanup EXIT INT TERM
wait "$server_pid" "$web_pid"
'''
[tasks.docker-build]
description = "Build the Docker image"
run = "docker build -t cairnquire:dev ."
[tasks.fmt]
description = "Run format checks"
run = '''
cd apps/server
go fmt ./...
cd ../web
pnpm run format
'''
[tasks.ci]
description = "Run the core CI checks"
depends = ["web-build", "server-test", "server-build"]
run = "true"
[tasks.vulncheck]
description = "Run govulncheck"
dir = "apps/server"
run = "go run golang.org/x/vuln/cmd/govulncheck@v1.1.4 ./..."

View File

@@ -1,6 +1,6 @@
openapi: 3.1.0 openapi: 3.1.0
info: info:
title: MD Hub Secure Foundation API title: Cairnquire Foundation API
version: 0.1.0 version: 0.1.0
servers: servers:
- url: http://localhost:8080 - url: http://localhost:8080

View File

@@ -1,5 +1,5 @@
{ {
"name": "md-hub-secure", "name": "cairnquire",
"version": "1.0.0", "version": "1.0.0",
"private": true, "private": true,
"packageManager": "pnpm@10.10.0", "packageManager": "pnpm@10.10.0",

View File

@@ -2,7 +2,7 @@ syntax = "proto3";
package mdhub.protocol.v1; package mdhub.protocol.v1;
option go_package = "github.com/tim/md-hub-secure/packages/protocol/gen/go/mdhub/protocol/v1;protocolv1"; option go_package = "github.com/tim/cairnquire/packages/protocol/gen/go/mdhub/protocol/v1;protocolv1";
message SyncRequest { message SyncRequest {
string client_id = 1; string client_id = 1;

View File

@@ -1,2 +1,5 @@
packages: packages:
- 'apps/*' - 'apps/*'
# Delay installs of newly published packages by 7 days.
minimumReleaseAge: 10080

View File

@@ -1,4 +1,4 @@
# MD Hub Secure - Development Scripts # Cairnquire - Development Scripts
This directory contains helper scripts for monitoring development progress. This directory contains helper scripts for monitoring development progress.

View File

@@ -1,16 +1,16 @@
#!/bin/bash #!/bin/bash
# MD Hub Secure - Install Monitor # Cairnquire - Install Monitor
# Sets up the progress monitor to run in the background # Sets up the progress monitor to run in the background
set -euo pipefail set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_DIR="/Users/tim/developer/md-hub-secure" PROJECT_DIR="/Users/tim/developer/cairnquire"
MONITOR_SCRIPT="$SCRIPT_DIR/monitor.sh" MONITOR_SCRIPT="$SCRIPT_DIR/monitor.sh"
PID_FILE="$PROJECT_DIR/.monitor.pid" PID_FILE="$PROJECT_DIR/.monitor.pid"
LOG_FILE="$PROJECT_DIR/monitor.log" LOG_FILE="$PROJECT_DIR/monitor.log"
echo "MD Hub Secure - Monitor Installation" echo "Cairnquire - Monitor Installation"
echo "====================================" echo "===================================="
echo "" echo ""

View File

@@ -1,12 +1,12 @@
#!/bin/bash #!/bin/bash
# MD Hub Secure - Progress Monitor # Cairnquire - Progress Monitor
# Watches the project directory and updates docs/STATUS.md with progress # Watches the project directory and updates docs/STATUS.md with progress
# Exits if no filesystem changes detected between iterations # Exits if no filesystem changes detected between iterations
set -euo pipefail set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_DIR="/Users/tim/developer/md-hub-secure" PROJECT_DIR="/Users/tim/developer/cairnquire"
DOCS_DIR="$PROJECT_DIR/docs" DOCS_DIR="$PROJECT_DIR/docs"
STATUS_FILE="$DOCS_DIR/STATUS.md" STATUS_FILE="$DOCS_DIR/STATUS.md"
HASH_FILE="$DOCS_DIR/.last_state_hash" HASH_FILE="$DOCS_DIR/.last_state_hash"
@@ -293,7 +293,7 @@ EOF
--- ---
*This report auto-generated by MD Hub Secure progress monitor.* *This report auto-generated by Cairnquire progress monitor.*
*Next update in ~10 minutes or when filesystem changes detected.* *Next update in ~10 minutes or when filesystem changes detected.*
EOF EOF
@@ -302,7 +302,7 @@ EOF
# Main monitoring loop # Main monitoring loop
main() { main() {
log "Starting MD Hub Secure progress monitor..." log "Starting Cairnquire progress monitor..."
log "Project: $PROJECT_DIR" log "Project: $PROJECT_DIR"
log "Watching for filesystem changes every 10 minutes" log "Watching for filesystem changes every 10 minutes"
log "Press Ctrl+C to stop, or wait for auto-exit on inactivity" log "Press Ctrl+C to stop, or wait for auto-exit on inactivity"

View File

@@ -1,8 +1,8 @@
#!/bin/bash #!/bin/bash
# MD Hub Secure - Quick status check # Cairnquire - Quick status check
STATUS_FILE="/Users/tim/developer/md-hub-secure/docs/STATUS.md" STATUS_FILE="/Users/tim/developer/cairnquire/docs/STATUS.md"
PID_FILE="/Users/tim/developer/md-hub-secure/.monitor.pid" PID_FILE="/Users/tim/developer/cairnquire/.monitor.pid"
echo "" echo ""
if [[ -f "$PID_FILE" ]]; then if [[ -f "$PID_FILE" ]]; then