diff --git a/.gitignore b/.gitignore index ba1e798d..2cb485e2 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,10 @@ node_modules out dist +.turbo +apps/*/.turbo +apps/web/dist +apps/server/bin .DS_Store *.log .env diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 00000000..8fc31b9e --- /dev/null +++ b/Dockerfile @@ -0,0 +1,62 @@ +# syntax=docker/dockerfile:1.7 + +# ZenNotes self-hosted build from the monorepo. +# +# Stages: +# 1. web-build -> npm workspace install + Vite build for apps/web +# 2. server-build -> Go build for apps/server with the web bundle embedded +# 3. runtime -> minimal image with only the server binary + +FROM node:22-alpine AS web-build +WORKDIR /app + +COPY package.json package-lock.json turbo.json tsconfig.base.json tsconfig.json tailwind.config.js postcss.config.js ./ +COPY apps/web/package.json apps/web/package.json +COPY packages/app-core/package.json packages/app-core/package.json +COPY packages/bridge-contract/package.json packages/bridge-contract/package.json +COPY packages/shared-domain/package.json packages/shared-domain/package.json +COPY packages/shared-ui/package.json packages/shared-ui/package.json +COPY apps/desktop/package.json apps/desktop/package.json +COPY apps/server/package.json apps/server/package.json + +RUN npm ci --no-audit --no-fund --loglevel=error + +COPY apps apps +COPY packages packages + +RUN npm run build --workspace @zennotes/web + +FROM golang:1.22-alpine AS server-build +WORKDIR /app + +COPY apps/server/go.mod apps/server/go.sum ./apps/server/ +WORKDIR /app/apps/server +RUN go mod download + +WORKDIR /app +COPY apps/server apps/server +COPY --from=web-build /app/apps/web/dist/ /app/apps/server/web/dist/ + +ENV CGO_ENABLED=0 \ + GOOS=linux \ + GOFLAGS=-trimpath + +WORKDIR /app/apps/server +RUN go build -ldflags="-s -w" -o /out/zennotes-server ./cmd/zennotes-server + +FROM scratch +LABEL org.opencontainers.image.title="ZenNotes" \ + org.opencontainers.image.description="Self-hosted ZenNotes web/server bundle from the monorepo." \ + org.opencontainers.image.source="https://github.com/ZenNotes/zennotes" + +COPY --from=server-build /out/zennotes-server /zennotes-server + +ENV ZENNOTES_BIND=0.0.0.0:7878 \ + ZENNOTES_CONFIG_PATH=/data/server.json \ + ZENNOTES_DEFAULT_VAULT_PATH=/workspace \ + ZENNOTES_BROWSE_ROOTS=/workspace + +EXPOSE 7878 +VOLUME ["/workspace", "/data"] + +ENTRYPOINT ["/zennotes-server"] diff --git a/Makefile b/Makefile new file mode 100644 index 00000000..6186e74d --- /dev/null +++ b/Makefile @@ -0,0 +1,112 @@ +IMAGE ?= zennotes-selfhosted:local +PORT ?= 7878 +CONTENT_ROOT ?= ./vault +DATA ?= ./data +APP_URL ?= http://localhost:$(PORT) +COMPOSE := $(shell docker compose version >/dev/null 2>&1 && echo "docker compose" || echo "docker-compose") +OPEN_BROWSER := $(shell command -v open 2>/dev/null || command -v xdg-open 2>/dev/null) + +.PHONY: help install dev desktop web-dev server-dev web-stack \ + build desktop-build web-build server-build \ + up down restart logs status open rebuild nuke clean + +help: + @echo "" + @echo " Setup" + @echo " make install — install npm workspace dependencies" + @echo "" + @echo " Local development" + @echo " make desktop — run the Electron desktop app in dev mode" + @echo " make web-dev — run the Vite web client in dev mode" + @echo " make server-dev — run the Go server in dev mode" + @echo " make web-stack — run server + web dev together" + @echo "" + @echo " Local builds" + @echo " make build — build the full monorepo" + @echo " make desktop-build — build the Electron desktop app" + @echo " make web-build — build apps/web" + @echo " make server-build — build apps/server with the latest embedded web bundle" + @echo "" + @echo " Docker" + @echo " make up — build and start the self-hosted server" + @echo " make down — stop the container" + @echo " make restart — restart the container" + @echo " make logs — follow logs" + @echo " make status — show compose status" + @echo " make open — open the app in your browser" + @echo " make rebuild — force a full rebuild" + @echo " make nuke — tear down and remove local image/build output" + @echo " make clean — remove local web/server build output" + @echo "" + @echo " Useful Docker vars" + @echo " CONTENT_ROOT=~/iCloud Drive/Obsidian — host folder mounted into the container" + @echo " PORT=7878 — host port" + @echo "" + +install: + npm ci + +dev: desktop + +desktop: + npm run dev:desktop + +web-dev: + npm run dev:web + +server-dev: + npm run dev:server + +web-stack: + npm run dev:web-stack + +build: + npm run build + +desktop-build: + npm run build --workspace @zennotes/desktop + +web-build: + npm run build --workspace @zennotes/web + +up: + @mkdir -p "$(CONTENT_ROOT)" "$(DATA)" + @ZENNOTES_IMAGE="$(IMAGE)" ZENNOTES_HOST_PORT="$(PORT)" ZENNOTES_HOST_CONTENT_ROOT="$(CONTENT_ROOT)" ZENNOTES_HOST_DATA="$(DATA)" $(COMPOSE) up --build -d + @printf "\nZenNotes is running at $(APP_URL)\n\n" +ifneq ($(strip $(OPEN_BROWSER)),) + @$(OPEN_BROWSER) $(APP_URL) >/dev/null 2>&1 || true +endif + +down: + @$(COMPOSE) down + +restart: + @$(COMPOSE) restart zennotes 2>/dev/null || $(MAKE) --no-print-directory up + +logs: + @$(COMPOSE) logs -f + +status: + @$(COMPOSE) ps + +open: +ifneq ($(strip $(OPEN_BROWSER)),) + @$(OPEN_BROWSER) $(APP_URL) +else + @echo "Open $(APP_URL) manually." +endif + +rebuild: + @mkdir -p "$(CONTENT_ROOT)" "$(DATA)" + @ZENNOTES_IMAGE="$(IMAGE)" ZENNOTES_HOST_PORT="$(PORT)" ZENNOTES_HOST_CONTENT_ROOT="$(CONTENT_ROOT)" ZENNOTES_HOST_DATA="$(DATA)" $(COMPOSE) build --no-cache + @$(MAKE) --no-print-directory up + +nuke: + @$(COMPOSE) down --rmi local --volumes || true + @rm -rf apps/web/dist apps/server/bin apps/server/web/dist $(DATA) + +server-build: web-build + npm run build --workspace @zennotes/server + +clean: + rm -rf apps/web/dist apps/server/bin apps/server/web/dist diff --git a/README.md b/README.md index 7eacf4f6..87a1005d 100644 --- a/README.md +++ b/README.md @@ -1,47 +1,56 @@ # ZenNotes

- ZenNotes app icon + ZenNotes app icon

-ZenNotes is a keyboard-first desktop notes app built on Electron, -React, TypeScript, and CodeMirror 6. It keeps notes as ordinary local -Markdown files, adds a Vim-friendly editing and navigation model, and -ships a first-party MCP server so tools like Claude Code, Claude -Desktop, and Codex can work directly against the same vault. +ZenNotes is a keyboard-first Markdown notes app with a shared product core and multiple runtimes: -The goal is not "another markdown renderer." The goal is a local-first -notes environment that still feels fast and intentional when you live in -it all day. +- a desktop app built with Electron +- a self-hosted web app backed by a Go server +- a future hosted deployment mode built on the same web/server stack -Download the latest build from [GitHub Releases](https://github.com/ZenNotes/zennotes/releases/latest). +ZenNotes keeps your notes as ordinary Markdown files on disk. It adds Vim-friendly editing, split and preview workflows, tasks, tags, archive/trash, diagrams, search, daily notes, and MCP integration on top of the files you already own. + +Download the latest desktop build from [GitHub Releases](https://github.com/ZenNotes/zennotes/releases/latest). +Website: [zennotes.org](https://zennotes.org) ## What ZenNotes is for - writing and organizing plain-file Markdown notes without a database - moving quickly with keyboard-first navigation and Vim motions - working across edit, split, and preview modes without losing context -- keeping task management, tags, search, archive, and trash inside the - same vault +- keeping tasks, tags, search, archive, trash, and quick capture inside the same vault - rendering math and diagrams directly from Markdown -- letting MCP-capable coding / note-taking tools read and write the - vault safely through a bundled server +- exposing the vault to MCP-capable tools through a first-party server +- self-hosting the app on your own machine or home server + +## Product modes + +ZenNotes now ships from one monorepo with one shared app core. -## Core product ideas +- `desktop`: Electron shell, native menus, updater, floating windows, desktop packaging +- `self-hosted`: browser frontend plus Go server, suitable for home servers and LAN use +- `hosted`: planned as the same web/server stack with auth and multi-user storage added later + +The source of truth for user-facing features is the shared UI in `packages/app-core`. + +## Core ideas ### Plain files first -Every note lives on disk as a normal `.md` file inside a chosen vault. -ZenNotes does not invent a hidden store for note content. The app adds -views, metadata extraction, search, and rendering on top of the files -you already own. +Every note is a normal `.md` file inside a chosen vault. ZenNotes does not store note content in a hidden database. ### Keyboard-first by default -The app assumes you want fast navigation, not pointer-heavy chrome. -There is first-class Vim mode, leader-key flows, remappable shortcuts, -buffer switching, pane motion, command palette access, local ex prompts, -and which-key style hint overlays. +ZenNotes assumes you want to move fast: + +- first-class Vim mode +- leader-key flows +- command palette +- pane and tab motion +- local ex commands +- built-in help ### Preview is part of the workflow @@ -51,36 +60,39 @@ ZenNotes supports: - preview mode - split mode - pinned reference panes -- detached note windows +- detached note windows on desktop -That makes it useful both as a writing tool and as a reading / -researching tool. +### Shared vault, shared tooling -### AI tooling should work with the real vault - -ZenNotes includes a standalone MCP server entry and a settings UI that -can install the server into supported clients. The intent is simple: -your assistant should operate on the same notes you do, using normal -Markdown files and safe vault operations. +ZenNotes includes a first-party MCP server and desktop install flows for compatible clients, so tools can work on the same vault you do instead of a copy. ## Feature overview ### Notes, folders, and lifecycle -Each vault is organized into four top-level folders: +ZenNotes can: + +- create, rename, duplicate, move, archive, unarchive, trash, restore, and reveal notes and folders +- watch the vault for external changes +- reopen your workspace layout with tabs and panes + +System folders still exist, but the vault model is more flexible now: -- `inbox/` for active work -- `quick/` for fast capture -- `archive/` for cold storage -- `trash/` for recoverable deletion +- `quick`, `archive`, and `trash` remain built-in lifecycle areas +- the main notes area can be either: + - `inbox/` + - the vault root directly, for Obsidian-style flat vaults -ZenNotes can create, rename, duplicate, move, archive, unarchive, trash, -restore, and reveal notes and folders. The app also watches the vault on -disk, so external edits are reflected back into the UI. +The built-in folder labels are also customizable in the UI without changing the underlying internal ids. -Attachments are stored under a vault-local attachments directory -(`attachements/` in the current implementation, with legacy `_assets/` -support still recognized). +### Daily notes + +Daily notes are optional and can be enabled from Settings. + +- when enabled, ZenNotes can open or create today's note automatically +- the title is a simple ISO date like `2026-04-21` +- daily notes live in a dedicated directory under your primary notes area +- the default directory is `Daily Notes` ### Editor and preview @@ -89,13 +101,13 @@ The editor stack is CodeMirror 6 with a Markdown-oriented workflow: - live preview behavior in the editor - heading folding - outline extraction and jumps -- word wrap controls - configurable line numbers +- configurable line-height and typography controls - syntax highlighting for fenced code blocks -- local asset embedding -- inline PDF support +- wiki links, callouts, tables, footnotes, and local embeds +- Vim block cursor and keyboard navigation -Preview mode renders: +Preview and split mode support: - GitHub-flavored Markdown - KaTeX math @@ -107,254 +119,365 @@ Preview mode renders: - footnotes - wiki links and backlinks -Expanded diagram viewing is built in for diagram-heavy notes. - -### Search, tags, tasks, and built-in views +### Search, tasks, tags, and built-in views ZenNotes includes: -- note search by title / path +- note search by title and path - vault-wide text search - tags view - tasks view - archive view - trash view - quick notes view -- built-in help view +- built-in help/manual -Vault text search can use the built-in engine, `ripgrep`, or `fzf`, -with auto-detection and optional custom binary paths exposed in -Settings. +Vault text search can use the built-in engine, `ripgrep`, or `fzf`, with auto-detection and optional custom binary paths. -Task parsing is markdown-native: checkboxes stay as normal `- [ ]` / -`- [x]` lines in the note body and are surfaced into the global Tasks -view. +### Obsidian-friendly vault support -### Themes, fonts, and app customization +ZenNotes now works better with existing Obsidian-style vaults. -The app exposes a substantial settings surface: +- primary notes can live at the vault root instead of requiring `inbox/` +- loose files anywhere in the vault are surfaced as files/assets +- embedded files like `![[image.png]]` resolve more like Obsidian +- new referenced files default to the vault root instead of a required attachments folder +- legacy `attachements/` and `_assets/` folders are still recognized -- multiple theme families and light / dark / auto modes -- independent interface, text, and monospace font selection +This means imported vaults with top-level notes, folders, and loose images/files behave much more naturally. + +### Files and local assets + +ZenNotes supports local files in notes and in the sidebar/list views. + +- local images and files can appear directly in the vault tree +- desktop context menus include reveal-in-file-manager actions +- desktop uses Finder on macOS and the platform file manager on Windows/Linux +- watcher updates now include non-Markdown file changes, so deleting files externally updates the UI without a manual refresh + +### Themes, fonts, and customization + +The settings surface includes: + +- theme families and light/dark/auto modes +- interface, text, and monospace font selection - editor font size and line-height controls - preview and editor width controls -- dark-sidebar option - content alignment - keymap overrides - Vim toggles and leader hint behavior - search backend selection +- vault layout settings +- daily notes settings +- system-folder display labels -### MCP integration +## Desktop vs web -ZenNotes ships a dedicated MCP server and installation flows for: +Both runtimes share the same core app, but they do not expose identical platform features. -- Claude Code -- Claude Desktop -- Codex CLI - -The app can: +Desktop-only features include: -- detect whether the ZenNotes MCP entry is installed -- install or uninstall it for each supported client -- show the exact runtime used to launch the server -- edit the server's default note-shaping instructions from Settings +- native menus +- app updater +- floating note windows +- MCP install/uninstall flows for supported clients +- reveal in Finder / platform file manager +- packaging and signed releases -The MCP server exposes vault operations like reading notes, creating -notes, moving notes, appending to notes, searching text, listing notes, -listing assets, toggling tasks, and related filesystem-safe actions. +Web/self-hosted mode includes: -## Download +- the same shared note UI and workflows +- a Go backend for vault access and file watching +- a server-side vault picker/browser +- browser access on a LAN or home server -The fastest way to get the app is the latest GitHub release: +## Monorepo layout -- [Latest release](https://github.com/ZenNotes/zennotes/releases/latest) +ZenNotes now uses a single monorepo. -Release assets are built per platform in GitHub Actions and uploaded to -the matching release automatically: +```text +apps/ + desktop/ Electron shell, preload, updater, packaging + web/ Vite/PWA shell and HTTP bridge + server/ Go server for self-hosted and hosted deployments +packages/ + app-core/ Shared React application and renderer logic + bridge-contract/ Typed runtime contract between UI and host + shared-domain/ Shared types and note/task/view models + shared-ui/ Reusable UI primitives +tooling/ + scripts/ Shared tooling hooks and migration scripts +docs/ +``` -- macOS: `.dmg` and `.zip` -- Windows: installer `.exe` and `.zip` -- Linux: `.AppImage` and `.deb` +Read [docs/monorepo-architecture.md](docs/monorepo-architecture.md) for the architectural boundary between the shared app core and the platform-specific shells. -If the repository is private, GitHub access to the release assets follows -the repository's normal permissions. +## Quick start -## Vault model +### Requirements -ZenNotes expects a chosen vault root and will ensure the basic folder -layout exists on first open. The app also seeds a welcome note the first -time a vault is initialized. +- Node.js 22+ +- npm +- Go 1.22+ for the server build path +- Docker optional, for self-hosting -High-level behavior: +### Install dependencies -- only `inbox`, `quick`, and `archive` are treated as searchable note - folders -- `trash` is recoverable deletion, not part of normal search -- tags and wiki links are extracted from note bodies -- attachment presence is inferred from local links in Markdown -- vault changes are watched and pushed into the renderer over IPC +```bash +npm ci +``` -## Development +## Local development -### Requirements +### Desktop app -- Node.js 22+ recommended -- npm -- macOS, Windows, or Linux for Electron development +```bash +npm run dev:desktop +``` -### Install +or: ```bash -npm install +make desktop ``` -### Run the app in development +### Web client ```bash -npm run dev +npm run dev:web ``` -This starts the Electron main process, preload bundle, and renderer via -`electron-vite`. +or: -### Typecheck +```bash +make web-dev +``` + +### Go server ```bash -npm run typecheck +npm run dev:server ``` -### Build +or: ```bash -npm run build +make server-dev ``` -This produces: +### Web + server together -- `out/main` for the Electron main process -- `out/preload` for the preload bridge -- `out/renderer` for the renderer bundle +```bash +npm run dev:web-stack +``` -The standalone MCP server is built as `out/main/mcp.js`. +or: -## Packaging +```bash +make web-stack +``` -Available package scripts: +Important dev note: + +- the browser app and the Go server are separate processes in dev mode +- frontend-only changes usually need only the web dev server +- backend changes need the Go server restarted +- if the web client is newer than the running server, ZenNotes now shows a clearer error instead of raw 404 noise for newer API flows like the vault picker + +## Root scripts + +From the repository root: + +| Script | Purpose | +| ----------------------- | ----------------------------------------------- | +| `npm run dev` | Alias for `npm run dev:desktop` | +| `npm run dev:desktop` | Run the Electron desktop app in development | +| `npm run dev:web` | Run the Vite web client | +| `npm run dev:server` | Run the Go server | +| `npm run dev:web-stack` | Run web + server development together | +| `npm run start` | Start the built desktop app | +| `npm run typecheck` | Run monorepo typechecks | +| `npm run test` | Run monorepo tests | +| `npm run test:run` | Run the full test suite | +| `npm run build` | Build the monorepo and then build the Go server | +| `npm run build:prod` | Typecheck + test + build | +| `npm run pack` | Desktop packaged output | +| `npm run dist:mac` | Build macOS desktop distributables | +| `npm run dist:win` | Build Windows desktop distributables | +| `npm run dist:linux` | Build Linux desktop distributables | + +## Makefile commands + +The root `Makefile` provides a simpler interface: + +| Command | Purpose | +| -------------------- | ------------------------------------------------------- | +| `make install` | Install workspace dependencies | +| `make desktop` | Run the Electron app in dev mode | +| `make web-dev` | Run the web client | +| `make server-dev` | Run the Go server | +| `make web-stack` | Run web + server together | +| `make build` | Build the full monorepo | +| `make desktop-build` | Build the Electron app | +| `make web-build` | Build `apps/web` | +| `make server-build` | Build `apps/server` with the latest embedded web bundle | +| `make up` | Build and start the self-hosted Docker stack | +| `make down` | Stop the Docker stack | +| `make restart` | Restart the Docker stack | +| `make logs` | Follow Docker logs | +| `make status` | Show Docker status | +| `make open` | Open the self-hosted app in a browser | +| `make rebuild` | Force a full Docker rebuild | +| `make nuke` | Remove local Docker image/build output | +| `make clean` | Remove local web/server build output | + +Run `make help` to print the same summary. + +## Self-hosting with Docker + +### Start the self-hosted app -| Script | Purpose | -| --- | --- | -| `npm run dev` | Run the app in development mode | -| `npm run build` | Build all Electron bundles | -| `npm run start` | Preview the built app | -| `npm run test:run` | Run the automated test suite | -| `npm run typecheck` | Run node + web TypeScript checks | -| `npm run pack` | Build and create unpacked app output | -| `npm run dist:mac` | Build macOS distributables | -| `npm run dist:win` | Build Windows distributables | -| `npm run dist:linux` | Build Linux distributables | +```bash +make up +``` -Icon packaging notes live in [build/README.md](build/README.md). +Then open: -### Signed macOS releases +- [http://localhost:7878](http://localhost:7878) -Public macOS releases are wired for hardened runtime signing and -notarization. The release workflow expects these GitHub Actions secrets: +### Default Docker mounts -- `MACOS_CERTIFICATE_P12` -- `MACOS_CERTIFICATE_PASSWORD` -- `APPLE_ID` -- `APPLE_APP_SPECIFIC_PASSWORD` -- `APPLE_TEAM_ID` +The current Docker setup mounts: -Optional Windows signing can be supplied with: +- host `./vault` -> container `/workspace` +- host `./data` -> container `/data` -- `WINDOWS_CERTIFICATE_P12` -- `WINDOWS_CERTIFICATE_PASSWORD` +The server stores its config under `/data/server.json` by default. -Tagged releases fail the macOS release job if the required Apple signing -or notarization secrets are missing, which prevents accidentally shipping -an unsigned public mac build. +### Choosing a different host folder -## Repository layout +You can mount a different host content root: -```text -src/ - main/ Electron main process, vault I/O, watchers, TikZ, MCP install management - preload/ Context bridge / IPC surface exposed to the renderer - renderer/ React app, editor UI, preview UI, settings, panes, styles - mcp/ Standalone MCP server entry, tool definitions, default instructions - shared/ Shared IPC contracts and cross-process types -build/ Packaging resources (icons, installer assets) -out/ Built output generated by electron-vite +```bash +CONTENT_ROOT="$HOME/Library/Mobile Documents/com~apple~CloudDocs" make up ``` -## Architecture notes +That works for paths with spaces too. -### Main process +Useful variables: -`src/main/` is responsible for: +- `CONTENT_ROOT`: host folder mounted into the container +- `DATA`: host directory used for persisted server config +- `PORT`: published host port +- `IMAGE`: Docker image tag -- window lifecycle -- persisted app config -- vault selection and layout bootstrapping -- filesystem operations for notes, folders, archive, trash, and assets -- vault watching -- task scanning -- vault-wide text search -- TikZ rendering -- MCP client install / uninstall flows +### Docker browse model + +Important limitation: + +- the Docker container can only browse folders that are mounted into it +- it cannot browse your entire host filesystem + +So if you want to browse an Obsidian vault, iCloud Drive, or another directory from the web picker, that directory needs to be mounted into the container first. + +### Relevant container env vars + +The current compose/runtime flow supports: + +- `ZENNOTES_BIND` +- `ZENNOTES_CONFIG_PATH` +- `ZENNOTES_DEFAULT_VAULT_PATH` +- `ZENNOTES_BROWSE_ROOTS` +- `ZENNOTES_VAULT_PATH` + +Behavior notes: + +- `ZENNOTES_VAULT_PATH` hard-locks the server to a specific vault path +- `ZENNOTES_DEFAULT_VAULT_PATH` sets the starting vault when no saved selection exists +- `ZENNOTES_BROWSE_ROOTS` limits what the web picker can browse + +## Web vault picker + +The self-hosted web build now includes a server-backed vault chooser. + +- it browses folders on the server, not the browser machine +- it starts from sensible locations instead of requiring blind path typing +- it supports a simpler folder-picker flow for choosing the active vault +- on macOS-hosted servers, common shortcuts like iCloud Drive are supported when available + +If you start the server with `ZENNOTES_VAULT_PATH`, manual vault switching is intentionally disabled. + +## MCP integration + +ZenNotes ships a dedicated MCP server and desktop install flows for: -### Preload +- Claude Code +- Claude Desktop +- Codex -`src/preload/index.ts` exposes the app's typed bridge through -`window.zen`, including vault operations, search, task scanning, window -controls, TikZ rendering, clipboard helpers, and MCP settings actions. +The desktop app can: -### Renderer +- detect whether the ZenNotes MCP entry is installed +- install or uninstall it for each supported client +- show the exact runtime used to launch the server +- edit the server's default instructions from Settings -`src/renderer/` holds the desktop UI: +The MCP server exposes vault operations such as: -- sidebar, note list, editor, preview, floating note windows -- command palette, help view, tags/tasks/archive/trash views -- pane layout and tab state -- settings UI -- theme system -- diagram rendering for Mermaid, TikZ, JSXGraph, and function-plot +- reading notes +- creating notes +- moving notes +- appending to notes +- listing notes +- searching vault text +- listing files/assets +- toggling tasks -State is managed with Zustand. +## Building and packaging desktop releases -### MCP server +### Build everything -`src/mcp/index.ts` is a standalone stdio server built on -`@modelcontextprotocol/sdk`. It exposes vault operations to compatible -clients and ships opinionated note-writing instructions tailored to -ZenNotes' markdown features and vault model. +```bash +npm run build:prod +``` -Those instructions can be overridden by the user and are persisted as a -plain Markdown file under the app's user-data directory. +### Desktop package scripts -## Why the MCP story matters here +```bash +npm run pack +npm run dist:mac +npm run dist:win +npm run dist:linux +``` -ZenNotes is intentionally opinionated about how assistants should write -notes: +### Signed macOS releases -- use the vault as shared storage, not as a scratchpad -- prefer surgical edits over blind overwrites -- lean on KaTeX and diagram fences instead of ASCII approximations -- connect notes through wiki links -- preserve user-owned content and frontmatter +Public macOS releases are wired for hardened runtime signing and notarization. -That behavior is encoded in the bundled MCP instructions and surfaced in -Settings so users can tune it without forking the app. +The GitHub Actions release workflow expects: + +- `MACOS_CERTIFICATE_P12` +- `MACOS_CERTIFICATE_PASSWORD` +- `APPLE_ID` +- `APPLE_APP_SPECIFIC_PASSWORD` +- `APPLE_TEAM_ID` + +Optional Windows signing can be supplied with: + +- `WINDOWS_CERTIFICATE_P12` +- `WINDOWS_CERTIFICATE_PASSWORD` + +Tagged releases fail the macOS release job if the required Apple signing or notarization secrets are missing. That prevents accidentally shipping an unsigned public mac build. ## Current status -ZenNotes is still an actively changing codebase. The app already has a -meaningful feature surface, but the product and interaction model are -still being refined quickly. +ZenNotes is actively evolving. The desktop app is the more mature runtime today, while the self-hosted web/server path is being brought into parity through the shared monorepo core. + +That means: -If you are contributing, expect UI polish, keyboard flows, diagram -rendering, and MCP ergonomics to keep evolving. +- many features are shared already +- some platform-specific behavior still lives in the shell layers +- the README will keep evolving as desktop, web, and self-hosted flows converge further ## License diff --git a/build/Icon/Icon-iOS-Dark-1024x1024@1x.png b/apps/desktop/build/Icon/Icon-iOS-Dark-1024x1024@1x.png similarity index 100% rename from build/Icon/Icon-iOS-Dark-1024x1024@1x.png rename to apps/desktop/build/Icon/Icon-iOS-Dark-1024x1024@1x.png diff --git a/build/Icon/Icon-iOS-Dark-128x128@1x.png b/apps/desktop/build/Icon/Icon-iOS-Dark-128x128@1x.png similarity index 100% rename from build/Icon/Icon-iOS-Dark-128x128@1x.png rename to apps/desktop/build/Icon/Icon-iOS-Dark-128x128@1x.png diff --git a/build/Icon/Icon-iOS-Dark-128x128@2x.png b/apps/desktop/build/Icon/Icon-iOS-Dark-128x128@2x.png similarity index 100% rename from build/Icon/Icon-iOS-Dark-128x128@2x.png rename to apps/desktop/build/Icon/Icon-iOS-Dark-128x128@2x.png diff --git a/build/Icon/Icon-iOS-Dark-16x16@1x.png b/apps/desktop/build/Icon/Icon-iOS-Dark-16x16@1x.png similarity index 100% rename from build/Icon/Icon-iOS-Dark-16x16@1x.png rename to apps/desktop/build/Icon/Icon-iOS-Dark-16x16@1x.png diff --git a/build/Icon/Icon-iOS-Dark-16x16@2x.png b/apps/desktop/build/Icon/Icon-iOS-Dark-16x16@2x.png similarity index 100% rename from build/Icon/Icon-iOS-Dark-16x16@2x.png rename to apps/desktop/build/Icon/Icon-iOS-Dark-16x16@2x.png diff --git a/build/Icon/Icon-iOS-Dark-20x20@2x.png b/apps/desktop/build/Icon/Icon-iOS-Dark-20x20@2x.png similarity index 100% rename from build/Icon/Icon-iOS-Dark-20x20@2x.png rename to apps/desktop/build/Icon/Icon-iOS-Dark-20x20@2x.png diff --git a/build/Icon/Icon-iOS-Dark-20x20@3x.png b/apps/desktop/build/Icon/Icon-iOS-Dark-20x20@3x.png similarity index 100% rename from build/Icon/Icon-iOS-Dark-20x20@3x.png rename to apps/desktop/build/Icon/Icon-iOS-Dark-20x20@3x.png diff --git a/build/Icon/Icon-iOS-Dark-256x256@1x.png b/apps/desktop/build/Icon/Icon-iOS-Dark-256x256@1x.png similarity index 100% rename from build/Icon/Icon-iOS-Dark-256x256@1x.png rename to apps/desktop/build/Icon/Icon-iOS-Dark-256x256@1x.png diff --git a/build/Icon/Icon-iOS-Dark-256x256@2x.png b/apps/desktop/build/Icon/Icon-iOS-Dark-256x256@2x.png similarity index 100% rename from build/Icon/Icon-iOS-Dark-256x256@2x.png rename to apps/desktop/build/Icon/Icon-iOS-Dark-256x256@2x.png diff --git a/build/Icon/Icon-iOS-Dark-29x29@2x.png b/apps/desktop/build/Icon/Icon-iOS-Dark-29x29@2x.png similarity index 100% rename from build/Icon/Icon-iOS-Dark-29x29@2x.png rename to apps/desktop/build/Icon/Icon-iOS-Dark-29x29@2x.png diff --git a/build/Icon/Icon-iOS-Dark-29x29@3x.png b/apps/desktop/build/Icon/Icon-iOS-Dark-29x29@3x.png similarity index 100% rename from build/Icon/Icon-iOS-Dark-29x29@3x.png rename to apps/desktop/build/Icon/Icon-iOS-Dark-29x29@3x.png diff --git a/build/Icon/Icon-iOS-Dark-32x32@1x.png b/apps/desktop/build/Icon/Icon-iOS-Dark-32x32@1x.png similarity index 100% rename from build/Icon/Icon-iOS-Dark-32x32@1x.png rename to apps/desktop/build/Icon/Icon-iOS-Dark-32x32@1x.png diff --git a/build/Icon/Icon-iOS-Dark-32x32@2x.png b/apps/desktop/build/Icon/Icon-iOS-Dark-32x32@2x.png similarity index 100% rename from build/Icon/Icon-iOS-Dark-32x32@2x.png rename to apps/desktop/build/Icon/Icon-iOS-Dark-32x32@2x.png diff --git a/build/Icon/Icon-iOS-Dark-38x38@2x.png b/apps/desktop/build/Icon/Icon-iOS-Dark-38x38@2x.png similarity index 100% rename from build/Icon/Icon-iOS-Dark-38x38@2x.png rename to apps/desktop/build/Icon/Icon-iOS-Dark-38x38@2x.png diff --git a/build/Icon/Icon-iOS-Dark-38x38@3x.png b/apps/desktop/build/Icon/Icon-iOS-Dark-38x38@3x.png similarity index 100% rename from build/Icon/Icon-iOS-Dark-38x38@3x.png rename to apps/desktop/build/Icon/Icon-iOS-Dark-38x38@3x.png diff --git a/build/Icon/Icon-iOS-Dark-40x40@2x.png b/apps/desktop/build/Icon/Icon-iOS-Dark-40x40@2x.png similarity index 100% rename from build/Icon/Icon-iOS-Dark-40x40@2x.png rename to apps/desktop/build/Icon/Icon-iOS-Dark-40x40@2x.png diff --git a/build/Icon/Icon-iOS-Dark-40x40@3x.png b/apps/desktop/build/Icon/Icon-iOS-Dark-40x40@3x.png similarity index 100% rename from build/Icon/Icon-iOS-Dark-40x40@3x.png rename to apps/desktop/build/Icon/Icon-iOS-Dark-40x40@3x.png diff --git a/build/Icon/Icon-iOS-Dark-512x512@1x.png b/apps/desktop/build/Icon/Icon-iOS-Dark-512x512@1x.png similarity index 100% rename from build/Icon/Icon-iOS-Dark-512x512@1x.png rename to apps/desktop/build/Icon/Icon-iOS-Dark-512x512@1x.png diff --git a/build/Icon/Icon-iOS-Dark-60x60@2x.png b/apps/desktop/build/Icon/Icon-iOS-Dark-60x60@2x.png similarity index 100% rename from build/Icon/Icon-iOS-Dark-60x60@2x.png rename to apps/desktop/build/Icon/Icon-iOS-Dark-60x60@2x.png diff --git a/build/Icon/Icon-iOS-Dark-60x60@3x.png b/apps/desktop/build/Icon/Icon-iOS-Dark-60x60@3x.png similarity index 100% rename from build/Icon/Icon-iOS-Dark-60x60@3x.png rename to apps/desktop/build/Icon/Icon-iOS-Dark-60x60@3x.png diff --git a/build/Icon/Icon-iOS-Dark-64x64@2x.png b/apps/desktop/build/Icon/Icon-iOS-Dark-64x64@2x.png similarity index 100% rename from build/Icon/Icon-iOS-Dark-64x64@2x.png rename to apps/desktop/build/Icon/Icon-iOS-Dark-64x64@2x.png diff --git a/build/Icon/Icon-iOS-Dark-64x64@3x.png b/apps/desktop/build/Icon/Icon-iOS-Dark-64x64@3x.png similarity index 100% rename from build/Icon/Icon-iOS-Dark-64x64@3x.png rename to apps/desktop/build/Icon/Icon-iOS-Dark-64x64@3x.png diff --git a/build/Icon/Icon-iOS-Dark-68x68@2x.png b/apps/desktop/build/Icon/Icon-iOS-Dark-68x68@2x.png similarity index 100% rename from build/Icon/Icon-iOS-Dark-68x68@2x.png rename to apps/desktop/build/Icon/Icon-iOS-Dark-68x68@2x.png diff --git a/build/Icon/Icon-iOS-Dark-76x76@2x.png b/apps/desktop/build/Icon/Icon-iOS-Dark-76x76@2x.png similarity index 100% rename from build/Icon/Icon-iOS-Dark-76x76@2x.png rename to apps/desktop/build/Icon/Icon-iOS-Dark-76x76@2x.png diff --git a/build/Icon/Icon-iOS-Dark-83.5x83.5@2x.png b/apps/desktop/build/Icon/Icon-iOS-Dark-83.5x83.5@2x.png similarity index 100% rename from build/Icon/Icon-iOS-Dark-83.5x83.5@2x.png rename to apps/desktop/build/Icon/Icon-iOS-Dark-83.5x83.5@2x.png diff --git a/build/README.md b/apps/desktop/build/README.md similarity index 100% rename from build/README.md rename to apps/desktop/build/README.md diff --git a/build/after-install.sh b/apps/desktop/build/after-install.sh similarity index 100% rename from build/after-install.sh rename to apps/desktop/build/after-install.sh diff --git a/build/entitlements.mac.inherit.plist b/apps/desktop/build/entitlements.mac.inherit.plist similarity index 100% rename from build/entitlements.mac.inherit.plist rename to apps/desktop/build/entitlements.mac.inherit.plist diff --git a/build/entitlements.mac.plist b/apps/desktop/build/entitlements.mac.plist similarity index 100% rename from build/entitlements.mac.plist rename to apps/desktop/build/entitlements.mac.plist diff --git a/build/icon.png b/apps/desktop/build/icon.png similarity index 100% rename from build/icon.png rename to apps/desktop/build/icon.png diff --git a/build/icons/128x128.png b/apps/desktop/build/icons/128x128.png similarity index 100% rename from build/icons/128x128.png rename to apps/desktop/build/icons/128x128.png diff --git a/build/icons/16x16.png b/apps/desktop/build/icons/16x16.png similarity index 100% rename from build/icons/16x16.png rename to apps/desktop/build/icons/16x16.png diff --git a/build/icons/24x24.png b/apps/desktop/build/icons/24x24.png similarity index 100% rename from build/icons/24x24.png rename to apps/desktop/build/icons/24x24.png diff --git a/build/icons/256x256.png b/apps/desktop/build/icons/256x256.png similarity index 100% rename from build/icons/256x256.png rename to apps/desktop/build/icons/256x256.png diff --git a/build/icons/32x32.png b/apps/desktop/build/icons/32x32.png similarity index 100% rename from build/icons/32x32.png rename to apps/desktop/build/icons/32x32.png diff --git a/build/icons/48x48.png b/apps/desktop/build/icons/48x48.png similarity index 100% rename from build/icons/48x48.png rename to apps/desktop/build/icons/48x48.png diff --git a/build/icons/512x512.png b/apps/desktop/build/icons/512x512.png similarity index 100% rename from build/icons/512x512.png rename to apps/desktop/build/icons/512x512.png diff --git a/build/icons/64x64.png b/apps/desktop/build/icons/64x64.png similarity index 100% rename from build/icons/64x64.png rename to apps/desktop/build/icons/64x64.png diff --git a/electron.vite.config.ts b/apps/desktop/electron.vite.config.ts similarity index 83% rename from electron.vite.config.ts rename to apps/desktop/electron.vite.config.ts index a62395df..a25eb99e 100644 --- a/electron.vite.config.ts +++ b/apps/desktop/electron.vite.config.ts @@ -75,7 +75,8 @@ export default defineConfig({ }, resolve: { alias: { - '@shared': resolve(__dirname, 'src/shared') + '@shared': resolve(__dirname, '../../packages/shared-domain/src'), + '@bridge-contract': resolve(__dirname, '../../packages/bridge-contract/src') } } }, @@ -89,7 +90,8 @@ export default defineConfig({ }, resolve: { alias: { - '@shared': resolve(__dirname, 'src/shared') + '@shared': resolve(__dirname, '../../packages/shared-domain/src'), + '@bridge-contract': resolve(__dirname, '../../packages/bridge-contract/src') } } }, @@ -109,8 +111,9 @@ export default defineConfig({ }, resolve: { alias: { - '@renderer': resolve(__dirname, 'src/renderer/src'), - '@shared': resolve(__dirname, 'src/shared') + '@renderer': resolve(__dirname, '../../packages/app-core/src'), + '@shared': resolve(__dirname, '../../packages/shared-domain/src'), + '@bridge-contract': resolve(__dirname, '../../packages/bridge-contract/src') } }, plugins: [react()] diff --git a/apps/desktop/package.json b/apps/desktop/package.json new file mode 100644 index 00000000..b6b0d903 --- /dev/null +++ b/apps/desktop/package.json @@ -0,0 +1,167 @@ +{ + "name": "@zennotes/desktop", + "productName": "ZenNotes", + "version": "1.0.4", + "description": "ZenNotes desktop shell", + "private": true, + "main": "./out/main/index.js", + "author": { + "name": "Adib Hanna", + "email": "adibhanna@gmail.com" + }, + "license": "MIT", + "repository": { + "type": "git", + "url": "git+https://github.com/ZenNotes/zennotes.git" + }, + "bugs": { + "url": "https://github.com/ZenNotes/zennotes/issues" + }, + "homepage": "https://github.com/ZenNotes/zennotes/releases/latest", + "engines": { + "node": ">=22" + }, + "scripts": { + "dev": "electron-vite dev", + "build": "electron-vite build", + "build:prod": "npm run typecheck && npm run test:run && npm run build", + "start": "electron-vite preview", + "test": "vitest", + "test:run": "vitest run", + "typecheck:node": "tsc --noEmit -p tsconfig.node.json", + "typecheck:web": "tsc --noEmit -p tsconfig.web.json", + "typecheck": "npm run typecheck:node && npm run typecheck:web", + "pack": "npm run build:prod && electron-builder --dir", + "dist:mac": "npm run build:prod && electron-builder --mac --publish never", + "dist:win": "npm run build:prod && electron-builder --win --publish never", + "dist:linux": "npm run build:prod && electron-builder --linux --publish never" + }, + "dependencies": { + "@codemirror/autocomplete": "^6.18.3", + "@codemirror/commands": "^6.7.1", + "@codemirror/lang-markdown": "^6.3.1", + "@codemirror/language": "^6.10.6", + "@codemirror/language-data": "^6.5.1", + "@codemirror/search": "^6.5.8", + "@codemirror/state": "^6.5.0", + "@codemirror/view": "^6.35.3", + "@lezer/highlight": "^1.2.1", + "@modelcontextprotocol/sdk": "^1.29.0", + "@replit/codemirror-vim": "^6.3.0", + "@zennotes/app-core": "*", + "@zennotes/bridge-contract": "*", + "@zennotes/shared-domain": "*", + "chokidar": "^4.0.3", + "codemirror": "^6.0.1", + "dompurify": "^3.3.4", + "electron-updater": "^6.8.3", + "font-list": "^2.0.2", + "function-plot": "^1.25.3", + "fuse.js": "^7.0.0", + "gray-matter": "^4.0.3", + "highlight.js": "^11.10.0", + "jsxgraph": "^1.12.2", + "katex": "^0.16.15", + "mermaid": "^11.4.1", + "node-tikzjax": "^1.0.5", + "prettier": "^3.8.2", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "rehype-highlight": "^7.0.1", + "rehype-katex": "^7.0.1", + "rehype-raw": "^7.0.0", + "rehype-stringify": "^10.0.1", + "remark-breaks": "^4.0.0", + "remark-frontmatter": "^5.0.0", + "remark-gfm": "^4.0.0", + "remark-math": "^6.0.0", + "remark-parse": "^11.0.0", + "remark-rehype": "^11.1.1", + "unified": "^11.0.5", + "unist-util-visit": "^5.0.0", + "zustand": "^5.0.2" + }, + "devDependencies": { + "@electron/notarize": "^3.1.0", + "@types/node": "^25.6.0", + "@types/react": "^18.3.17", + "@types/react-dom": "^18.3.5", + "@vitejs/plugin-react": "^4.3.4", + "autoprefixer": "^10.4.20", + "electron": "^41.2.1", + "electron-builder": "^25.1.8", + "electron-vite": "^2.3.0", + "jsdom": "^29.0.2", + "postcss": "^8.5.10", + "tailwindcss": "^3.4.17", + "typescript": "^5.7.2", + "vite": "^5.4.11", + "vitest": "^2.1.8" + }, + "build": { + "appId": "com.adibhanna.zennotes", + "executableName": "ZenNotes", + "productName": "ZenNotes", + "artifactName": "${productName}-${version}-${os}-${arch}.${ext}", + "asar": true, + "compression": "maximum", + "npmRebuild": false, + "buildDependenciesFromSource": false, + "directories": { + "buildResources": "build", + "output": "../../dist" + }, + "files": [ + "out/**/*", + "!**/*.map" + ], + "extraResources": [ + { + "from": "build/icon.png", + "to": "icon.png" + } + ], + "afterSign": "scripts/notarize.cjs", + "publish": [ + { + "provider": "github", + "owner": "ZenNotes", + "repo": "zennotes" + } + ], + "electronUpdaterCompatibility": ">=2.16", + "mac": { + "category": "public.app-category.productivity", + "hardenedRuntime": true, + "gatekeeperAssess": false, + "entitlements": "build/entitlements.mac.plist", + "entitlementsInherit": "build/entitlements.mac.inherit.plist", + "extendInfo": { + "CFBundleName": "ZenNotes", + "CFBundleDisplayName": "ZenNotes" + }, + "target": [ + "dmg", + "zip" + ] + }, + "win": { + "target": [ + "nsis", + "zip" + ] + }, + "linux": { + "target": [ + "AppImage", + "deb" + ], + "icon": "build/icons", + "maintainer": "Adib Hanna ", + "category": "Office" + }, + "deb": { + "afterInstall": "build/after-install.sh" + } + } +} diff --git a/apps/desktop/postcss.config.js b/apps/desktop/postcss.config.js new file mode 100644 index 00000000..85f717cc --- /dev/null +++ b/apps/desktop/postcss.config.js @@ -0,0 +1,6 @@ +module.exports = { + plugins: { + tailwindcss: {}, + autoprefixer: {} + } +} diff --git a/scripts/notarize.cjs b/apps/desktop/scripts/notarize.cjs similarity index 100% rename from scripts/notarize.cjs rename to apps/desktop/scripts/notarize.cjs diff --git a/src/main/demo-tour-data.ts b/apps/desktop/src/main/demo-tour-data.ts similarity index 83% rename from src/main/demo-tour-data.ts rename to apps/desktop/src/main/demo-tour-data.ts index 0f7078aa..cf306fa7 100644 --- a/src/main/demo-tour-data.ts +++ b/apps/desktop/src/main/demo-tour-data.ts @@ -6,7 +6,7 @@ export interface DemoTourTemplateFile { export const DEMO_TOUR_NOTES: DemoTourTemplateFile[] = [ { path: "inbox/demo/00 — Start Here.md", - body: "# Start here — ZenNotes feature tour\n\nThis folder is a guided demo vault for ZenNotes as it exists today. It covers markdown rendering, keyboard-first workflows, search, views, settings, and the vault-level features that sit on top of plain files.\n\n## How to use this tour\n\n- Open notes in **Edit**, **Split**, and **Preview** to see where each feature is most useful.\n- Use `Space p` or the outline panel on longer notes.\n- Use `Space f` to search notes by title and path.\n- Use `Space s t` to fuzzy-search text across the vault.\n- Open **Help** from the footer or type `:help` from normal mode for the built-in manual.\n- Try `⌘.` to toggle **Zen mode** while reading any note here.\n\n## The tour\n\n1. [[01 — Markdown Basics]] — headings, emphasis, lists, blockquotes, frontmatter, and slash-command-friendly structure\n2. [[02 — Code Blocks]] — fenced code blocks, inline code, syntax highlighting, and code-writing workflows\n3. [[03 — Tables and Task Lists]] — tables, task metadata, and the vault-wide Tasks view\n4. [[04 — Math with KaTeX]] — inline math, block math, aligned equations, and formulas in preview\n5. [[05 — Mermaid Diagrams]] — flow, sequence, state, gantt, and graph diagrams rendered from markdown fences\n6. [[05b — Math Diagrams]] — TikZ, JSXGraph, and function-plot for paper-grade figures, interactive geometry, and quick plots\n7. [[06 — Callouts and Footnotes]] — callouts, footnotes, highlights, images, and local attachments\n8. [[07 — Wiki Links and Tags]] — wikilinks, tags, backlinks, connections, and search\n9. [[08 — Daily Notes]] — daily logs, quick capture, date shortcuts, and date-friendly note habits\n10. [[09 — Vim Cheat Sheet]] — the app-specific motions, leader flows, folds, and ex commands\n11. [[10 — Ideas and Tasks]] — a realistic note that composes multiple features at once\n12. [[11 — Workspace, Search, and Views]] — tabs, splits, outline, archive, trash, quick notes, and session restore\n13. [[12 — Settings and Keymaps]] — themes, fonts, leader hints, search backends, custom binary paths, and remappable shortcuts\n14. [[13 — Commands, Help, and Demo Tour]] — command palette discovery, ex commands, built-in Help, and starter-tour generation\n15. [[14 — Reference Pane and Floating Windows]] — pinned notes, research context, and detached note windows\n16. [[15 — Search Backends and Fuzzy Workflows]] — note search, vault text search, Auto resolution, fzf, ripgrep, and custom binary paths\n\n## What this demo folder covers\n\nZenNotes is more than a markdown renderer. Across this folder you can try:\n\n- plain file-based notes with no hidden database\n- live preview plus dedicated preview and split modes\n- heading folding and outline jumps\n- wikilinks, tags, backlinks, and unresolved-link discovery\n- quick capture via Quick Notes\n- Inbox, Archive, and Trash as separate lifecycle stages\n- vault-wide Tasks and Tags views\n- note search and vault text search\n- Mermaid, TikZ, JSXGraph, and function-plot diagram rendering\n- optional external search backends like `fzf` and `ripgrep`\n- slash commands and `@` date insertion\n- Vim mode, leader hints, ex commands, and pane motion\n- settings, keymap overrides, and appearance controls\n- command palette, built-in Help, and seeded onboarding content\n- reference-pane and floating-window workflows\n- session restore for panes, tabs, built-in views, and window bounds\n\n## The point\n\nEvery file here is ordinary markdown on disk. Open the folder in ZenNotes, `vim`, VS Code, or another markdown editor and the notes are still yours.\n\n#demo #reference #tour\n" + body: "# Start here — ZenNotes feature tour\n\nThis folder is a guided demo vault for ZenNotes as it exists today. It covers markdown rendering, keyboard-first workflows, search, views, settings, and the vault-level features that sit on top of plain files.\n\n## How to use this tour\n\n- Open notes in **Edit**, **Split**, and **Preview** to see where each feature is most useful.\n- Use `Space p` or the outline panel on longer notes.\n- Use `Space f` to search notes by title and path.\n- Use `Space s t` to fuzzy-search text across the vault.\n- Open **Help** from the footer or type `:help` from normal mode for the built-in manual.\n- Try `⌘.` to toggle **Zen mode** while reading any note here.\n\n## The tour\n\n1. [[01 — Markdown Basics]] — headings, emphasis, lists, blockquotes, frontmatter, and slash-command-friendly structure\n2. [[02 — Code Blocks]] — fenced code blocks, inline code, syntax highlighting, and code-writing workflows\n3. [[03 — Tables and Task Lists]] — tables, task metadata, and the vault-wide Tasks view\n4. [[04 — Math with KaTeX]] — inline math, block math, aligned equations, and formulas in preview\n5. [[05 — Mermaid Diagrams]] — flow, sequence, state, gantt, and graph diagrams rendered from markdown fences\n6. [[05b — Math Diagrams]] — TikZ, JSXGraph, and function-plot for paper-grade figures, interactive geometry, and quick plots\n7. [[06 — Callouts and Footnotes]] — callouts, footnotes, highlights, images, and local files\n8. [[07 — Wiki Links and Tags]] — wikilinks, tags, backlinks, connections, and search\n9. [[08 — Daily Notes]] — daily logs, quick capture, date shortcuts, and date-friendly note habits\n10. [[09 — Vim Cheat Sheet]] — the app-specific motions, leader flows, folds, and ex commands\n11. [[10 — Ideas and Tasks]] — a realistic note that composes multiple features at once\n12. [[11 — Workspace, Search, and Views]] — tabs, splits, outline, archive, trash, quick notes, and session restore\n13. [[12 — Settings and Keymaps]] — themes, fonts, leader hints, search backends, custom binary paths, and remappable shortcuts\n14. [[13 — Commands, Help, and Demo Tour]] — command palette discovery, ex commands, built-in Help, and starter-tour generation\n15. [[14 — Reference Pane and Floating Windows]] — pinned notes, research context, and detached note windows\n16. [[15 — Search Backends and Fuzzy Workflows]] — note search, vault text search, Auto resolution, fzf, ripgrep, and custom binary paths\n\n## What this demo folder covers\n\nZenNotes is more than a markdown renderer. Across this folder you can try:\n\n- plain file-based notes with no hidden database\n- live preview plus dedicated preview and split modes\n- heading folding and outline jumps\n- wikilinks, tags, backlinks, and unresolved-link discovery\n- quick capture via Quick Notes\n- Inbox, Archive, and Trash as separate lifecycle stages\n- vault-wide Tasks and Tags views\n- note search and vault text search\n- Mermaid, TikZ, JSXGraph, and function-plot diagram rendering\n- optional external search backends like `fzf` and `ripgrep`\n- slash commands and `@` date insertion\n- Vim mode, leader hints, ex commands, and pane motion\n- settings, keymap overrides, and appearance controls\n- command palette, built-in Help, and seeded onboarding content\n- reference-pane and floating-window workflows\n- session restore for panes, tabs, built-in views, and window bounds\n\n## The point\n\nEvery file here is ordinary markdown on disk. Open the folder in ZenNotes, `vim`, VS Code, or another markdown editor and the notes are still yours.\n\n#demo #reference #tour\n" }, { path: "inbox/demo/01 — Markdown Basics.md", @@ -34,7 +34,7 @@ export const DEMO_TOUR_NOTES: DemoTourTemplateFile[] = [ }, { path: "inbox/demo/06 — Callouts and Footnotes.md", - body: "# Callouts, footnotes, attachments, and embeds\n\nThis note covers the rich block-level extras that still live comfortably inside markdown files.\n\n## Callouts\n\nCallouts are blockquotes that start with `> [!type]`.\n\n> [!note]\n> Use note callouts for extra context that should stand out without becoming a new section.\n\n> [!tip] Keyboard tip\n> Press `Space o` to open the buffer switcher when tabs are hidden or you want to jump fast between open buffers.\n\n> [!warning]\n> Moving a note to Trash asks for confirmation, but permanently deleting from Trash is still destructive.\n\n> [!info] Multi-line\n> Callouts can contain:\n> - lists\n> - `inline code`\n> - [[07 — Wiki Links and Tags|wikilinks]]\n> - and multiple paragraphs\n\n> [!quote] Portable by design\n> ZenNotes adds workflow around markdown, not lock-in around data.\n\n## Footnotes\n\nFootnotes link both ways and stay readable in the raw file.[^workflow]\n\nFootnotes are useful for side comments that should not interrupt the main flow.[^tip]\n\n[^workflow]: Footnote references use `[^label]` inline and `[^label]: text` at the bottom of the note.\n[^tip]: They work well in long writing, specs, and research notes where parenthetical digressions get noisy.\n\n## Strikethrough and highlights\n\n~~Legacy wording~~ can stay visible for history, while ==highlights== are good for passages you want to notice quickly during review.\n\n## Images and local attachments\n\nAttachments stay local to the vault. Dropping a file into the editor inserts a normal markdown reference to the file under the vault attachments folder.\n\nExample image:\n\n![ZenNotes demo card](<../../attachements/zennotes-demo-card.svg>)\n\nThat relative path is the recommended form because it keeps the note portable inside the vault:\n\n```md\n![ZenNotes demo card](<../../attachements/zennotes-demo-card.svg>)\n```\n\n## Attachment workflows\n\n- Use the footer **Assets** action to browse attachment files.\n- Image embeds render inline in preview and split mode.\n- PDFs can be opened in the pinned reference pane so you can read beside your notes.\n- Because attachments are just files, reveal them in Finder and manage them with normal tools if you want.\n\nFor the larger reading workflow around pinned notes, PDFs, and detached note windows, see [[14 — Reference Pane and Floating Windows]].\n\n## Why this matters\n\nZenNotes is strongest when prose, references, and assets live together:\n\n- callouts for guidance or warnings\n- footnotes for side context\n- images for screenshots and visual notes\n- PDFs in the reference pane for side-by-side reading\n\n#demo #reference #attachments\n" + body: "# Callouts, footnotes, files, and embeds\n\nThis note covers the rich block-level extras that still live comfortably inside markdown files.\n\n## Callouts\n\nCallouts are blockquotes that start with `> [!type]`.\n\n> [!note]\n> Use note callouts for extra context that should stand out without becoming a new section.\n\n> [!tip] Keyboard tip\n> Press `Space o` to open the buffer switcher when tabs are hidden or you want to jump fast between open buffers.\n\n> [!warning]\n> Moving a note to Trash asks for confirmation, but permanently deleting from Trash is still destructive.\n\n> [!info] Multi-line\n> Callouts can contain:\n> - lists\n> - `inline code`\n> - [[07 — Wiki Links and Tags|wikilinks]]\n> - and multiple paragraphs\n\n> [!quote] Portable by design\n> ZenNotes adds workflow around markdown, not lock-in around data.\n\n## Footnotes\n\nFootnotes link both ways and stay readable in the raw file.[^workflow]\n\nFootnotes are useful for side comments that should not interrupt the main flow.[^tip]\n\n[^workflow]: Footnote references use `[^label]` inline and `[^label]: text` at the bottom of the note.\n[^tip]: They work well in long writing, specs, and research notes where parenthetical digressions get noisy.\n\n## Strikethrough and highlights\n\n~~Legacy wording~~ can stay visible for history, while ==highlights== are good for passages you want to notice quickly during review.\n\n## Images and local files\n\nFiles stay local to the vault. Dropping a file into the editor inserts a normal markdown reference to the file, and by default ZenNotes places it in the vault root.\n\nExample image:\n\n![ZenNotes demo card](<../../zennotes-demo-card.svg>)\n\nThat relative path is the recommended form because it keeps the note portable inside the vault:\n\n```md\n![ZenNotes demo card](<../../zennotes-demo-card.svg>)\n```\n\n## File workflows\n\n- Use the footer **Files** action to browse files anywhere in the vault.\n- Image embeds render inline in preview and split mode.\n- PDFs can be opened in the pinned reference pane so you can read beside your notes.\n- Because these are just files, reveal them in Finder and manage them with normal tools if you want.\n\nFor the larger reading workflow around pinned notes, PDFs, and detached note windows, see [[14 — Reference Pane and Floating Windows]].\n\n## Why this matters\n\nZenNotes is strongest when prose, references, and files live together:\n\n- callouts for guidance or warnings\n- footnotes for side context\n- images for screenshots and visual notes\n- PDFs in the reference pane for side-by-side reading\n\n#demo #reference #attachments\n" }, { path: "inbox/demo/07 — Wiki Links and Tags.md", @@ -54,7 +54,7 @@ export const DEMO_TOUR_NOTES: DemoTourTemplateFile[] = [ }, { path: "inbox/demo/11 — Workspace, Search, and Views.md", - body: "# Workspace, search, and views\n\nThis note covers the part of ZenNotes that is not just markdown rendering: how the workspace behaves while you are moving around a vault.\n\n## The three working zones\n\nZenNotes is organized around three persistent areas:\n\n1. **Sidebar** for folders, built-in rows, tags, and utility entry points\n2. **Note list** for the current folder, attachments, or list-like result sets\n3. **Editor pane** for tabs, splits, preview, built-in views, and focused writing\n\nThe useful part is that each zone has its own keyboard loop, so you can stay off the mouse without losing place.\n\n## Edit, split, and preview\n\nEach note can be viewed in three ways:\n\n- **Edit** for raw markdown authoring\n- **Split** for source and rendered output side by side\n- **Preview** for reading-only rendering\n\nYou can switch modes from the toolbar, from the command palette, or from ex commands like:\n\n```vim\n:view edit\n:view split\n:view preview\n```\n\n## Tabs, buffers, and panes\n\n- tabs can be on or off\n- panes can split right or down\n- if tabs are hidden, buffers are still open behind the scenes\n- `Space o` or `:buffers` opens the buffer switcher\n\nThis keeps ZenNotes usable for both tab-heavy and low-chrome workflows.\n\n## Search modes\n\n### Note search\n\n- `⌘P` globally\n- `Space f` in Vim mode\n- `⌘F` or `Ctrl+F` as an extra direct shortcut when Vim mode is off\n- searches note titles and paths\n\n### Vault text search\n\n- `Space s t`\n- searches matching text lines across note contents\n- opens the note and jumps to the matching line\n- can run on built-in search, `ripgrep`, or `fzf`\n- Settings show the runtime backend that is actually being used\n\n## Quick Notes, Inbox, Archive, Trash\n\nThese four areas represent different stages of note life:\n\n- **Quick Notes** for fast capture\n- **Inbox** for active notes\n- **Archive** for cold storage\n- **Trash** for recoverable deletion\n\nBehavior differs by design:\n\n- clicking **Quick Notes** still folds and unfolds the sidebar section\n- Quick Notes can also open as a dedicated list tab from its context menu\n- **Archive** opens as a main-pane list view\n- **Trash** opens as a main-pane recovery view\n\nThat keeps the sidebar singular instead of turning it into a second file browser.\n\n## Outline, connections, and references\n\n- **Outline** gives you a heading list for the active note\n- **Connections** show backlinks, outbound links, and unresolved links\n- **Reference pane** is for pinning a note or PDF beside your current work\n\nThis is the part of the app that becomes valuable once a vault turns into more than a pile of files.\n\n## Help, Settings, and Assets\n\nThe footer utilities keep the secondary surfaces discoverable:\n\n- **Assets** for local attachments\n- **Help** for the built-in manual\n- **Settings** for personalization, Vim behavior, search backends, fonts, layout, and keymaps\n\nFor the command palette and seeded onboarding flow, see [[13 — Commands, Help, and Demo Tour]].\nFor detached note workflows and side-by-side reading context, see [[14 — Reference Pane and Floating Windows]].\n\n## Zen mode\n\nZen mode hides:\n\n- title bar\n- sidebar\n- note list\n- tabs\n- pane header chrome\n- outline and connections\n- status bar\n\nOnly the active editor, preview, or split content remains. It is the cleanest way to focus on a single note.\n\n## Session restore\n\nZenNotes remembers:\n\n- open tabs\n- splits\n- built-in views like Help, Tasks, Archive, or Trash\n- sidebar layout\n- main window position, size, and maximized state\n\nClosing and reopening the app should bring you back to roughly where you left off instead of starting from a blank shell.\n\n#demo #workspace #search #reference\n" + body: "# Workspace, search, and views\n\nThis note covers the part of ZenNotes that is not just markdown rendering: how the workspace behaves while you are moving around a vault.\n\n## The three working zones\n\nZenNotes is organized around three persistent areas:\n\n1. **Sidebar** for folders, built-in rows, tags, and utility entry points\n2. **Note list** for the current folder, files, or list-like result sets\n3. **Editor pane** for tabs, splits, preview, built-in views, and focused writing\n\nThe useful part is that each zone has its own keyboard loop, so you can stay off the mouse without losing place.\n\n## Edit, split, and preview\n\nEach note can be viewed in three ways:\n\n- **Edit** for raw markdown authoring\n- **Split** for source and rendered output side by side\n- **Preview** for reading-only rendering\n\nYou can switch modes from the toolbar, from the command palette, or from ex commands like:\n\n```vim\n:view edit\n:view split\n:view preview\n```\n\n## Tabs, buffers, and panes\n\n- tabs can be on or off\n- panes can split right or down\n- if tabs are hidden, buffers are still open behind the scenes\n- `Space o` or `:buffers` opens the buffer switcher\n\nThis keeps ZenNotes usable for both tab-heavy and low-chrome workflows.\n\n## Search modes\n\n### Note search\n\n- `⌘P` globally\n- `Space f` in Vim mode\n- `⌘F` or `Ctrl+F` as an extra direct shortcut when Vim mode is off\n- searches note titles and paths\n\n### Vault text search\n\n- `Space s t`\n- searches matching text lines across note contents\n- opens the note and jumps to the matching line\n- can run on built-in search, `ripgrep`, or `fzf`\n- Settings show the runtime backend that is actually being used\n\n## Quick Notes, Inbox, Archive, Trash\n\nThese four areas represent different stages of note life:\n\n- **Quick Notes** for fast capture\n- **Inbox** for active notes\n- **Archive** for cold storage\n- **Trash** for recoverable deletion\n\nBehavior differs by design:\n\n- clicking **Quick Notes** still folds and unfolds the sidebar section\n- Quick Notes can also open as a dedicated list tab from its context menu\n- **Archive** opens as a main-pane list view\n- **Trash** opens as a main-pane recovery view\n\nThat keeps the sidebar singular instead of turning it into a second file browser.\n\n## Outline, connections, and references\n\n- **Outline** gives you a heading list for the active note\n- **Connections** show backlinks, outbound links, and unresolved links\n- **Reference pane** is for pinning a note or PDF beside your current work\n\nThis is the part of the app that becomes valuable once a vault turns into more than a pile of files.\n\n## Help, Settings, and Files\n\nThe footer utilities keep the secondary surfaces discoverable:\n\n- **Files** for local files\n- **Help** for the built-in manual\n- **Settings** for personalization, Vim behavior, search backends, fonts, layout, and keymaps\n\nFor the command palette and seeded onboarding flow, see [[13 — Commands, Help, and Demo Tour]].\nFor detached note workflows and side-by-side reading context, see [[14 — Reference Pane and Floating Windows]].\n\n## Zen mode\n\nZen mode hides:\n\n- title bar\n- sidebar\n- note list\n- tabs\n- pane header chrome\n- outline and connections\n- status bar\n\nOnly the active editor, preview, or split content remains. It is the cleanest way to focus on a single note.\n\n## Session restore\n\nZenNotes remembers:\n\n- open tabs\n- splits\n- built-in views like Help, Tasks, Archive, or Trash\n- sidebar layout\n- main window position, size, and maximized state\n\nClosing and reopening the app should bring you back to roughly where you left off instead of starting from a blank shell.\n\n#demo #workspace #search #reference\n" }, { path: "inbox/demo/12 — Settings and Keymaps.md", @@ -62,11 +62,11 @@ export const DEMO_TOUR_NOTES: DemoTourTemplateFile[] = [ }, { path: "inbox/demo/13 — Commands, Help, and Demo Tour.md", - body: "# Commands, help, and demo tour\n\nZenNotes is keyboard-first, so discoverability matters. This note covers the command palette, the built-in Help manual, and the demo-tour commands that can seed a starter vault for new users.\n\n## Command palette\n\nOpen the command palette with:\n\n- `⇧⌘P`\n- `:commands`\n- `:cmd query`\n\nUse it when you cannot remember a shortcut, when Vim mode is off, or when you want to browse what the app can do without digging through menus.\n\nTypical commands worth trying:\n\n- `Open Help`\n- `Open Settings`\n- `Search notes`\n- `Generate Demo Tour Notes`\n- `Remove Demo Tour Notes`\n- `Switch to Edit Mode`\n- `Switch to Split Mode`\n- `Switch to Preview Mode`\n- `Open Tasks`\n- `Open Trash`\n\n## Ex commands\n\nIf you live in normal mode, the ex line is the fastest path for many actions:\n\n```vim\n:help\n:tasks\n:trash\n:buffers\n:view split\n:zen\n:cmd help\n```\n\nThe ex line also supports completion with `Tab`, including command arguments like `:view edit|split|preview` and `:zen toggle|on|off`.\n\n## Built-in Help\n\nZenNotes ships with an in-app manual instead of making you leave the app to learn it.\n\nWays to open it:\n\n- footer **Help**\n- `:help`\n- command palette → `Open Help`\n\nThe Help view covers:\n\n- quick start\n- core concepts\n- shortcuts\n- Vim flows\n- ex commands\n- settings\n- search backends\n\n## Demo tour commands\n\nThe demo vault itself is seedable from inside the app.\n\nUse:\n\n- command palette → `Generate Demo Tour Notes`\n- command palette → `Remove Demo Tour Notes`\n- `:demo_generate`\n- `:demo_remove`\n\n### What generation does\n\n- creates a guided note set under `inbox/demo`\n- adds the bundled demo attachment under the vault attachments folder\n- opens the tour start note so the onboarding flow begins immediately\n\n### What removal does\n\n- removes the seeded demo notes\n- removes the bundled demo attachment\n- leaves the rest of the vault alone\n\nThat makes the tour useful for:\n\n- first-time users\n- resettable demos\n- showing the product to someone else\n- smoke-testing renderer features in one place\n\n## Why this matters\n\nThe app can stay low-chrome and still be discoverable if:\n\n- commands are searchable\n- Help is built in\n- the starter content is one command away\n\nThat combination is a large part of what makes a keyboard-first app approachable instead of intimidating.\n\n## Try this now\n\n- Open the command palette and search for `help`\n- Run `:cmd zen`\n- Run `Generate Demo Tour Notes` in a test vault\n- Open [[12 — Settings and Keymaps]] after this note to see how the shortcuts behind these commands can be remapped\n\n#demo #commands #help #onboarding\n" + body: "# Commands, help, and demo tour\n\nZenNotes is keyboard-first, so discoverability matters. This note covers the command palette, the built-in Help manual, and the demo-tour commands that can seed a starter vault for new users.\n\n## Command palette\n\nOpen the command palette with:\n\n- `⇧⌘P`\n- `:commands`\n- `:cmd query`\n\nUse it when you cannot remember a shortcut, when Vim mode is off, or when you want to browse what the app can do without digging through menus.\n\nTypical commands worth trying:\n\n- `Open Help`\n- `Open Settings`\n- `Search notes`\n- `Generate Demo Tour Notes`\n- `Remove Demo Tour Notes`\n- `Switch to Edit Mode`\n- `Switch to Split Mode`\n- `Switch to Preview Mode`\n- `Open Tasks`\n- `Open Trash`\n\n## Ex commands\n\nIf you live in normal mode, the ex line is the fastest path for many actions:\n\n```vim\n:help\n:tasks\n:trash\n:buffers\n:view split\n:zen\n:cmd help\n```\n\nThe ex line also supports completion with `Tab`, including command arguments like `:view edit|split|preview` and `:zen toggle|on|off`.\n\n## Built-in Help\n\nZenNotes ships with an in-app manual instead of making you leave the app to learn it.\n\nWays to open it:\n\n- footer **Help**\n- `:help`\n- command palette → `Open Help`\n\nThe Help view covers:\n\n- quick start\n- core concepts\n- shortcuts\n- Vim flows\n- ex commands\n- settings\n- search backends\n\n## Demo tour commands\n\nThe demo vault itself is seedable from inside the app.\n\nUse:\n\n- command palette → `Generate Demo Tour Notes`\n- command palette → `Remove Demo Tour Notes`\n- `:demo_generate`\n- `:demo_remove`\n\n### What generation does\n\n- creates a guided note set under `inbox/demo`\n- adds the bundled demo file at the vault root\n- opens the tour start note so the onboarding flow begins immediately\n\n### What removal does\n\n- removes the seeded demo notes\n- removes the bundled demo file\n- leaves the rest of the vault alone\n\nThat makes the tour useful for:\n\n- first-time users\n- resettable demos\n- showing the product to someone else\n- smoke-testing renderer features in one place\n\n## Why this matters\n\nThe app can stay low-chrome and still be discoverable if:\n\n- commands are searchable\n- Help is built in\n- the starter content is one command away\n\nThat combination is a large part of what makes a keyboard-first app approachable instead of intimidating.\n\n## Try this now\n\n- Open the command palette and search for `help`\n- Run `:cmd zen`\n- Run `Generate Demo Tour Notes` in a test vault\n- Open [[12 — Settings and Keymaps]] after this note to see how the shortcuts behind these commands can be remapped\n\n#demo #commands #help #onboarding\n" }, { path: "inbox/demo/14 — Reference Pane and Floating Windows.md", - body: "# Reference pane and floating windows\n\nZenNotes is strongest when you can keep context visible while still writing. This note covers the pinned reference pane, link preview workflows, and floating notes.\n\n## Reference pane\n\nThe reference pane is for keeping a second document visible while you work in the main note.\n\nGood uses:\n\n- drafting against a spec\n- reading a PDF while taking notes\n- comparing two notes side by side\n- keeping a glossary or checklist open while editing\n\n## What can live there\n\n- another markdown note\n- a PDF\n- a linked document opened from the current note\n\nThis keeps the main pane focused on writing while the side pane holds supporting material.\n\n## Link-following flows\n\nWhen the cursor is on a wikilink or markdown link:\n\n- `gd` follows it in Vim mode\n- PDFs can pin into the reference pane\n- missing notes can be created from the link target\n\nThat means links are not just navigation. They can become working context.\n\n## Connections + reference workflow\n\nThe **Connections** panel works well with the reference pane:\n\n- inspect backlinks\n- move to a related note\n- peek a backlink\n- pin the most useful one beside the current draft\n\nThis is especially useful for research notes and longer documentation trees.\n\n## Floating windows\n\nSometimes you do not want a second pane inside the same layout. In that case, a note can open in its own floating window from the context menu.\n\nFloating windows are useful when:\n\n- you want a scratch note on another monitor\n- you are comparing two notes without disturbing the main layout\n- you want a temporary detached reference\n\nThey are intentional, separate work surfaces, not just accidental duplicate tabs.\n\n## Research pattern\n\nOne practical pattern:\n\n1. Keep the current draft in **Edit** or **Split**\n2. Open **Connections**\n3. Find a related note or PDF\n4. Pin it in the reference pane or open it in a floating window\n5. Keep writing without losing context\n\n## Good companion notes in this tour\n\n- [[07 — Wiki Links and Tags]] for backlinks, tags, and search\n- [[11 — Workspace, Search, and Views]] for the larger pane model\n- [[06 — Callouts and Footnotes]] for local attachments\n- [[10 — Ideas and Tasks]] for a note that benefits from supporting context\n\n## Try this now\n\n- Open this note, then pin [[11 — Workspace, Search, and Views]]\n- Open **Connections** on [[10 — Ideas and Tasks]]\n- Follow a wikilink with `gd`\n- Open a note in a floating window from its context menu\n\n#demo #reference #research #windows\n" + body: "# Reference pane and floating windows\n\nZenNotes is strongest when you can keep context visible while still writing. This note covers the pinned reference pane, link preview workflows, and floating notes.\n\n## Reference pane\n\nThe reference pane is for keeping a second document visible while you work in the main note.\n\nGood uses:\n\n- drafting against a spec\n- reading a PDF while taking notes\n- comparing two notes side by side\n- keeping a glossary or checklist open while editing\n\n## What can live there\n\n- another markdown note\n- a PDF\n- a linked document opened from the current note\n\nThis keeps the main pane focused on writing while the side pane holds supporting material.\n\n## Link-following flows\n\nWhen the cursor is on a wikilink or markdown link:\n\n- `gd` follows it in Vim mode\n- PDFs can pin into the reference pane\n- missing notes can be created from the link target\n\nThat means links are not just navigation. They can become working context.\n\n## Connections + reference workflow\n\nThe **Connections** panel works well with the reference pane:\n\n- inspect backlinks\n- move to a related note\n- peek a backlink\n- pin the most useful one beside the current draft\n\nThis is especially useful for research notes and longer documentation trees.\n\n## Floating windows\n\nSometimes you do not want a second pane inside the same layout. In that case, a note can open in its own floating window from the context menu.\n\nFloating windows are useful when:\n\n- you want a scratch note on another monitor\n- you are comparing two notes without disturbing the main layout\n- you want a temporary detached reference\n\nThey are intentional, separate work surfaces, not just accidental duplicate tabs.\n\n## Research pattern\n\nOne practical pattern:\n\n1. Keep the current draft in **Edit** or **Split**\n2. Open **Connections**\n3. Find a related note or PDF\n4. Pin it in the reference pane or open it in a floating window\n5. Keep writing without losing context\n\n## Good companion notes in this tour\n\n- [[07 — Wiki Links and Tags]] for backlinks, tags, and search\n- [[11 — Workspace, Search, and Views]] for the larger pane model\n- [[06 — Callouts and Footnotes]] for local files\n- [[10 — Ideas and Tasks]] for a note that benefits from supporting context\n\n## Try this now\n\n- Open this note, then pin [[11 — Workspace, Search, and Views]]\n- Open **Connections** on [[10 — Ideas and Tasks]]\n- Follow a wikilink with `gd`\n- Open a note in a floating window from its context menu\n\n#demo #reference #research #windows\n" }, { path: "inbox/demo/15 — Search Backends and Fuzzy Workflows.md", @@ -76,7 +76,7 @@ export const DEMO_TOUR_NOTES: DemoTourTemplateFile[] = [ export const DEMO_TOUR_ASSETS: DemoTourTemplateFile[] = [ { - path: "attachements/zennotes-demo-card.svg", + path: "zennotes-demo-card.svg", body: "\n \n \n \n \n \n \n \n \n \n \n \n \n \n DEMO\n ZenNotes Demo\n Local files, keyboard-first flows, and markdown-friendly structure.\n \n \n \n \n \n \n \n SEE ALSO: HELP, SEARCH, OUTLINE, TASKS, QUICK NOTES\n\n" }, ] \ No newline at end of file diff --git a/src/main/index.ts b/apps/desktop/src/main/index.ts similarity index 98% rename from src/main/index.ts rename to apps/desktop/src/main/index.ts index 4eaeecf1..535e0dad 100644 --- a/src/main/index.ts +++ b/apps/desktop/src/main/index.ts @@ -14,7 +14,6 @@ import type { import { absolutePath, archiveNote, - assetsAbsolutePath, createFolder, createNote, deleteFolder, @@ -25,6 +24,7 @@ import { ensureVaultLayout, folderAbsolutePath, generateDemoTour, + getVaultSettings, hasAssetsDir, importFiles, listAssets, @@ -40,6 +40,7 @@ import { restoreFromTrash, searchVaultTextCapabilities, searchVaultText, + setVaultSettings, type PersistedWindowState, updateConfig, unarchiveNote, @@ -625,6 +626,16 @@ function registerIpc(): void { return await setVault(result.filePaths[0]) }) + ipcMain.handle(IPC.VAULT_GET_SETTINGS, async () => { + const v = requireVault() + return await getVaultSettings(v.root) + }) + + ipcMain.handle(IPC.VAULT_SET_SETTINGS, async (_e, next) => { + const v = requireVault() + return await setVaultSettings(v.root, next) + }) + ipcMain.handle(IPC.VAULT_LIST_NOTES, async () => { const v = requireVault() return await listNotes(v.root) @@ -798,15 +809,14 @@ function registerIpc(): void { IPC.VAULT_REVEAL_FOLDER, async (_e, folder: NoteFolder, subpath: string) => { const v = requireVault() - const abs = folderAbsolutePath(v.root, folder, subpath) + const abs = await folderAbsolutePath(v.root, folder, subpath) await shell.openPath(abs) } ) ipcMain.handle(IPC.VAULT_REVEAL_ASSETS_DIR, async () => { const v = requireVault() - const abs = assetsAbsolutePath(v.root) - await shell.openPath(abs) + await shell.openPath(v.root) }) // Route window chrome controls to the window that actually sent the diff --git a/src/main/mcp-integrations.ts b/apps/desktop/src/main/mcp-integrations.ts similarity index 100% rename from src/main/mcp-integrations.ts rename to apps/desktop/src/main/mcp-integrations.ts diff --git a/src/main/perf.ts b/apps/desktop/src/main/perf.ts similarity index 100% rename from src/main/perf.ts rename to apps/desktop/src/main/perf.ts diff --git a/src/main/tasks.ts b/apps/desktop/src/main/tasks.ts similarity index 89% rename from src/main/tasks.ts rename to apps/desktop/src/main/tasks.ts index 555a3fa6..8bfe2ba1 100644 --- a/src/main/tasks.ts +++ b/apps/desktop/src/main/tasks.ts @@ -2,7 +2,7 @@ import { promises as fs } from 'node:fs' import path from 'node:path' import type { NoteFolder, NoteMeta } from '@shared/ipc' import { parseTasksFromBody, type VaultTask } from '@shared/tasks' -import { listNotes } from './vault' +import { folderForRelativePath, listNotes } from './vault' // Trash is excluded — trashed notes should never surface as live tasks. function includesFolder(folder: NoteFolder): boolean { @@ -49,8 +49,8 @@ export async function scanTasksForPath( relPath: string ): Promise { const posix = relPath.split(path.sep).join('/') - const firstSeg = posix.split('/', 1)[0] as NoteFolder - if (!LIVE_FOLDERS.has(firstSeg)) return [] + const folder = folderForRelativePath(posix) + if (!folder || !LIVE_FOLDERS.has(folder)) return [] const abs = path.join(root, posix.split('/').join(path.sep)) let body: string @@ -60,5 +60,5 @@ export async function scanTasksForPath( return [] } const title = path.basename(posix, path.extname(posix)) - return parseTasksFromBody(body, { path: posix, title, folder: firstSeg }) + return parseTasksFromBody(body, { path: posix, title, folder }) } diff --git a/src/main/tikz.ts b/apps/desktop/src/main/tikz.ts similarity index 100% rename from src/main/tikz.ts rename to apps/desktop/src/main/tikz.ts diff --git a/src/main/updater.ts b/apps/desktop/src/main/updater.ts similarity index 100% rename from src/main/updater.ts rename to apps/desktop/src/main/updater.ts diff --git a/src/main/vault.test.ts b/apps/desktop/src/main/vault.test.ts similarity index 100% rename from src/main/vault.test.ts rename to apps/desktop/src/main/vault.test.ts diff --git a/src/main/vault.ts b/apps/desktop/src/main/vault.ts similarity index 81% rename from src/main/vault.ts rename to apps/desktop/src/main/vault.ts index 6bf6b084..2b4575a6 100644 --- a/src/main/vault.ts +++ b/apps/desktop/src/main/vault.ts @@ -3,8 +3,11 @@ import { execFile, spawn } from 'node:child_process' import path from 'node:path' import { promisify } from 'node:util' import { app } from 'electron' -import type { +import { + DEFAULT_DAILY_NOTES_DIRECTORY, AssetMeta, + type PrimaryNotesLocation, + type VaultSettings, FolderEntry, ImportedAsset, ImportedAssetKind, @@ -24,9 +27,13 @@ import { DEMO_TOUR_ASSETS, DEMO_TOUR_NOTES } from './demo-tour-data' const CONFIG_FILE = 'zennotes.config.json' const FOLDERS: NoteFolder[] = ['inbox', 'quick', 'archive', 'trash'] +const SYSTEM_FOLDERS = new Set(FOLDERS) const PRIMARY_ATTACHMENTS_DIR = 'attachements' const LEGACY_ATTACHMENTS_DIRS = ['_assets'] const ATTACHMENTS_DIRS = [PRIMARY_ATTACHMENTS_DIR, ...LEGACY_ATTACHMENTS_DIRS] +const INTERNAL_VAULT_DIR = '.zennotes' +const VAULT_SETTINGS_FILE = 'vault.json' +const RESERVED_ROOT_NAMES = new Set([...FOLDERS, ...ATTACHMENTS_DIRS, INTERNAL_VAULT_DIR]) const FENCED_CODE_BLOCK_RE = /(^|\n)```[^\n]*\n[\s\S]*?\n```[ \t]*(?=\n|$)/g const IMAGE_EXTENSIONS = new Set([ '.apng', @@ -50,6 +57,14 @@ const SEARCH_EXECUTABLE_NAMES = { fzf: new Set(['fzf', 'fzf.exe']) } as const +const DEFAULT_VAULT_SETTINGS: VaultSettings = { + primaryNotesLocation: 'inbox', + dailyNotes: { + enabled: false, + directory: DEFAULT_DAILY_NOTES_DIRECTORY + } +} + interface VaultTextSearchCandidate { path: string title: string @@ -165,20 +180,157 @@ export async function updateConfig( return nextConfig } +function vaultSettingsPath(root: string): string { + return path.join(root, INTERNAL_VAULT_DIR, VAULT_SETTINGS_FILE) +} + +function cloneVaultSettings(settings: VaultSettings): VaultSettings { + return { + primaryNotesLocation: settings.primaryNotesLocation, + dailyNotes: { + enabled: settings.dailyNotes.enabled, + directory: settings.dailyNotes.directory + } + } +} + +function normalizeDailyNotesDirectory(value: unknown): string { + if (typeof value !== 'string') return DEFAULT_DAILY_NOTES_DIRECTORY + const trimmed = value.trim().replace(/^\/+|\/+$/g, '') + return trimmed || DEFAULT_DAILY_NOTES_DIRECTORY +} + +function normalizePrimaryNotesLocation(value: unknown): PrimaryNotesLocation { + return value === 'root' ? 'root' : 'inbox' +} + +function normalizeVaultSettings( + value: unknown, + fallbackPrimary: PrimaryNotesLocation = DEFAULT_VAULT_SETTINGS.primaryNotesLocation +): VaultSettings { + if (!value || typeof value !== 'object') { + return { + primaryNotesLocation: fallbackPrimary, + dailyNotes: { + enabled: DEFAULT_VAULT_SETTINGS.dailyNotes.enabled, + directory: DEFAULT_DAILY_NOTES_DIRECTORY + } + } + } + const candidate = value as { + primaryNotesLocation?: unknown + dailyNotes?: { enabled?: unknown; directory?: unknown } | null + } + return { + primaryNotesLocation: normalizePrimaryNotesLocation( + candidate.primaryNotesLocation ?? fallbackPrimary + ), + dailyNotes: { + enabled: + typeof candidate.dailyNotes?.enabled === 'boolean' + ? candidate.dailyNotes.enabled + : DEFAULT_VAULT_SETTINGS.dailyNotes.enabled, + directory: normalizeDailyNotesDirectory(candidate.dailyNotes?.directory) + } + } +} + +async function inferPrimaryNotesLocation(root: string): Promise { + let entries: Dirent[] + try { + entries = await fs.readdir(root, { withFileTypes: true }) + } catch { + return DEFAULT_VAULT_SETTINGS.primaryNotesLocation + } + for (const entry of entries) { + if (entry.name.startsWith('.')) continue + if (RESERVED_ROOT_NAMES.has(entry.name)) continue + if (entry.isDirectory()) return 'root' + if (entry.isFile() && entry.name.toLowerCase().endsWith('.md')) return 'root' + } + return DEFAULT_VAULT_SETTINGS.primaryNotesLocation +} + +async function vaultLooksEmpty(root: string): Promise { + let entries: Dirent[] + try { + entries = await fs.readdir(root, { withFileTypes: true }) + } catch { + return true + } + for (const entry of entries) { + if (entry.name.startsWith('.')) continue + if (entry.name === INTERNAL_VAULT_DIR) continue + return false + } + return true +} + +export async function getVaultSettings(root: string): Promise { + let fallbackPrimary = DEFAULT_VAULT_SETTINGS.primaryNotesLocation + try { + fallbackPrimary = await inferPrimaryNotesLocation(root) + const raw = await fs.readFile(vaultSettingsPath(root), 'utf8') + return normalizeVaultSettings(JSON.parse(raw), fallbackPrimary) + } catch { + return normalizeVaultSettings(null, fallbackPrimary) + } +} + +export async function setVaultSettings( + root: string, + next: VaultSettings +): Promise { + const fallbackPrimary = await inferPrimaryNotesLocation(root) + const normalized = normalizeVaultSettings(next, fallbackPrimary) + await fs.mkdir(path.dirname(vaultSettingsPath(root)), { recursive: true }) + await fs.writeFile(vaultSettingsPath(root), JSON.stringify(normalized, null, 2), 'utf8') + if (normalized.primaryNotesLocation === 'inbox') { + await fs.mkdir(path.join(root, 'inbox'), { recursive: true }) + } + return cloneVaultSettings(normalized) +} + +async function primaryNotesRoot(root: string): Promise { + const settings = await getVaultSettings(root) + return settings.primaryNotesLocation === 'root' ? root : path.join(root, 'inbox') +} + +async function folderRoot(root: string, folder: NoteFolder): Promise { + if (folder === 'inbox') return await primaryNotesRoot(root) + return path.join(root, folder) +} + +export function folderForRelativePath(rel: string): NoteFolder | null { + const normalized = toPosix(rel) + const top = normalized.split('/')[0] + if (SYSTEM_FOLDERS.has(top as NoteFolder)) return top as NoteFolder + if (!top || top.startsWith('.')) return null + if (RESERVED_ROOT_NAMES.has(top)) return null + return 'inbox' +} + /** * Ensure the expected vault folder layout exists and seed a welcome note * the very first time a vault is opened. */ export async function ensureVaultLayout(root: string): Promise { await fs.mkdir(root, { recursive: true }) + const wasEmpty = await vaultLooksEmpty(root) + const settings = await getVaultSettings(root) for (const f of FOLDERS) { + if (f === 'inbox' && settings.primaryNotesLocation === 'root') continue await fs.mkdir(path.join(root, f), { recursive: true }) } - const welcomePath = path.join(root, 'inbox', 'Welcome.md') - try { - await fs.access(welcomePath) - } catch { - await fs.writeFile(welcomePath, WELCOME_NOTE, 'utf8') + if (wasEmpty) { + const welcomeDir = await primaryNotesRoot(root) + await fs.mkdir(welcomeDir, { recursive: true }) + const welcomePath = path.join(welcomeDir, 'Welcome.md') + try { + await fs.access(welcomePath) + } catch { + await fs.writeFile(welcomePath, WELCOME_NOTE, 'utf8') + } } } @@ -195,10 +347,7 @@ function markdownDestination(p: string): string { } function folderOf(root: string, absPath: string): NoteFolder | null { - const rel = toPosix(path.relative(root, absPath)) - const top = rel.split('/')[0] - if (FOLDERS.includes(top as NoteFolder)) return top as NoteFolder - return null + return folderForRelativePath(path.relative(root, absPath)) } function stripCodeContent(body: string): string { @@ -208,6 +357,18 @@ function stripCodeContent(body: string): string { .replace(/`[^`\n]*`/g, ' ') } +function localAssetTargetKind(target: string): ImportedAssetKind | null { + const clean = target.split('#')[0]?.split('?')[0] ?? target + const lastDot = clean.lastIndexOf('.') + if (lastDot === -1) return null + const ext = clean.slice(lastDot).toLowerCase() + if (IMAGE_EXTENSIONS.has(ext)) return 'image' + if (PDF_EXTENSIONS.has(ext)) return 'pdf' + if (AUDIO_EXTENSIONS.has(ext)) return 'audio' + if (VIDEO_EXTENSIONS.has(ext)) return 'video' + return 'file' +} + /** Pull unique `#tags` out of markdown text, ignoring fenced/inline code. */ function extractTags(body: string): string[] { const stripped = stripCodeContent(body) @@ -226,6 +387,7 @@ function extractTags(body: string): string[] { function bodyHasLocalAsset(body: string): boolean { const stripped = stripCodeContent(body) const linkRe = /(!?)\[[^\]]*\]\((<[^>]+>|[^)\s]+)(?:\s+"[^"]*")?\)/g + const embedRe = /!\[\[([^\]|]+?)(?:\|[^\]]+)?\]\]/g let m: RegExpExecArray | null while ((m = linkRe.exec(stripped)) !== null) { let href = (m[2] ?? '').trim() @@ -233,18 +395,10 @@ function bodyHasLocalAsset(body: string): boolean { if (!href || href.startsWith('#') || href.startsWith('//')) continue // Skip URLs (anything with a scheme like http:, mailto:, file:, …). if (/^[a-zA-Z][a-zA-Z\d+.-]*:/.test(href)) continue - const clean = href.split('#')[0]?.split('?')[0] ?? href - const lastDot = clean.lastIndexOf('.') - if (lastDot === -1) continue - const ext = clean.slice(lastDot).toLowerCase() - if ( - IMAGE_EXTENSIONS.has(ext) || - PDF_EXTENSIONS.has(ext) || - AUDIO_EXTENSIONS.has(ext) || - VIDEO_EXTENSIONS.has(ext) - ) { - return true - } + if (localAssetTargetKind(href)) return true + } + while ((m = embedRe.exec(stripped)) !== null) { + if (localAssetTargetKind((m[1] ?? '').trim())) return true } return false } @@ -253,11 +407,15 @@ function bodyHasLocalAsset(body: string): boolean { * `[[target|label]]` by discarding the label. Ignores fenced/inline code. */ function extractWikilinks(body: string): string[] { const stripped = stripCodeContent(body) - const re = /\[\[([^\]|]+?)(?:\|[^\]]+)?\]\]/g + const re = /(!?)\[\[([^\]|]+?)(?:\|[^\]]+)?\]\]/g const seen = new Set() let m: RegExpExecArray | null while ((m = re.exec(stripped)) !== null) { - seen.add(m[1].trim()) + const bang = m[1] ?? '' + const target = (m[2] ?? '').trim() + if (!target) continue + if (bang === '!' && localAssetTargetKind(target)) continue + seen.add(target) } return [...seen] } @@ -268,6 +426,7 @@ function buildExcerpt(body: string): string { const text = stripCodeContent(withoutFront) .replace(/!\[[^\]]*\]\([^)]*\)/g, ' ') .replace(/\[([^\]]+)\]\([^)]*\)/g, '$1') + .replace(/!\[\[([^\]|]+)(?:\|([^\]]+))?\]\]/g, (_, a, b) => b || a) .replace(/\[\[([^\]|]+)(?:\|([^\]]+))?\]\]/g, (_, a, b) => b || a) .replace(/^#{1,6}\s+/gm, '') .replace(/[*_~>]+/g, '') @@ -420,14 +579,17 @@ function resolveSearchBackend( } function noteFolderFromRelPath(relPath: string): NoteFolder | null { - const top = relPath.split('/')[0] - if (top === 'inbox' || top === 'quick' || top === 'archive') return top - return null + return folderForRelativePath(relPath) } async function collectBuiltinSearchCandidates(root: string): Promise { const candidates: VaultTextSearchCandidate[] = [] - const walkFolder = async (folder: NoteFolder, dirAbs: string): Promise => { + const walkFolder = async ( + folder: NoteFolder, + dirAbs: string, + topAbs: string, + isPrimaryRoot: boolean + ): Promise => { let entries: Dirent[] try { entries = await fs.readdir(dirAbs, { withFileTypes: true }) @@ -439,7 +601,8 @@ async function collectBuiltinSearchCandidates(root: string): Promise { + const dir = await folderRoot(root, folder) + return toPosix(path.relative(root, dir)) || '.' + }) + ) const result = await execFileAsync( ripgrep, [ @@ -496,7 +667,7 @@ async function collectRipgrepSearchCandidates( '-g', '*.md', '^', - ...SEARCHABLE_TEXT_FOLDERS + ...searchRoots ], { cwd: root, @@ -731,7 +902,8 @@ async function readSiblingOrder(abs: string): Promise { export async function listFolders(root: string): Promise { const out: FolderEntry[] = [] for (const folder of FOLDERS) { - const topAbs = path.join(root, folder) + const topAbs = await folderRoot(root, folder) + const isPrimaryRoot = folder === 'inbox' && path.resolve(topAbs) === path.resolve(root) const walk = async (dirAbs: string, subpath: string): Promise => { let entries: Dirent[] try { @@ -742,6 +914,7 @@ export async function listFolders(root: string): Promise { for (const [index, e] of entries.entries()) { if (!e.isDirectory()) continue if (e.name.startsWith('.')) continue + if (isPrimaryRoot && dirAbs === topAbs && RESERVED_ROOT_NAMES.has(e.name)) continue const nextSub = subpath ? `${subpath}/${e.name}` : e.name out.push({ folder, subpath: nextSub, siblingOrder: index }) await walk(path.join(dirAbs, e.name), nextSub) @@ -754,7 +927,12 @@ export async function listFolders(root: string): Promise { export async function listNotes(root: string): Promise { const metas: NoteMeta[] = [] - const walkFolder = async (folder: NoteFolder, dirAbs: string): Promise => { + const walkFolder = async ( + folder: NoteFolder, + dirAbs: string, + topAbs: string, + isPrimaryRoot: boolean + ): Promise => { let entries: Dirent[] try { entries = await fs.readdir(dirAbs, { withFileTypes: true }) @@ -765,7 +943,8 @@ export async function listNotes(root: string): Promise { const full = path.join(dirAbs, entry.name) if (entry.isDirectory()) { if (entry.name.startsWith('.')) continue - await walkFolder(folder, full) + if (isPrimaryRoot && dirAbs === topAbs && RESERVED_ROOT_NAMES.has(entry.name)) continue + await walkFolder(folder, full, topAbs, isPrimaryRoot) continue } if (entry.isFile() && entry.name.toLowerCase().endsWith('.md')) { @@ -775,7 +954,9 @@ export async function listNotes(root: string): Promise { } for (const folder of FOLDERS) { - await walkFolder(folder, path.join(root, folder)) + const topAbs = await folderRoot(root, folder) + const isPrimaryRoot = folder === 'inbox' && path.resolve(topAbs) === path.resolve(root) + await walkFolder(folder, topAbs, topAbs, isPrimaryRoot) } return metas } @@ -900,9 +1081,8 @@ export async function createNote( ): Promise { const base = (title && title.trim()) || 'Untitled' const clean = subpath.replace(/^\/+|\/+$/g, '') - const dir = clean - ? resolveSafe(root, path.posix.join(folder, clean)) - : path.join(root, folder) + const topRoot = await folderRoot(root, folder) + const dir = clean ? resolveSafe(topRoot, clean) : topRoot await fs.mkdir(dir, { recursive: true }) const finalTitle = await uniqueTitle(dir, base) const abs = path.join(dir, `${finalTitle}.md`) @@ -952,7 +1132,7 @@ async function moveBetweenFolders( ): Promise { const abs = resolveSafe(root, rel) const filename = path.basename(abs) - const destDir = path.join(root, target) + const destDir = await folderRoot(root, target) await fs.mkdir(destDir, { recursive: true }) const baseTitle = path.basename(filename, path.extname(filename)) const finalTitle = await uniqueTitle(destDir, baseTitle) @@ -1008,7 +1188,7 @@ export async function createFolder( ): Promise { const trimmed = subpath.replace(/^\/+|\/+$/g, '') if (!trimmed) throw new Error('Folder name is required') - const abs = resolveSafe(root, path.posix.join(topFolder, trimmed)) + const abs = resolveSafe(await folderRoot(root, topFolder), trimmed) await fs.mkdir(abs, { recursive: true }) } @@ -1029,8 +1209,9 @@ export async function renameFolder( if (!oldClean) throw new Error('Cannot rename the top-level folder') if (!newClean) throw new Error('Target folder name is required') - const oldAbs = resolveSafe(root, path.posix.join(topFolder, oldClean)) - const newAbs = resolveSafe(root, path.posix.join(topFolder, newClean)) + const topRoot = await folderRoot(root, topFolder) + const oldAbs = resolveSafe(topRoot, oldClean) + const newAbs = resolveSafe(topRoot, newClean) if (newAbs === oldAbs) return newClean const sep = path.sep @@ -1076,7 +1257,7 @@ export async function deleteFolder( ): Promise { const clean = subpath.replace(/^\/+|\/+$/g, '') if (!clean) throw new Error('Cannot delete the top-level folder') - const abs = resolveSafe(root, path.posix.join(topFolder, clean)) + const abs = resolveSafe(await folderRoot(root, topFolder), clean) await fs.rm(abs, { recursive: true, force: true }) } @@ -1092,7 +1273,8 @@ export async function duplicateFolder( ): Promise { const clean = subpath.replace(/^\/+|\/+$/g, '') if (!clean) throw new Error('Cannot duplicate the top-level folder') - const oldAbs = resolveSafe(root, path.posix.join(topFolder, clean)) + const topRoot = await folderRoot(root, topFolder) + const oldAbs = resolveSafe(topRoot, clean) const parentAbs = path.dirname(oldAbs) const baseName = path.basename(oldAbs) let copyName = `${baseName} copy` @@ -1108,10 +1290,7 @@ export async function duplicateFolder( } const newAbs = path.join(parentAbs, copyName) await fs.cp(oldAbs, newAbs, { recursive: true }) - return path - .relative(path.join(root, topFolder), newAbs) - .split(path.sep) - .join('/') + return path.relative(topRoot, newAbs).split(path.sep).join('/') } /** Build the absolute on-disk path for a vault folder / subfolder. */ @@ -1119,11 +1298,12 @@ export function folderAbsolutePath( root: string, topFolder: NoteFolder, subpath: string -): string { +): Promise { const clean = subpath.replace(/^\/+|\/+$/g, '') - return clean - ? resolveSafe(root, path.posix.join(topFolder, clean)) - : path.join(root, topFolder) + return (async () => { + const topRoot = await folderRoot(root, topFolder) + return clean ? resolveSafe(topRoot, clean) : topRoot + })() } export function assetsAbsolutePath(root: string): string { @@ -1186,6 +1366,8 @@ export async function removeDemoTour(root: string): Promise } export async function hasAssetsDir(root: string): Promise { + const assets = await listAssets(root) + if (assets.length > 0) return true for (const dirName of ATTACHMENTS_DIRS) { try { const stat = await fs.stat(path.join(root, dirName)) @@ -1199,43 +1381,37 @@ export async function hasAssetsDir(root: string): Promise { export async function listAssets(root: string): Promise { const out: AssetMeta[] = [] - const walk = async (dirAbs: string): Promise => { + const walk = async (dirAbs: string, topAbs = dirAbs): Promise => { let entries: Dirent[] try { entries = await fs.readdir(dirAbs, { withFileTypes: true }) } catch { return } - for (const entry of entries) { + for (const [index, entry] of entries.entries()) { if (entry.name.startsWith('.')) continue const full = path.join(dirAbs, entry.name) if (entry.isDirectory()) { - await walk(full) + if (dirAbs === root && entry.name === INTERNAL_VAULT_DIR) continue + await walk(full, topAbs) continue } if (!entry.isFile()) continue + if (entry.name.toLowerCase().endsWith('.md')) continue const stat = await fs.stat(full) const rel = toPosix(path.relative(root, full)) out.push({ path: rel, name: path.basename(full), kind: classifyImportedAsset(entry.name), + siblingOrder: index, size: stat.size, updatedAt: stat.mtimeMs }) } } - for (const dirName of ATTACHMENTS_DIRS) { - const attachmentsRoot = path.join(root, dirName) - try { - const stat = await fs.stat(attachmentsRoot) - if (!stat.isDirectory()) continue - } catch { - continue - } - await walk(attachmentsRoot) - } + await walk(root, root) out.sort((a, b) => b.updatedAt - a.updatedAt || a.name.localeCompare(b.name, undefined, { sensitivity: 'base' })) return out } @@ -1256,9 +1432,8 @@ export async function moveNote( const oldAbs = resolveSafe(root, oldRel) const filename = path.basename(oldAbs) const cleanSub = targetSubpath.replace(/^\/+|\/+$/g, '') - const destDir = cleanSub - ? resolveSafe(root, path.posix.join(targetFolder, cleanSub)) - : path.join(root, targetFolder) + const targetRoot = await folderRoot(root, targetFolder) + const destDir = cleanSub ? resolveSafe(targetRoot, cleanSub) : targetRoot // No-op if the source already lives at the destination. if (path.dirname(oldAbs) === destDir) { @@ -1295,8 +1470,7 @@ export async function importFiles( noteRelPath: string, sourcePaths: string[] ): Promise { - const assetsDir = assetsAbsolutePath(root) - await fs.mkdir(assetsDir, { recursive: true }) + await fs.mkdir(root, { recursive: true }) const noteDir = path.posix.dirname(toPosix(noteRelPath)) const imported: ImportedAsset[] = [] @@ -1306,8 +1480,8 @@ export async function importFiles( const stat = await fs.stat(sourceAbs) if (!stat.isFile()) continue - const finalName = await uniqueFilename(assetsDir, path.basename(sourceAbs)) - const destAbs = path.join(assetsDir, finalName) + const finalName = await uniqueFilename(root, path.basename(sourceAbs)) + const destAbs = path.join(root, finalName) await fs.copyFile(sourceAbs, destAbs) const vaultRelPath = toPosix(path.relative(root, destAbs)) diff --git a/src/main/watcher.ts b/apps/desktop/src/main/watcher.ts similarity index 82% rename from src/main/watcher.ts rename to apps/desktop/src/main/watcher.ts index 06dee977..e1365f65 100644 --- a/src/main/watcher.ts +++ b/apps/desktop/src/main/watcher.ts @@ -1,8 +1,9 @@ import path from 'node:path' import chokidar, { FSWatcher } from 'chokidar' import type { NoteFolder, VaultChangeEvent, VaultChangeKind } from '@shared/ipc' +import { folderForRelativePath } from './vault' -const FOLDERS: NoteFolder[] = ['inbox', 'quick', 'archive', 'trash'] +const ATTACHMENTS_DIRS = new Set(['attachements', '_assets']) function toPosix(p: string): string { return p.split(path.sep).join('/') @@ -10,9 +11,10 @@ function toPosix(p: string): string { function folderOf(root: string, abs: string): NoteFolder | null { const rel = toPosix(path.relative(root, abs)) + const folder = folderForRelativePath(rel) + if (folder) return folder const top = rel.split('/')[0] - if (FOLDERS.includes(top as NoteFolder)) return top as NoteFolder - return null + return ATTACHMENTS_DIRS.has(top) ? 'inbox' : null } export class VaultWatcher { @@ -36,7 +38,8 @@ export class VaultWatcher { }) const handler = (kind: VaultChangeKind) => (absPath: string) => { - if (!absPath.toLowerCase().endsWith('.md')) return + const base = path.basename(absPath) + if (base.startsWith('.')) return if (!this.root) return const folder = folderOf(this.root, absPath) if (!folder) return diff --git a/src/mcp/index.ts b/apps/desktop/src/mcp/index.ts similarity index 100% rename from src/mcp/index.ts rename to apps/desktop/src/mcp/index.ts diff --git a/src/mcp/instructions-store.ts b/apps/desktop/src/mcp/instructions-store.ts similarity index 100% rename from src/mcp/instructions-store.ts rename to apps/desktop/src/mcp/instructions-store.ts diff --git a/src/mcp/instructions.ts b/apps/desktop/src/mcp/instructions.ts similarity index 100% rename from src/mcp/instructions.ts rename to apps/desktop/src/mcp/instructions.ts diff --git a/src/mcp/vault-ops.ts b/apps/desktop/src/mcp/vault-ops.ts similarity index 100% rename from src/mcp/vault-ops.ts rename to apps/desktop/src/mcp/vault-ops.ts diff --git a/apps/desktop/src/preload/index.d.ts b/apps/desktop/src/preload/index.d.ts new file mode 100644 index 00000000..863365f4 --- /dev/null +++ b/apps/desktop/src/preload/index.d.ts @@ -0,0 +1,9 @@ +import type { ZenBridge } from '@zennotes/bridge-contract/bridge' + +declare global { + interface Window { + zen: ZenBridge + } +} + +export {} diff --git a/src/preload/index.ts b/apps/desktop/src/preload/index.ts similarity index 88% rename from src/preload/index.ts rename to apps/desktop/src/preload/index.ts index 8127b4ad..f29550c4 100644 --- a/src/preload/index.ts +++ b/apps/desktop/src/preload/index.ts @@ -1,15 +1,19 @@ import { clipboard, contextBridge, ipcRenderer, webUtils } from 'electron' import path from 'node:path' +import appPackage from '../../package.json' +import type { ZenAppInfo, ZenBridge, ZenCapabilities } from '@zennotes/bridge-contract/bridge' import { IPC } from '@shared/ipc' import type { AppUpdateState, AssetMeta, + DirectoryBrowseResult, VaultDemoTourResult, FolderEntry, ImportedAsset, NoteContent, NoteFolder, NoteMeta, + VaultSettings, VaultTextSearchBackendPreference, VaultTextSearchCapabilities, VaultTextSearchToolPaths, @@ -25,7 +29,26 @@ import type { McpServerRuntime } from '@shared/mcp-clients' -const api = { +const DESKTOP_CAPABILITIES: ZenCapabilities = { + supportsUpdater: true, + supportsNativeMenus: true, + supportsFloatingWindows: true, + supportsLocalFilesystemPickers: true, + supportsDesktopNotifications: true +} + +const DESKTOP_APP_INFO: ZenAppInfo = { + name: appPackage.name, + productName: appPackage.productName, + version: appPackage.version, + description: appPackage.description, + homepage: appPackage.homepage, + runtime: 'desktop' +} + +const api: ZenBridge = { + getCapabilities: (): ZenCapabilities => DESKTOP_CAPABILITIES, + getAppInfo: (): ZenAppInfo => DESKTOP_APP_INFO, platform: (): Promise => ipcRenderer.invoke(IPC.APP_PLATFORM), platformSync: (): NodeJS.Platform => process.platform, listSystemFonts: (): Promise => ipcRenderer.invoke(IPC.APP_LIST_FONTS), @@ -45,6 +68,15 @@ const api = { getCurrentVault: (): Promise => ipcRenderer.invoke(IPC.VAULT_GET_CURRENT), pickVault: (): Promise => ipcRenderer.invoke(IPC.VAULT_PICK), + selectVaultPath: async (): Promise => { + throw new Error('Manual vault path selection is only available in the web build') + }, + browseServerDirectories: async (): Promise => { + throw new Error('Server filesystem browsing is only available in the web build') + }, + getVaultSettings: (): Promise => ipcRenderer.invoke(IPC.VAULT_GET_SETTINGS), + setVaultSettings: (next: VaultSettings): Promise => + ipcRenderer.invoke(IPC.VAULT_SET_SETTINGS, next), listNotes: (): Promise => ipcRenderer.invoke(IPC.VAULT_LIST_NOTES), listFolders: (): Promise => @@ -211,6 +243,6 @@ const api = { clipboardReadText: (): string => clipboard.readText() } -export type ZenApi = typeof api +export type ZenApi = ZenBridge contextBridge.exposeInMainWorld('zen', api) diff --git a/src/renderer/index.html b/apps/desktop/src/renderer/index.html similarity index 96% rename from src/renderer/index.html rename to apps/desktop/src/renderer/index.html index 9478560f..0616beaf 100644 --- a/src/renderer/index.html +++ b/apps/desktop/src/renderer/index.html @@ -32,6 +32,6 @@
- + diff --git a/apps/desktop/src/renderer/main.tsx b/apps/desktop/src/renderer/main.tsx new file mode 100644 index 00000000..42bd6c05 --- /dev/null +++ b/apps/desktop/src/renderer/main.tsx @@ -0,0 +1,41 @@ +import { installZenBridge } from '@zennotes/bridge-contract/bridge' +import { renderZenNotesApp } from '@zennotes/app-core/main' + +const root = document.getElementById('root') + +function renderBootError(message: string): void { + if (!root) return + root.replaceChildren() + const pre = document.createElement('pre') + pre.style.padding = '24px' + pre.style.color = '#b42318' + pre.style.background = '#fff7f7' + pre.style.font = '14px/1.5 ui-monospace, SFMono-Regular, Menlo, monospace' + pre.style.whiteSpace = 'pre-wrap' + pre.textContent = message + root.appendChild(pre) +} + +window.addEventListener('error', (event) => { + console.error('[desktop-renderer] uncaught error', event.error ?? event.message) + renderBootError(String(event.error?.stack ?? event.error ?? event.message)) +}) + +window.addEventListener('unhandledrejection', (event) => { + console.error('[desktop-renderer] unhandled rejection', event.reason) + renderBootError(String(event.reason?.stack ?? event.reason)) +}) + +try { + if (!window.zen) { + throw new Error('window.zen bridge is unavailable in the desktop renderer') + } + if (!root) { + throw new Error('Renderer root element #root was not found') + } + installZenBridge(window.zen) + renderZenNotesApp(root) +} catch (error) { + console.error('[desktop-renderer] boot failed', error) + renderBootError(String(error instanceof Error ? error.stack ?? error.message : error)) +} diff --git a/apps/desktop/tailwind.config.js b/apps/desktop/tailwind.config.js new file mode 100644 index 00000000..c4774227 --- /dev/null +++ b/apps/desktop/tailwind.config.js @@ -0,0 +1,50 @@ +/** @type {import('tailwindcss').Config} */ +module.exports = { + content: ['./src/renderer/index.html', '../../packages/app-core/src/**/*.{ts,tsx}'], + theme: { + extend: { + colors: { + paper: { + 50: 'rgb(var(--z-bg-softer) / )', + 100: 'rgb(var(--z-bg) / )', + 200: 'rgb(var(--z-bg-1) / )', + 300: 'rgb(var(--z-bg-2) / )', + 400: 'rgb(var(--z-bg-3) / )', + 500: 'rgb(var(--z-bg-4) / )' + }, + ink: { + 900: 'rgb(var(--z-fg) / )', + 800: 'rgb(var(--z-fg-1) / )', + 700: 'rgb(var(--z-fg-2) / )', + 600: 'rgb(var(--z-grey-2) / )', + 500: 'rgb(var(--z-grey-1) / )', + 400: 'rgb(var(--z-grey-0) / )', + 300: 'rgb(var(--z-grey-dim) / )' + }, + accent: { + DEFAULT: 'rgb(var(--z-accent) / )', + soft: 'rgb(var(--z-accent-soft) / )', + muted: 'rgb(var(--z-accent-muted) / )' + } + }, + fontFamily: { + sans: [ + '-apple-system', + 'BlinkMacSystemFont', + '"SF Pro Text"', + '"Inter"', + 'system-ui', + 'sans-serif' + ], + serif: ['"Iowan Old Style"', '"Source Serif Pro"', 'Georgia', 'serif'], + mono: ['"JetBrains Mono"', '"SF Mono"', 'Menlo', 'monospace'] + }, + boxShadow: { + panel: + '0 1px 0 0 rgb(var(--z-shadow) / 0.04), 0 8px 28px -12px rgb(var(--z-shadow) / 0.18)', + float: '0 20px 60px -20px rgb(var(--z-shadow) / 0.28)' + } + } + }, + plugins: [] +} diff --git a/apps/desktop/tsconfig.json b/apps/desktop/tsconfig.json new file mode 100644 index 00000000..155ebaa6 --- /dev/null +++ b/apps/desktop/tsconfig.json @@ -0,0 +1,7 @@ +{ + "files": [], + "references": [ + { "path": "./tsconfig.node.json" }, + { "path": "./tsconfig.web.json" } + ] +} diff --git a/apps/desktop/tsconfig.node.json b/apps/desktop/tsconfig.node.json new file mode 100644 index 00000000..0524e0ff --- /dev/null +++ b/apps/desktop/tsconfig.node.json @@ -0,0 +1,18 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "lib": ["ES2022"], + "types": ["node", "electron-vite/node"], + "baseUrl": ".", + "paths": { + "@shared/*": ["../../packages/shared-domain/src/*"], + "@bridge-contract/*": ["../../packages/bridge-contract/src/*"] + } + }, + "include": [ + "electron.vite.config.ts", + "src/main/**/*", + "src/mcp/**/*", + "src/preload/**/*" + ] +} diff --git a/apps/desktop/tsconfig.web.json b/apps/desktop/tsconfig.web.json new file mode 100644 index 00000000..5bdcd421 --- /dev/null +++ b/apps/desktop/tsconfig.web.json @@ -0,0 +1,24 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "jsx": "react-jsx", + "useDefineForClassFields": true, + "isolatedModules": true, + "noUnusedLocals": false, + "noUnusedParameters": false, + "types": ["vite/client"], + "baseUrl": ".", + "paths": { + "@renderer/*": ["../../packages/app-core/src/*"], + "@shared/*": ["../../packages/shared-domain/src/*"], + "@bridge-contract/*": ["../../packages/bridge-contract/src/*"], + "@zennotes/app-core/*": ["../../packages/app-core/src/*"], + "@zennotes/bridge-contract/*": ["../../packages/bridge-contract/src/*"] + } + }, + "include": [ + "src/renderer/**/*", + "src/preload/*.d.ts" + ] +} diff --git a/apps/desktop/vitest.config.ts b/apps/desktop/vitest.config.ts new file mode 100644 index 00000000..6492d7c4 --- /dev/null +++ b/apps/desktop/vitest.config.ts @@ -0,0 +1,16 @@ +import path from 'node:path' +import { defineConfig } from 'vitest/config' + +export default defineConfig({ + resolve: { + alias: { + '@shared': path.resolve(__dirname, '../../packages/shared-domain/src'), + '@renderer': path.resolve(__dirname, '../../packages/app-core/src'), + '@bridge-contract': path.resolve(__dirname, '../../packages/bridge-contract/src') + } + }, + test: { + environment: 'node', + include: ['src/**/*.test.ts'] + } +}) diff --git a/apps/server/cmd/zennotes-server/main.go b/apps/server/cmd/zennotes-server/main.go new file mode 100644 index 00000000..e781c8c5 --- /dev/null +++ b/apps/server/cmd/zennotes-server/main.go @@ -0,0 +1,70 @@ +package main + +import ( + "context" + "errors" + "log" + "net/http" + "os" + "os/signal" + "syscall" + "time" + + "github.com/ZenNotes/zennotes/apps/server/internal/config" + "github.com/ZenNotes/zennotes/apps/server/internal/httpserver" + "github.com/ZenNotes/zennotes/apps/server/internal/vault" + "github.com/ZenNotes/zennotes/apps/server/internal/watcher" + "github.com/ZenNotes/zennotes/apps/server/web" +) + +func main() { + log.SetFlags(log.LstdFlags | log.Lmicroseconds) + + cfg := config.Load() + log.Printf("vault: %s", cfg.VaultPath) + log.Printf("bind: %s", cfg.Bind) + + v, err := vault.New(cfg.VaultPath) + if err != nil { + log.Fatalf("vault init: %v", err) + } + + _ = config.Save(cfg, v.Root()) + + w, err := watcher.Start(v.Root()) + if err != nil { + log.Fatalf("watcher start: %v", err) + } + defer w.Close() + + dist, err := web.Dist() + if err != nil { + log.Printf("warning: embedded web bundle not available: %v", err) + dist = nil + } + + srv := httpserver.New(v, w, dist, cfg) + httpSrv := &http.Server{ + Addr: cfg.Bind, + Handler: srv.Router(), + ReadTimeout: 0, // Websocket-friendly. + WriteTimeout: 0, + } + + ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer cancel() + + go func() { + log.Printf("listening on http://%s", cfg.Bind) + if err := httpSrv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { + log.Fatalf("http serve: %v", err) + } + }() + + <-ctx.Done() + log.Printf("shutting down…") + + shutdownCtx, stopShutdown := context.WithTimeout(context.Background(), 5*time.Second) + defer stopShutdown() + _ = httpSrv.Shutdown(shutdownCtx) +} diff --git a/apps/server/go.mod b/apps/server/go.mod new file mode 100644 index 00000000..01666d2c --- /dev/null +++ b/apps/server/go.mod @@ -0,0 +1,11 @@ +module github.com/ZenNotes/zennotes/apps/server + +go 1.22 + +require ( + github.com/coder/websocket v1.8.12 + github.com/fsnotify/fsnotify v1.8.0 + github.com/go-chi/chi/v5 v5.2.0 +) + +require golang.org/x/sys v0.21.0 // indirect diff --git a/apps/server/go.sum b/apps/server/go.sum new file mode 100644 index 00000000..80d8eb24 --- /dev/null +++ b/apps/server/go.sum @@ -0,0 +1,8 @@ +github.com/coder/websocket v1.8.12 h1:5bUXkEPPIbewrnkU8LTCLVaxi4N4J8ahufH2vlo4NAo= +github.com/coder/websocket v1.8.12/go.mod h1:LNVeNrXQZfe5qhS9ALED3uA+l5pPqvwXg3CKoDBB2gs= +github.com/fsnotify/fsnotify v1.8.0 h1:dAwr6QBTBZIkG8roQaJjGof0pp0EeF+tNV7YBP3F/8M= +github.com/fsnotify/fsnotify v1.8.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= +github.com/go-chi/chi/v5 v5.2.0 h1:Aj1EtB0qR2Rdo2dG4O94RIU35w2lvQSj6BRA4+qwFL0= +github.com/go-chi/chi/v5 v5.2.0/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8= +golang.org/x/sys v0.21.0 h1:rF+pYz3DAGSQAxAu1CbC7catZg4ebC4UIeIhKxBZvws= +golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= diff --git a/apps/server/internal/config/config.go b/apps/server/internal/config/config.go new file mode 100644 index 00000000..43312a39 --- /dev/null +++ b/apps/server/internal/config/config.go @@ -0,0 +1,104 @@ +package config + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" +) + +type Config struct { + VaultPath string `json:"vaultPath"` + DefaultVaultPath string `json:"-"` + BrowseRoots []string `json:"-"` + Bind string `json:"bind"` + AuthToken string `json:"authToken"` +} + +func configFilePath() string { + if v := os.Getenv("ZENNOTES_CONFIG_PATH"); v != "" { + return v + } + if home, err := os.UserHomeDir(); err == nil { + return filepath.Join(home, ".zennotes", "server.json") + } + return ".zennotes-server.json" +} + +func Load() Config { + cfg := Config{ + Bind: "127.0.0.1:7878", + } + if raw, err := os.ReadFile(configFilePath()); err == nil { + var stored Config + if json.Unmarshal(raw, &stored) == nil { + if stored.VaultPath != "" { + cfg.VaultPath = stored.VaultPath + } + if stored.Bind != "" { + cfg.Bind = stored.Bind + } + if stored.AuthToken != "" { + cfg.AuthToken = stored.AuthToken + } + } + } + if v := os.Getenv("ZENNOTES_VAULT_PATH"); v != "" { + cfg.VaultPath = v + } + if v := os.Getenv("ZENNOTES_DEFAULT_VAULT_PATH"); v != "" { + cfg.DefaultVaultPath = v + } + if v := os.Getenv("ZENNOTES_BROWSE_ROOTS"); v != "" { + parts := strings.Split(v, ",") + cfg.BrowseRoots = make([]string, 0, len(parts)) + for _, part := range parts { + if trimmed := strings.TrimSpace(part); trimmed != "" { + cfg.BrowseRoots = append(cfg.BrowseRoots, trimmed) + } + } + } + if v := os.Getenv("ZENNOTES_BIND"); v != "" { + cfg.Bind = v + } + if v := os.Getenv("ZENNOTES_AUTH_TOKEN"); v != "" { + cfg.AuthToken = v + } + if cfg.VaultPath == "" { + if cfg.DefaultVaultPath != "" { + cfg.VaultPath = cfg.DefaultVaultPath + } else { + if home, err := os.UserHomeDir(); err == nil { + cfg.VaultPath = filepath.Join(home, "ZenNotesVault") + } else { + cfg.VaultPath = "./vault" + } + } + } + return cfg +} + +func SaveHost(cfg Config) error { + target := configFilePath() + if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil { + return err + } + out, err := json.MarshalIndent(cfg, "", " ") + if err != nil { + return err + } + return os.WriteFile(target, out, 0o644) +} + +// Save writes the effective config inside the vault, for transparency. +func Save(cfg Config, vaultRoot string) error { + dir := filepath.Join(vaultRoot, ".zennotes") + if err := os.MkdirAll(dir, 0o755); err != nil { + return err + } + out, err := json.MarshalIndent(cfg, "", " ") + if err != nil { + return err + } + return os.WriteFile(filepath.Join(dir, "server.json"), out, 0o644) +} diff --git a/apps/server/internal/httpserver/server.go b/apps/server/internal/httpserver/server.go new file mode 100644 index 00000000..e61f7366 --- /dev/null +++ b/apps/server/internal/httpserver/server.go @@ -0,0 +1,881 @@ +package httpserver + +import ( + "context" + "encoding/json" + "errors" + "io/fs" + "log" + "mime" + "net/http" + "os" + "path/filepath" + "runtime" + "sort" + "strings" + "sync" + "time" + + "github.com/ZenNotes/zennotes/apps/server/internal/config" + "github.com/ZenNotes/zennotes/apps/server/internal/vault" + "github.com/ZenNotes/zennotes/apps/server/internal/watcher" + "github.com/coder/websocket" + "github.com/go-chi/chi/v5" + "github.com/go-chi/chi/v5/middleware" +) + +type Server struct { + mu sync.RWMutex + Config config.Config + Vault *vault.Vault + Watcher *watcher.Watcher + Static fs.FS // embedded web bundle, may be nil in dev +} + +func New(v *vault.Vault, w *watcher.Watcher, static fs.FS, cfg config.Config) *Server { + return &Server{Vault: v, Watcher: w, Static: static, Config: cfg} +} + +func (s *Server) currentVault() *vault.Vault { + s.mu.RLock() + defer s.mu.RUnlock() + return s.Vault +} + +func (s *Server) currentWatcher() *watcher.Watcher { + s.mu.RLock() + defer s.mu.RUnlock() + return s.Watcher +} + +func (s *Server) switchVaultRoot(nextPath string) (*vault.Vault, error) { + nextVault, err := vault.New(nextPath) + if err != nil { + return nil, err + } + nextWatcher, err := watcher.Start(nextVault.Root()) + if err != nil { + return nil, err + } + + s.mu.Lock() + prevWatcher := s.Watcher + s.Vault = nextVault + s.Watcher = nextWatcher + s.Config.VaultPath = nextVault.Root() + cfg := s.Config + s.mu.Unlock() + + if prevWatcher != nil { + prevWatcher.Close() + } + _ = config.SaveHost(cfg) + _ = config.Save(cfg, nextVault.Root()) + return nextVault, nil +} + +func (s *Server) Router() http.Handler { + r := chi.NewRouter() + r.Use(middleware.RequestID) + r.Use(middleware.RealIP) + r.Use(middleware.Recoverer) + r.Use(corsAllowDev) + + r.Route("/api", func(r chi.Router) { + r.Get("/healthz", s.healthz) + r.Get("/version", s.version) + r.Get("/platform", s.platform) + r.Get("/vault", s.vaultInfo) + r.Get("/vault/settings", s.vaultSettings) + r.Post("/vault/settings", s.setVaultSettings) + r.Post("/vault/select", s.selectVault) + r.Get("/fs/browse", s.browseDirectories) + + r.Get("/notes", s.listNotes) + r.Get("/folders", s.listFolders) + r.Get("/assets", s.listAssets) + r.Get("/assets/exists", s.assetsExists) + r.Get("/assets/raw", s.rawAsset) + r.Post("/assets/upload", s.uploadAsset) + + r.Get("/notes/read", s.readNote) + r.Post("/notes/write", s.writeNote) + r.Post("/notes/create", s.createNote) + r.Post("/notes/rename", s.renameNote) + r.Post("/notes/delete", s.deleteNote) + r.Post("/notes/trash", s.trashNote) + r.Post("/notes/restore", s.restoreNote) + r.Post("/notes/empty-trash", s.emptyTrash) + r.Post("/notes/archive", s.archiveNote) + r.Post("/notes/unarchive", s.unarchiveNote) + r.Post("/notes/duplicate", s.duplicateNote) + r.Post("/notes/move", s.moveNote) + + r.Post("/folders/create", s.createFolder) + r.Post("/folders/rename", s.renameFolder) + r.Post("/folders/delete", s.deleteFolder) + r.Post("/folders/duplicate", s.duplicateFolder) + + r.Get("/search/capabilities", s.searchCapabilities) + r.Get("/search/text", s.searchText) + + r.Get("/tasks", s.allTasks) + r.Get("/tasks/for", s.tasksFor) + + r.Post("/demo/generate", s.demoGenerate) + r.Post("/demo/remove", s.demoRemove) + + r.Get("/watch", s.watchWS) + }) + + // Static / PWA fallback. + if s.Static != nil { + r.Get("/*", s.serveStatic) + } + return r +} + +func corsAllowDev(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + origin := r.Header.Get("Origin") + if origin != "" && (strings.Contains(origin, "127.0.0.1") || strings.Contains(origin, "localhost")) { + w.Header().Set("Access-Control-Allow-Origin", origin) + w.Header().Set("Access-Control-Allow-Credentials", "true") + w.Header().Set("Access-Control-Allow-Methods", "GET, POST, DELETE, OPTIONS") + w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization, If-Match") + if r.Method == http.MethodOptions { + w.WriteHeader(http.StatusNoContent) + return + } + } + next.ServeHTTP(w, r) + }) +} + +// --- Responses --- + +func writeJSON(w http.ResponseWriter, code int, v any) { + w.Header().Set("Content-Type", "application/json; charset=utf-8") + w.WriteHeader(code) + _ = json.NewEncoder(w).Encode(v) +} + +func writeError(w http.ResponseWriter, err error) { + if errors.Is(err, vault.ErrPathEscape) { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + log.Printf("handler error: %v", err) + http.Error(w, err.Error(), http.StatusInternalServerError) +} + +func readJSON[T any](r *http.Request, out *T) error { + return json.NewDecoder(r.Body).Decode(out) +} + +// --- Handlers: meta --- + +func (s *Server) healthz(w http.ResponseWriter, _ *http.Request) { + writeJSON(w, http.StatusOK, map[string]any{"ok": true}) +} + +func (s *Server) version(w http.ResponseWriter, _ *http.Request) { + writeJSON(w, http.StatusOK, map[string]any{ + "version": "0.1.0-web", + "go": runtime.Version(), + }) +} + +func (s *Server) platform(w http.ResponseWriter, _ *http.Request) { + plat := "linux" + switch runtime.GOOS { + case "darwin": + plat = "darwin" + case "windows": + plat = "win32" + case "linux": + plat = "linux" + } + writeJSON(w, http.StatusOK, map[string]string{"platform": plat}) +} + +func (s *Server) vaultInfo(w http.ResponseWriter, _ *http.Request) { + writeJSON(w, http.StatusOK, s.currentVault().Info()) +} + +func (s *Server) vaultSettings(w http.ResponseWriter, _ *http.Request) { + settings, err := s.currentVault().GetSettings() + if err != nil { + writeError(w, err) + return + } + writeJSON(w, http.StatusOK, settings) +} + +func (s *Server) setVaultSettings(w http.ResponseWriter, r *http.Request) { + var req vault.VaultSettings + if err := readJSON(r, &req); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + settings, err := s.currentVault().SetSettings(req) + if err != nil { + writeError(w, err) + return + } + writeJSON(w, http.StatusOK, settings) +} + +func (s *Server) selectVault(w http.ResponseWriter, r *http.Request) { + if osPath := strings.TrimSpace(os.Getenv("ZENNOTES_VAULT_PATH")); osPath != "" { + http.Error(w, "vault path is managed by ZENNOTES_VAULT_PATH", http.StatusConflict) + return + } + var req struct { + Path string `json:"path"` + } + if err := readJSON(r, &req); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + if strings.TrimSpace(req.Path) == "" { + http.Error(w, "vault path is required", http.StatusBadRequest) + return + } + nextVault, err := s.switchVaultRoot(req.Path) + if err != nil { + writeError(w, err) + return + } + writeJSON(w, http.StatusOK, nextVault.Info()) +} + +type directoryBrowseEntry struct { + Name string `json:"name"` + Path string `json:"path"` +} + +type directoryBrowseShortcut struct { + Label string `json:"label"` + Path string `json:"path"` +} + +type directoryBrowseResult struct { + CurrentPath string `json:"currentPath"` + ParentPath *string `json:"parentPath"` + Entries []directoryBrowseEntry `json:"entries"` + Shortcuts []directoryBrowseShortcut `json:"shortcuts"` +} + +func appendBrowseShortcut(shortcuts []directoryBrowseShortcut, label string, path string) []directoryBrowseShortcut { + cleaned := strings.TrimSpace(path) + if cleaned == "" { + return shortcuts + } + for _, shortcut := range shortcuts { + if shortcut.Path == cleaned { + return shortcuts + } + } + if info, err := os.Stat(cleaned); err != nil || !info.IsDir() { + return shortcuts + } + return append(shortcuts, directoryBrowseShortcut{Label: label, Path: cleaned}) +} + +func browseRootLabel(path string, index int) string { + cleaned := filepath.Clean(path) + root := filesystemRootForPath(cleaned) + if cleaned == root { + return "Mounted Root" + } + base := filepath.Base(cleaned) + if base == "." || base == string(filepath.Separator) || strings.TrimSpace(base) == "" { + return "Mounted Root" + } + if index == 0 { + return base + } + return base +} + +func defaultBrowsePath() string { + if home, err := os.UserHomeDir(); err == nil && strings.TrimSpace(home) != "" { + return home + } + if runtime.GOOS == "windows" { + return `C:\` + } + return string(filepath.Separator) +} + +func filesystemRootForPath(p string) string { + if volume := filepath.VolumeName(p); volume != "" { + return volume + string(filepath.Separator) + } + return string(filepath.Separator) +} + +func (s *Server) browseDirectories(w http.ResponseWriter, r *http.Request) { + requested := strings.TrimSpace(r.URL.Query().Get("path")) + target := requested + if target == "" { + target = defaultBrowsePath() + } + if !filepath.IsAbs(target) { + abs, err := filepath.Abs(target) + if err != nil { + writeError(w, err) + return + } + target = abs + } + target = filepath.Clean(target) + + info, err := os.Stat(target) + if err != nil { + writeError(w, err) + return + } + if !info.IsDir() { + http.Error(w, "path is not a directory", http.StatusBadRequest) + return + } + + dirEntries, err := os.ReadDir(target) + if err != nil { + writeError(w, err) + return + } + + entries := make([]directoryBrowseEntry, 0, len(dirEntries)) + for _, entry := range dirEntries { + childPath := filepath.Join(target, entry.Name()) + childInfo, err := os.Stat(childPath) + if err != nil || !childInfo.IsDir() { + continue + } + entries = append(entries, directoryBrowseEntry{ + Name: entry.Name(), + Path: childPath, + }) + } + sort.Slice(entries, func(i, j int) bool { + left := strings.ToLower(entries[i].Name) + right := strings.ToLower(entries[j].Name) + if left == right { + return entries[i].Name < entries[j].Name + } + return left < right + }) + + parentPath := filepath.Dir(target) + var parent *string + if parentPath != "" && parentPath != target { + parent = &parentPath + } + + shortcuts := make([]directoryBrowseShortcut, 0, 8) + root := filesystemRootForPath(target) + shortcuts = appendBrowseShortcut(shortcuts, "Root", root) + if home, err := os.UserHomeDir(); err == nil && strings.TrimSpace(home) != "" { + shortcuts = appendBrowseShortcut(shortcuts, "Home", home) + shortcuts = appendBrowseShortcut(shortcuts, "Desktop", filepath.Join(home, "Desktop")) + shortcuts = appendBrowseShortcut(shortcuts, "Documents", filepath.Join(home, "Documents")) + shortcuts = appendBrowseShortcut(shortcuts, "Downloads", filepath.Join(home, "Downloads")) + if runtime.GOOS == "darwin" { + shortcuts = appendBrowseShortcut( + shortcuts, + "iCloud Drive", + filepath.Join(home, "Library", "Mobile Documents", "com~apple~CloudDocs"), + ) + } + } + if current := s.currentVault(); current != nil { + rootPath := current.Root() + shortcuts = appendBrowseShortcut(shortcuts, "Current Vault", rootPath) + } + for idx, rootPath := range s.Config.BrowseRoots { + shortcuts = appendBrowseShortcut(shortcuts, browseRootLabel(rootPath, idx), rootPath) + } + + writeJSON(w, http.StatusOK, directoryBrowseResult{ + CurrentPath: target, + ParentPath: parent, + Entries: entries, + Shortcuts: shortcuts, + }) +} + +// --- Listing --- + +func (s *Server) listNotes(w http.ResponseWriter, _ *http.Request) { + notes, err := s.currentVault().ListNotes() + if err != nil { + writeError(w, err) + return + } + writeJSON(w, http.StatusOK, notes) +} + +func (s *Server) listFolders(w http.ResponseWriter, _ *http.Request) { + folders, err := s.currentVault().ListFolders() + if err != nil { + writeError(w, err) + return + } + writeJSON(w, http.StatusOK, folders) +} + +func (s *Server) listAssets(w http.ResponseWriter, _ *http.Request) { + assets, err := s.currentVault().ListAssets() + if err != nil { + writeError(w, err) + return + } + writeJSON(w, http.StatusOK, assets) +} + +func (s *Server) assetsExists(w http.ResponseWriter, _ *http.Request) { + writeJSON(w, http.StatusOK, map[string]bool{"exists": s.currentVault().HasAssetsDir()}) +} + +// --- Notes --- + +func (s *Server) readNote(w http.ResponseWriter, r *http.Request) { + rel := r.URL.Query().Get("path") + note, err := s.currentVault().ReadNote(rel) + if err != nil { + writeError(w, err) + return + } + writeJSON(w, http.StatusOK, note) +} + +func (s *Server) writeNote(w http.ResponseWriter, r *http.Request) { + var req struct { + Path string `json:"path"` + Body string `json:"body"` + } + if err := readJSON(r, &req); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + meta, err := s.currentVault().WriteNote(req.Path, req.Body) + if err != nil { + writeError(w, err) + return + } + writeJSON(w, http.StatusOK, meta) +} + +func (s *Server) createNote(w http.ResponseWriter, r *http.Request) { + var req struct { + Folder vault.NoteFolder `json:"folder"` + Title string `json:"title"` + Subpath string `json:"subpath"` + } + if err := readJSON(r, &req); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + meta, err := s.currentVault().CreateNote(req.Folder, req.Title, req.Subpath) + if err != nil { + writeError(w, err) + return + } + writeJSON(w, http.StatusOK, meta) +} + +func (s *Server) renameNote(w http.ResponseWriter, r *http.Request) { + var req struct { + Path string `json:"path"` + Title string `json:"title"` + } + if err := readJSON(r, &req); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + meta, err := s.currentVault().RenameNote(req.Path, req.Title) + if err != nil { + writeError(w, err) + return + } + writeJSON(w, http.StatusOK, meta) +} + +func (s *Server) deleteNote(w http.ResponseWriter, r *http.Request) { + var req struct { + Path string `json:"path"` + } + if err := readJSON(r, &req); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + if err := s.currentVault().DeleteNote(req.Path); err != nil { + writeError(w, err) + return + } + w.WriteHeader(http.StatusNoContent) +} + +func (s *Server) trashNote(w http.ResponseWriter, r *http.Request) { + var req struct { + Path string `json:"path"` + } + if err := readJSON(r, &req); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + meta, err := s.currentVault().MoveToTrash(req.Path) + if err != nil { + writeError(w, err) + return + } + writeJSON(w, http.StatusOK, meta) +} + +func (s *Server) restoreNote(w http.ResponseWriter, r *http.Request) { + var req struct { + Path string `json:"path"` + } + if err := readJSON(r, &req); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + meta, err := s.currentVault().RestoreFromTrash(req.Path) + if err != nil { + writeError(w, err) + return + } + writeJSON(w, http.StatusOK, meta) +} + +func (s *Server) emptyTrash(w http.ResponseWriter, _ *http.Request) { + if err := s.currentVault().EmptyTrash(); err != nil { + writeError(w, err) + return + } + w.WriteHeader(http.StatusNoContent) +} + +func (s *Server) archiveNote(w http.ResponseWriter, r *http.Request) { + var req struct { + Path string `json:"path"` + } + if err := readJSON(r, &req); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + meta, err := s.currentVault().ArchiveNote(req.Path) + if err != nil { + writeError(w, err) + return + } + writeJSON(w, http.StatusOK, meta) +} + +func (s *Server) unarchiveNote(w http.ResponseWriter, r *http.Request) { + var req struct { + Path string `json:"path"` + } + if err := readJSON(r, &req); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + meta, err := s.currentVault().UnarchiveNote(req.Path) + if err != nil { + writeError(w, err) + return + } + writeJSON(w, http.StatusOK, meta) +} + +func (s *Server) duplicateNote(w http.ResponseWriter, r *http.Request) { + var req struct { + Path string `json:"path"` + } + if err := readJSON(r, &req); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + meta, err := s.currentVault().DuplicateNote(req.Path) + if err != nil { + writeError(w, err) + return + } + writeJSON(w, http.StatusOK, meta) +} + +func (s *Server) moveNote(w http.ResponseWriter, r *http.Request) { + var req struct { + Path string `json:"path"` + TargetFolder vault.NoteFolder `json:"targetFolder"` + TargetSubpath string `json:"targetSubpath"` + } + if err := readJSON(r, &req); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + meta, err := s.currentVault().MoveNote(req.Path, req.TargetFolder, req.TargetSubpath) + if err != nil { + writeError(w, err) + return + } + writeJSON(w, http.StatusOK, meta) +} + +// --- Folders --- + +func (s *Server) createFolder(w http.ResponseWriter, r *http.Request) { + var req struct { + Folder vault.NoteFolder `json:"folder"` + Subpath string `json:"subpath"` + } + if err := readJSON(r, &req); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + if err := s.currentVault().CreateFolder(req.Folder, req.Subpath); err != nil { + writeError(w, err) + return + } + w.WriteHeader(http.StatusNoContent) +} + +func (s *Server) renameFolder(w http.ResponseWriter, r *http.Request) { + var req struct { + Folder vault.NoteFolder `json:"folder"` + OldSubpath string `json:"oldSubpath"` + NewSubpath string `json:"newSubpath"` + } + if err := readJSON(r, &req); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + out, err := s.currentVault().RenameFolder(req.Folder, req.OldSubpath, req.NewSubpath) + if err != nil { + writeError(w, err) + return + } + writeJSON(w, http.StatusOK, map[string]string{"subpath": out}) +} + +func (s *Server) deleteFolder(w http.ResponseWriter, r *http.Request) { + var req struct { + Folder vault.NoteFolder `json:"folder"` + Subpath string `json:"subpath"` + } + if err := readJSON(r, &req); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + if err := s.currentVault().DeleteFolder(req.Folder, req.Subpath); err != nil { + writeError(w, err) + return + } + w.WriteHeader(http.StatusNoContent) +} + +func (s *Server) duplicateFolder(w http.ResponseWriter, r *http.Request) { + var req struct { + Folder vault.NoteFolder `json:"folder"` + Subpath string `json:"subpath"` + } + if err := readJSON(r, &req); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + out, err := s.currentVault().DuplicateFolder(req.Folder, req.Subpath) + if err != nil { + writeError(w, err) + return + } + writeJSON(w, http.StatusOK, map[string]string{"subpath": out}) +} + +// --- Tasks + Search --- + +func (s *Server) allTasks(w http.ResponseWriter, _ *http.Request) { + tasks, err := s.currentVault().ScanTasks() + if err != nil { + writeError(w, err) + return + } + writeJSON(w, http.StatusOK, tasks) +} + +func (s *Server) tasksFor(w http.ResponseWriter, r *http.Request) { + rel := r.URL.Query().Get("path") + tasks, err := s.currentVault().ScanTasksForPath(rel) + if err != nil { + writeError(w, err) + return + } + writeJSON(w, http.StatusOK, tasks) +} + +func (s *Server) searchCapabilities(w http.ResponseWriter, _ *http.Request) { + writeJSON(w, http.StatusOK, s.currentVault().SearchCapabilities()) +} + +func (s *Server) searchText(w http.ResponseWriter, r *http.Request) { + q := r.URL.Query().Get("q") + matches, err := s.currentVault().SearchText(q) + if err != nil { + writeError(w, err) + return + } + writeJSON(w, http.StatusOK, matches) +} + +// --- Demo tour --- + +func (s *Server) demoGenerate(w http.ResponseWriter, _ *http.Request) { + res, err := s.currentVault().GenerateDemoTour() + if err != nil { + writeError(w, err) + return + } + writeJSON(w, http.StatusOK, res) +} + +func (s *Server) demoRemove(w http.ResponseWriter, _ *http.Request) { + res, err := s.currentVault().RemoveDemoTour() + if err != nil { + writeError(w, err) + return + } + writeJSON(w, http.StatusOK, res) +} + +// --- Assets --- + +func (s *Server) rawAsset(w http.ResponseWriter, r *http.Request) { + rel := r.URL.Query().Get("path") + abs, err := s.currentVault().AssetAbsPath(rel) + if err != nil { + writeError(w, err) + return + } + ext := strings.ToLower(filepath.Ext(abs)) + if t := mime.TypeByExtension(ext); t != "" { + w.Header().Set("Content-Type", t) + } + w.Header().Set("Cache-Control", "private, max-age=3600") + http.ServeFile(w, r, abs) +} + +func (s *Server) uploadAsset(w http.ResponseWriter, r *http.Request) { + if err := r.ParseMultipartForm(128 << 20); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + notePath := r.FormValue("notePath") + file, header, err := r.FormFile("file") + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + defer file.Close() + asset, err := s.currentVault().ImportAsset(notePath, header.Filename, file) + if err != nil { + writeError(w, err) + return + } + writeJSON(w, http.StatusOK, asset) +} + +// --- WebSocket watcher --- + +func (s *Server) watchWS(w http.ResponseWriter, r *http.Request) { + ws, err := websocket.Accept(w, r, &websocket.AcceptOptions{ + InsecureSkipVerify: true, // Allow Vite dev server origin. + OriginPatterns: []string{"*"}, + }) + if err != nil { + log.Printf("ws accept failed: %v", err) + return + } + defer ws.Close(websocket.StatusNormalClosure, "") + ctx, cancel := context.WithCancel(r.Context()) + defer cancel() + + events, unsubscribe := s.currentWatcher().Subscribe() + defer unsubscribe() + + pingTicker := time.NewTicker(25 * time.Second) + defer pingTicker.Stop() + + for { + select { + case <-ctx.Done(): + return + case ev, ok := <-events: + if !ok { + return + } + payload, _ := json.Marshal(ev) + if err := ws.Write(ctx, websocket.MessageText, payload); err != nil { + return + } + case <-pingTicker.C: + if err := ws.Ping(ctx); err != nil { + return + } + } + } +} + +// --- Static / PWA fallback --- + +func (s *Server) serveStatic(w http.ResponseWriter, r *http.Request) { + urlPath := strings.TrimPrefix(r.URL.Path, "/") + if urlPath == "" { + urlPath = "index.html" + } + f, err := s.Static.Open(urlPath) + if err != nil { + // SPA fallback: serve index.html for unknown paths. + fallback, err2 := s.Static.Open("index.html") + if err2 != nil { + http.NotFound(w, r) + return + } + defer fallback.Close() + w.Header().Set("Content-Type", "text/html; charset=utf-8") + _, _ = copyReadSeeker(w, fallback) + return + } + defer f.Close() + ext := strings.ToLower(filepath.Ext(urlPath)) + if t := mime.TypeByExtension(ext); t != "" { + w.Header().Set("Content-Type", t) + } + _, _ = copyReadSeeker(w, f) +} + +func copyReadSeeker(w http.ResponseWriter, f fs.File) (int64, error) { + if rs, ok := f.(interface { + Read(p []byte) (int, error) + }); ok { + buf := make([]byte, 32*1024) + var total int64 + for { + n, err := rs.Read(buf) + if n > 0 { + if _, werr := w.Write(buf[:n]); werr != nil { + return total, werr + } + total += int64(n) + } + if err != nil { + if err.Error() == "EOF" { + return total, nil + } + return total, err + } + } + } + return 0, nil +} diff --git a/apps/server/internal/vault/demo-tour.json b/apps/server/internal/vault/demo-tour.json new file mode 100644 index 00000000..bf645a07 --- /dev/null +++ b/apps/server/internal/vault/demo-tour.json @@ -0,0 +1 @@ +{"notes":[{"path":"inbox/demo/00 — Start Here.md","body":"# Start here — ZenNotes feature tour\n\nThis folder is a guided demo vault for ZenNotes as it exists today. It covers markdown rendering, keyboard-first workflows, search, views, settings, and the vault-level features that sit on top of plain files.\n\n## How to use this tour\n\n- Open notes in **Edit**, **Split**, and **Preview** to see where each feature is most useful.\n- Use `Space p` or the outline panel on longer notes.\n- Use `Space f` to search notes by title and path.\n- Use `Space s t` to fuzzy-search text across the vault.\n- Open **Help** from the footer or type `:help` from normal mode for the built-in manual.\n- Try `⌘.` to toggle **Zen mode** while reading any note here.\n\n## The tour\n\n1. [[01 — Markdown Basics]] — headings, emphasis, lists, blockquotes, frontmatter, and slash-command-friendly structure\n2. [[02 — Code Blocks]] — fenced code blocks, inline code, syntax highlighting, and code-writing workflows\n3. [[03 — Tables and Task Lists]] — tables, task metadata, and the vault-wide Tasks view\n4. [[04 — Math with KaTeX]] — inline math, block math, aligned equations, and formulas in preview\n5. [[05 — Mermaid Diagrams]] — flow, sequence, state, gantt, and graph diagrams rendered from markdown fences\n6. [[05b — Math Diagrams]] — TikZ, JSXGraph, and function-plot for paper-grade figures, interactive geometry, and quick plots\n7. [[06 — Callouts and Footnotes]] — callouts, footnotes, highlights, images, and local files\n8. [[07 — Wiki Links and Tags]] — wikilinks, tags, backlinks, connections, and search\n9. [[08 — Daily Notes]] — daily logs, quick capture, date shortcuts, and date-friendly note habits\n10. [[09 — Vim Cheat Sheet]] — the app-specific motions, leader flows, folds, and ex commands\n11. [[10 — Ideas and Tasks]] — a realistic note that composes multiple features at once\n12. [[11 — Workspace, Search, and Views]] — tabs, splits, outline, archive, trash, quick notes, and session restore\n13. [[12 — Settings and Keymaps]] — themes, fonts, leader hints, search backends, custom binary paths, and remappable shortcuts\n14. [[13 — Commands, Help, and Demo Tour]] — command palette discovery, ex commands, built-in Help, and starter-tour generation\n15. [[14 — Reference Pane and Floating Windows]] — pinned notes, research context, and detached note windows\n16. [[15 — Search Backends and Fuzzy Workflows]] — note search, vault text search, Auto resolution, fzf, ripgrep, and custom binary paths\n\n## What this demo folder covers\n\nZenNotes is more than a markdown renderer. Across this folder you can try:\n\n- plain file-based notes with no hidden database\n- live preview plus dedicated preview and split modes\n- heading folding and outline jumps\n- wikilinks, tags, backlinks, and unresolved-link discovery\n- quick capture via Quick Notes\n- Inbox, Archive, and Trash as separate lifecycle stages\n- vault-wide Tasks and Tags views\n- note search and vault text search\n- Mermaid, TikZ, JSXGraph, and function-plot diagram rendering\n- optional external search backends like `fzf` and `ripgrep`\n- slash commands and `@` date insertion\n- Vim mode, leader hints, ex commands, and pane motion\n- settings, keymap overrides, and appearance controls\n- command palette, built-in Help, and seeded onboarding content\n- reference-pane and floating-window workflows\n- session restore for panes, tabs, built-in views, and window bounds\n\n## The point\n\nEvery file here is ordinary markdown on disk. Open the folder in ZenNotes, `vim`, VS Code, or another markdown editor and the notes are still yours.\n\n#demo #reference #tour\n"},{"path":"inbox/demo/01 — Markdown Basics.md","body":"# Markdown basics\n\nZenNotes starts with ordinary markdown. The app adds keyboard-first workflows around it, but the source stays portable and readable everywhere.\n\n## Headings\n\n```\n# Heading 1\n## Heading 2\n### Heading 3\n#### Heading 4\n```\n\nHeadings matter for more than styling:\n\n- they show up in the **outline**\n- they can be folded with `zc` and unfolded with `zo`\n- long notes can be searched by heading with `Space p`\n\n## Emphasis\n\n*Italic* with single asterisks, **bold** with double, ***bold italic*** with triple, `inline code` with backticks, ~~strikethrough~~ with tildes, and ==highlight== with double equals.\n\n## Paragraphs and line breaks\n\nA blank line starts a new paragraph.\nA single newline usually stays in the same paragraph.\n\nLeave two trailing spaces when you really want a hard line break. \nLike this.\n\n## Lists\n\nUnordered:\n\n- Apples\n- Bananas\n - Cavendish\n - Plantain\n- Cherries\n\nOrdered:\n\n1. Draft the note\n2. Refine the structure\n3. Ship the change\n\n## Links\n\n- External: [ZenNotes](https://lumarylabs.com)\n- Autolink: \n- Wikilink: [[07 — Wiki Links and Tags]]\n- Custom label: [[11 — Workspace, Search, and Views|workspace guide]]\n\n## Blockquotes and dividers\n\n> Markdown still does a lot with very little.\n>\n> ZenNotes just makes it faster to navigate and work with.\n\n---\n\n## Frontmatter\n\nYAML frontmatter works fine at the top of a note:\n\n```yaml\n---\ntitle: My Note\ndate: 2026-04-16\ntags: [project, research]\npriority: high\n---\n```\n\nZenNotes does not require frontmatter, but features like daily notes, tags, and task defaults can make use of it.\n\n## Slash commands\n\nZenNotes also helps you write these structures faster:\n\n- type `/` at the start of a line or after whitespace\n- choose items like headings, bullets, numbered lists, tasks, callouts, code blocks, tables, math blocks, links, images, and dividers\n- keep typing after `/` to filter the insert menu\n\nThat means markdown stays plain, but you do not have to remember every snippet from scratch.\n\n## What to try in this note\n\n- Put the cursor on a heading and fold it.\n- Switch the note between **Edit**, **Split**, and **Preview**.\n- Open the outline with `Space p`.\n- Search for this note with `Space f`.\n\n## What's next\n\nJump to [[02 — Code Blocks]] for syntax highlighting, [[06 — Callouts and Footnotes]] for richer block styles, or back to [[00 — Start Here]].\n\n#demo #markdown\n"},{"path":"inbox/demo/02 — Code Blocks.md","body":"# Code blocks\n\nZenNotes treats code fences as plain markdown on disk and renders them with syntax highlighting in preview and split view.\n\n## A fast way to insert them\n\nType `/` and choose **Code block** if you do not want to type the fence manually.\n\n## TypeScript\n\n```ts\nexport interface User {\n id: string\n name: string\n roles: string[]\n}\n\nexport async function fetchUser(id: string): Promise {\n const response = await fetch(`/api/users/${id}`)\n if (!response.ok) return null\n return (await response.json()) as User\n}\n```\n\n## Python\n\n```python\nfrom dataclasses import dataclass\n\n@dataclass\nclass Point:\n x: float\n y: float\n\n def distance_to(self, other: \"Point\") -> float:\n return ((self.x - other.x) ** 2 + (self.y - other.y) ** 2) ** 0.5\n```\n\n## Bash\n\n```bash\n#!/usr/bin/env bash\nset -euo pipefail\n\nfor note in inbox/*.md; do\n words=$(wc -w < \"$note\")\n printf \"%6d %s\\n\" \"$words\" \"$(basename \"$note\")\"\ndone\n```\n\n## Rust\n\n```rust\nuse std::collections::HashMap;\n\nfn word_count(text: &str) -> HashMap {\n let mut counts = HashMap::new();\n for word in text.split_whitespace() {\n *counts.entry(word.to_lowercase()).or_insert(0) += 1;\n }\n counts\n}\n```\n\n## JSON\n\n```json\n{\n \"name\": \"ZenNotes\",\n \"productName\": \"ZenNotes\",\n \"version\": \"0.1.0\",\n \"scripts\": {\n \"dev\": \"electron-vite dev\",\n \"build\": \"electron-vite build\"\n }\n}\n```\n\n## Diff\n\n```diff\n- Space /\n+ Space s t\n```\n\n## Plain text\n\n```\nNo language tag, no syntax highlighting.\nUseful for raw config examples or ASCII notes.\n```\n\n## Inline code\n\nUse `inline code` when the snippet belongs inside a sentence.\n\n## Workflow notes\n\n- **Edit** mode is best for writing or refactoring the raw fence.\n- **Split** mode is ideal when you want source on one side and highlighted output on the other.\n- Fenced blocks are ignored by the task scanner, so `- [ ]` inside code stays an example, not a live task.\n- Vault text search can still find matching text inside code fences because they are part of the note body.\n\n## What's next\n\nSee [[05 — Mermaid Diagrams]] for Mermaid fences, [[05b — Math Diagrams]] for TikZ, JSXGraph, and function-plot, or [[10 — Ideas and Tasks]] for how snippets mix with prose and planning in a real note.\n\n#demo #code\n"},{"path":"inbox/demo/03 — Tables and Task Lists.md","body":"# Tables and task lists\n\n## Tables\n\nPlain GFM tables. Alignment is controlled with colons in the divider row.\n\n| Feature | Support | Notes |\n| ---------- | :--------: | --------------------------------------------------------- |\n| Headings | ✅ | Fold from the editor gutter and jump via the outline. |\n| Wiki links | ✅ | `[[Title]]` resolves by note name. |\n| Tags | ✅ | Written inline as `#like-this`. |\n| Math | ✅ | KaTeX, inline and display. |\n| Mermaid | ✅ | Rendered inside preview and split view. |\n| Search | ✅ | Notes by title/path, vault text by fuzzy content search. |\n| Sync | File-based | Use any sync tool that watches folders. |\n\nRight-aligned numbers:\n\n| Quarter | Revenue | Delta |\n| ------: | -------: | -----: |\n| Q1 | $124,300 | +4.2% |\n| Q2 | $131,980 | +6.2% |\n| Q3 | $129,010 | −2.3% |\n| Q4 | $152,407 | +18.1% |\n\n## Task lists\n\nEvery checkbox survives on disk as normal markdown like `- [ ]` and `- [x]`.\n\n## What ZenNotes task parsing supports\n\n### Core checkboxes\n\n- [ ] Open task\n- [x] Completed task\n- [X] Uppercase `X` also counts as completed\n\n### Different list styles still count\n\n- [ ] Bulleted task using `-`\n+ [ ] Bulleted task using `+`\n* [ ] Bulleted task using `*`\n1. [ ] Ordered task using `1.`\n2) [ ] Ordered task using `2)`\n> - [ ] Blockquoted task lines are parsed too\n\n### Nested tasks\n\n- [ ] Weekly review\n - [ ] Clear inbox to zero\n - [ ] Triage [[10 — Ideas and Tasks]]\n - [x] Back up vault\n - [ ] Plan next week\n - [ ] Monday — design review\n - [ ] Tuesday — code-freeze prep\n - [x] Saturday — offline\n\n### Metadata tokens on the task line\n\n- [ ] Ship the onboarding checklist due:2026-04-18 !high #onboarding #docs\n- [ ] Refresh demo screenshots due:2026-04-22 !med #demo #assets\n- [ ] Clean up seed notes !low #maintenance\n- [ ] Wait for design sign-off @waiting #design\n- [ ] Review vault search UX due:2026-04-30 !high #search #ux\n\nThe parser understands these tokens:\n\n| Token | Meaning | Example |\n| ----- | ------- | ------- |\n| `due:YYYY-MM-DD` | ISO due date used for grouping | `due:2026-04-22` |\n| `!high` / `!med` / `!low` | Priority marker | `!high` |\n| `@waiting` | Moves the task into the Waiting group | `@waiting` |\n| `#tag` | Inline task tag, searchable in the Tasks view | `#design` |\n\n### What the Tasks view does with them\n\n- Tasks with no due date land in **Today**\n- Tasks due today or already overdue also land in **Today**\n- Tasks due in the future land in **Upcoming**\n- Tasks with `@waiting` land in **Waiting**\n- Checked tasks land in **Done**\n- Overdue tasks contribute to the overdue count in the **Today** section\n\n### Filtering and navigation\n\nPress the sidebar **Tasks** row to scan every live note across **Inbox**, **Quick Notes**, and **Archive**. From there you can:\n\n- filter by task content\n- filter by note title\n- filter by inline `#tags`\n- filter by priority markers like `!high`\n- press `Enter` or `o` to open the source note\n- press `Space` or `x` to toggle the selected task without leaving the list\n\n### Ignored on purpose\n\nTasks inside fenced code blocks are not parsed, so you can document task syntax safely:\n\n```md\n- [ ] This looks like a task\n- [x] But code fences are ignored by the vault-wide task scanner\n- [ ] That makes examples and snippets safe\n```\n\n### Note-level defaults\n\nYou can also set due date and priority defaults in frontmatter, then override them inline per task:\n\n```yaml\n---\ndue: 2026-05-01\npriority: high\n---\n```\n\nWith defaults like that, a plain line such as `- [ ] Draft roadmap` inherits the due date and priority even without repeating the tokens.\n\n### Rendering checklist\n\nEvery item below is wired up:\n\n- [x] Paragraphs\n- [x] Emphasis: _italic_, **bold**, ~~strike~~\n- [x] Ordered and unordered lists\n- [x] Tables\n- [x] Task lists\n- [x] Blockquotes\n- [x] Footnotes (see [[06 — Callouts and Footnotes]])\n- [x] Math blocks (see [[04 — Math with KaTeX]])\n- [x] Mermaid (see [[05 — Mermaid Diagrams]])\n- [x] TikZ, JSXGraph, and function-plot (see [[05b — Math Diagrams]])\n- [x] Vault-wide Tasks grouping and filtering\n- [ ] Screenshots in the tour due:2026-04-25 !med #docs\n\n## Tasks as an app feature\n\nThe Tasks tab is not just a renderer demo. It is a vault-wide operational view for planning and review. Use it when you want one place to see what is due, what is waiting, what is done, and where each task lives.\n\n#demo #tasks #tables\n"},{"path":"inbox/demo/04 — Math with KaTeX.md","body":"# Math with KaTeX\n\nZenNotes renders LaTeX math via KaTeX. The source stays plain markdown while preview and split mode give you readable math output.\n\n## A fast way to insert math\n\nType `/` and choose **Math block** when you want display math without typing the fence from memory.\n\n## Inline math\n\nEuler's identity is $e^{i\\pi} + 1 = 0$. \nThe area of a circle is $A = \\pi r^2$. \nA quadratic has roots $x = \\dfrac{-b \\pm \\sqrt{b^2 - 4ac}}{2a}$.\n\n## Display blocks\n\n$$\n\\int_{-\\infty}^{\\infty} e^{-x^2}\\, dx = \\sqrt{\\pi}\n$$\n\n$$\n\\frac{\\partial}{\\partial t} \\Psi(x, t) = -\\frac{\\hbar^2}{2m} \\frac{\\partial^2}{\\partial x^2} \\Psi(x, t) + V(x)\\Psi(x, t)\n$$\n\n## Aligned equations\n\n$$\n\\begin{aligned}\n(a + b)^2 &= a^2 + 2ab + b^2 \\\\\n(a - b)^2 &= a^2 - 2ab + b^2 \\\\\na^2 - b^2 &= (a + b)(a - b)\n\\end{aligned}\n$$\n\n## Matrices\n\n$$\n\\mathbf{A} =\n\\begin{bmatrix}\n 1 & 2 & 3 \\\\\n 4 & 5 & 6 \\\\\n 7 & 8 & 9\n\\end{bmatrix}\n\\qquad\n\\det(\\mathbf{A}) = 0\n$$\n\n## Summations, limits, derivatives\n\n$$\n\\sum_{n=1}^{\\infty} \\frac{1}{n^2} = \\frac{\\pi^2}{6}\n\\qquad\n\\lim_{x \\to 0} \\frac{\\sin x}{x} = 1\n\\qquad\n\\frac{d}{dx} \\ln x = \\frac{1}{x}\n$$\n\n## Probability and finance\n\n$$\nP(A \\mid B) = \\frac{P(B \\mid A) P(A)}{P(B)}\n$$\n\n$$\nC = S_0 \\Phi(d_1) - K e^{-rT} \\Phi(d_2)\n$$\n\n$$\nd_1 = \\frac{\\ln(S_0 / K) + (r + \\tfrac{1}{2}\\sigma^2) T}{\\sigma \\sqrt{T}}, \\qquad d_2 = d_1 - \\sigma \\sqrt{T}\n$$\n\n## Why this matters in ZenNotes\n\n- **Edit** mode keeps the raw LaTeX visible.\n- **Split** mode is great when you want source and rendered math side by side.\n- **Preview** mode turns math-heavy notes into something closer to a paper or spec.\n- Vault text search still sees the underlying source, which makes formulas searchable as text.\n\n## What's next\n\nWhen the note needs geometry, plotted functions, or figure-quality diagrams rather than equation layout, jump to [[05b — Math Diagrams]].\n\n#demo #math #reference\n"},{"path":"inbox/demo/05 — Mermaid Diagrams.md","body":"# Mermaid diagrams\n\nMermaid fences render inline in ZenNotes. They are still just markdown code blocks on disk, so you can version them, diff them, and edit them anywhere.\n\nFor TikZ, JSXGraph, and function-plot, see [[05b — Math Diagrams]].\n\n## A fast way to insert one\n\nType `/` and choose **Code block**, then change the language to `mermaid`.\n\n## Flowchart\n\n```mermaid\nflowchart LR\n A([User types]) --> B{Vim mode?}\n B -- yes --> C[CodeMirror vim keymap]\n B -- no --> D[Standard editing]\n C --> E[Save to .md]\n D --> E\n E --> F([File on disk])\n```\n\n## Sequence diagram\n\n```mermaid\nsequenceDiagram\n autonumber\n actor U as User\n participant R as Renderer\n participant M as Main process\n participant D as Disk\n\n U->>R: Type in editor\n R->>M: writeNote(path, body)\n M->>D: fs.writeFile(...)\n D-->>M: ok\n M-->>R: NoteMeta\n R-->>U: Clean tab title\n```\n\n## State diagram\n\n```mermaid\nstateDiagram-v2\n [*] --> Draft\n Draft --> Review : Submit\n Review --> Draft : Request changes\n Review --> Approved : Accept\n Approved --> Published : Ship\n Published --> Archived : 90 days\n Archived --> [*]\n```\n\n## Gantt chart\n\n```mermaid\ngantt\n title Product roadmap\n dateFormat YYYY-MM-DD\n axisFormat %b %d\n\n section Editor\n Vim motions polish :done, vim1, 2026-03-10, 5d\n Outline panel :done, out1, 2026-03-17, 3d\n Attachments preview :active, att1, 2026-04-15, 7d\n Multi-window sync : mws1, after att1, 5d\n\n section Release\n QA pass : qa1, after mws1, 3d\n Ship :milestone, rel1, after qa1, 0d\n```\n\n## Pie chart\n\n```mermaid\npie title How the day was spent\n \"Deep work\" : 45\n \"Meetings\" : 15\n \"Slack\" : 10\n \"Reading\" : 20\n \"Breaks\" : 10\n```\n\n## Vault map\n\n```mermaid\ngraph TB\n subgraph Lifecycle\n Q[Quick Notes]\n I[Inbox]\n A[Archive]\n T[Trash]\n end\n Q --> I\n I --> A\n I --> T\n A --> I\n T --> I\n```\n\n## Working with diagrams in the app\n\n- **Split** mode is usually the sweet spot: raw source on one side, rendered diagram on the other.\n- Diagrams are still searchable because the source fence lives in the note body.\n- If Mermaid syntax breaks, ZenNotes falls back to showing the source block, which makes failures debuggable instead of mysterious.\n\n## What's next\n\nStay in diagram mode with [[05b — Math Diagrams]] if you want interactive geometry, coordinate figures, or compact function plots.\n\n#demo #mermaid #diagrams\n"},{"path":"inbox/demo/05b — Math Diagrams.md","body":"# Math diagrams — TikZ, JSXGraph, and function-plot\n\nBeyond Mermaid (see [[05 — Mermaid Diagrams]]) and KaTeX (see [[04 — Math with KaTeX]]), ZenNotes renders three more diagram types from plain fenced code blocks. Each one shines at a different job.\n\nSwitch to **Preview** or **Split** mode to see them rendered. The source stays plain markdown on disk.\n\n---\n\n## TikZ — figure-quality math diagrams\n\nUse when you want paper-grade vector figures: coordinate systems, geometry, commutative diagrams, automata, trees, plots. The full TikZ + pgfplots toolchain compiles on-device via WebAssembly — no network, no LaTeX install.\n\n### A parabola with axes\n\n```tikz\n\\begin{tikzpicture}\n \\draw[->, thick] (-2.2,0) -- (2.2,0) node[right] {$x$};\n \\draw[->, thick] (0,-0.5) -- (0,4.5) node[above] {$y$};\n \\draw[domain=-2:2, smooth, thick, blue] plot (\\x,{\\x*\\x});\n \\node[blue, above right] at (1.4, 1.96) {$y = x^2$};\n\\end{tikzpicture}\n```\n\n### A triangle with labelled vertices\n\n```tikz\n\\begin{tikzpicture}\n \\coordinate[label=below left:$A$] (A) at (0,0);\n \\coordinate[label=below right:$B$] (B) at (4,0);\n \\coordinate[label=above:$C$] (C) at (1.5,3);\n \\draw[thick] (A) -- (B) -- (C) -- cycle;\n \\draw[dashed] (C) -- ($ (A)!(C)!(B) $) node[pos=0.5, right] {$h$};\n\\end{tikzpicture}\n```\n\n### A small commutative diagram\n\n```tikz\n\\begin{tikzpicture}[node distance=2.2cm, every node/.style={font=\\small}]\n \\node (A) {$A$};\n \\node (B) [right of=A] {$B$};\n \\node (C) [below of=A] {$C$};\n \\node (D) [right of=C] {$D$};\n \\draw[->] (A) -- node[above] {$f$} (B);\n \\draw[->] (A) -- node[left] {$g$} (C);\n \\draw[->] (B) -- node[right] {$h$} (D);\n \\draw[->] (C) -- node[below] {$k$} (D);\n\\end{tikzpicture}\n```\n\n---\n\n## JSXGraph — interactive geometry and plots\n\nUse when you want the diagram to be **draggable** and **live**. Points move, sliders animate, curves reflow. Configuration is a small JSON object — no JavaScript required.\n\nEach object takes a `type` (the JSXGraph element name) and `args` (the element's constructor arguments). Assign an `id` to reference an object from a later one using `\"@id\"` — useful for attaching points to curves, for example.\n\n### Sine wave with a point on the curve\n\nJSXGraph's `functiongraph` evaluates string expressions with its built-in **JessieCode** parser — so write `sin(x)`, `cos(x)`, `x^2`, `exp(x)`, etc. directly (no `Math.` prefix).\n\n```jsxgraph\n{\n \"boundingbox\": [-6.5, 1.6, 6.5, -1.6],\n \"axis\": true,\n \"objects\": [\n {\n \"id\": \"curve\",\n \"type\": \"functiongraph\",\n \"args\": [\"sin(x)\"],\n \"attributes\": { \"strokeColor\": \"#6caedf\", \"strokeWidth\": 2 }\n },\n {\n \"type\": \"glider\",\n \"args\": [1, 0, \"@curve\"],\n \"attributes\": {\n \"name\": \"P\",\n \"size\": 4,\n \"strokeColor\": \"#d35e0c\",\n \"fillColor\": \"#d35e0c\"\n }\n }\n ]\n}\n```\n\nDrag `P` along the curve.\n\n### Unit circle with a labelled point\n\n```jsxgraph\n{\n \"boundingbox\": [-1.6, 1.6, 1.6, -1.6],\n \"axis\": true,\n \"width\": 360,\n \"height\": 360,\n \"objects\": [\n {\n \"type\": \"circle\",\n \"args\": [[0, 0], 1],\n \"attributes\": { \"strokeColor\": \"#945e80\" }\n },\n {\n \"type\": \"point\",\n \"args\": [0.7, 0.7141],\n \"attributes\": {\n \"name\": \"Q\",\n \"fillColor\": \"#6c782e\",\n \"strokeColor\": \"#6c782e\"\n }\n }\n ]\n}\n```\n\n### Two lines and their intersection\n\n```jsxgraph\n{\n \"boundingbox\": [-5, 5, 5, -5],\n \"axis\": true,\n \"objects\": [\n { \"id\": \"A\", \"type\": \"point\", \"args\": [-3, -2], \"attributes\": { \"name\": \"A\" } },\n { \"id\": \"B\", \"type\": \"point\", \"args\": [ 3, 2], \"attributes\": { \"name\": \"B\" } },\n { \"id\": \"C\", \"type\": \"point\", \"args\": [-3, 2], \"attributes\": { \"name\": \"C\" } },\n { \"id\": \"D\", \"type\": \"point\", \"args\": [ 3, -2], \"attributes\": { \"name\": \"D\" } },\n {\n \"id\": \"L1\",\n \"type\": \"line\",\n \"args\": [\"@A\", \"@B\"],\n \"attributes\": { \"strokeColor\": \"#45707a\" }\n },\n {\n \"id\": \"L2\",\n \"type\": \"line\",\n \"args\": [\"@C\", \"@D\"],\n \"attributes\": { \"strokeColor\": \"#c14a4a\" }\n },\n {\n \"type\": \"intersection\",\n \"args\": [\"@L1\", \"@L2\", 0],\n \"attributes\": { \"name\": \"X\", \"size\": 4, \"fillColor\": \"#b47109\" }\n }\n ]\n}\n```\n\nDrag any of `A`–`D` and the intersection follows.\n\n---\n\n## function-plot — quick Cartesian plots\n\nSmallest and simplest of the three. Give it functions, get a plot. Great for calculus-style notes and quick sanity checks.\n\nThe fence body is the options object passed to [function-plot](https://mauriciopoppe.github.io/function-plot/). Expression syntax is standard JavaScript math — `Math.PI`, `Math.sin(x)`, etc. — plus the `x^2` shorthand for powers.\n\n### Several functions on one axis\n\n```function-plot\n{\n \"yAxis\": { \"domain\": [-1.5, 1.5] },\n \"xAxis\": { \"domain\": [-6.28, 6.28] },\n \"grid\": true,\n \"data\": [\n { \"fn\": \"sin(x)\", \"color\": \"#45707a\" },\n { \"fn\": \"cos(x)\", \"color\": \"#c14a4a\" },\n { \"fn\": \"x / 3.14159265\", \"color\": \"#6c782e\" }\n ]\n}\n```\n\n### A derivative annotation\n\nHover the curve — the tangent slope updates live.\n\n```function-plot\n{\n \"yAxis\": { \"domain\": [-2, 8] },\n \"xAxis\": { \"domain\": [-3, 3] },\n \"grid\": true,\n \"data\": [\n {\n \"fn\": \"x^2\",\n \"derivative\": { \"fn\": \"2 * x\", \"updateOnMouseMove\": true },\n \"color\": \"#945e80\"\n }\n ]\n}\n```\n\n### A parametric curve\n\n```function-plot\n{\n \"xAxis\": { \"domain\": [-1.5, 1.5] },\n \"yAxis\": { \"domain\": [-1.5, 1.5] },\n \"grid\": true,\n \"data\": [\n {\n \"graphType\": \"polyline\",\n \"fnType\": \"parametric\",\n \"x\": \"cos(t)\",\n \"y\": \"sin(t)\",\n \"range\": [0, 6.283],\n \"color\": \"#b47109\"\n }\n ]\n}\n```\n\n---\n\n## When to reach for which\n\n| You want… | Use |\n| ---------------------------------------------------------------- | ------------------------------------------- |\n| Paper-grade static figure, TikZ muscle-memory, LaTeX portability | **TikZ** |\n| Interactive geometry, draggable points, geometry theorems | **JSXGraph** |\n| Quick plot of a few functions, minimal config | **function-plot** |\n| Flow / sequence / state / gantt / ER diagram | **Mermaid** (see [[05 — Mermaid Diagrams]]) |\n| Inline formulas, display equations | **KaTeX** (see [[04 — Math with KaTeX]]) |\n\n#demo #math #diagrams #tikz #jsxgraph #function-plot\n"},{"path":"inbox/demo/06 — Callouts and Footnotes.md","body":"# Callouts, footnotes, files, and embeds\n\nThis note covers the rich block-level extras that still live comfortably inside markdown files.\n\n## Callouts\n\nCallouts are blockquotes that start with `> [!type]`.\n\n> [!note]\n> Use note callouts for extra context that should stand out without becoming a new section.\n\n> [!tip] Keyboard tip\n> Press `Space o` to open the buffer switcher when tabs are hidden or you want to jump fast between open buffers.\n\n> [!warning]\n> Moving a note to Trash asks for confirmation, but permanently deleting from Trash is still destructive.\n\n> [!info] Multi-line\n> Callouts can contain:\n> - lists\n> - `inline code`\n> - [[07 — Wiki Links and Tags|wikilinks]]\n> - and multiple paragraphs\n\n> [!quote] Portable by design\n> ZenNotes adds workflow around markdown, not lock-in around data.\n\n## Footnotes\n\nFootnotes link both ways and stay readable in the raw file.[^workflow]\n\nFootnotes are useful for side comments that should not interrupt the main flow.[^tip]\n\n[^workflow]: Footnote references use `[^label]` inline and `[^label]: text` at the bottom of the note.\n[^tip]: They work well in long writing, specs, and research notes where parenthetical digressions get noisy.\n\n## Strikethrough and highlights\n\n~~Legacy wording~~ can stay visible for history, while ==highlights== are good for passages you want to notice quickly during review.\n\n## Images and local files\n\nFiles stay local to the vault. Dropping a file into the editor inserts a normal markdown reference to the file, and by default ZenNotes places it in the vault root.\n\nExample image:\n\n![ZenNotes demo card](<../../zennotes-demo-card.svg>)\n\nThat relative path is the recommended form because it keeps the note portable inside the vault:\n\n```md\n![ZenNotes demo card](<../../zennotes-demo-card.svg>)\n```\n\n## File workflows\n\n- Use the footer **Files** action to browse files anywhere in the vault.\n- Image embeds render inline in preview and split mode.\n- PDFs can be opened in the pinned reference pane so you can read beside your notes.\n- Because these are just files, reveal them in Finder and manage them with normal tools if you want.\n\nFor the larger reading workflow around pinned notes, PDFs, and detached note windows, see [[14 — Reference Pane and Floating Windows]].\n\n## Why this matters\n\nZenNotes is strongest when prose, references, and files live together:\n\n- callouts for guidance or warnings\n- footnotes for side context\n- images for screenshots and visual notes\n- PDFs in the reference pane for side-by-side reading\n\n#demo #reference #attachments\n"},{"path":"inbox/demo/07 — Wiki Links and Tags.md","body":"# Wiki links, tags, backlinks, and search\n\nThese features turn a folder of markdown files into a navigable vault.\n\n## Wiki links\n\nPoint at other notes with `[[double brackets]]`. ZenNotes resolves them by note title, case-insensitively.\n\n- Shortest form: [[01 — Markdown Basics]]\n- Custom display text: [[11 — Workspace, Search, and Views|workspace guide]]\n- Missing note: [[A Future Note]] — opening it offers to create the note\n\nYou can follow links with the mouse or keyboard:\n\n- in Vim mode, put the cursor on a link and press `gd`\n- markdown links and wikilinks both work\n- PDFs can open directly into the reference pane\n\n## Tags\n\nTags are plain inline text. They start with `#` and become searchable structure.\n\nThis demo folder uses tags like:\n\n- #demo\n- #reference\n- #tasks\n- #vim\n- #search\n- #workspace\n\nThe **Tags** view lets you browse notes matching one or more selected tags in a dedicated main-pane list.\n\n## Connections\n\nThe **Connections** panel helps you inspect:\n\n- outbound links from the current note\n- backlinks into the current note\n- unresolved link targets that still need a note\n\nThis is especially useful when you are writing specs, research notes, or project docs and want context without leaving the active note.\n\n## Search modes\n\nZenNotes has two distinct searches:\n\n### Note search\n\n- `⌘P` opens the note search palette\n- `Space f` opens the same search in Vim mode\n- this search matches note titles and paths\n\n### Vault text search\n\n- `Space s t` opens vault text search\n- it searches matching text lines across **Inbox**, **Quick Notes**, and **Archive**\n- selecting a result opens the note and jumps to the matched line\n\nVault text search can run on different backends:\n\n- **Auto** prefers `fzf`, then `ripgrep`, then built-in\n- **Built-in** keeps everything inside ZenNotes\n- **ripgrep** and **fzf** can be chosen explicitly\n- custom binary paths can be configured in **Settings**\n- the app shows the resolved runtime backend so you can see what is actually being used\n\n## Graph of this tour\n\n```mermaid\ngraph LR\n A[[00 — Start Here]]\n A --> B[[01 — Markdown Basics]]\n A --> C[[02 — Code Blocks]]\n A --> D[[03 — Tables and Task Lists]]\n A --> E[[04 — Math with KaTeX]]\n A --> F[[05 — Mermaid Diagrams]]\n A --> G[[05b — Math Diagrams]]\n A --> H[[06 — Callouts and Footnotes]]\n A --> I[[07 — Wiki Links and Tags]]\n A --> J[[08 — Daily Notes]]\n A --> K[[09 — Vim Cheat Sheet]]\n A --> L[[10 — Ideas and Tasks]]\n A --> M[[11 — Workspace, Search, and Views]]\n A --> N[[12 — Settings and Keymaps]]\n A --> O[[13 — Commands, Help, and Demo Tour]]\n A --> P[[14 — Reference Pane and Floating Windows]]\n A --> Q[[15 — Search Backends and Fuzzy Workflows]]\n```\n\n#demo #reference #search #links\n"},{"path":"inbox/demo/08 — Daily Notes.md","body":"---\ntitle: 2026-04-16\ndate: 2026-04-16\ntags: [daily, log, demo]\n---\n\n# Thursday, 2026-04-16\n\n> [!tip] Pattern\n> A daily note is still just a `.md` file. Keep it under `inbox/daily/`, `quick/`, or wherever your vault makes sense. If you name it `YYYY-MM-DD.md`, it sorts chronologically without extra tooling.\n\n## Why daily notes fit ZenNotes well\n\n- they stay file-based and sync-friendly\n- they pair naturally with quick capture\n- they work well with tasks, tags, and links\n- reopening the app restores your tabs, panes, and window bounds, so an active daily workflow is easy to resume\n\n## Agenda\n\n- [ ] Morning: triage [[10 — Ideas and Tasks]]\n- [ ] 10:00 — design review\n- [ ] 12:00 — lunch\n- [x] 14:00 — code-freeze prep\n- [ ] Evening: reading — Seeing Like a State, chapter 3\n\n## Quick capture and dates\n\nQuick Notes are for fast capture. From there you can:\n\n- keep the note in Quick Notes\n- move it into Inbox\n- archive it later\n- trash it with confirmation if it is no longer useful\n\nDate helpers are also built in:\n\n- type `@` to insert **Today**, **Yesterday**, or **Tomorrow**\n- the inserted value is an ISO date like `2026-04-16`\n- ISO dates stay readable, sortable, and easy to search\n\nExamples:\n\n- Review due @today\n- Follow up on search backend docs @tomorrow\n- Closed the previous thread @yesterday\n\n## Log\n\n- Shipped the vault text search backend picker.\n- Updated the demo vault so it covers the current product surface.\n- Verified that session restore brings back the working layout after relaunch.\n\n## Wins\n\n- The same note works in edit, split, or preview mode.\n- Tasks here show up in the vault-wide Tasks view.\n- Links here also show up in Connections.\n\n## Follow-ups\n\n- [ ] Add a sample PDF so the reference-pane flow is demonstrated with a real file.\n- [ ] Add more screenshots for the search palette.\n- [ ] Refine the help text for view-specific ex prompts.\n\n## Notes for tomorrow\n\n- [ ] Carry over open tasks from [[03 — Tables and Task Lists]]\n- [ ] Review [[12 — Settings and Keymaps]] for any missing personalization features\n\n#daily #log #demo\n"},{"path":"inbox/demo/09 — Vim Cheat Sheet.md","body":"# Vim cheat sheet for ZenNotes\n\nZenNotes ships with Vim mode on by default. The editor uses CodeMirror Vim bindings, and the app adds its own keyboard-first flows around panes, panels, search, and built-in views.\n\n## Global shortcuts\n\n| Keys | Action |\n| --- | --- |\n| `⌘P` | Search notes |\n| `⇧⌘P` | Open command palette |\n| `⇧⌘N` | New Quick Note |\n| `⌘,` | Open Settings |\n| `⌘1` | Toggle sidebar |\n| `⌘2` | Toggle connections |\n| `⌘3` | Toggle outline panel |\n| `⌘.` | Toggle Zen mode |\n| `⌘W` | Close active tab or built-in view |\n| `⌥Z` | Toggle word wrap |\n\nIf you explicitly turn Vim mode off, `⌘F` or `Ctrl+F` becomes an extra direct note-search shortcut.\n\n## Pane and panel motion\n\n| Keys | Action |\n| --- | --- |\n| `Ctrl-w h` / `j` / `k` / `l` | Move focus between sidebar, note list, editor panes, outline, and connections |\n| `Ctrl-w v` | Split right |\n| `Ctrl-w s` | Split down |\n| `Ctrl-o` | Jump back in note history |\n| `Ctrl-i` | Jump forward in note history |\n\n## Leader (`Space`) shortcuts\n\n| Keys | Action |\n| --- | --- |\n| `Space o` | Open buffers |\n| `Space f` | Search notes |\n| `Space s t` | Search vault text |\n| `Space e` | Toggle sidebar |\n| `Space p` | Open note outline |\n| `Space l f` | Format the active note |\n| `Space`, then pause | Show leader hints when enabled |\n\nLeader hints can be **timed** or **sticky** in Settings. Sticky mode stays open until you press `Space` again or `Esc`.\n\n## Folding\n\n| Keys | Action |\n| --- | --- |\n| `zc` | Fold the heading at the cursor |\n| `zo` | Unfold the heading at the cursor |\n| `zM` | Fold all headings |\n| `zR` | Unfold all headings |\n\n## Links and hint mode\n\n| Keys | Action |\n| --- | --- |\n| `gd` | Follow wikilink, markdown link, or open/create note under cursor |\n| `f` | Hint mode for clickable targets when not in insert mode |\n\n## Sidebar, list, and built-in views\n\nWhen focus is in the sidebar, note list, Tasks, Tags, Archive, Trash, or Quick Notes tab:\n\n| Keys | Action |\n| --- | --- |\n| `j` / `k` | Move selection |\n| `gg` / `G` | Jump to top / bottom |\n| `Enter` / `l` | Open selected item |\n| `h` | Collapse or move back |\n| `o` | Toggle selected folder |\n| `/` | Filter the current list or view |\n| `m` | Open the context menu for the selected row |\n| `Esc` | Return toward the editor |\n\nView-specific extras:\n\n| Keys | Action |\n| --- | --- |\n| `Space` / `x` | Toggle selected task in **Tasks** |\n| `r` | Restore selected note in **Trash** |\n| `x` / `d` | Permanently delete selected note in **Trash** |\n| `:` | Open the local ex prompt in **Tasks** or **Tags** |\n\n## Preview and connections\n\nWhen focus is in rendered preview or the connections panel:\n\n| Keys | Action |\n| --- | --- |\n| `j` / `k` | Scroll line by line |\n| `Ctrl-d` / `Ctrl-u` | Half-page down / up |\n| `gg` / `G` | Jump to top / bottom |\n| `p` | Peek the selected backlink in Connections |\n| `h` / `Esc` | Back out toward the editor |\n\n## Ex commands\n\nType `:` in normal mode:\n\n| Command | Action |\n| --- | --- |\n| `:w` | Save the active note |\n| `:q` | Close the current tab or built-in view |\n| `:wq` | Save and close |\n| `:help` | Open the built-in manual |\n| `:tasks` | Open Tasks |\n| `:tag foo bar` | Open Tags filtered to `foo` and `bar` |\n| `:trash` | Open Trash |\n| `:e path` / `:edit path` | Open or create a note by vault-relative path |\n| `:new [path]` | Create a new note |\n| `:split` / `:vsplit` | Split the current tab down or right |\n| `:bn` / `:bp` | Next / previous tab |\n| `:buffers` / `:ls` | Open the buffer switcher |\n| `:bd` / `:bc` | Close the active tab |\n| `:view edit|split|preview` | Switch the current pane mode |\n| `:editmode` / `:splitmode` / `:previewmode` | Direct aliases for note mode changes |\n| `:zen` / `:zen on` / `:zen off` | Toggle or force Zen mode |\n| `:format` | Format the active note |\n| `:fold` / `:unfold` | Fold or unfold the current heading |\n| `:foldall` / `:unfoldall` | Fold or unfold every heading |\n| `:cmd query` / `:commands` | Run or browse command palette entries |\n| `Tab` on the ex line | Complete commands and supported arguments |\n\n## One more important note\n\nEvery shortcut above can now be remapped in [[12 — Settings and Keymaps]]. Vim mode is the default, but the app no longer hardcodes every sequence forever.\n\n#demo #vim #reference\n"},{"path":"inbox/demo/10 — Ideas and Tasks.md","body":"# Ideas and tasks — a realistic note\n\nThis is the kind of note most real users end up writing: prose, todos, links, snippets, diagrams, and operational context all mixed together. It shows how ZenNotes features compose instead of living in isolated demos.\n\n> [!note]\n> Status as of 2026-04-16. Use this note to test search, outline, connections, Tasks, and split view in one place.\n\n## Open questions\n\n- [ ] Should attachment previews appear inline for PDFs by default?\n- [ ] Is the built-in text-search backend fast enough on large vaults when neither `fzf` nor `ripgrep` is available?\n- [ ] Do we expose tag renaming from the UI, or keep it intentionally file-grep first?\n\n## Working notes\n\n- Quick capture starts in **Quick Notes**, but anything important should graduate into **Inbox**.\n- Cold notes belong in **Archive**, which now opens as a dedicated main-pane list view.\n- Deleted notes should go through **Trash**, where restore and permanent delete are separated on purpose.\n- If tabs are hidden, `Space o` or `:buffers` becomes the fastest way to recover the current working set.\n\n## Now\n\n- [ ] Add a sample PDF + image to the tour so [[06 — Callouts and Footnotes]] can illustrate attachments and reference-pane workflows.\n- [x] Document the Tasks tab behavior in [[03 — Tables and Task Lists]].\n- [ ] Collect feedback on [[09 — Vim Cheat Sheet]] now that keymaps are configurable.\n- [ ] Confirm the search backend badge is visible enough in the vault text search palette.\n\n## Shipped\n\n- [x] Vault text search can use **Auto**, **Built-in**, **ripgrep**, or **fzf**.\n- [x] Custom binary paths can be configured when `rg` or `fzf` live outside `PATH`.\n- [x] Settings now show the resolved runtime backend instead of only the requested one.\n- [x] Archive and Trash both behave as list-style built-in tabs instead of sidebar dump zones.\n\n## Cross-references\n\n- Tour index: [[00 — Start Here]]\n- Search and links: [[07 — Wiki Links and Tags]]\n- Workspace guide: [[11 — Workspace, Search, and Views]]\n- Settings and keymaps: [[12 — Settings and Keymaps]]\n\n## A snippet I keep forgetting\n\nConverting a buffer to hex in Node:\n\n```ts\nimport { randomBytes } from 'node:crypto'\n\nconst buf = randomBytes(16)\nconsole.log(buf.toString('hex'))\n```\n\nConverting back:\n\n```ts\nconst hex = '01020304abcdef'\nconst buf = Buffer.from(hex, 'hex')\n```\n\n## Rough architecture sketch\n\n```mermaid\nflowchart TB\n subgraph Main\n V[Vault I/O]\n W[Watcher]\n T[Task scanner]\n S[Vault text search]\n end\n subgraph Renderer\n E[Editor]\n SB[Sidebar]\n P[Preview]\n O[Outline]\n C[Connections]\n end\n E <-->|IPC| V\n SB -->|IPC| V\n P -->|IPC| V\n O --> E\n C --> E\n V --> T\n V --> S\n W -->|events| V\n```\n\n## A little math\n\nThe rough cost model people keep re-deriving:\n\n$$\nT \\approx 3 \\cdot t \\cdot \\frac{m}{\\text{bandwidth}}\n$$\n\n## Workflow checklist\n\n- [ ] Try this note in **Edit**, **Split**, and **Preview**\n- [ ] Open the **outline** and jump to \"Workflow checklist\"\n- [ ] Open **Connections** and inspect backlinks\n- [ ] Search for `backend` with `Space s t`\n- [ ] Toggle **Zen mode**\n\n#demo #tasks #planning #workspace\n"},{"path":"inbox/demo/11 — Workspace, Search, and Views.md","body":"# Workspace, search, and views\n\nThis note covers the part of ZenNotes that is not just markdown rendering: how the workspace behaves while you are moving around a vault.\n\n## The three working zones\n\nZenNotes is organized around three persistent areas:\n\n1. **Sidebar** for folders, built-in rows, tags, and utility entry points\n2. **Note list** for the current folder, files, or list-like result sets\n3. **Editor pane** for tabs, splits, preview, built-in views, and focused writing\n\nThe useful part is that each zone has its own keyboard loop, so you can stay off the mouse without losing place.\n\n## Edit, split, and preview\n\nEach note can be viewed in three ways:\n\n- **Edit** for raw markdown authoring\n- **Split** for source and rendered output side by side\n- **Preview** for reading-only rendering\n\nYou can switch modes from the toolbar, from the command palette, or from ex commands like:\n\n```vim\n:view edit\n:view split\n:view preview\n```\n\n## Tabs, buffers, and panes\n\n- tabs can be on or off\n- panes can split right or down\n- if tabs are hidden, buffers are still open behind the scenes\n- `Space o` or `:buffers` opens the buffer switcher\n\nThis keeps ZenNotes usable for both tab-heavy and low-chrome workflows.\n\n## Search modes\n\n### Note search\n\n- `⌘P` globally\n- `Space f` in Vim mode\n- `⌘F` or `Ctrl+F` as an extra direct shortcut when Vim mode is off\n- searches note titles and paths\n\n### Vault text search\n\n- `Space s t`\n- searches matching text lines across note contents\n- opens the note and jumps to the matching line\n- can run on built-in search, `ripgrep`, or `fzf`\n- Settings show the runtime backend that is actually being used\n\n## Quick Notes, Inbox, Archive, Trash\n\nThese four areas represent different stages of note life:\n\n- **Quick Notes** for fast capture\n- **Inbox** for active notes\n- **Archive** for cold storage\n- **Trash** for recoverable deletion\n\nBehavior differs by design:\n\n- clicking **Quick Notes** still folds and unfolds the sidebar section\n- Quick Notes can also open as a dedicated list tab from its context menu\n- **Archive** opens as a main-pane list view\n- **Trash** opens as a main-pane recovery view\n\nThat keeps the sidebar singular instead of turning it into a second file browser.\n\n## Outline, connections, and references\n\n- **Outline** gives you a heading list for the active note\n- **Connections** show backlinks, outbound links, and unresolved links\n- **Reference pane** is for pinning a note or PDF beside your current work\n\nThis is the part of the app that becomes valuable once a vault turns into more than a pile of files.\n\n## Help, Settings, and Files\n\nThe footer utilities keep the secondary surfaces discoverable:\n\n- **Files** for local files\n- **Help** for the built-in manual\n- **Settings** for personalization, Vim behavior, search backends, fonts, layout, and keymaps\n\nFor the command palette and seeded onboarding flow, see [[13 — Commands, Help, and Demo Tour]].\nFor detached note workflows and side-by-side reading context, see [[14 — Reference Pane and Floating Windows]].\n\n## Zen mode\n\nZen mode hides:\n\n- title bar\n- sidebar\n- note list\n- tabs\n- pane header chrome\n- outline and connections\n- status bar\n\nOnly the active editor, preview, or split content remains. It is the cleanest way to focus on a single note.\n\n## Session restore\n\nZenNotes remembers:\n\n- open tabs\n- splits\n- built-in views like Help, Tasks, Archive, or Trash\n- sidebar layout\n- main window position, size, and maximized state\n\nClosing and reopening the app should bring you back to roughly where you left off instead of starting from a blank shell.\n\n#demo #workspace #search #reference\n"},{"path":"inbox/demo/12 — Settings and Keymaps.md","body":"# Settings and keymaps\n\nZenNotes is keyboard-first by default, but it is not rigid anymore. Settings now cover both presentation and behavior.\n\n## Appearance\n\nFrom Settings you can tune:\n\n- theme family\n- light or dark mode\n- theme variant or contrast\n- dark sidebar treatment\n\nThe point is to keep the app comfortable for long sessions without changing the underlying note files.\n\n## Editor behavior\n\nKey editor settings include:\n\n- Vim mode on or off\n- leader key hints on or off\n- timed vs sticky leader hints\n- leader hint duration\n- live preview\n- note tabs\n- word wrap\n- PDF behavior in edit mode\n- date-titled Quick Notes\n\n## Vault text search backends\n\nVault text search can be powered by:\n\n- **Auto**\n- **Built-in**\n- **ripgrep**\n- **fzf**\n\nYou can also set explicit binary paths for `rg` and `fzf` in case they live outside your normal `PATH`.\n\nZenNotes now shows:\n\n- what tools are available\n- what backend is configured\n- what backend is actually being used at runtime\n\nThat matters because **Auto** can fall back, and explicit backends can also fall back when the configured binary path is missing.\n\n## Typography and layout\n\nYou can tune:\n\n- interface font\n- reading font\n- monospace font\n- editor and preview font size\n- line height\n- reading width\n- editor width\n- centered vs left-aligned content\n- line numbers\n\nThese are workflow settings, not note-format settings. The markdown file stays the same.\n\n## Keymaps\n\nKeymaps are now configurable from inside the app:\n\n- global shortcuts\n- leader sequences\n- pane-prefix motions\n- Vim-specific editor actions\n- list and view navigation\n\nThat means you can remap things like:\n\n- search notes\n- search vault text\n- toggle Zen mode\n- pane movement\n- fold motions\n- leader flows such as `Space s t`\n\nMulti-step sequences are supported, so the keymap system can handle more than single shortcuts.\n\n## Vault and About\n\nThe rest of Settings handles the vault and app identity:\n\n- reveal or change the vault location\n- inspect the app version\n- see the About section\n- find the Lumary Labs link\n- remember that Settings save automatically on this device\n\n## Practical advice\n\nIf you are learning the app:\n\n1. keep Vim mode on\n2. enable leader hints\n3. leave search backend on **Auto**\n4. only start remapping after the defaults feel familiar\n\nThat gives you the clearest path through the built-in help, demos, and keyboard flows.\n\nFor a deeper walkthrough of runtime backend selection, fallbacks, and fuzzy content search behavior, see [[15 — Search Backends and Fuzzy Workflows]].\n\n#demo #settings #keymaps #reference\n"},{"path":"inbox/demo/13 — Commands, Help, and Demo Tour.md","body":"# Commands, help, and demo tour\n\nZenNotes is keyboard-first, so discoverability matters. This note covers the command palette, the built-in Help manual, and the demo-tour commands that can seed a starter vault for new users.\n\n## Command palette\n\nOpen the command palette with:\n\n- `⇧⌘P`\n- `:commands`\n- `:cmd query`\n\nUse it when you cannot remember a shortcut, when Vim mode is off, or when you want to browse what the app can do without digging through menus.\n\nTypical commands worth trying:\n\n- `Open Help`\n- `Open Settings`\n- `Search notes`\n- `Generate Demo Tour Notes`\n- `Remove Demo Tour Notes`\n- `Switch to Edit Mode`\n- `Switch to Split Mode`\n- `Switch to Preview Mode`\n- `Open Tasks`\n- `Open Trash`\n\n## Ex commands\n\nIf you live in normal mode, the ex line is the fastest path for many actions:\n\n```vim\n:help\n:tasks\n:trash\n:buffers\n:view split\n:zen\n:cmd help\n```\n\nThe ex line also supports completion with `Tab`, including command arguments like `:view edit|split|preview` and `:zen toggle|on|off`.\n\n## Built-in Help\n\nZenNotes ships with an in-app manual instead of making you leave the app to learn it.\n\nWays to open it:\n\n- footer **Help**\n- `:help`\n- command palette → `Open Help`\n\nThe Help view covers:\n\n- quick start\n- core concepts\n- shortcuts\n- Vim flows\n- ex commands\n- settings\n- search backends\n\n## Demo tour commands\n\nThe demo vault itself is seedable from inside the app.\n\nUse:\n\n- command palette → `Generate Demo Tour Notes`\n- command palette → `Remove Demo Tour Notes`\n- `:demo_generate`\n- `:demo_remove`\n\n### What generation does\n\n- creates a guided note set under `inbox/demo`\n- adds the bundled demo file at the vault root\n- opens the tour start note so the onboarding flow begins immediately\n\n### What removal does\n\n- removes the seeded demo notes\n- removes the bundled demo file\n- leaves the rest of the vault alone\n\nThat makes the tour useful for:\n\n- first-time users\n- resettable demos\n- showing the product to someone else\n- smoke-testing renderer features in one place\n\n## Why this matters\n\nThe app can stay low-chrome and still be discoverable if:\n\n- commands are searchable\n- Help is built in\n- the starter content is one command away\n\nThat combination is a large part of what makes a keyboard-first app approachable instead of intimidating.\n\n## Try this now\n\n- Open the command palette and search for `help`\n- Run `:cmd zen`\n- Run `Generate Demo Tour Notes` in a test vault\n- Open [[12 — Settings and Keymaps]] after this note to see how the shortcuts behind these commands can be remapped\n\n#demo #commands #help #onboarding\n"},{"path":"inbox/demo/14 — Reference Pane and Floating Windows.md","body":"# Reference pane and floating windows\n\nZenNotes is strongest when you can keep context visible while still writing. This note covers the pinned reference pane, link preview workflows, and floating notes.\n\n## Reference pane\n\nThe reference pane is for keeping a second document visible while you work in the main note.\n\nGood uses:\n\n- drafting against a spec\n- reading a PDF while taking notes\n- comparing two notes side by side\n- keeping a glossary or checklist open while editing\n\n## What can live there\n\n- another markdown note\n- a PDF\n- a linked document opened from the current note\n\nThis keeps the main pane focused on writing while the side pane holds supporting material.\n\n## Link-following flows\n\nWhen the cursor is on a wikilink or markdown link:\n\n- `gd` follows it in Vim mode\n- PDFs can pin into the reference pane\n- missing notes can be created from the link target\n\nThat means links are not just navigation. They can become working context.\n\n## Connections + reference workflow\n\nThe **Connections** panel works well with the reference pane:\n\n- inspect backlinks\n- move to a related note\n- peek a backlink\n- pin the most useful one beside the current draft\n\nThis is especially useful for research notes and longer documentation trees.\n\n## Floating windows\n\nSometimes you do not want a second pane inside the same layout. In that case, a note can open in its own floating window from the context menu.\n\nFloating windows are useful when:\n\n- you want a scratch note on another monitor\n- you are comparing two notes without disturbing the main layout\n- you want a temporary detached reference\n\nThey are intentional, separate work surfaces, not just accidental duplicate tabs.\n\n## Research pattern\n\nOne practical pattern:\n\n1. Keep the current draft in **Edit** or **Split**\n2. Open **Connections**\n3. Find a related note or PDF\n4. Pin it in the reference pane or open it in a floating window\n5. Keep writing without losing context\n\n## Good companion notes in this tour\n\n- [[07 — Wiki Links and Tags]] for backlinks, tags, and search\n- [[11 — Workspace, Search, and Views]] for the larger pane model\n- [[06 — Callouts and Footnotes]] for local files\n- [[10 — Ideas and Tasks]] for a note that benefits from supporting context\n\n## Try this now\n\n- Open this note, then pin [[11 — Workspace, Search, and Views]]\n- Open **Connections** on [[10 — Ideas and Tasks]]\n- Follow a wikilink with `gd`\n- Open a note in a floating window from its context menu\n\n#demo #reference #research #windows\n"},{"path":"inbox/demo/15 — Search Backends and Fuzzy Workflows.md","body":"# Search backends and fuzzy workflows\n\nZenNotes has two different search surfaces, and the deeper one can be powered by different backends.\n\n## Two searches, two jobs\n\n### Note search\n\nUse when you want to find a note by title or path:\n\n- `⌘P`\n- `Space f`\n\nThis is the fastest way to jump to a file you already roughly know.\n\n### Vault text search\n\nUse when you want to find matching text inside note bodies:\n\n- `Space s t`\n\nThis searches across note content and jumps directly to the matching line when you open a result.\n\n## Backends\n\nVault text search can run on:\n\n- **Auto**\n- **Built-in**\n- **ripgrep**\n- **fzf**\n\n### Auto\n\n`Auto` prefers:\n\n1. `fzf`\n2. `ripgrep`\n3. built-in fallback\n\nThat makes the app adapt to what is installed on the machine.\n\n### Built-in\n\nUse this when you want:\n\n- zero external dependencies\n- predictable behavior across machines\n- a search path that always exists even when no tools are installed\n\n### ripgrep\n\nUse this when you want:\n\n- strong plain-text search performance\n- system-level tooling you may already use outside the app\n- a backend that is familiar to terminal users\n\n### fzf\n\nUse this when you want:\n\n- terminal-style fuzzy matching behavior\n- ranking that feels close to launcher workflows\n- an external backend often used by Vim and Neovim users\n\n## Custom binary paths\n\nIf `rg` or `fzf` are not in your normal `PATH`, ZenNotes lets you point to them directly from Settings.\n\nExamples:\n\n- `/opt/homebrew/bin/rg`\n- `/opt/homebrew/bin/fzf`\n- `/usr/local/bin/rg`\n\nBlank means “use whatever is on PATH”.\n\n## Runtime backend vs configured backend\n\nZenNotes shows:\n\n- what you configured\n- what tools are available\n- what backend is actually being used\n\nThat distinction matters because:\n\n- `Auto` may resolve differently on different machines\n- explicit `ripgrep` or `fzf` settings can still fall back if the binary path is invalid\n\n## Search result behavior\n\nVault text search is designed to be navigational, not just informational:\n\n- results stay keyboard navigable\n- the active row stays in view while you move\n- the matching text is highlighted in the result\n- opening a result moves the cursor to the match in the note\n\nThis makes it feel more like a picker than a grep dump.\n\n## Good habits\n\n- use note search when you know the file\n- use vault text search when you only know the phrase\n- leave the backend on **Auto** unless you have a reason to force one\n- configure explicit binary paths if your tools live outside `PATH`\n\n## Related notes\n\n- [[07 — Wiki Links and Tags]] for search in the context of notes, tags, and links\n- [[11 — Workspace, Search, and Views]] for where these pickers fit into the app\n- [[12 — Settings and Keymaps]] for changing the backend and remapping the shortcut\n\n#demo #search #fzf #ripgrep #reference\n"}],"assets":[{"path":"zennotes-demo-card.svg","body":"\n \n \n \n \n \n \n \n \n \n \n \n \n \n DEMO\n ZenNotes Demo\n Local files, keyboard-first flows, and markdown-friendly structure.\n \n \n \n \n \n \n \n SEE ALSO: HELP, SEARCH, OUTLINE, TASKS, QUICK NOTES\n\n"}]} \ No newline at end of file diff --git a/apps/server/internal/vault/demo.go b/apps/server/internal/vault/demo.go new file mode 100644 index 00000000..3998778b --- /dev/null +++ b/apps/server/internal/vault/demo.go @@ -0,0 +1,119 @@ +package vault + +import ( + _ "embed" + "encoding/json" + "os" + "path/filepath" + "strings" +) + +//go:embed demo-tour.json +var demoTourJSON []byte + +type demoFile struct { + Path string `json:"path"` + Body string `json:"body"` +} + +type demoTour struct { + Notes []demoFile `json:"notes"` + Assets []demoFile `json:"assets"` +} + +// DemoTourResult mirrors shared/ipc.ts VaultDemoTourResult. +type DemoTourResult struct { + NotePaths []string `json:"notePaths"` + AssetPaths []string `json:"assetPaths"` +} + +func loadDemoTour() (*demoTour, error) { + tour := &demoTour{} + if err := json.Unmarshal(demoTourJSON, tour); err != nil { + return nil, err + } + return tour, nil +} + +// GenerateDemoTour seeds the vault with the built-in tour notes and +// the demo attachment. Existing files are overwritten. +func (v *Vault) GenerateDemoTour() (DemoTourResult, error) { + v.mu.Lock() + defer v.mu.Unlock() + tour, err := loadDemoTour() + if err != nil { + return DemoTourResult{}, err + } + result := DemoTourResult{NotePaths: []string{}, AssetPaths: []string{}} + for _, note := range tour.Notes { + abs, err := SafeJoin(v.root, note.Path) + if err != nil { + return DemoTourResult{}, err + } + if err := os.MkdirAll(filepath.Dir(abs), 0o755); err != nil { + return DemoTourResult{}, err + } + if err := os.WriteFile(abs, []byte(note.Body), 0o644); err != nil { + return DemoTourResult{}, err + } + result.NotePaths = append(result.NotePaths, filepath.ToSlash(note.Path)) + } + for _, asset := range tour.Assets { + abs, err := SafeJoin(v.root, asset.Path) + if err != nil { + return DemoTourResult{}, err + } + if err := os.MkdirAll(filepath.Dir(abs), 0o755); err != nil { + return DemoTourResult{}, err + } + if err := os.WriteFile(abs, []byte(asset.Body), 0o644); err != nil { + return DemoTourResult{}, err + } + result.AssetPaths = append(result.AssetPaths, filepath.ToSlash(asset.Path)) + } + return result, nil +} + +// RemoveDemoTour deletes the demo notes + asset if they exist. Also +// removes empty parent directories under inbox/demo. +func (v *Vault) RemoveDemoTour() (DemoTourResult, error) { + v.mu.Lock() + defer v.mu.Unlock() + tour, err := loadDemoTour() + if err != nil { + return DemoTourResult{}, err + } + result := DemoTourResult{NotePaths: []string{}, AssetPaths: []string{}} + removedDirs := map[string]bool{} + for _, note := range tour.Notes { + abs, err := SafeJoin(v.root, note.Path) + if err != nil { + continue + } + if err := os.Remove(abs); err == nil { + result.NotePaths = append(result.NotePaths, filepath.ToSlash(note.Path)) + removedDirs[filepath.Dir(abs)] = true + } + } + for _, asset := range tour.Assets { + abs, err := SafeJoin(v.root, asset.Path) + if err != nil { + continue + } + if err := os.Remove(abs); err == nil { + result.AssetPaths = append(result.AssetPaths, filepath.ToSlash(asset.Path)) + } + } + for dir := range removedDirs { + // Walk up from each removed-note directory and rmdir empties, + // stopping at the vault root. + d := dir + for strings.HasPrefix(d, v.root) && d != v.root { + if err := os.Remove(d); err != nil { + break + } + d = filepath.Dir(d) + } + } + return result, nil +} diff --git a/apps/server/internal/vault/parse.go b/apps/server/internal/vault/parse.go new file mode 100644 index 00000000..c79eda3f --- /dev/null +++ b/apps/server/internal/vault/parse.go @@ -0,0 +1,381 @@ +package vault + +import ( + "regexp" + "strings" +) + +// Regexes below mirror the TS extractors in src/main/vault.ts. They are +// intentionally the same shape so the extracted metadata matches the +// desktop build byte-for-byte for the common cases. + +var ( + fencedBlockRe = regexp.MustCompile("(?m)(^|\n)```[^\n]*\n[\\s\\S]*?\n```[ \t]*(?:\n|$)") + inlineCodeRe = regexp.MustCompile("`[^`\n]*`") + tagRe = regexp.MustCompile(`(?:^|\s)#([A-Za-z][\w\-/]*)`) + wikilinkRe = regexp.MustCompile(`(!?)\[\[([^\]|]+?)(?:\|[^\]]+)?\]\]`) + linkRe = regexp.MustCompile(`(!?)\[[^\]]*\]\(([^)\s]+)(?:\s+"[^"]*")?\)`) + embedRe = regexp.MustCompile(`!\[\[([^\]|]+?)(?:\|[^\]]+)?\]\]`) + frontmatterRe = regexp.MustCompile(`(?s)\A---\n(.*?)\n---\n?`) + headingRe = regexp.MustCompile(`(?m)^#{1,6}\s+`) + imageMdRe = regexp.MustCompile(`!\[[^\]]*\]\([^)]*\)`) + mdLinkRe = regexp.MustCompile(`\[([^\]]+)\]\([^)]*\)`) + mdEmbedAltRe = regexp.MustCompile(`!\[\[([^\]|]+)(?:\|([^\]]+))?\]\]`) + mdWikiAltRe = regexp.MustCompile(`\[\[([^\]|]+)(?:\|([^\]]+))?\]\]`) + markupTrimRe = regexp.MustCompile(`[*_~>]+`) + wsCollapseRe = regexp.MustCompile(`\s+`) +) + +var attachmentExts = map[string]bool{ + ".apng": true, ".avif": true, ".gif": true, ".jpeg": true, ".jpg": true, + ".png": true, ".svg": true, ".webp": true, ".pdf": true, + ".aac": true, ".flac": true, ".m4a": true, ".mp3": true, ".ogg": true, ".wav": true, + ".m4v": true, ".mov": true, ".mp4": true, ".ogv": true, ".webm": true, +} + +func stripCodeContent(body string) string { + out := fencedBlockRe.ReplaceAllString(body, "$1 ") + out = inlineCodeRe.ReplaceAllString(out, " ") + return out +} + +// ExtractTags returns unique #tags from a markdown body, ignoring code. +func ExtractTags(body string) []string { + stripped := stripCodeContent(body) + seen := map[string]bool{} + out := []string{} + for _, m := range tagRe.FindAllStringSubmatch(stripped, -1) { + if len(m) >= 2 { + tag := m[1] + if !seen[tag] { + seen[tag] = true + out = append(out, tag) + } + } + } + return out +} + +// ExtractWikilinks returns unique [[wikilink]] targets, ignoring code. +func ExtractWikilinks(body string) []string { + stripped := stripCodeContent(body) + seen := map[string]bool{} + out := []string{} + for _, m := range wikilinkRe.FindAllStringSubmatch(stripped, -1) { + if len(m) >= 3 { + bang := m[1] + target := strings.TrimSpace(m[2]) + if target == "" { + continue + } + if bang == "!" && localAssetTargetKind(target) != "" { + continue + } + if !seen[target] { + seen[target] = true + out = append(out, target) + } + } + } + return out +} + +// BodyHasLocalAsset is the same cheap heuristic as the TS version. +func BodyHasLocalAsset(body string) bool { + stripped := stripCodeContent(body) + for _, m := range linkRe.FindAllStringSubmatch(stripped, -1) { + if len(m) < 3 { + continue + } + href := strings.TrimSpace(m[2]) + if href == "" || strings.HasPrefix(href, "#") || strings.HasPrefix(href, "//") { + continue + } + if matched, _ := regexpMatchScheme(href); matched { + continue + } + if localAssetTargetKind(href) != "" { + return true + } + } + for _, m := range embedRe.FindAllStringSubmatch(stripped, -1) { + if len(m) < 2 { + continue + } + if localAssetTargetKind(strings.TrimSpace(m[1])) != "" { + return true + } + } + return false +} + +var schemeRe = regexp.MustCompile(`^[a-zA-Z][a-zA-Z\d+.\-]*:`) + +func regexpMatchScheme(href string) (bool, error) { + return schemeRe.MatchString(href), nil +} + +// BuildExcerpt makes a short plaintext preview from markdown. +func BuildExcerpt(body string) string { + withoutFront := frontmatterRe.ReplaceAllString(body, "") + text := stripCodeContent(withoutFront) + text = imageMdRe.ReplaceAllString(text, " ") + text = mdLinkRe.ReplaceAllString(text, "$1") + text = mdEmbedAltRe.ReplaceAllStringFunc(text, func(s string) string { + m := mdEmbedAltRe.FindStringSubmatch(s) + if len(m) >= 3 && m[2] != "" { + return m[2] + } + if len(m) >= 2 { + return m[1] + } + return "" + }) + text = mdWikiAltRe.ReplaceAllStringFunc(text, func(s string) string { + m := mdWikiAltRe.FindStringSubmatch(s) + if len(m) >= 3 && m[2] != "" { + return m[2] + } + if len(m) >= 2 { + return m[1] + } + return "" + }) + text = headingRe.ReplaceAllString(text, "") + text = markupTrimRe.ReplaceAllString(text, "") + text = wsCollapseRe.ReplaceAllString(text, " ") + text = strings.TrimSpace(text) + if len(text) > 220 { + text = text[:220] + } + return text +} + +func localAssetTargetKind(target string) string { + clean := target + if i := strings.IndexAny(clean, "#?"); i >= 0 { + clean = clean[:i] + } + dot := strings.LastIndexByte(clean, '.') + if dot < 0 { + return "" + } + ext := strings.ToLower(clean[dot:]) + if attachmentExts[ext] { + return ext + } + return "" +} + +// --- Task parsing (mirrors shared/tasks.ts parseTasksFromBody) --- + +var ( + taskLineRe = regexp.MustCompile(`^(\s*(?:[-*+]|\d+\.)\s+)\[( |x|X)\](.*)$`) + inlineDueRe = regexp.MustCompile(`(?i)(?:^|\s)due:(\S+)`) + inlinePriority = regexp.MustCompile(`(?i)(?:^|\s)!(high|med|medium|low|h|m|l)\b`) + inlineWaitingRe = regexp.MustCompile(`(?i)(?:^|\s)@waiting\b`) + inlineTagRe = regexp.MustCompile(`(?i)(?:^|\s)#([a-z0-9][a-z0-9/_\-]*)`) + isoDateRe = regexp.MustCompile(`^\d{4}-\d{2}-\d{2}$`) +) + +func isValidIsoDate(s string) bool { + if !isoDateRe.MatchString(s) { + return false + } + return true +} + +func normalizePriority(raw string) string { + v := strings.ToLower(strings.TrimSpace(raw)) + switch v { + case "high", "h": + return "high" + case "med", "medium", "m": + return "med" + case "low", "l": + return "low" + } + return "" +} + +type noteDefaults struct { + Due string + Priority string + Status string +} + +func parseNoteDefaults(body string) noteDefaults { + m := frontmatterRe.FindStringSubmatch(body) + if len(m) < 2 { + return noteDefaults{} + } + var d noteDefaults + for _, line := range strings.Split(m[1], "\n") { + trimmed := strings.TrimSpace(line) + if trimmed == "" || strings.HasPrefix(trimmed, "#") { + continue + } + colon := strings.IndexByte(trimmed, ':') + if colon < 1 { + continue + } + key := strings.ToLower(strings.TrimSpace(trimmed[:colon])) + val := unquote(strings.TrimSpace(trimmed[colon+1:])) + switch key { + case "due": + if isValidIsoDate(val) { + d.Due = val + } + case "priority": + if p := normalizePriority(val); p != "" { + d.Priority = p + } + case "status": + d.Status = strings.ToLower(val) + } + } + return d +} + +func unquote(v string) string { + t := strings.TrimSpace(v) + if len(t) >= 2 { + first, last := t[0], t[len(t)-1] + if (first == '"' || first == '\'') && first == last { + return t[1 : len(t)-1] + } + } + return t +} + +// ParseTasks walks a markdown body and returns every checkbox task. +func ParseTasks(path, title string, folder NoteFolder, body string) []Task { + normalized := strings.ReplaceAll(body, "\r\n", "\n") + defaults := parseNoteDefaults(normalized) + lines := strings.Split(normalized, "\n") + + out := []Task{} + taskIndex := 0 + inFence := false + fenceMarker := "" + + fenceStart := regexp.MustCompile("^([ \t]*)(`{3,}|~{3,})") + + for i, line := range lines { + if fm := fenceStart.FindStringSubmatch(line); fm != nil { + marker := fm[2] + if !inFence { + inFence = true + fenceMarker = marker + } else if marker == fenceMarker { + inFence = false + fenceMarker = "" + } + continue + } + if inFence { + continue + } + m := taskLineRe.FindStringSubmatch(line) + if m == nil { + continue + } + checkedChar := m[2] + tail := strings.TrimPrefix(m[3], "]") + checked := checkedChar == "x" || checkedChar == "X" + + due := "" + priority := "" + waiting := false + tags := []string{} + stripped := tail + + if dm := inlineDueRe.FindStringSubmatch(stripped); dm != nil { + if isValidIsoDate(dm[1]) { + due = dm[1] + } + stripped = inlineDueRe.ReplaceAllString(stripped, " ") + } + if pm := inlinePriority.FindStringSubmatch(stripped); pm != nil { + priority = normalizePriority(pm[1]) + stripped = inlinePriority.ReplaceAllString(stripped, " ") + } + if inlineWaitingRe.MatchString(stripped) { + waiting = true + stripped = inlineWaitingRe.ReplaceAllString(stripped, " ") + } + for _, tm := range inlineTagRe.FindAllStringSubmatch(tail, -1) { + if len(tm) >= 2 { + tag := strings.ToLower(tm[1]) + dupe := false + for _, t := range tags { + if t == tag { + dupe = true + break + } + } + if !dupe { + tags = append(tags, tag) + } + } + } + stripped = strings.TrimSpace(wsCollapseRe.ReplaceAllString(stripped, " ")) + content := stripped + if content == "" { + content = strings.TrimSpace(tail) + } + + if due == "" { + due = defaults.Due + } + if priority == "" { + priority = defaults.Priority + } + + task := Task{ + ID: fmtTaskID(path, taskIndex), + SourcePath: path, + NoteTitle: title, + NoteFolder: folder, + LineNumber: i, + TaskIndex: taskIndex, + RawText: line, + Content: content, + Checked: checked, + Due: due, + Priority: priority, + Waiting: waiting, + Tags: tags, + } + out = append(out, task) + taskIndex++ + } + return out +} + +func fmtTaskID(path string, idx int) string { + return path + "#" + itoa(idx) +} + +func itoa(n int) string { + if n == 0 { + return "0" + } + neg := false + if n < 0 { + neg = true + n = -n + } + var buf [20]byte + i := len(buf) + for n > 0 { + i-- + buf[i] = byte('0' + n%10) + n /= 10 + } + if neg { + i-- + buf[i] = '-' + } + return string(buf[i:]) +} diff --git a/apps/server/internal/vault/safepath.go b/apps/server/internal/vault/safepath.go new file mode 100644 index 00000000..c5f13b3a --- /dev/null +++ b/apps/server/internal/vault/safepath.go @@ -0,0 +1,37 @@ +package vault + +import ( + "errors" + "path/filepath" + "strings" +) + +var ErrPathEscape = errors.New("path escapes vault root") + +// SafeJoin cleans a user-supplied relative POSIX path and joins it +// onto `root`, refusing anything that resolves outside `root`. Always +// returns an OS-native absolute path. +func SafeJoin(root, rel string) (string, error) { + if root == "" { + return "", errors.New("root is empty") + } + rootAbs, err := filepath.Abs(root) + if err != nil { + return "", err + } + cleaned := filepath.Clean("/" + strings.TrimPrefix(rel, "/")) + joined := filepath.Join(rootAbs, filepath.FromSlash(cleaned)) + relBack, err := filepath.Rel(rootAbs, joined) + if err != nil { + return "", err + } + if relBack == ".." || strings.HasPrefix(relBack, ".."+string(filepath.Separator)) { + return "", ErrPathEscape + } + return joined, nil +} + +// ToPosix converts an OS-native path to forward-slash form. +func ToPosix(p string) string { + return filepath.ToSlash(p) +} diff --git a/apps/server/internal/vault/types.go b/apps/server/internal/vault/types.go new file mode 100644 index 00000000..9ff594ff --- /dev/null +++ b/apps/server/internal/vault/types.go @@ -0,0 +1,152 @@ +package vault + +import ( + "path/filepath" + "strings" +) + +// Types in this file mirror the TypeScript interfaces in +// `src/shared/ipc.ts` and `src/shared/tasks.ts`. The JSON tags must +// match the TS field names exactly so the client can consume the +// responses without translation. + +type NoteFolder string +type PrimaryNotesLocation string + +const ( + FolderInbox NoteFolder = "inbox" + FolderQuick NoteFolder = "quick" + FolderArchive NoteFolder = "archive" + FolderTrash NoteFolder = "trash" + + PrimaryNotesInbox PrimaryNotesLocation = "inbox" + PrimaryNotesRoot PrimaryNotesLocation = "root" + DefaultDailyNotesDirectory = "Daily Notes" +) + +func IsValidFolder(f NoteFolder) bool { + switch f { + case FolderInbox, FolderQuick, FolderArchive, FolderTrash: + return true + } + return false +} + +var AllFolders = []NoteFolder{FolderInbox, FolderQuick, FolderArchive, FolderTrash} + +func FolderForRelativePath(rel string) (NoteFolder, bool) { + normalized := filepath.ToSlash(rel) + top := strings.SplitN(normalized, "/", 2)[0] + if IsValidFolder(NoteFolder(top)) { + return NoteFolder(top), true + } + if top == "" || strings.HasPrefix(top, ".") { + return "", false + } + if _, reserved := reservedRootNames[top]; reserved { + return "", false + } + return FolderInbox, true +} + +type DailyNotesSettings struct { + Enabled bool `json:"enabled"` + Directory string `json:"directory"` +} + +type VaultSettings struct { + PrimaryNotesLocation PrimaryNotesLocation `json:"primaryNotesLocation"` + DailyNotes DailyNotesSettings `json:"dailyNotes"` +} + +// NoteMeta — vault-relative note metadata. Mirrors shared/ipc.ts NoteMeta. +type NoteMeta struct { + Path string `json:"path"` + Title string `json:"title"` + Folder NoteFolder `json:"folder"` + SiblingOrder int `json:"siblingOrder"` + CreatedAt int64 `json:"createdAt"` + UpdatedAt int64 `json:"updatedAt"` + Size int64 `json:"size"` + Tags []string `json:"tags"` + Wikilinks []string `json:"wikilinks"` + HasAttachments bool `json:"hasAttachments"` + Excerpt string `json:"excerpt"` +} + +// NoteContent extends NoteMeta with the raw body. +type NoteContent struct { + NoteMeta + Body string `json:"body"` +} + +// FolderEntry — mirrors shared/ipc.ts FolderEntry. +type FolderEntry struct { + Folder NoteFolder `json:"folder"` + Subpath string `json:"subpath"` + SiblingOrder int `json:"siblingOrder"` +} + +// AssetMeta — mirrors shared/ipc.ts AssetMeta. +type AssetMeta struct { + Path string `json:"path"` + Name string `json:"name"` + Kind string `json:"kind"` + SiblingOrder int `json:"siblingOrder"` + Size int64 `json:"size"` + UpdatedAt int64 `json:"updatedAt"` +} + +// ImportedAsset — mirrors shared/ipc.ts ImportedAsset. +type ImportedAsset struct { + Name string `json:"name"` + Path string `json:"path"` + Markdown string `json:"markdown"` + Kind string `json:"kind"` +} + +// VaultInfo — mirrors shared/ipc.ts VaultInfo. +type VaultInfo struct { + Root string `json:"root"` + Name string `json:"name"` +} + +// TextSearchCapabilities — mirrors shared/ipc.ts VaultTextSearchCapabilities. +type TextSearchCapabilities struct { + Ripgrep bool `json:"ripgrep"` + Fzf bool `json:"fzf"` +} + +// TextSearchMatch — mirrors shared/ipc.ts VaultTextSearchMatch. +type TextSearchMatch struct { + Path string `json:"path"` + Title string `json:"title"` + Folder NoteFolder `json:"folder"` + LineNumber int `json:"lineNumber"` + Offset int `json:"offset"` + LineText string `json:"lineText"` +} + +// Task — mirrors shared/tasks.ts VaultTask. +type Task struct { + ID string `json:"id"` + SourcePath string `json:"sourcePath"` + NoteTitle string `json:"noteTitle"` + NoteFolder NoteFolder `json:"noteFolder"` + LineNumber int `json:"lineNumber"` + TaskIndex int `json:"taskIndex"` + RawText string `json:"rawText"` + Content string `json:"content"` + Checked bool `json:"checked"` + Due string `json:"due,omitempty"` + Priority string `json:"priority,omitempty"` + Waiting bool `json:"waiting"` + Tags []string `json:"tags"` +} + +// ChangeEvent — mirrors shared/ipc.ts VaultChangeEvent. +type ChangeEvent struct { + Kind string `json:"kind"` // "add" | "change" | "unlink" + Path string `json:"path"` + Folder NoteFolder `json:"folder"` +} diff --git a/apps/server/internal/vault/vault.go b/apps/server/internal/vault/vault.go new file mode 100644 index 00000000..7b4bcace --- /dev/null +++ b/apps/server/internal/vault/vault.go @@ -0,0 +1,1111 @@ +package vault + +import ( + "encoding/json" + "errors" + "fmt" + "io" + "os" + "path/filepath" + "sort" + "strings" + "sync" + "time" +) + +const ( + PrimaryAttachmentsDir = "attachements" + internalVaultDir = ".zennotes" + vaultSettingsFile = "vault.json" +) + +var legacyAttachmentsDirs = []string{"_assets"} +var reservedRootNames = map[string]struct{}{ + string(FolderInbox): {}, + string(FolderQuick): {}, + string(FolderArchive): {}, + string(FolderTrash): {}, + PrimaryAttachmentsDir: {}, + internalVaultDir: {}, +} + +func init() { + for _, dir := range legacyAttachmentsDirs { + reservedRootNames[dir] = struct{}{} + } +} + +// Vault encapsulates all operations against a filesystem vault root. +// It is concurrency-safe at the public-method level; internally most +// ops do a short RW-lock dance around mutating operations. +type Vault struct { + root string + mu sync.RWMutex +} + +func New(root string) (*Vault, error) { + abs, err := filepath.Abs(root) + if err != nil { + return nil, err + } + if err := os.MkdirAll(abs, 0o755); err != nil { + return nil, err + } + v := &Vault{root: abs} + if err := v.EnsureLayout(); err != nil { + return nil, err + } + return v, nil +} + +func (v *Vault) Root() string { + return v.root +} + +func (v *Vault) Info() VaultInfo { + return VaultInfo{Root: v.root, Name: filepath.Base(v.root)} +} + +func cloneSettings(settings VaultSettings) VaultSettings { + return VaultSettings{ + PrimaryNotesLocation: settings.PrimaryNotesLocation, + DailyNotes: DailyNotesSettings{ + Enabled: settings.DailyNotes.Enabled, + Directory: settings.DailyNotes.Directory, + }, + } +} + +func normalizeDailyNotesDirectory(value string) string { + trimmed := strings.Trim(value, "/") + if trimmed == "" { + return DefaultDailyNotesDirectory + } + return trimmed +} + +func normalizePrimaryNotesLocation(value PrimaryNotesLocation) PrimaryNotesLocation { + if value == PrimaryNotesRoot { + return PrimaryNotesRoot + } + return PrimaryNotesInbox +} + +func normalizeVaultSettings(value VaultSettings, fallbackPrimary PrimaryNotesLocation) VaultSettings { + return VaultSettings{ + PrimaryNotesLocation: normalizePrimaryNotesLocation(func() PrimaryNotesLocation { + if value.PrimaryNotesLocation == "" { + return fallbackPrimary + } + return value.PrimaryNotesLocation + }()), + DailyNotes: DailyNotesSettings{ + Enabled: value.DailyNotes.Enabled, + Directory: normalizeDailyNotesDirectory(value.DailyNotes.Directory), + }, + } +} + +func (v *Vault) settingsPath() string { + return filepath.Join(v.root, internalVaultDir, vaultSettingsFile) +} + +func (v *Vault) inferPrimaryNotesLocation() PrimaryNotesLocation { + entries, err := os.ReadDir(v.root) + if err != nil { + return PrimaryNotesInbox + } + for _, entry := range entries { + name := entry.Name() + if strings.HasPrefix(name, ".") { + continue + } + if _, reserved := reservedRootNames[name]; reserved { + continue + } + if entry.IsDir() || strings.EqualFold(filepath.Ext(name), ".md") { + return PrimaryNotesRoot + } + } + return PrimaryNotesInbox +} + +func (v *Vault) vaultLooksEmpty() bool { + entries, err := os.ReadDir(v.root) + if err != nil { + return true + } + for _, entry := range entries { + name := entry.Name() + if strings.HasPrefix(name, ".") || name == internalVaultDir { + continue + } + return false + } + return true +} + +func (v *Vault) GetSettings() (VaultSettings, error) { + fallbackPrimary := v.inferPrimaryNotesLocation() + raw, err := os.ReadFile(v.settingsPath()) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return normalizeVaultSettings(VaultSettings{}, fallbackPrimary), nil + } + return VaultSettings{}, err + } + var settings VaultSettings + if err := json.Unmarshal(raw, &settings); err != nil { + return VaultSettings{}, err + } + return normalizeVaultSettings(settings, fallbackPrimary), nil +} + +func (v *Vault) SetSettings(next VaultSettings) (VaultSettings, error) { + fallbackPrimary := v.inferPrimaryNotesLocation() + normalized := normalizeVaultSettings(next, fallbackPrimary) + if err := os.MkdirAll(filepath.Dir(v.settingsPath()), 0o755); err != nil { + return VaultSettings{}, err + } + data, err := json.MarshalIndent(normalized, "", " ") + if err != nil { + return VaultSettings{}, err + } + if err := os.WriteFile(v.settingsPath(), data, 0o644); err != nil { + return VaultSettings{}, err + } + if normalized.PrimaryNotesLocation == PrimaryNotesInbox { + if err := os.MkdirAll(filepath.Join(v.root, string(FolderInbox)), 0o755); err != nil { + return VaultSettings{}, err + } + } + return cloneSettings(normalized), nil +} + +func (v *Vault) primaryNotesRoot() (string, error) { + settings, err := v.GetSettings() + if err != nil { + return "", err + } + if settings.PrimaryNotesLocation == PrimaryNotesRoot { + return v.root, nil + } + return filepath.Join(v.root, string(FolderInbox)), nil +} + +func (v *Vault) folderRoot(folder NoteFolder) (string, error) { + if folder == FolderInbox { + return v.primaryNotesRoot() + } + return filepath.Join(v.root, string(folder)), nil +} + +// EnsureLayout creates the four top-level folders and seeds a welcome +// note if the vault is empty. Matches src/main/vault.ts ensureVaultLayout. +func (v *Vault) EnsureLayout() error { + wasEmpty := v.vaultLooksEmpty() + settings, err := v.GetSettings() + if err != nil { + return err + } + for _, f := range AllFolders { + if f == FolderInbox && settings.PrimaryNotesLocation == PrimaryNotesRoot { + continue + } + if err := os.MkdirAll(filepath.Join(v.root, string(f)), 0o755); err != nil { + return err + } + } + if wasEmpty { + welcomeDir, err := v.primaryNotesRoot() + if err != nil { + return err + } + if err := os.MkdirAll(welcomeDir, 0o755); err != nil { + return err + } + welcome := filepath.Join(welcomeDir, "Welcome.md") + if _, err := os.Stat(welcome); errors.Is(err, os.ErrNotExist) { + _ = os.WriteFile(welcome, []byte(welcomeNote), 0o644) + } + } + return nil +} + +// --- Listing --- + +// ListNotes walks every top-level folder and returns metadata for each +// note. Sibling order is the directory-listing order per folder, which +// matches the TS version's behaviour for non-sorted filesystems. +func (v *Vault) ListNotes() ([]NoteMeta, error) { + v.mu.RLock() + defer v.mu.RUnlock() + + out := []NoteMeta{} + for _, folder := range AllFolders { + folderRoot, err := v.folderRoot(folder) + if err != nil { + return nil, err + } + isPrimaryRoot := folder == FolderInbox && filepath.Clean(folderRoot) == filepath.Clean(v.root) + err = filepath.WalkDir(folderRoot, func(path string, d os.DirEntry, err error) error { + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return nil + } + return err + } + if d.IsDir() { + if strings.HasPrefix(d.Name(), ".") && path != folderRoot { + return filepath.SkipDir + } + if isPrimaryRoot && path != folderRoot { + parent := filepath.Dir(path) + if filepath.Clean(parent) == filepath.Clean(folderRoot) { + if _, reserved := reservedRootNames[d.Name()]; reserved { + return filepath.SkipDir + } + } + } + return nil + } + if isPrimaryRoot { + parent := filepath.Dir(path) + if filepath.Clean(parent) == filepath.Clean(folderRoot) { + if _, reserved := reservedRootNames[d.Name()]; reserved { + return filepath.SkipDir + } + } + } + if !strings.EqualFold(filepath.Ext(d.Name()), ".md") { + return nil + } + meta, err := v.readMeta(folder, path) + if err != nil { + return nil // skip unreadable files silently + } + out = append(out, meta) + return nil + }) + if err != nil { + return nil, err + } + } + + // sibling order per directory (by appearance in out for that dir) + assignSiblingOrder(out, func(m NoteMeta) string { + return filepath.Dir(m.Path) + }, func(m *NoteMeta, i int) { m.SiblingOrder = i }) + return out, nil +} + +func assignSiblingOrder[T any](list []T, key func(T) string, set func(*T, int)) { + counts := map[string]int{} + for i := range list { + k := key(list[i]) + set(&list[i], counts[k]) + counts[k]++ + } +} + +// ListFolders enumerates every non-root subdirectory under each top-level folder. +func (v *Vault) ListFolders() ([]FolderEntry, error) { + v.mu.RLock() + defer v.mu.RUnlock() + out := []FolderEntry{} + for _, folder := range AllFolders { + folderRoot, err := v.folderRoot(folder) + if err != nil { + return nil, err + } + isPrimaryRoot := folder == FolderInbox && filepath.Clean(folderRoot) == filepath.Clean(v.root) + err = filepath.WalkDir(folderRoot, func(path string, d os.DirEntry, err error) error { + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return nil + } + return err + } + if !d.IsDir() { + return nil + } + if path == folderRoot { + return nil + } + if strings.HasPrefix(d.Name(), ".") { + return filepath.SkipDir + } + if isPrimaryRoot { + parent := filepath.Dir(path) + if filepath.Clean(parent) == filepath.Clean(folderRoot) { + if _, reserved := reservedRootNames[d.Name()]; reserved { + return filepath.SkipDir + } + } + } + rel, err := filepath.Rel(folderRoot, path) + if err != nil { + return nil + } + out = append(out, FolderEntry{ + Folder: folder, + Subpath: filepath.ToSlash(rel), + }) + return nil + }) + if err != nil { + return nil, err + } + } + sort.SliceStable(out, func(i, j int) bool { + if out[i].Folder != out[j].Folder { + return out[i].Folder < out[j].Folder + } + return out[i].Subpath < out[j].Subpath + }) + assignSiblingOrder(out, func(f FolderEntry) string { + parent := filepath.Dir(f.Subpath) + return string(f.Folder) + "/" + parent + }, func(f *FolderEntry, i int) { f.SiblingOrder = i }) + return out, nil +} + +// ListAssets walks the attachments directory. +func (v *Vault) ListAssets() ([]AssetMeta, error) { + v.mu.RLock() + defer v.mu.RUnlock() + out := []AssetMeta{} + var walk func(dir string) error + walk = func(dir string) error { + entries, err := os.ReadDir(dir) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return nil + } + return err + } + for index, entry := range entries { + name := entry.Name() + if strings.HasPrefix(name, ".") { + continue + } + full := filepath.Join(dir, name) + if entry.IsDir() { + if filepath.Clean(dir) == filepath.Clean(v.root) && name == internalVaultDir { + continue + } + if err := walk(full); err != nil { + return err + } + continue + } + if !entry.Type().IsRegular() || strings.EqualFold(filepath.Ext(name), ".md") { + continue + } + info, err := entry.Info() + if err != nil { + continue + } + rel, err := filepath.Rel(v.root, full) + if err != nil { + continue + } + out = append(out, AssetMeta{ + Path: filepath.ToSlash(rel), + Name: name, + Kind: kindForExt(strings.ToLower(filepath.Ext(name))), + SiblingOrder: index, + Size: info.Size(), + UpdatedAt: info.ModTime().UnixMilli(), + }) + } + return nil + } + if err := walk(v.root); err != nil { + return nil, err + } + sort.SliceStable(out, func(i, j int) bool { + return out[i].UpdatedAt > out[j].UpdatedAt + }) + return out, nil +} + +func (v *Vault) HasAssetsDir() bool { + v.mu.RLock() + defer v.mu.RUnlock() + if assets, err := v.ListAssets(); err == nil && len(assets) > 0 { + return true + } + for _, dir := range append([]string{PrimaryAttachmentsDir}, legacyAttachmentsDirs...) { + info, err := os.Stat(filepath.Join(v.root, dir)) + if err == nil && info.IsDir() { + return true + } + } + return false +} + +func kindForExt(ext string) string { + switch ext { + case ".apng", ".avif", ".gif", ".jpeg", ".jpg", ".png", ".svg", ".webp": + return "image" + case ".pdf": + return "pdf" + case ".aac", ".flac", ".m4a", ".mp3", ".ogg", ".wav": + return "audio" + case ".m4v", ".mov", ".mp4", ".ogv", ".webm": + return "video" + } + return "file" +} + +// --- Read / Write --- + +func (v *Vault) readMeta(folder NoteFolder, abs string) (NoteMeta, error) { + info, err := os.Stat(abs) + if err != nil { + return NoteMeta{}, err + } + body, err := os.ReadFile(abs) + if err != nil { + return NoteMeta{}, err + } + bodyStr := string(body) + + rel, err := filepath.Rel(v.root, abs) + if err != nil { + return NoteMeta{}, err + } + relPosix := filepath.ToSlash(rel) + title := strings.TrimSuffix(filepath.Base(abs), filepath.Ext(abs)) + + return NoteMeta{ + Path: relPosix, + Title: title, + Folder: folder, + CreatedAt: info.ModTime().UnixMilli(), + UpdatedAt: info.ModTime().UnixMilli(), + Size: info.Size(), + Tags: ExtractTags(bodyStr), + Wikilinks: ExtractWikilinks(bodyStr), + HasAttachments: BodyHasLocalAsset(bodyStr), + Excerpt: BuildExcerpt(bodyStr), + }, nil +} + +func (v *Vault) ReadNote(rel string) (NoteContent, error) { + v.mu.RLock() + defer v.mu.RUnlock() + abs, err := SafeJoin(v.root, rel) + if err != nil { + return NoteContent{}, err + } + info, err := os.Stat(abs) + if err != nil { + return NoteContent{}, err + } + body, err := os.ReadFile(abs) + if err != nil { + return NoteContent{}, err + } + folder, _ := v.folderOf(abs) + bodyStr := string(body) + rel = filepath.ToSlash(rel) + title := strings.TrimSuffix(filepath.Base(abs), filepath.Ext(abs)) + meta := NoteMeta{ + Path: rel, + Title: title, + Folder: folder, + CreatedAt: info.ModTime().UnixMilli(), + UpdatedAt: info.ModTime().UnixMilli(), + Size: info.Size(), + Tags: ExtractTags(bodyStr), + Wikilinks: ExtractWikilinks(bodyStr), + HasAttachments: BodyHasLocalAsset(bodyStr), + Excerpt: BuildExcerpt(bodyStr), + } + return NoteContent{NoteMeta: meta, Body: bodyStr}, nil +} + +func (v *Vault) WriteNote(rel, body string) (NoteMeta, error) { + v.mu.Lock() + defer v.mu.Unlock() + abs, err := SafeJoin(v.root, rel) + if err != nil { + return NoteMeta{}, err + } + if err := os.MkdirAll(filepath.Dir(abs), 0o755); err != nil { + return NoteMeta{}, err + } + if err := os.WriteFile(abs, []byte(body), 0o644); err != nil { + return NoteMeta{}, err + } + folder, _ := v.folderOf(abs) + return v.readMeta(folder, abs) +} + +func (v *Vault) folderOf(abs string) (NoteFolder, bool) { + rel, err := filepath.Rel(v.root, abs) + if err != nil { + return "", false + } + return FolderForRelativePath(rel) +} + +// --- Create / Rename / Delete --- + +func (v *Vault) CreateNote(folder NoteFolder, title, subpath string) (NoteMeta, error) { + v.mu.Lock() + defer v.mu.Unlock() + if !IsValidFolder(folder) { + return NoteMeta{}, fmt.Errorf("invalid folder: %s", folder) + } + if title == "" { + title = defaultTitle() + } + title = sanitizeFileStem(title) + dir, err := v.folderRoot(folder) + if err != nil { + return NoteMeta{}, err + } + if subpath != "" { + sub, err := SafeJoin(dir, subpath) + if err != nil { + return NoteMeta{}, err + } + dir = sub + } + if err := os.MkdirAll(dir, 0o755); err != nil { + return NoteMeta{}, err + } + abs := uniquePath(dir, title, ".md") + if err := os.WriteFile(abs, []byte(""), 0o644); err != nil { + return NoteMeta{}, err + } + return v.readMeta(folder, abs) +} + +func (v *Vault) RenameNote(rel, nextTitle string) (NoteMeta, error) { + v.mu.Lock() + defer v.mu.Unlock() + abs, err := SafeJoin(v.root, rel) + if err != nil { + return NoteMeta{}, err + } + nextTitle = sanitizeFileStem(nextTitle) + if nextTitle == "" { + return NoteMeta{}, errors.New("empty title") + } + dir := filepath.Dir(abs) + newAbs := uniquePath(dir, nextTitle, ".md") + if err := os.Rename(abs, newAbs); err != nil { + return NoteMeta{}, err + } + folder, _ := v.folderOf(newAbs) + return v.readMeta(folder, newAbs) +} + +func (v *Vault) DeleteNote(rel string) error { + v.mu.Lock() + defer v.mu.Unlock() + abs, err := SafeJoin(v.root, rel) + if err != nil { + return err + } + return os.Remove(abs) +} + +// --- Trash / Restore / Archive / Unarchive / Duplicate / Move --- + +func (v *Vault) MoveToTrash(rel string) (NoteMeta, error) { + return v.moveToTop(rel, FolderTrash) +} +func (v *Vault) RestoreFromTrash(rel string) (NoteMeta, error) { + return v.moveToTop(rel, FolderInbox) +} +func (v *Vault) ArchiveNote(rel string) (NoteMeta, error) { + return v.moveToTop(rel, FolderArchive) +} +func (v *Vault) UnarchiveNote(rel string) (NoteMeta, error) { + return v.moveToTop(rel, FolderInbox) +} + +func (v *Vault) moveToTop(rel string, target NoteFolder) (NoteMeta, error) { + v.mu.Lock() + defer v.mu.Unlock() + abs, err := SafeJoin(v.root, rel) + if err != nil { + return NoteMeta{}, err + } + title := strings.TrimSuffix(filepath.Base(abs), filepath.Ext(abs)) + destDir, err := v.folderRoot(target) + if err != nil { + return NoteMeta{}, err + } + if err := os.MkdirAll(destDir, 0o755); err != nil { + return NoteMeta{}, err + } + newAbs := uniquePath(destDir, title, ".md") + if err := os.Rename(abs, newAbs); err != nil { + return NoteMeta{}, err + } + return v.readMeta(target, newAbs) +} + +func (v *Vault) EmptyTrash() error { + v.mu.Lock() + defer v.mu.Unlock() + trashDir := filepath.Join(v.root, string(FolderTrash)) + entries, err := os.ReadDir(trashDir) + if err != nil { + return nil + } + for _, e := range entries { + _ = os.RemoveAll(filepath.Join(trashDir, e.Name())) + } + return nil +} + +func (v *Vault) DuplicateNote(rel string) (NoteMeta, error) { + v.mu.Lock() + defer v.mu.Unlock() + abs, err := SafeJoin(v.root, rel) + if err != nil { + return NoteMeta{}, err + } + folder, _ := v.folderOf(abs) + title := strings.TrimSuffix(filepath.Base(abs), filepath.Ext(abs)) + " copy" + newAbs := uniquePath(filepath.Dir(abs), sanitizeFileStem(title), ".md") + if err := copyFile(abs, newAbs); err != nil { + return NoteMeta{}, err + } + return v.readMeta(folder, newAbs) +} + +func (v *Vault) MoveNote(rel string, target NoteFolder, targetSubpath string) (NoteMeta, error) { + v.mu.Lock() + defer v.mu.Unlock() + abs, err := SafeJoin(v.root, rel) + if err != nil { + return NoteMeta{}, err + } + if !IsValidFolder(target) { + return NoteMeta{}, fmt.Errorf("invalid folder: %s", target) + } + destDir, err := v.folderRoot(target) + if err != nil { + return NoteMeta{}, err + } + if targetSubpath != "" { + sub, err := SafeJoin(destDir, targetSubpath) + if err != nil { + return NoteMeta{}, err + } + destDir = sub + } + if err := os.MkdirAll(destDir, 0o755); err != nil { + return NoteMeta{}, err + } + title := strings.TrimSuffix(filepath.Base(abs), filepath.Ext(abs)) + newAbs := uniquePath(destDir, title, ".md") + if err := os.Rename(abs, newAbs); err != nil { + return NoteMeta{}, err + } + return v.readMeta(target, newAbs) +} + +// --- Folders --- + +func (v *Vault) CreateFolder(folder NoteFolder, subpath string) error { + v.mu.Lock() + defer v.mu.Unlock() + if !IsValidFolder(folder) { + return fmt.Errorf("invalid folder: %s", folder) + } + base, err := v.folderRoot(folder) + if err != nil { + return err + } + abs, err := SafeJoin(base, subpath) + if err != nil { + return err + } + return os.MkdirAll(abs, 0o755) +} + +func (v *Vault) RenameFolder(folder NoteFolder, oldSub, newSub string) (string, error) { + v.mu.Lock() + defer v.mu.Unlock() + base, err := v.folderRoot(folder) + if err != nil { + return "", err + } + oldAbs, err := SafeJoin(base, oldSub) + if err != nil { + return "", err + } + newAbs, err := SafeJoin(base, newSub) + if err != nil { + return "", err + } + if err := os.MkdirAll(filepath.Dir(newAbs), 0o755); err != nil { + return "", err + } + if err := os.Rename(oldAbs, newAbs); err != nil { + return "", err + } + rel, _ := filepath.Rel(base, newAbs) + return filepath.ToSlash(rel), nil +} + +func (v *Vault) DeleteFolder(folder NoteFolder, subpath string) error { + v.mu.Lock() + defer v.mu.Unlock() + base, err := v.folderRoot(folder) + if err != nil { + return err + } + abs, err := SafeJoin(base, subpath) + if err != nil { + return err + } + if abs == base { + return errors.New("refusing to delete top-level folder") + } + return os.RemoveAll(abs) +} + +func (v *Vault) DuplicateFolder(folder NoteFolder, subpath string) (string, error) { + v.mu.Lock() + defer v.mu.Unlock() + base, err := v.folderRoot(folder) + if err != nil { + return "", err + } + src, err := SafeJoin(base, subpath) + if err != nil { + return "", err + } + parent := filepath.Dir(src) + baseName := filepath.Base(src) + " copy" + dst := uniqueDir(parent, baseName) + if err := copyDir(src, dst); err != nil { + return "", err + } + rel, _ := filepath.Rel(base, dst) + return filepath.ToSlash(rel), nil +} + +// --- Tasks --- + +func (v *Vault) ScanTasks() ([]Task, error) { + v.mu.RLock() + defer v.mu.RUnlock() + all := []Task{} + for _, folder := range []NoteFolder{FolderInbox, FolderQuick, FolderArchive} { + folderRoot, err := v.folderRoot(folder) + if err != nil { + return nil, err + } + isPrimaryRoot := folder == FolderInbox && filepath.Clean(folderRoot) == filepath.Clean(v.root) + _ = filepath.WalkDir(folderRoot, func(path string, d os.DirEntry, err error) error { + if err != nil { + return nil + } + if d.IsDir() { + if strings.HasPrefix(d.Name(), ".") && path != folderRoot { + return filepath.SkipDir + } + if isPrimaryRoot && path != folderRoot { + parent := filepath.Dir(path) + if filepath.Clean(parent) == filepath.Clean(folderRoot) { + if _, reserved := reservedRootNames[d.Name()]; reserved { + return filepath.SkipDir + } + } + } + return nil + } + if isPrimaryRoot { + parent := filepath.Dir(path) + if filepath.Clean(parent) == filepath.Clean(folderRoot) { + if _, reserved := reservedRootNames[d.Name()]; reserved { + return nil + } + } + } + if !strings.EqualFold(filepath.Ext(d.Name()), ".md") { + return nil + } + body, err := os.ReadFile(path) + if err != nil { + return nil + } + rel, _ := filepath.Rel(v.root, path) + relPosix := filepath.ToSlash(rel) + title := strings.TrimSuffix(filepath.Base(path), filepath.Ext(path)) + tasks := ParseTasks(relPosix, title, folder, string(body)) + all = append(all, tasks...) + return nil + }) + } + return all, nil +} + +func (v *Vault) ScanTasksForPath(rel string) ([]Task, error) { + v.mu.RLock() + defer v.mu.RUnlock() + abs, err := SafeJoin(v.root, rel) + if err != nil { + return nil, err + } + body, err := os.ReadFile(abs) + if err != nil { + return nil, err + } + folder, _ := v.folderOf(abs) + title := strings.TrimSuffix(filepath.Base(abs), filepath.Ext(abs)) + return ParseTasks(filepath.ToSlash(rel), title, folder, string(body)), nil +} + +// --- Text search --- + +func (v *Vault) SearchCapabilities() TextSearchCapabilities { + return TextSearchCapabilities{Ripgrep: false, Fzf: false} +} + +func (v *Vault) SearchText(query string) ([]TextSearchMatch, error) { + v.mu.RLock() + defer v.mu.RUnlock() + query = strings.TrimSpace(query) + if query == "" { + return []TextSearchMatch{}, nil + } + needle := strings.ToLower(query) + out := []TextSearchMatch{} + for _, folder := range []NoteFolder{FolderInbox, FolderQuick, FolderArchive} { + folderRoot, err := v.folderRoot(folder) + if err != nil { + return nil, err + } + isPrimaryRoot := folder == FolderInbox && filepath.Clean(folderRoot) == filepath.Clean(v.root) + _ = filepath.WalkDir(folderRoot, func(path string, d os.DirEntry, err error) error { + if err != nil { + return nil + } + if d.IsDir() { + if strings.HasPrefix(d.Name(), ".") && path != folderRoot { + return filepath.SkipDir + } + if isPrimaryRoot && path != folderRoot { + parent := filepath.Dir(path) + if filepath.Clean(parent) == filepath.Clean(folderRoot) { + if _, reserved := reservedRootNames[d.Name()]; reserved { + return filepath.SkipDir + } + } + } + return nil + } + if isPrimaryRoot { + parent := filepath.Dir(path) + if filepath.Clean(parent) == filepath.Clean(folderRoot) { + if _, reserved := reservedRootNames[d.Name()]; reserved { + return nil + } + } + } + if !strings.EqualFold(filepath.Ext(d.Name()), ".md") { + return nil + } + body, err := os.ReadFile(path) + if err != nil { + return nil + } + rel, _ := filepath.Rel(v.root, path) + relPosix := filepath.ToSlash(rel) + title := strings.TrimSuffix(filepath.Base(path), filepath.Ext(path)) + lines := strings.Split(string(body), "\n") + offset := 0 + for i, line := range lines { + if strings.Contains(strings.ToLower(line), needle) { + collapsed := wsCollapseRe.ReplaceAllString(line, " ") + collapsed = strings.TrimSpace(collapsed) + if len(collapsed) > 220 { + collapsed = collapsed[:220] + } + out = append(out, TextSearchMatch{ + Path: relPosix, + Title: title, + Folder: folder, + LineNumber: i + 1, + Offset: offset, + LineText: collapsed, + }) + } + offset += len(line) + 1 + } + return nil + }) + } + if len(out) > 200 { + out = out[:200] + } + return out, nil +} + +// --- Assets upload + raw serving --- + +// ImportAsset writes raw bytes into the vault root and returns the +// markdown snippet to embed relative to the source note. +func (v *Vault) ImportAsset(notePath, filename string, body io.Reader) (ImportedAsset, error) { + v.mu.Lock() + defer v.mu.Unlock() + if err := os.MkdirAll(v.root, 0o755); err != nil { + return ImportedAsset{}, err + } + safeName := sanitizeFileName(filename) + if safeName == "" { + safeName = "file" + } + ext := filepath.Ext(safeName) + stem := strings.TrimSuffix(safeName, ext) + abs := uniquePath(v.root, stem, ext) + f, err := os.Create(abs) + if err != nil { + return ImportedAsset{}, err + } + defer f.Close() + if _, err := io.Copy(f, body); err != nil { + return ImportedAsset{}, err + } + rel := filepath.ToSlash(filepath.Base(abs)) + noteDir := filepath.Dir(filepath.FromSlash(notePath)) + if noteDir == "." { + noteDir = "" + } + markdownPath := rel + if noteDir != "" { + if relative, err := filepath.Rel(noteDir, rel); err == nil { + markdownPath = filepath.ToSlash(relative) + } + } + kind := kindForExt(strings.ToLower(filepath.Ext(abs))) + markdown := makeAssetMarkdown(markdownPath, kind, filepath.Base(abs)) + return ImportedAsset{ + Name: filepath.Base(abs), + Path: rel, + Markdown: markdown, + Kind: kind, + }, nil +} + +func (v *Vault) AssetAbsPath(rel string) (string, error) { + v.mu.RLock() + defer v.mu.RUnlock() + return SafeJoin(v.root, rel) +} + +func makeAssetMarkdown(relPath, kind, name string) string { + dest := "<" + strings.ReplaceAll(relPath, ">", "%3E") + ">" + switch kind { + case "image": + return "![" + name + "](" + dest + ")" + default: + return "[" + name + "](" + dest + ")" + } +} + +// --- Misc helpers --- + +var forbiddenFilenameChars = []string{"/", "\\", ":", "*", "?", "\"", "<", ">", "|"} + +func sanitizeFileStem(title string) string { + t := title + for _, c := range forbiddenFilenameChars { + t = strings.ReplaceAll(t, c, "") + } + t = strings.TrimSpace(t) + if t == "" { + t = defaultTitle() + } + return t +} + +func sanitizeFileName(name string) string { + ext := filepath.Ext(name) + stem := strings.TrimSuffix(name, ext) + return sanitizeFileStem(stem) + ext +} + +func defaultTitle() string { + return "Untitled-" + time.Now().Format("2006-01-02-150405") +} + +func uniquePath(dir, stem, ext string) string { + candidate := filepath.Join(dir, stem+ext) + if _, err := os.Stat(candidate); errors.Is(err, os.ErrNotExist) { + return candidate + } + for i := 2; ; i++ { + candidate = filepath.Join(dir, fmt.Sprintf("%s %d%s", stem, i, ext)) + if _, err := os.Stat(candidate); errors.Is(err, os.ErrNotExist) { + return candidate + } + } +} + +func uniqueDir(parent, base string) string { + candidate := filepath.Join(parent, base) + if _, err := os.Stat(candidate); errors.Is(err, os.ErrNotExist) { + return candidate + } + for i := 2; ; i++ { + candidate = filepath.Join(parent, fmt.Sprintf("%s %d", base, i)) + if _, err := os.Stat(candidate); errors.Is(err, os.ErrNotExist) { + return candidate + } + } +} + +func copyFile(src, dst string) error { + in, err := os.Open(src) + if err != nil { + return err + } + defer in.Close() + out, err := os.Create(dst) + if err != nil { + return err + } + defer out.Close() + _, err = io.Copy(out, in) + return err +} + +func copyDir(src, dst string) error { + return filepath.WalkDir(src, func(path string, d os.DirEntry, err error) error { + if err != nil { + return err + } + rel, err := filepath.Rel(src, path) + if err != nil { + return err + } + target := filepath.Join(dst, rel) + if d.IsDir() { + return os.MkdirAll(target, 0o755) + } + return copyFile(path, target) + }) +} + +const welcomeNote = `# Welcome to ZenNotes + +ZenNotes keeps your notes as plain markdown files. Press ` + "`?`" + ` to see the +keybinding cheat sheet, or start typing to begin. + +- Notes live in ` + "`inbox/`" + `, ` + "`quick/`" + `, ` + "`archive/`" + `, and ` + "`trash/`" + `. +- Every word you write stays on disk, under your control. +- Vim motions are on by default. +` diff --git a/apps/server/internal/watcher/watcher.go b/apps/server/internal/watcher/watcher.go new file mode 100644 index 00000000..61933512 --- /dev/null +++ b/apps/server/internal/watcher/watcher.go @@ -0,0 +1,163 @@ +package watcher + +import ( + "log" + "os" + "path/filepath" + "strings" + "sync" + + "github.com/ZenNotes/zennotes/apps/server/internal/vault" + "github.com/fsnotify/fsnotify" +) + +// Watcher recursively watches the vault root and fans out change +// events to any subscribed channels. Mirrors the chokidar-based +// watcher in src/main/watcher.ts. +type Watcher struct { + root string + fs *fsnotify.Watcher + mu sync.Mutex + subs map[chan vault.ChangeEvent]struct{} + closed bool + stopCh chan struct{} +} + +func Start(root string) (*Watcher, error) { + fsw, err := fsnotify.NewWatcher() + if err != nil { + return nil, err + } + w := &Watcher{ + root: root, + fs: fsw, + subs: map[chan vault.ChangeEvent]struct{}{}, + stopCh: make(chan struct{}), + } + // Recursively add all existing directories under the vault. + _ = filepath.WalkDir(root, func(path string, d os.DirEntry, err error) error { + if err != nil { + return nil + } + if d.IsDir() { + name := d.Name() + if path != root && strings.HasPrefix(name, ".") { + return filepath.SkipDir + } + _ = fsw.Add(path) + } + return nil + }) + go w.loop() + return w, nil +} + +func (w *Watcher) Subscribe() (<-chan vault.ChangeEvent, func()) { + ch := make(chan vault.ChangeEvent, 64) + w.mu.Lock() + w.subs[ch] = struct{}{} + w.mu.Unlock() + return ch, func() { + w.mu.Lock() + if _, ok := w.subs[ch]; ok { + delete(w.subs, ch) + close(ch) + } + w.mu.Unlock() + } +} + +func (w *Watcher) Close() { + w.mu.Lock() + if w.closed { + w.mu.Unlock() + return + } + w.closed = true + close(w.stopCh) + for ch := range w.subs { + delete(w.subs, ch) + close(ch) + } + w.mu.Unlock() + _ = w.fs.Close() +} + +func (w *Watcher) loop() { + for { + select { + case <-w.stopCh: + return + case err, ok := <-w.fs.Errors: + if !ok { + return + } + log.Printf("watcher error: %v", err) + case ev, ok := <-w.fs.Events: + if !ok { + return + } + w.handle(ev) + } + } +} + +func (w *Watcher) handle(ev fsnotify.Event) { + base := filepath.Base(ev.Name) + if strings.HasPrefix(base, ".") { + return + } + info, statErr := os.Stat(ev.Name) + if statErr == nil && info.IsDir() { + if ev.Op&fsnotify.Create != 0 { + _ = w.fs.Add(ev.Name) + } + return + } + rel, err := filepath.Rel(w.root, ev.Name) + if err != nil { + return + } + relPosix := filepath.ToSlash(rel) + if strings.HasPrefix(relPosix, ".") || strings.Contains(relPosix, "/.") { + return + } + folder, ok := vault.FolderForRelativePath(relPosix) + if !ok { + if relPosix == vault.PrimaryAttachmentsDir || + strings.HasPrefix(relPosix, vault.PrimaryAttachmentsDir+"/") || + relPosix == "_assets" || + strings.HasPrefix(relPosix, "_assets/") { + folder = vault.FolderInbox + } else { + return + } + } + + kind := "" + switch { + case ev.Op&fsnotify.Create != 0: + kind = "add" + case ev.Op&fsnotify.Write != 0: + kind = "change" + case ev.Op&fsnotify.Remove != 0, ev.Op&fsnotify.Rename != 0: + kind = "unlink" + default: + return + } + + change := vault.ChangeEvent{ + Kind: kind, + Path: relPosix, + Folder: folder, + } + + w.mu.Lock() + for ch := range w.subs { + select { + case ch <- change: + default: + } + } + w.mu.Unlock() +} diff --git a/apps/server/package.json b/apps/server/package.json new file mode 100644 index 00000000..cd34fe8a --- /dev/null +++ b/apps/server/package.json @@ -0,0 +1,13 @@ +{ + "name": "@zennotes/server", + "private": true, + "version": "1.0.4", + "scripts": { + "dev": "go run ./cmd/zennotes-server", + "typecheck": "go test ./...", + "test": "go test ./...", + "test:run": "go test ./...", + "sync-web": "node ../../tooling/scripts/sync-web-dist.mjs", + "build": "npm run sync-web && go build -trimpath -ldflags='-s -w' -o bin/zennotes-server ./cmd/zennotes-server" + } +} diff --git a/apps/server/web/embed.go b/apps/server/web/embed.go new file mode 100644 index 00000000..0fedd582 --- /dev/null +++ b/apps/server/web/embed.go @@ -0,0 +1,16 @@ +package web + +import ( + "embed" + "io/fs" +) + +//go:embed all:dist +var dist embed.FS + +// Dist returns the embedded PWA bundle rooted at `dist/`. When the +// client bundle has not been built yet, the subtree is empty and the +// caller should fall back to proxying to Vite dev in development. +func Dist() (fs.FS, error) { + return fs.Sub(dist, "dist") +} diff --git a/apps/web/index.html b/apps/web/index.html new file mode 100644 index 00000000..67fe6858 --- /dev/null +++ b/apps/web/index.html @@ -0,0 +1,39 @@ + + + + + + + + + + ZenNotes + + +
+ + + + diff --git a/apps/web/package.json b/apps/web/package.json new file mode 100644 index 00000000..32d81a0c --- /dev/null +++ b/apps/web/package.json @@ -0,0 +1,66 @@ +{ + "name": "@zennotes/web", + "private": true, + "version": "1.0.4", + "type": "module", + "description": "ZenNotes web client for self-hosted and hosted deployments", + "homepage": "https://zennotes.org", + "scripts": { + "dev": "vite", + "build": "tsc --noEmit && vite build", + "build:nocheck": "vite build", + "preview": "vite preview", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@zennotes/app-core": "*", + "@zennotes/bridge-contract": "*", + "@zennotes/shared-domain": "*", + "@codemirror/autocomplete": "^6.18.3", + "@codemirror/commands": "^6.7.1", + "@codemirror/lang-markdown": "^6.3.1", + "@codemirror/language": "^6.10.6", + "@codemirror/language-data": "^6.5.1", + "@codemirror/search": "^6.5.8", + "@codemirror/state": "^6.5.0", + "@codemirror/view": "^6.35.3", + "@lezer/highlight": "^1.2.1", + "@replit/codemirror-vim": "^6.3.0", + "codemirror": "^6.0.1", + "dompurify": "^3.3.4", + "function-plot": "^1.25.3", + "fuse.js": "^7.0.0", + "gray-matter": "^4.0.3", + "highlight.js": "^11.10.0", + "jsxgraph": "^1.12.2", + "katex": "^0.16.15", + "mermaid": "^11.4.1", + "prettier": "^3.8.2", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "rehype-highlight": "^7.0.1", + "rehype-katex": "^7.0.1", + "rehype-raw": "^7.0.0", + "rehype-stringify": "^10.0.1", + "remark-breaks": "^4.0.0", + "remark-frontmatter": "^5.0.0", + "remark-gfm": "^4.0.0", + "remark-math": "^6.0.0", + "remark-parse": "^11.0.0", + "remark-rehype": "^11.1.1", + "unified": "^11.0.5", + "unist-util-visit": "^5.0.0", + "zustand": "^5.0.2" + }, + "devDependencies": { + "@types/node": "^22.10.5", + "@types/react": "^18.3.17", + "@types/react-dom": "^18.3.5", + "@vitejs/plugin-react": "^4.3.4", + "autoprefixer": "^10.4.20", + "postcss": "^8.5.10", + "tailwindcss": "^3.4.17", + "typescript": "^5.7.2", + "vite": "^5.4.11" + } +} diff --git a/apps/web/postcss.config.js b/apps/web/postcss.config.js new file mode 100644 index 00000000..2b75bd8a --- /dev/null +++ b/apps/web/postcss.config.js @@ -0,0 +1,6 @@ +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {} + } +} diff --git a/apps/web/public/manifest.webmanifest b/apps/web/public/manifest.webmanifest new file mode 100644 index 00000000..e8bd4630 --- /dev/null +++ b/apps/web/public/manifest.webmanifest @@ -0,0 +1,24 @@ +{ + "name": "ZenNotes", + "short_name": "ZenNotes", + "description": "Keyboard-first markdown notes with Vim motions and plain local files.", + "start_url": "/", + "scope": "/", + "display": "standalone", + "background_color": "#f7f5f0", + "theme_color": "#f7f5f0", + "icons": [ + { + "src": "/icon-192.png", + "sizes": "192x192", + "type": "image/png", + "purpose": "any maskable" + }, + { + "src": "/icon-512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "any maskable" + } + ] +} diff --git a/apps/web/public/sw.js b/apps/web/public/sw.js new file mode 100644 index 00000000..2a8e79ac --- /dev/null +++ b/apps/web/public/sw.js @@ -0,0 +1,61 @@ +// Minimal PWA service worker for ZenNotes Web. +// +// Strategy: +// - App shell (HTML / JS / CSS bundles): cache-first after first load +// so the PWA opens instantly and works offline for reads. +// - API requests (/api/*): always hit the network; those responses +// are not cached here (the IndexedDB layer inside the app can do +// smarter per-note caching later). +// +// This file is deliberately small — Workbox or similar can replace it +// later without changing the UI. + +const CACHE_NAME = 'zennotes-shell-v1' +const APP_SHELL = ['/', '/index.html', '/manifest.webmanifest'] + +self.addEventListener('install', (event) => { + event.waitUntil( + caches + .open(CACHE_NAME) + .then((cache) => cache.addAll(APP_SHELL).catch(() => {})) + .then(() => self.skipWaiting()) + ) +}) + +self.addEventListener('activate', (event) => { + event.waitUntil( + caches + .keys() + .then((keys) => + Promise.all(keys.filter((k) => k !== CACHE_NAME).map((k) => caches.delete(k))) + ) + .then(() => self.clients.claim()) + ) +}) + +self.addEventListener('fetch', (event) => { + const req = event.request + if (req.method !== 'GET') return + const url = new URL(req.url) + + // Never cache API or websocket traffic. + if (url.pathname.startsWith('/api/')) return + + // Same-origin assets: cache-first with background refresh. + if (url.origin === self.location.origin) { + event.respondWith( + caches.match(req).then((cached) => { + const network = fetch(req) + .then((res) => { + if (res && res.status === 200 && res.type === 'basic') { + const copy = res.clone() + caches.open(CACHE_NAME).then((cache) => cache.put(req, copy)) + } + return res + }) + .catch(() => cached) + return cached || network + }) + ) + } +}) diff --git a/apps/web/src/bridge/http-bridge.ts b/apps/web/src/bridge/http-bridge.ts new file mode 100644 index 00000000..f0bff09f --- /dev/null +++ b/apps/web/src/bridge/http-bridge.ts @@ -0,0 +1,813 @@ +/** + * HTTP/WebSocket implementation of the `window.zen` API. + * + * The Electron preload (`src/preload/index.ts` in the desktop build) + * exposes a `zen` object on `window` with ~60 methods. The web client + * needs an object with the exact same shape, backed by HTTP calls to + * the Go server instead of Electron IPC. Swapping this object is the + * one and only change needed to keep every UI component in + * `src/components/**` working without edits. + * + * Not every desktop-only method has a meaningful web equivalent + * (native menus, window chrome, auto-updater, TikZ subprocess). Those + * resolve to sensible no-ops or "unsupported" states so the UI never + * crashes; the user just doesn't see the corresponding affordance. + */ + +import appPackage from '../../package.json' +import { + installZenBridge, + type ZenAppInfo, + type ZenBridge, + type ZenCapabilities +} from '@zennotes/bridge-contract/bridge' +import type { + AppUpdateState, + AssetMeta, + DirectoryBrowseResult, + FolderEntry, + ImportedAsset, + NoteContent, + NoteFolder, + NoteMeta, + VaultSettings, + TikzRenderResponse, + VaultChangeEvent, + VaultDemoTourResult, + VaultInfo, + VaultTextSearchBackendPreference, + VaultTextSearchCapabilities, + VaultTextSearchMatch, + VaultTextSearchToolPaths +} from '@shared/ipc' +import type { VaultTask } from '@shared/tasks' +import type { + McpClientId, + McpClientStatus, + McpInstructionsPayload, + McpServerRuntime +} from '@shared/mcp-clients' + +const WEB_CAPABILITIES: ZenCapabilities = { + supportsUpdater: false, + supportsNativeMenus: false, + supportsFloatingWindows: false, + supportsLocalFilesystemPickers: false, + supportsDesktopNotifications: false +} + +const WEB_APP_INFO: ZenAppInfo = { + name: appPackage.name, + productName: 'ZenNotes', + version: appPackage.version, + description: appPackage.description, + homepage: appPackage.homepage, + runtime: 'web' +} + +const API_BASE = '/api' + +type JsonBody = Record | unknown[] +type JsonRequestInit = Omit & { body?: JsonBody } + +class HttpRequestError extends Error { + status: number + path: string + + constructor(status: number, path: string, message: string) { + super(message) + this.name = 'HttpRequestError' + this.status = status + this.path = path + } +} + +function wrapRouteUpgradeError(path: string, err: unknown): never { + if ( + err instanceof HttpRequestError && + err.status === 404 && + (path.startsWith('/fs/browse') || path === '/vault/select') + ) { + throw new Error( + 'Your ZenNotes server is running an older build and does not support the new vault picker yet. Restart `npm run dev:server` and reload the page.' + ) + } + throw err instanceof Error ? err : new Error(String(err)) +} + +async function jsonRequest( + path: string, + init?: JsonRequestInit +): Promise { + const headers = new Headers(init?.headers) + const hasBody = init?.body !== undefined + if (hasBody && !headers.has('Content-Type')) { + headers.set('Content-Type', 'application/json') + } + const res = await fetch(`${API_BASE}${path}`, { + ...init, + headers, + body: hasBody ? JSON.stringify(init!.body) : undefined, + credentials: 'same-origin' + }) + if (!res.ok) { + const text = await res.text().catch(() => '') + throw new HttpRequestError( + res.status, + path, + `HTTP ${res.status} ${res.statusText} for ${path}${text ? `: ${text}` : ''}` + ) + } + if (res.status === 204) return undefined as unknown as T + const ctype = res.headers.get('Content-Type') || '' + if (ctype.includes('application/json')) { + return (await res.json()) as T + } + return (await res.text()) as unknown as T +} + +function notImplemented(name: string): never { + throw new Error(`zen.${name} is not available in the web build`) +} + +// -------------------------------------------------------------------- +// Platform / system +// -------------------------------------------------------------------- + +let cachedPlatform: NodeJS.Platform | null = null +async function platform(): Promise { + if (cachedPlatform) return cachedPlatform + const ua = navigator.userAgent.toLowerCase() + let guess: NodeJS.Platform = 'linux' + if (ua.includes('mac') || ua.includes('iphone') || ua.includes('ipad')) guess = 'darwin' + else if (ua.includes('win')) guess = 'win32' + try { + const resp = await jsonRequest<{ platform: NodeJS.Platform }>('/platform') + cachedPlatform = resp.platform || guess + } catch { + cachedPlatform = guess + } + return cachedPlatform +} + +function platformSync(): NodeJS.Platform { + if (cachedPlatform) return cachedPlatform + const ua = navigator.userAgent.toLowerCase() + if (ua.includes('mac') || ua.includes('iphone') || ua.includes('ipad')) return 'darwin' + if (ua.includes('win')) return 'win32' + return 'linux' +} + +// -------------------------------------------------------------------- +// Vault info +// -------------------------------------------------------------------- + +async function getCurrentVault(): Promise { + try { + return await jsonRequest('/vault') + } catch { + return null + } +} + +function getVaultSettings(): Promise { + return jsonRequest('/vault/settings') +} + +function setVaultSettings(next: VaultSettings): Promise { + return jsonRequest('/vault/settings', { + method: 'POST', + body: next as unknown as Record + }) +} + +async function pickVault(): Promise { + const current = await getCurrentVault() + const suggested = current?.root ?? '' + const nextPath = window.prompt( + 'Enter the path to the vault directory on the server running ZenNotes.', + suggested + ) + if (!nextPath || !nextPath.trim()) return null + try { + return await jsonRequest('/vault/select', { + method: 'POST', + body: { path: nextPath.trim() } + }) + } catch (err) { + window.alert((err as Error).message) + return null + } +} + +function selectVaultPath(path: string): Promise { + return jsonRequest('/vault/select', { + method: 'POST', + body: { path } + }).catch((err) => wrapRouteUpgradeError('/vault/select', err)) +} + +function browseServerDirectories(path = ''): Promise { + const query = path ? `?path=${encodeURIComponent(path)}` : '' + return jsonRequest(`/fs/browse${query}`).catch((err) => + wrapRouteUpgradeError('/fs/browse', err) + ) +} + +// -------------------------------------------------------------------- +// Note listing / reading / writing +// -------------------------------------------------------------------- + +function listNotes(): Promise { + return jsonRequest('/notes') +} + +function listFolders(): Promise { + return jsonRequest('/folders') +} + +function listAssets(): Promise { + return jsonRequest('/assets') +} + +function hasAssetsDir(): Promise { + return jsonRequest<{ exists: boolean }>('/assets/exists').then(r => r.exists) +} + +function readNote(relPath: string): Promise { + return jsonRequest(`/notes/read?path=${encodeURIComponent(relPath)}`) +} + +function writeNote(relPath: string, body: string): Promise { + return jsonRequest('/notes/write', { + method: 'POST', + body: { path: relPath, body } + }) +} + +function createNote( + folder: NoteFolder, + title?: string, + subpath?: string +): Promise { + return jsonRequest('/notes/create', { + method: 'POST', + body: { folder, title, subpath } + }) +} + +function renameNote(relPath: string, nextTitle: string): Promise { + return jsonRequest('/notes/rename', { + method: 'POST', + body: { path: relPath, title: nextTitle } + }) +} + +function deleteNote(relPath: string): Promise { + return jsonRequest('/notes/delete', { + method: 'POST', + body: { path: relPath } + }) +} + +function moveToTrash(relPath: string): Promise { + return jsonRequest('/notes/trash', { + method: 'POST', + body: { path: relPath } + }) +} + +function restoreFromTrash(relPath: string): Promise { + return jsonRequest('/notes/restore', { + method: 'POST', + body: { path: relPath } + }) +} + +function emptyTrash(): Promise { + return jsonRequest('/notes/empty-trash', { method: 'POST' }) +} + +function archiveNote(relPath: string): Promise { + return jsonRequest('/notes/archive', { + method: 'POST', + body: { path: relPath } + }) +} + +function unarchiveNote(relPath: string): Promise { + return jsonRequest('/notes/unarchive', { + method: 'POST', + body: { path: relPath } + }) +} + +function duplicateNote(relPath: string): Promise { + return jsonRequest('/notes/duplicate', { + method: 'POST', + body: { path: relPath } + }) +} + +function moveNote( + relPath: string, + targetFolder: NoteFolder, + targetSubpath: string +): Promise { + return jsonRequest('/notes/move', { + method: 'POST', + body: { path: relPath, targetFolder, targetSubpath } + }) +} + +async function revealNote(_relPath: string): Promise { + // No OS file manager on the web. +} + +async function revealFolder(_folder: NoteFolder, _subpath: string): Promise { + // No OS file manager on the web. +} + +async function revealAssetsDir(): Promise { + // No OS file manager on the web. +} + +// -------------------------------------------------------------------- +// Folders +// -------------------------------------------------------------------- + +function createFolder(folder: NoteFolder, subpath: string): Promise { + return jsonRequest('/folders/create', { + method: 'POST', + body: { folder, subpath } + }) +} + +function renameFolder( + folder: NoteFolder, + oldSubpath: string, + newSubpath: string +): Promise { + return jsonRequest<{ subpath: string }>('/folders/rename', { + method: 'POST', + body: { folder, oldSubpath, newSubpath } + }).then(r => r.subpath) +} + +function deleteFolder(folder: NoteFolder, subpath: string): Promise { + return jsonRequest('/folders/delete', { + method: 'POST', + body: { folder, subpath } + }) +} + +function duplicateFolder(folder: NoteFolder, subpath: string): Promise { + return jsonRequest<{ subpath: string }>('/folders/duplicate', { + method: 'POST', + body: { folder, subpath } + }).then(r => r.subpath) +} + +// -------------------------------------------------------------------- +// Search +// -------------------------------------------------------------------- + +function getVaultTextSearchCapabilities( + _paths: VaultTextSearchToolPaths = {} +): Promise { + return jsonRequest('/search/capabilities') +} + +function searchVaultText( + query: string, + backend: VaultTextSearchBackendPreference = 'auto', + _paths: VaultTextSearchToolPaths = {} +): Promise { + const qs = new URLSearchParams({ q: query, backend }) + return jsonRequest(`/search/text?${qs.toString()}`) +} + +// -------------------------------------------------------------------- +// Tasks +// -------------------------------------------------------------------- + +function scanTasks(): Promise { + return jsonRequest('/tasks') +} + +function scanTasksForPath(relPath: string): Promise { + return jsonRequest(`/tasks/for?path=${encodeURIComponent(relPath)}`) +} + +// -------------------------------------------------------------------- +// Demo tour +// -------------------------------------------------------------------- + +function generateDemoTour(): Promise { + return jsonRequest('/demo/generate', { method: 'POST' }) +} + +function removeDemoTour(): Promise { + return jsonRequest('/demo/remove', { method: 'POST' }) +} + +// -------------------------------------------------------------------- +// Assets (uploads, zen-asset URL resolution) +// -------------------------------------------------------------------- + +async function importFilesToNote( + notePath: string, + sourcePaths: string[] +): Promise { + // In the browser "sourcePaths" carries File[] smuggled through + // getPathForFile (which returns the File object itself in the web + // build — see below). Upload each as multipart. + const results: ImportedAsset[] = [] + for (const raw of sourcePaths) { + const file = webDroppedFiles.get(raw) + if (!file) continue + const form = new FormData() + form.append('file', file, file.name) + form.append('notePath', notePath) + const res = await fetch(`${API_BASE}/assets/upload`, { + method: 'POST', + body: form, + credentials: 'same-origin' + }) + if (!res.ok) throw new Error(`upload failed: ${res.status}`) + const asset = (await res.json()) as ImportedAsset + results.push(asset) + webDroppedFiles.delete(raw) + } + return results +} + +// Bucket for File objects "pretending" to be filesystem paths. The +// renderer expects `getPathForFile` to return a string it can later +// pass to `importFilesToNote`. On the web, we mint a synthetic token +// here and look it up at import time. +const webDroppedFiles = new Map() + +function getPathForFile(file: File): string | null { + if (!file) return null + const token = `web-drop://${crypto.randomUUID()}/${encodeURIComponent(file.name)}` + webDroppedFiles.set(token, file) + return token +} + +function resolveLocalAssetUrl( + _vaultRoot: string, + notePath: string, + href: string +): string | null { + const trimmed = href.trim() + if (!trimmed || trimmed.startsWith('#') || trimmed.startsWith('//')) return null + if (/^[a-zA-Z][a-zA-Z\d+.-]*:/.test(trimmed)) return null + + const stripQueryAndHash = (value: string): string => { + const hashIdx = value.indexOf('#') + const queryIdx = value.indexOf('?') + const cutIdx = + hashIdx === -1 + ? queryIdx + : queryIdx === -1 + ? hashIdx + : Math.min(hashIdx, queryIdx) + return cutIdx === -1 ? value : value.slice(0, cutIdx) + } + const decodeHrefPath = (value: string): string => { + const cleaned = stripQueryAndHash(value) + try { + return decodeURIComponent(cleaned) + } catch { + return cleaned + } + } + + const noteDir = notePath.includes('/') ? notePath.slice(0, notePath.lastIndexOf('/')) : '' + const decodedHref = decodeHrefPath(trimmed) + let target: string + if (decodedHref.startsWith('/')) { + target = decodedHref.replace(/^\/+/, '') + } else if (noteDir) { + target = posixJoin(noteDir, decodedHref) + } else { + target = decodedHref + } + target = posixNormalize(target) + if (target.startsWith('../') || target === '..') return null + return `${API_BASE}/assets/raw?path=${encodeURIComponent(target)}` +} + +function resolveVaultAssetUrl(_vaultRoot: string, assetPath: string): string | null { + const trimmed = assetPath.trim() + if (!trimmed) return null + const normalized = posixNormalize(trimmed.replace(/^\/+/, '')) + if (normalized.startsWith('../') || normalized === '..') return null + return `${API_BASE}/assets/raw?path=${encodeURIComponent(normalized)}` +} + +function posixJoin(a: string, b: string): string { + if (!a) return b + if (!b) return a + if (a.endsWith('/')) return `${a}${b}` + return `${a}/${b}` +} + +function posixNormalize(input: string): string { + const parts = input.split('/') + const out: string[] = [] + for (const part of parts) { + if (!part || part === '.') continue + if (part === '..') { + if (out.length === 0) return '..' + out.pop() + } else { + out.push(part) + } + } + return out.join('/') +} + +// -------------------------------------------------------------------- +// WebSocket watcher (vault change events) +// -------------------------------------------------------------------- + +type VaultChangeListener = (ev: VaultChangeEvent) => void +const vaultChangeListeners = new Set() +let watchSocket: WebSocket | null = null +let watchReconnectTimer: number | null = null + +function ensureWatchSocket(): void { + if (watchSocket && watchSocket.readyState <= 1) return + const proto = window.location.protocol === 'https:' ? 'wss:' : 'ws:' + const url = `${proto}//${window.location.host}${API_BASE}/watch` + const ws = new WebSocket(url) + watchSocket = ws + ws.addEventListener('message', e => { + try { + const ev = JSON.parse(String(e.data)) as VaultChangeEvent + for (const cb of vaultChangeListeners) cb(ev) + } catch { + // ignore malformed frames + } + }) + ws.addEventListener('close', () => { + watchSocket = null + if (vaultChangeListeners.size > 0 && watchReconnectTimer === null) { + watchReconnectTimer = window.setTimeout(() => { + watchReconnectTimer = null + ensureWatchSocket() + }, 1500) + } + }) + ws.addEventListener('error', () => { + ws.close() + }) +} + +function onVaultChange(cb: VaultChangeListener): () => void { + vaultChangeListeners.add(cb) + ensureWatchSocket() + return () => { + vaultChangeListeners.delete(cb) + if (vaultChangeListeners.size === 0 && watchSocket) { + watchSocket.close() + watchSocket = null + } + } +} + +// -------------------------------------------------------------------- +// Settings / updater / window (stubs for web) +// -------------------------------------------------------------------- + +const settingsListeners = new Set<() => void>() +function onOpenSettings(cb: () => void): () => void { + settingsListeners.add(cb) + return () => settingsListeners.delete(cb) +} + +async function getAppIconDataUrl(): Promise { + return null +} + +async function listSystemFonts(): Promise { + // Baseline cross-platform fonts. The desktop build enumerates via + // node-font-list; the browser can't. This gives the settings + // font-picker a usable default set. + return [ + 'Arial', + 'Avenir', + 'Charter', + 'Georgia', + 'Helvetica', + 'Helvetica Neue', + 'Iowan Old Style', + 'JetBrains Mono', + 'Menlo', + 'Monaco', + 'SF Mono', + 'SF Pro Text', + 'Segoe UI', + 'Source Serif Pro', + 'Times New Roman', + 'Verdana' + ] +} + +async function zoomInApp(): Promise { + return 1 +} +async function zoomOutApp(): Promise { + return 1 +} +async function resetAppZoom(): Promise { + return 1 +} + +const unsupportedUpdateState: AppUpdateState = { + phase: 'unsupported', + currentVersion: '0.0.0-web', + availableVersion: null, + releaseName: null, + releaseDate: null, + releaseNotes: null, + progressPercent: null, + transferredBytes: null, + totalBytes: null, + bytesPerSecond: null, + message: 'The web build updates automatically when you reload.' +} + +async function getAppUpdateState(): Promise { + return unsupportedUpdateState +} +async function checkForAppUpdates(): Promise { + return unsupportedUpdateState +} +async function checkForAppUpdatesWithUi(): Promise { + window.location.reload() +} +async function downloadAppUpdate(): Promise { + return unsupportedUpdateState +} +async function installAppUpdate(): Promise { + window.location.reload() +} + +function onAppUpdateState(_cb: (state: AppUpdateState) => void): () => void { + return () => {} +} + +function windowMinimize(): void {} +function windowToggleMaximize(): void {} +function windowClose(): void {} +async function openNoteWindow(relPath: string): Promise { + const url = `${window.location.origin}/?note=${encodeURIComponent(relPath)}` + window.open(url, '_blank', 'noopener') +} + +async function renderTikz(_source: string): Promise { + return { ok: false, error: 'TikZ rendering is not available in the web build yet.' } +} + +// -------------------------------------------------------------------- +// MCP (web build cannot install into local clients — return disabled) +// -------------------------------------------------------------------- + +async function mcpGetRuntime(): Promise { + return { + nodePath: null, + scriptPath: null, + available: false, + reason: 'MCP client installation is only available in the desktop build.' + } as unknown as McpServerRuntime +} + +async function mcpGetStatuses(): Promise { + return [] +} + +async function mcpInstall(_id: McpClientId): Promise { + return notImplemented('mcpInstall') +} + +async function mcpUninstall(_id: McpClientId): Promise { + return notImplemented('mcpUninstall') +} + +async function mcpGetInstructions(): Promise { + return { custom: null, effective: '', defaults: '' } as unknown as McpInstructionsPayload +} + +async function mcpSetInstructions( + _next: string | null +): Promise { + return notImplemented('mcpSetInstructions') +} + +// -------------------------------------------------------------------- +// Clipboard (web build uses navigator.clipboard) +// -------------------------------------------------------------------- + +function clipboardWriteText(text: string): void { + try { + void navigator.clipboard?.writeText(text) + } catch { + // ignore + } +} + +function clipboardReadText(): string { + // navigator.clipboard.readText is async — the desktop build has a + // synchronous Electron clipboard. Return empty string; callers that + // need the value should fall back to async paste events. + return '' +} + +// -------------------------------------------------------------------- +// Assemble the `zen` API object +// -------------------------------------------------------------------- + +export const httpBridge: ZenBridge = { + getCapabilities: (): ZenCapabilities => WEB_CAPABILITIES, + getAppInfo: (): ZenAppInfo => WEB_APP_INFO, + platform, + platformSync, + listSystemFonts, + getAppIconDataUrl, + zoomInApp, + zoomOutApp, + resetAppZoom, + getAppUpdateState, + checkForAppUpdates, + checkForAppUpdatesWithUi, + downloadAppUpdate, + installAppUpdate, + + getCurrentVault, + pickVault, + selectVaultPath, + browseServerDirectories, + getVaultSettings, + setVaultSettings, + + listNotes, + listFolders, + listAssets, + hasAssetsDir, + generateDemoTour, + removeDemoTour, + getVaultTextSearchCapabilities, + searchVaultText, + readNote, + scanTasks, + scanTasksForPath, + writeNote, + createNote, + renameNote, + deleteNote, + moveToTrash, + restoreFromTrash, + emptyTrash, + archiveNote, + unarchiveNote, + duplicateNote, + revealNote, + moveNote, + importFilesToNote, + createFolder, + renameFolder, + deleteFolder, + duplicateFolder, + revealFolder, + revealAssetsDir, + getPathForFile, + resolveLocalAssetUrl, + resolveVaultAssetUrl, + + onVaultChange, + onOpenSettings, + onAppUpdateState, + + windowMinimize, + windowToggleMaximize, + windowClose, + openNoteWindow, + renderTikz, + + mcpGetRuntime, + mcpGetStatuses, + mcpInstall, + mcpUninstall, + mcpGetInstructions, + mcpSetInstructions, + clipboardWriteText, + clipboardReadText +} + +export function installBridge(): void { + if (typeof window === 'undefined') return + installZenBridge(httpBridge) +} diff --git a/src/renderer/src/env.d.ts b/apps/web/src/env.d.ts similarity index 52% rename from src/renderer/src/env.d.ts rename to apps/web/src/env.d.ts index 5a1964b2..0a695636 100644 --- a/src/renderer/src/env.d.ts +++ b/apps/web/src/env.d.ts @@ -1,9 +1,9 @@ /// -import type { ZenApi } from '../../preload/index' +import type { ZenBridge } from '@zennotes/bridge-contract/bridge' declare global { interface Window { - zen: ZenApi + zen: ZenBridge } } diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx new file mode 100644 index 00000000..c2d04125 --- /dev/null +++ b/apps/web/src/main.tsx @@ -0,0 +1,5 @@ +import { renderZenNotesApp } from '@zennotes/app-core/main' +import { installBridge } from './bridge/http-bridge' + +installBridge() +renderZenNotesApp(document.getElementById('root')!) diff --git a/apps/web/tailwind.config.js b/apps/web/tailwind.config.js new file mode 100644 index 00000000..9538f269 --- /dev/null +++ b/apps/web/tailwind.config.js @@ -0,0 +1,50 @@ +/** @type {import('tailwindcss').Config} */ +export default { + content: ['./index.html', './src/**/*.{ts,tsx}', '../../packages/app-core/src/**/*.{ts,tsx}'], + theme: { + extend: { + colors: { + paper: { + 50: 'rgb(var(--z-bg-softer) / )', + 100: 'rgb(var(--z-bg) / )', + 200: 'rgb(var(--z-bg-1) / )', + 300: 'rgb(var(--z-bg-2) / )', + 400: 'rgb(var(--z-bg-3) / )', + 500: 'rgb(var(--z-bg-4) / )' + }, + ink: { + 900: 'rgb(var(--z-fg) / )', + 800: 'rgb(var(--z-fg-1) / )', + 700: 'rgb(var(--z-fg-2) / )', + 600: 'rgb(var(--z-grey-2) / )', + 500: 'rgb(var(--z-grey-1) / )', + 400: 'rgb(var(--z-grey-0) / )', + 300: 'rgb(var(--z-grey-dim) / )' + }, + accent: { + DEFAULT: 'rgb(var(--z-accent) / )', + soft: 'rgb(var(--z-accent-soft) / )', + muted: 'rgb(var(--z-accent-muted) / )' + } + }, + fontFamily: { + sans: [ + '-apple-system', + 'BlinkMacSystemFont', + '"SF Pro Text"', + '"Inter"', + 'system-ui', + 'sans-serif' + ], + serif: ['"Iowan Old Style"', '"Source Serif Pro"', 'Georgia', 'serif'], + mono: ['"JetBrains Mono"', '"SF Mono"', 'Menlo', 'monospace'] + }, + boxShadow: { + panel: + '0 1px 0 0 rgb(var(--z-shadow) / 0.04), 0 8px 28px -12px rgb(var(--z-shadow) / 0.18)', + float: '0 20px 60px -20px rgb(var(--z-shadow) / 0.28)' + } + } + }, + plugins: [] +} diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json new file mode 100644 index 00000000..eb0fa1d9 --- /dev/null +++ b/apps/web/tsconfig.json @@ -0,0 +1,23 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "jsx": "react-jsx", + "useDefineForClassFields": true, + "isolatedModules": true, + "noUnusedLocals": false, + "noUnusedParameters": false, + "noEmit": true, + "types": ["vite/client"], + "baseUrl": ".", + "paths": { + "@renderer/*": ["../../packages/app-core/src/*"], + "@shared/*": ["../../packages/shared-domain/src/*"], + "@bridge-contract/*": ["../../packages/bridge-contract/src/*"], + "@zennotes/app-core/*": ["../../packages/app-core/src/*"], + "@zennotes/bridge-contract/*": ["../../packages/bridge-contract/src/*"] + } + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] +} diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts new file mode 100644 index 00000000..4ba5f277 --- /dev/null +++ b/apps/web/vite.config.ts @@ -0,0 +1,94 @@ +import { resolve } from 'node:path' +import { defineConfig } from 'vite' +import react from '@vitejs/plugin-react' + +function rendererManualChunk(id: string): string | undefined { + if (!id.includes('node_modules')) return undefined + + if (id.includes('/react/') || id.includes('/react-dom/') || id.includes('/zustand/')) { + return 'vendor-react' + } + + if (id.includes('/@codemirror/language-data/')) { + return 'vendor-editor-languages' + } + + if ( + id.includes('/@codemirror/') || + id.includes('/codemirror/') || + id.includes('/@lezer/') || + id.includes('/@replit/codemirror-vim/') + ) { + return 'vendor-editor' + } + + if ( + id.includes('/remark-') || + id.includes('/rehype-') || + id.includes('/unified/') || + id.includes('/unist-util-visit/') || + id.includes('/gray-matter/') || + id.includes('/katex/') + ) { + return 'vendor-markdown' + } + + if (id.includes('/highlight.js/')) { + return 'vendor-highlight' + } + + if (id.includes('/mermaid/') || id.includes('/cytoscape/') || id.includes('/dagre/')) { + return 'vendor-mermaid' + } + + if (id.includes('/jsxgraph/')) { + return 'vendor-jsxgraph' + } + + if (id.includes('/function-plot/')) { + return 'vendor-function-plot' + } + + if (id.includes('/d3')) { + return 'vendor-d3' + } + + return undefined +} + +export default defineConfig({ + root: __dirname, + resolve: { + alias: [ + { find: '@renderer', replacement: resolve(__dirname, '../../packages/app-core/src') }, + { find: '@shared', replacement: resolve(__dirname, '../../packages/shared-domain/src') }, + { find: '@bridge-contract', replacement: resolve(__dirname, '../../packages/bridge-contract/src') } + ] + }, + server: { + port: 5173, + proxy: { + '/api': { + target: 'http://127.0.0.1:7878', + changeOrigin: true, + ws: true + }, + '/assets-data': { + target: 'http://127.0.0.1:7878', + changeOrigin: true + } + } + }, + plugins: [react()], + build: { + outDir: 'dist', + emptyOutDir: true, + chunkSizeWarningLimit: 3500, + sourcemap: false, + rollupOptions: { + output: { + manualChunks: rendererManualChunk + } + } + } +}) diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 00000000..5f7c8b24 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,18 @@ +services: + zennotes: + build: + context: . + dockerfile: Dockerfile + image: "${ZENNOTES_IMAGE:-zennotes-selfhosted:local}" + container_name: zennotes-selfhosted + restart: unless-stopped + ports: + - "${ZENNOTES_HOST_PORT:-7878}:7878" + volumes: + - "${ZENNOTES_HOST_CONTENT_ROOT:-./vault}:${ZENNOTES_CONTAINER_CONTENT_ROOT:-/workspace}" + - "${ZENNOTES_HOST_DATA:-./data}:/data" + environment: + ZENNOTES_BIND: 0.0.0.0:7878 + ZENNOTES_CONFIG_PATH: /data/server.json + ZENNOTES_DEFAULT_VAULT_PATH: "${ZENNOTES_CONTAINER_DEFAULT_VAULT_PATH:-${ZENNOTES_CONTAINER_CONTENT_ROOT:-/workspace}}" + ZENNOTES_BROWSE_ROOTS: "${ZENNOTES_BROWSE_ROOTS:-${ZENNOTES_CONTAINER_CONTENT_ROOT:-/workspace}}" diff --git a/docs/monorepo-architecture.md b/docs/monorepo-architecture.md new file mode 100644 index 00000000..8031ac76 --- /dev/null +++ b/docs/monorepo-architecture.md @@ -0,0 +1,57 @@ +# ZenNotes Monorepo Architecture + +ZenNotes now uses a single monorepo so the desktop app, self-hosted web app, and future hosted deployment can share one product core instead of drifting across separate repositories. + +## Layout + +```text +apps/ + desktop/ Electron shell, preload, updater, packaging + web/ Vite/PWA shell and HTTP bridge + server/ Go server for self-hosted and hosted deployments +packages/ + app-core/ Shared React application and renderer logic + bridge-contract/ Typed runtime contract between UI and host + shared-domain/ Shared types and note/task/view models + shared-ui/ Reusable UI primitives (small today, can grow later) +tooling/ + scripts/ Shared tooling hooks and migration scripts +``` + +## Source of Truth + +`packages/app-core` is the source of truth for user-facing features. + +Platform-specific code should stay in the app shells: + +- `apps/desktop` for Electron-only concerns such as windows, menus, updater, packaging +- `apps/web` for browser/PWA bootstrapping +- `apps/server` for HTTP/WebSocket serving, vault access, and deployment/runtime config + +## Bridge Contract + +The shared UI depends on the typed bridge in `packages/bridge-contract`. + +That contract covers: + +- note and folder CRUD +- search, tasks, archive, trash, tags +- asset operations +- watcher/subscription events +- update/runtime metadata +- capability flags for unsupported platform features + +Each runtime installs its own implementation: + +- Electron preload installs the desktop bridge +- The web client installs the HTTP bridge backed by the Go server + +## Deployment Modes + +ZenNotes should ship as: + +- desktop: `apps/desktop` +- self-hosted: `apps/web` + `apps/server` +- hosted: the same `apps/web` + `apps/server` stack, with auth/storage additions + +Hosted mode is a deployment mode of the same web stack, not a separate frontend. diff --git a/guide.md b/guide.md new file mode 100644 index 00000000..3642b506 --- /dev/null +++ b/guide.md @@ -0,0 +1,312 @@ +# ZenNotes Quick Run Guide + +This guide is for people who just cloned the repo and want to run ZenNotes quickly. + +It covers: + +- the desktop app +- the self-hosted web app +- the easiest Docker path for a home server or remote server + +If you want the full repo and architecture details, read [README.md](README.md). This file is the short version. + +## Choose the path you want + +Use this if: + +- you want the desktop app on your own machine: **Desktop** +- you want ZenNotes in a browser on your home server: **Self-hosted with Docker** +- you want to run the browser version from source without Docker: **Self-hosted from source** + +## 1. Run the desktop app + +### Requirements + +- Node.js 22+ +- npm + +### Steps + +```bash +npm ci +npm run dev:desktop +``` + +Or: + +```bash +make desktop +``` + +What this does: + +- installs the monorepo dependencies +- starts the Electron desktop app in development mode + +### Build the desktop app + +If you want to build it instead of running dev mode: + +```bash +npm run build:prod +npm run pack +``` + +Platform-specific desktop builds: + +```bash +npm run dist:mac +npm run dist:win +npm run dist:linux +``` + +## 2. Run the self-hosted web app with Docker + +This is the easiest path for most home-server users. + +### Requirements + +- Docker +- Docker Compose + +### Steps + +```bash +make up +``` + +Then open: + +- [http://localhost:7878](http://localhost:7878) if you are on the same machine +- `http://YOUR_SERVER_IP:7878` if you are running ZenNotes on another machine + +### What gets mounted by default + +By default, Docker mounts: + +- host `./vault` -> container `/workspace` +- host `./data` -> container `/data` + +That means: + +- your notes live in `./vault` +- ZenNotes server config lives in `./data` + +### Use a different vault folder + +If your notes already live somewhere else, mount that folder instead: + +```bash +CONTENT_ROOT="$HOME/Library/Mobile Documents/com~apple~CloudDocs" make up +``` + +Another example: + +```bash +CONTENT_ROOT="$HOME/Documents/MyVault" make up +``` + +Paths with spaces are supported. + +### Stop the Docker stack + +```bash +make down +``` + +### Rebuild the Docker stack + +```bash +make rebuild +``` + +### View logs + +```bash +make logs +``` + +## 3. Run the self-hosted web app from source + +Use this if you do not want Docker and you are okay running both the frontend and backend locally. + +### Requirements + +- Node.js 22+ +- npm +- Go 1.22+ + +### Steps + +Install dependencies: + +```bash +npm ci +``` + +Run both the web client and Go server together: + +```bash +make web-stack +``` + +Or run them separately: + +Terminal 1: + +```bash +npm run dev:server +``` + +Terminal 2: + +```bash +npm run dev:web +``` + +Then open the local web URL shown by Vite in your browser. + +### Important + +In source dev mode, the browser UI and the Go server are separate processes. + +That means: + +- frontend changes usually only need the web process +- backend changes need the Go server restarted + +## 4. Run the self-hosted server without Docker + +If you want a built server binary instead of dev mode: + +```bash +npm ci +make server-build +./apps/server/bin/zennotes-server +``` + +Then open: + +- [http://localhost:7878](http://localhost:7878) +- or `http://YOUR_SERVER_IP:7878` + +The built server embeds the web app, so you do not need to run `dev:web` for this path. + +## 5. Choose a vault in the web version + +When you first open the browser version, ZenNotes asks you to choose a vault folder. + +Important detail: + +- in the web version, you are browsing the **server's filesystem** +- not the browser machine's filesystem + +So: + +- if ZenNotes is running on your home server, the picker shows folders on that server +- if ZenNotes is running in Docker, the picker only sees folders mounted into the container + +## 6. Common problems + +### “I can’t browse to my real notes in Docker” + +Docker can only see mounted folders. + +Fix: + +- mount the folder you want with `CONTENT_ROOT=... make up` + +Example: + +```bash +CONTENT_ROOT="$HOME/Documents/ObsidianVault" make up +``` + +### “The web app says the picker or vault route is missing” + +This usually means: + +- the web client is newer than the running Go server + +Fix: + +- stop the server +- restart it with `npm run dev:server` or `make server-dev` +- reload the page + +### “I want to use iCloud Drive” + +For Docker: + +- you must mount the iCloud folder into the container + +Example: + +```bash +CONTENT_ROOT="$HOME/Library/Mobile Documents/com~apple~CloudDocs" make up +``` + +For non-Docker source runs: + +- the server can browse any folder the server process has permission to read + +### “The web app opens, but I can’t do anything” + +Make sure the Go server is running. + +For source runs: + +- `npm run dev:server` +- plus either `npm run dev:web` or `make web-stack` + +For Docker: + +- `make up` + +## 7. Handy commands + +### Desktop + +```bash +make desktop +``` + +### Web + server from source + +```bash +make web-stack +``` + +### Self-hosted with Docker + +```bash +make up +``` + +### Logs + +```bash +make logs +``` + +### Stop Docker + +```bash +make down +``` + +### Show all helper commands + +```bash +make help +``` + +## 8. What to use in practice + +Recommended defaults: + +- just want to try the desktop app locally: `make desktop` +- want ZenNotes on a home server: `make up` +- want to develop the browser version: `make web-stack` + +If you are unsure, start with Docker for self-hosting. It is the simplest setup for most users. diff --git a/package-lock.json b/package-lock.json index fd7f0a72..871da129 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,11 +1,25 @@ { - "name": "zennotes", + "name": "zennotes-monorepo", "version": "1.0.4", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "zennotes", + "name": "zennotes-monorepo", + "version": "1.0.4", + "workspaces": [ + "apps/*", + "packages/*" + ], + "devDependencies": { + "turbo": "^2.5.8" + }, + "engines": { + "node": ">=22" + } + }, + "apps/desktop": { + "name": "@zennotes/desktop", "version": "1.0.4", "license": "MIT", "dependencies": { @@ -20,6 +34,9 @@ "@lezer/highlight": "^1.2.1", "@modelcontextprotocol/sdk": "^1.29.0", "@replit/codemirror-vim": "^6.3.0", + "@zennotes/app-core": "*", + "@zennotes/bridge-contract": "*", + "@zennotes/shared-domain": "*", "chokidar": "^4.0.3", "codemirror": "^6.0.1", "dompurify": "^3.3.4", @@ -71,6 +88,82 @@ "node": ">=22" } }, + "apps/server": { + "name": "@zennotes/server", + "version": "1.0.4" + }, + "apps/web": { + "name": "@zennotes/web", + "version": "1.0.4", + "dependencies": { + "@codemirror/autocomplete": "^6.18.3", + "@codemirror/commands": "^6.7.1", + "@codemirror/lang-markdown": "^6.3.1", + "@codemirror/language": "^6.10.6", + "@codemirror/language-data": "^6.5.1", + "@codemirror/search": "^6.5.8", + "@codemirror/state": "^6.5.0", + "@codemirror/view": "^6.35.3", + "@lezer/highlight": "^1.2.1", + "@replit/codemirror-vim": "^6.3.0", + "@zennotes/app-core": "*", + "@zennotes/bridge-contract": "*", + "@zennotes/shared-domain": "*", + "codemirror": "^6.0.1", + "dompurify": "^3.3.4", + "function-plot": "^1.25.3", + "fuse.js": "^7.0.0", + "gray-matter": "^4.0.3", + "highlight.js": "^11.10.0", + "jsxgraph": "^1.12.2", + "katex": "^0.16.15", + "mermaid": "^11.4.1", + "prettier": "^3.8.2", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "rehype-highlight": "^7.0.1", + "rehype-katex": "^7.0.1", + "rehype-raw": "^7.0.0", + "rehype-stringify": "^10.0.1", + "remark-breaks": "^4.0.0", + "remark-frontmatter": "^5.0.0", + "remark-gfm": "^4.0.0", + "remark-math": "^6.0.0", + "remark-parse": "^11.0.0", + "remark-rehype": "^11.1.1", + "unified": "^11.0.5", + "unist-util-visit": "^5.0.0", + "zustand": "^5.0.2" + }, + "devDependencies": { + "@types/node": "^22.10.5", + "@types/react": "^18.3.17", + "@types/react-dom": "^18.3.5", + "@vitejs/plugin-react": "^4.3.4", + "autoprefixer": "^10.4.20", + "postcss": "^8.5.10", + "tailwindcss": "^3.4.17", + "typescript": "^5.7.2", + "vite": "^5.4.11" + } + }, + "apps/web/node_modules/@types/node": { + "version": "22.19.17", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.17.tgz", + "integrity": "sha512-wGdMcf+vPYM6jikpS/qhg6WiqSV/OhG+jeeHT/KlVqxYfD40iYJf9/AE1uQxVWFvU7MipKRkRv8NSHiCGgPr8Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "apps/web/node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, "node_modules/@alloc/quick-lru": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", @@ -3279,6 +3372,90 @@ "node": ">= 10" } }, + "node_modules/@turbo/darwin-64": { + "version": "2.9.6", + "resolved": "https://registry.npmjs.org/@turbo/darwin-64/-/darwin-64-2.9.6.tgz", + "integrity": "sha512-X/56SnVXIQZBLKwniGTwEQTGmtE5brSACnKMBWpY3YafuxVYefrC2acamfjgxP7BG5w3I+6jf0UrLoSzgPcSJg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@turbo/darwin-arm64": { + "version": "2.9.6", + "resolved": "https://registry.npmjs.org/@turbo/darwin-arm64/-/darwin-arm64-2.9.6.tgz", + "integrity": "sha512-aalBeSl4agT/QtYGDyf/XLajedWzUC9Vg/pm/YO6QQ93vkQ91Vz5uK1ta5RbVRDozQSz4njxUNqRNmOXDzW+qw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@turbo/linux-64": { + "version": "2.9.6", + "resolved": "https://registry.npmjs.org/@turbo/linux-64/-/linux-64-2.9.6.tgz", + "integrity": "sha512-YKi05jnNHaD7vevgYwahpzGwbsNNTwzU2c7VZdmdFm7+cGDP4oREUWSsainiMfRqjRuolQxBwRn8wf1jmu+YZA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@turbo/linux-arm64": { + "version": "2.9.6", + "resolved": "https://registry.npmjs.org/@turbo/linux-arm64/-/linux-arm64-2.9.6.tgz", + "integrity": "sha512-02o/ZS69cOYEDczXvOB2xmyrtzjQ2hVFtWZK1iqxXUfzMmTjZK4UumrfNnjckSg+gqeBfnPRHa0NstA173Ik3g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@turbo/windows-64": { + "version": "2.9.6", + "resolved": "https://registry.npmjs.org/@turbo/windows-64/-/windows-64-2.9.6.tgz", + "integrity": "sha512-wVdQjvnBI15wB6JrA+43CtUtagjIMmX6XYO758oZHAsCNSxqRlJtdyujih0D8OCnwCRWiGWGI63zAxR0hO6s9g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@turbo/windows-arm64": { + "version": "2.9.6", + "resolved": "https://registry.npmjs.org/@turbo/windows-arm64/-/windows-arm64-2.9.6.tgz", + "integrity": "sha512-1XUUyWW0W6FTSqGEhU8RHVqb2wP1SPkr7hIvBlMEwH9jr+sJQK5kqeosLJ/QaUv4ecSAd1ZhIrLoW7qslAzT4A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, "node_modules/@types/babel__core": { "version": "7.20.5", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", @@ -3936,6 +4113,34 @@ "node": ">=10.0.0" } }, + "node_modules/@zennotes/app-core": { + "resolved": "packages/app-core", + "link": true + }, + "node_modules/@zennotes/bridge-contract": { + "resolved": "packages/bridge-contract", + "link": true + }, + "node_modules/@zennotes/desktop": { + "resolved": "apps/desktop", + "link": true + }, + "node_modules/@zennotes/server": { + "resolved": "apps/server", + "link": true + }, + "node_modules/@zennotes/shared-domain": { + "resolved": "packages/shared-domain", + "link": true + }, + "node_modules/@zennotes/shared-ui": { + "resolved": "packages/shared-ui", + "link": true + }, + "node_modules/@zennotes/web": { + "resolved": "apps/web", + "link": true + }, "node_modules/7zip-bin": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/7zip-bin/-/7zip-bin-5.2.0.tgz", @@ -6758,6 +6963,7 @@ "integrity": "sha512-NoXo6Liy2heSklTI5OIZbCgXC1RzrDQsZkeEwXhdOro3FT1VBOvbubvscdPnjVuQ4AMwwv61oaH96AbiYg9EnQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "app-builder-lib": "25.1.8", "builder-util": "25.1.7", @@ -14093,6 +14299,24 @@ "license": "0BSD", "peer": true }, + "node_modules/turbo": { + "version": "2.9.6", + "resolved": "https://registry.npmjs.org/turbo/-/turbo-2.9.6.tgz", + "integrity": "sha512-+v2QJey7ZUeUiuigkU+uFfklvNUyPI2VO2vBpMYJA+a1hKFLFiKtUYlRHdb3P9CrAvMzi0upbjI4WT+zKtqkBg==", + "dev": true, + "license": "MIT", + "bin": { + "turbo": "bin/turbo" + }, + "optionalDependencies": { + "@turbo/darwin-64": "2.9.6", + "@turbo/darwin-arm64": "2.9.6", + "@turbo/linux-64": "2.9.6", + "@turbo/linux-arm64": "2.9.6", + "@turbo/windows-64": "2.9.6", + "@turbo/windows-arm64": "2.9.6" + } + }, "node_modules/type-fest": { "version": "0.13.1", "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.13.1.tgz", @@ -15064,6 +15288,22 @@ "type": "github", "url": "https://github.com/sponsors/wooorm" } + }, + "packages/app-core": { + "name": "@zennotes/app-core", + "version": "1.0.4" + }, + "packages/bridge-contract": { + "name": "@zennotes/bridge-contract", + "version": "1.0.4" + }, + "packages/shared-domain": { + "name": "@zennotes/shared-domain", + "version": "1.0.4" + }, + "packages/shared-ui": { + "name": "@zennotes/shared-ui", + "version": "1.0.4" } } } diff --git a/package.json b/package.json index f762c199..4e33072c 100644 --- a/package.json +++ b/package.json @@ -1,164 +1,34 @@ { - "name": "zennotes", - "productName": "ZenNotes", - "version": "1.0.4", - "description": "A keyboard-first markdown notes app with Vim motions and plain local files", + "name": "zennotes-monorepo", "private": true, - "main": "./out/main/index.js", - "author": { - "name": "Adib Hanna", - "email": "adibhanna@gmail.com" - }, - "license": "MIT", - "repository": { - "type": "git", - "url": "git+https://github.com/ZenNotes/zennotes.git" - }, - "bugs": { - "url": "https://github.com/ZenNotes/zennotes/issues" - }, - "homepage": "https://github.com/ZenNotes/zennotes/releases/latest", + "version": "1.0.4", + "description": "ZenNotes monorepo for desktop, web, and self-hosted server builds", + "packageManager": "npm@10.9.2", "engines": { "node": ">=22" }, + "workspaces": [ + "apps/*", + "packages/*" + ], "scripts": { - "dev": "electron-vite dev", - "build": "electron-vite build", + "dev": "npm run dev:desktop", + "dev:desktop": "npm run dev --workspace @zennotes/desktop", + "dev:web": "npm run dev --workspace @zennotes/web", + "dev:server": "npm run dev --workspace @zennotes/server", + "dev:web-stack": "node tooling/scripts/dev-web-stack.mjs", + "start": "npm run start --workspace @zennotes/desktop", + "typecheck": "turbo run typecheck", + "test": "turbo run test", + "test:run": "turbo run test:run", + "build": "turbo run build --filter=!@zennotes/server && npm run build --workspace @zennotes/server", "build:prod": "npm run typecheck && npm run test:run && npm run build", - "start": "electron-vite preview", - "test": "vitest", - "test:run": "vitest run", - "typecheck:node": "tsc --noEmit -p tsconfig.node.json", - "typecheck:web": "tsc --noEmit -p tsconfig.web.json", - "typecheck": "npm run typecheck:node && npm run typecheck:web", - "pack": "npm run build:prod && electron-builder --dir", - "dist:mac": "npm run build:prod && electron-builder --mac --publish never", - "dist:win": "npm run build:prod && electron-builder --win --publish never", - "dist:linux": "npm run build:prod && electron-builder --linux --publish never" - }, - "dependencies": { - "@codemirror/autocomplete": "^6.18.3", - "@codemirror/commands": "^6.7.1", - "@codemirror/lang-markdown": "^6.3.1", - "@codemirror/language": "^6.10.6", - "@codemirror/language-data": "^6.5.1", - "@codemirror/search": "^6.5.8", - "@codemirror/state": "^6.5.0", - "@codemirror/view": "^6.35.3", - "@lezer/highlight": "^1.2.1", - "@modelcontextprotocol/sdk": "^1.29.0", - "@replit/codemirror-vim": "^6.3.0", - "chokidar": "^4.0.3", - "codemirror": "^6.0.1", - "dompurify": "^3.3.4", - "electron-updater": "^6.8.3", - "font-list": "^2.0.2", - "function-plot": "^1.25.3", - "fuse.js": "^7.0.0", - "gray-matter": "^4.0.3", - "highlight.js": "^11.10.0", - "jsxgraph": "^1.12.2", - "katex": "^0.16.15", - "mermaid": "^11.4.1", - "node-tikzjax": "^1.0.5", - "prettier": "^3.8.2", - "react": "^18.3.1", - "react-dom": "^18.3.1", - "rehype-highlight": "^7.0.1", - "rehype-katex": "^7.0.1", - "rehype-raw": "^7.0.0", - "rehype-stringify": "^10.0.1", - "remark-breaks": "^4.0.0", - "remark-frontmatter": "^5.0.0", - "remark-gfm": "^4.0.0", - "remark-math": "^6.0.0", - "remark-parse": "^11.0.0", - "remark-rehype": "^11.1.1", - "unified": "^11.0.5", - "unist-util-visit": "^5.0.0", - "zustand": "^5.0.2" + "pack": "npm run pack --workspace @zennotes/desktop", + "dist:mac": "npm run dist:mac --workspace @zennotes/desktop", + "dist:win": "npm run dist:win --workspace @zennotes/desktop", + "dist:linux": "npm run dist:linux --workspace @zennotes/desktop" }, "devDependencies": { - "@electron/notarize": "^3.1.0", - "@types/node": "^25.6.0", - "@types/react": "^18.3.17", - "@types/react-dom": "^18.3.5", - "@vitejs/plugin-react": "^4.3.4", - "autoprefixer": "^10.4.20", - "electron": "^41.2.1", - "electron-builder": "^25.1.8", - "electron-vite": "^2.3.0", - "jsdom": "^29.0.2", - "postcss": "^8.5.10", - "tailwindcss": "^3.4.17", - "typescript": "^5.7.2", - "vite": "^5.4.11", - "vitest": "^2.1.8" - }, - "build": { - "appId": "com.adibhanna.zennotes", - "executableName": "ZenNotes", - "productName": "ZenNotes", - "artifactName": "${productName}-${version}-${os}-${arch}.${ext}", - "asar": true, - "compression": "maximum", - "npmRebuild": false, - "buildDependenciesFromSource": false, - "directories": { - "buildResources": "build", - "output": "dist" - }, - "files": [ - "out/**/*", - "!**/*.map" - ], - "extraResources": [ - { - "from": "build/icon.png", - "to": "icon.png" - } - ], - "afterSign": "scripts/notarize.cjs", - "publish": [ - { - "provider": "github", - "owner": "ZenNotes", - "repo": "zennotes" - } - ], - "electronUpdaterCompatibility": ">=2.16", - "mac": { - "category": "public.app-category.productivity", - "hardenedRuntime": true, - "gatekeeperAssess": false, - "entitlements": "build/entitlements.mac.plist", - "entitlementsInherit": "build/entitlements.mac.inherit.plist", - "extendInfo": { - "CFBundleName": "ZenNotes", - "CFBundleDisplayName": "ZenNotes" - }, - "target": [ - "dmg", - "zip" - ] - }, - "win": { - "target": [ - "nsis", - "zip" - ] - }, - "linux": { - "target": [ - "AppImage", - "deb" - ], - "icon": "icons", - "maintainer": "Adib Hanna ", - "category": "Office" - }, - "deb": { - "afterInstall": "build/after-install.sh" - } + "turbo": "^2.5.8" } } diff --git a/packages/app-core/package.json b/packages/app-core/package.json new file mode 100644 index 00000000..ef2306b6 --- /dev/null +++ b/packages/app-core/package.json @@ -0,0 +1,15 @@ +{ + "name": "@zennotes/app-core", + "private": true, + "version": "1.0.4", + "type": "module", + "exports": { + "./main": "./src/main.tsx" + }, + "scripts": { + "typecheck": "tsc --noEmit -p tsconfig.json", + "build": "tsc --noEmit -p tsconfig.json", + "test": "echo 'No app-core tests yet'", + "test:run": "echo 'No app-core tests yet'" + } +} diff --git a/src/renderer/src/App.tsx b/packages/app-core/src/App.tsx similarity index 98% rename from src/renderer/src/App.tsx rename to packages/app-core/src/App.tsx index 40548fc8..54071acf 100644 --- a/src/renderer/src/App.tsx +++ b/packages/app-core/src/App.tsx @@ -15,6 +15,7 @@ import { VimNav } from './components/VimNav' import { EmptyVault } from './components/EmptyVault' import { PromptHost } from './components/PromptHost' import { ConfirmHost } from './components/ConfirmHost' +import { ServerDirectoryPickerHost } from './components/ServerDirectoryPickerHost' import { PinnedReferencePane } from './components/PinnedReferencePane' import { resolveQuickNoteTitle } from './lib/quick-note-title' import { matchesShortcut } from './lib/keymaps' @@ -289,6 +290,9 @@ function App(): JSX.Element {
{!zenMode && } + + +
) } @@ -310,6 +314,7 @@ function App(): JSX.Element { {settingsOpen && } + ) diff --git a/src/renderer/src/assets/lumary-labs-logo.svg b/packages/app-core/src/assets/lumary-labs-logo.svg similarity index 100% rename from src/renderer/src/assets/lumary-labs-logo.svg rename to packages/app-core/src/assets/lumary-labs-logo.svg diff --git a/src/renderer/src/components/ArchiveView.tsx b/packages/app-core/src/components/ArchiveView.tsx similarity index 100% rename from src/renderer/src/components/ArchiveView.tsx rename to packages/app-core/src/components/ArchiveView.tsx diff --git a/src/renderer/src/components/BufferPalette.tsx b/packages/app-core/src/components/BufferPalette.tsx similarity index 100% rename from src/renderer/src/components/BufferPalette.tsx rename to packages/app-core/src/components/BufferPalette.tsx diff --git a/src/renderer/src/components/CollectionViewHeader.tsx b/packages/app-core/src/components/CollectionViewHeader.tsx similarity index 100% rename from src/renderer/src/components/CollectionViewHeader.tsx rename to packages/app-core/src/components/CollectionViewHeader.tsx diff --git a/src/renderer/src/components/CommandPalette.tsx b/packages/app-core/src/components/CommandPalette.tsx similarity index 100% rename from src/renderer/src/components/CommandPalette.tsx rename to packages/app-core/src/components/CommandPalette.tsx diff --git a/src/renderer/src/components/ConfirmHost.tsx b/packages/app-core/src/components/ConfirmHost.tsx similarity index 100% rename from src/renderer/src/components/ConfirmHost.tsx rename to packages/app-core/src/components/ConfirmHost.tsx diff --git a/src/renderer/src/components/ConfirmModal.tsx b/packages/app-core/src/components/ConfirmModal.tsx similarity index 100% rename from src/renderer/src/components/ConfirmModal.tsx rename to packages/app-core/src/components/ConfirmModal.tsx diff --git a/src/renderer/src/components/ConnectionsPanel.tsx b/packages/app-core/src/components/ConnectionsPanel.tsx similarity index 100% rename from src/renderer/src/components/ConnectionsPanel.tsx rename to packages/app-core/src/components/ConnectionsPanel.tsx diff --git a/src/renderer/src/components/ContextMenu.tsx b/packages/app-core/src/components/ContextMenu.tsx similarity index 100% rename from src/renderer/src/components/ContextMenu.tsx rename to packages/app-core/src/components/ContextMenu.tsx diff --git a/src/renderer/src/components/Editor.tsx b/packages/app-core/src/components/Editor.tsx similarity index 100% rename from src/renderer/src/components/Editor.tsx rename to packages/app-core/src/components/Editor.tsx diff --git a/src/renderer/src/components/EditorPane.tsx b/packages/app-core/src/components/EditorPane.tsx similarity index 98% rename from src/renderer/src/components/EditorPane.tsx rename to packages/app-core/src/components/EditorPane.tsx index 92006110..0cbfc641 100644 --- a/src/renderer/src/components/EditorPane.tsx +++ b/packages/app-core/src/components/EditorPane.tsx @@ -23,7 +23,8 @@ import { lineNumbers, tooltips } from '@codemirror/view' -import { vim } from '@replit/codemirror-vim' +import { Vim, getCM, vim } from '@replit/codemirror-vim' +import type { NoteFolder } from '@shared/ipc' import { defaultKeymap, history, @@ -91,6 +92,10 @@ import { getSystemFolderLabel, resolveSystemFolderLabels } from '../lib/system-folder-labels' +import { + isPrimaryNotesAtRoot, + noteFolderSubpath +} from '../lib/vault-layout' import { droppedPathsFromTransfer, hasDroppedFiles @@ -478,6 +483,19 @@ export function EditorPane({ pane }: { pane: PaneLeaf }): JSX.Element { ...searchKeymap, ...completionKeymap ]), + EditorView.domEventHandlers({ + keydown: (event, view) => { + if (event.key !== 'Escape') return false + const state = useStore.getState() + if (!state.vimMode) return false + const cm = getCM(view) + if (!cm?.state.vim?.insertMode) return false + event.preventDefault() + event.stopPropagation() + Vim.exitInsertMode(cm as Parameters[0], true) + return true + } + }), EditorView.updateListener.of((upd) => { if (!upd.docChanged) return if (upd.transactions.some((tr: Transaction) => tr.annotation(programmatic))) return @@ -1812,13 +1830,14 @@ function Breadcrumb({ onAutoFocusHandled, onRename }: { - note: { path: string; title: string; folder: string } + note: { path: string; title: string; folder: NoteFolder } autoFocus: boolean onAutoFocusHandled: () => void onRename: (next: string) => void }): JSX.Element { const setView = useStore((s) => s.setView) const systemFolderLabels = useStore((s) => s.systemFolderLabels) + const vaultSettings = useStore((s) => s.vaultSettings) const [editing, setEditing] = useState(false) const [value, setValue] = useState(note.title) const [warning, setWarning] = useState('') @@ -1840,15 +1859,17 @@ function Breadcrumb({ return () => cancelAnimationFrame(raf) }, [autoFocus, editingNow, onAutoFocusHandled]) - const parts = note.path.split('/') - const topFolder = parts[0] as 'inbox' | 'quick' | 'archive' | 'trash' - const segments = parts.slice(1, -1) - const ancestors: { label: string; onClick: () => void }[] = [ - { + const topFolder = note.folder + const segments = noteFolderSubpath(note, vaultSettings) + .split('/') + .filter(Boolean) + const ancestors: { label: string; onClick: () => void }[] = [] + if (!(topFolder === 'inbox' && isPrimaryNotesAtRoot(vaultSettings))) { + ancestors.push({ label: getSystemFolderLabel(topFolder, systemFolderLabels), onClick: () => setView({ kind: 'folder', folder: topFolder, subpath: '' }) - } - ] + }) + } let acc = '' for (const seg of segments) { acc = acc ? `${acc}/${seg}` : seg diff --git a/src/renderer/src/components/EmptyVault.tsx b/packages/app-core/src/components/EmptyVault.tsx similarity index 58% rename from src/renderer/src/components/EmptyVault.tsx rename to packages/app-core/src/components/EmptyVault.tsx index 53883795..b2f09dde 100644 --- a/src/renderer/src/components/EmptyVault.tsx +++ b/packages/app-core/src/components/EmptyVault.tsx @@ -4,6 +4,10 @@ import { EnsoLogo } from './EnsoLogo' export function EmptyVault(): JSX.Element { const openVaultPicker = useStore((s) => s.openVaultPicker) + const capabilities = window.zen.getCapabilities() + const appInfo = window.zen.getAppInfo() + const isServerVaultSetup = + appInfo.runtime === 'web' && !capabilities.supportsLocalFilesystemPickers const [appIconUrl, setAppIconUrl] = useState(null) useEffect(() => { @@ -31,15 +35,22 @@ export function EmptyVault(): JSX.Element {

Welcome to ZenNotes

- Choose a folder on your computer to use as your vault. ZenNotes will store your notes - there as plain markdown files — yours to keep, back up, and sync any way you like. + {isServerVaultSetup + ? 'Enter the path to the vault directory on the server running ZenNotes. The app will use that folder as your vault and keep your notes there as plain markdown files.' + : 'Choose a folder on your computer to use as your vault. ZenNotes will store your notes there as plain markdown files — yours to keep, back up, and sync any way you like.'}

+ {isServerVaultSetup && ( +

+ You can also preconfigure this on the server with{' '} + ZENNOTES_VAULT_PATH. +

+ )}
diff --git a/src/renderer/src/components/EnsoLogo.tsx b/packages/app-core/src/components/EnsoLogo.tsx similarity index 100% rename from src/renderer/src/components/EnsoLogo.tsx rename to packages/app-core/src/components/EnsoLogo.tsx diff --git a/src/renderer/src/components/FloatingNoteApp.tsx b/packages/app-core/src/components/FloatingNoteApp.tsx similarity index 100% rename from src/renderer/src/components/FloatingNoteApp.tsx rename to packages/app-core/src/components/FloatingNoteApp.tsx diff --git a/src/renderer/src/components/HelpView.tsx b/packages/app-core/src/components/HelpView.tsx similarity index 100% rename from src/renderer/src/components/HelpView.tsx rename to packages/app-core/src/components/HelpView.tsx diff --git a/src/renderer/src/components/HintOverlay.tsx b/packages/app-core/src/components/HintOverlay.tsx similarity index 100% rename from src/renderer/src/components/HintOverlay.tsx rename to packages/app-core/src/components/HintOverlay.tsx diff --git a/src/renderer/src/components/NoteHoverPreview.tsx b/packages/app-core/src/components/NoteHoverPreview.tsx similarity index 100% rename from src/renderer/src/components/NoteHoverPreview.tsx rename to packages/app-core/src/components/NoteHoverPreview.tsx diff --git a/src/renderer/src/components/NoteList.tsx b/packages/app-core/src/components/NoteList.tsx similarity index 69% rename from src/renderer/src/components/NoteList.tsx rename to packages/app-core/src/components/NoteList.tsx index a99dde0f..03fdaf96 100644 --- a/src/renderer/src/components/NoteList.tsx +++ b/packages/app-core/src/components/NoteList.tsx @@ -16,6 +16,11 @@ import { extractTags } from '../lib/tags' import { setDragPayload } from '../lib/dnd' import { usePrompt } from './PromptModal' import { resolveSystemFolderLabels } from '../lib/system-folder-labels' +import { + assetBelongsToFolderView, + isPrimaryNotesAtRoot, + noteBelongsToFolderView +} from '../lib/vault-layout' function escapeForAttr(value: string): string { if (typeof CSS !== 'undefined' && typeof CSS.escape === 'function') return CSS.escape(value) @@ -52,6 +57,7 @@ export function NoteList(): JSX.Element { const assetFiles = useStore((s) => s.assetFiles) const activeNote = useStore((s) => s.activeNote) const view = useStore((s) => s.view) + const vaultSettings = useStore((s) => s.vaultSettings) const selectedPath = useStore((s) => s.selectedPath) const selectNote = useStore((s) => s.selectNote) const createAndOpen = useStore((s) => s.createAndOpen) @@ -69,11 +75,13 @@ export function NoteList(): JSX.Element { const setFocusedPanel = useStore((s) => s.setFocusedPanel) const systemFolderLabels = useStore((s) => s.systemFolderLabels) const { prompt, modal: promptModal } = usePrompt() + const canRevealInFileManager = window.zen.getAppInfo().runtime === 'desktop' const folderLabels = useMemo( () => resolveSystemFolderLabels(systemFolderLabels), [systemFolderLabels] ) const [menu, setMenu] = useState<{ x: number; y: number; path: string } | null>(null) + const [assetMenu, setAssetMenu] = useState<{ x: number; y: number; path: string } | null>(null) const [assetLayout, setAssetLayout] = useState(() => { try { const raw = localStorage.getItem(ASSET_LAYOUT_KEY) @@ -239,6 +247,49 @@ export function NoteList(): JSX.Element { folderLabels.trash ]) + const assetMenuItems = useMemo(() => { + if (!assetMenu) return [] + const asset = assetFiles.find((entry) => entry.path === assetMenu.path) + if (!asset) return [] + + const url = assetUrl(vault?.root ?? null, asset.path) + const root = vault?.root ?? '' + const sep = root.includes('\\') ? '\\' : '/' + const abs = [root.replace(/[\\/]+$/, ''), ...asset.path.split('/').filter(Boolean)].join(sep) + + const items: ContextMenuItem[] = [ + { + label: 'Open', + onSelect: async () => { + if (url) window.open(url, '_blank') + } + }, + { + label: 'Copy Path', + onSelect: async () => { + window.zen.clipboardWriteText(asset.path) + } + }, + { + label: 'Copy Absolute Path', + onSelect: async () => { + window.zen.clipboardWriteText(abs) + } + } + ] + + if (canRevealInFileManager) { + items.push({ + label: 'Reveal in File Manager', + onSelect: async () => { + await window.zen.revealNote(asset.path) + } + }) + } + + return items + }, [assetMenu, assetFiles, canRevealInFileManager, vault]) + /** * Filter notes for the current view. For folder views we match the * top-level folder AND, when a subpath is active, limit to notes @@ -248,13 +299,17 @@ export function NoteList(): JSX.Element { */ const filtered = useMemo(() => { if (view.kind === 'assets') return [] - const prefix = view.subpath - ? `${view.folder}/${view.subpath}/` - : `${view.folder}/` - return notes.filter( - (n) => n.folder === view.folder && n.path.startsWith(prefix) + return notes.filter((n) => + noteBelongsToFolderView(n, view.folder, view.subpath, vaultSettings) ) - }, [notes, view]) + }, [notes, view, vaultSettings]) + + const filteredAssets = useMemo(() => { + if (view.kind === 'assets') return assetFiles + return assetFiles.filter((asset) => + assetBelongsToFolderView(asset, view.folder, view.subpath, vaultSettings) + ) + }, [assetFiles, view, vaultSettings]) /** * Stable ordering: we want the list sorted by updatedAt when the user @@ -294,6 +349,28 @@ export function NoteList(): JSX.Element { } }, [noteSortOrder]) + const assetSortComparator = useMemo<((a: AssetMeta, b: AssetMeta) => number) | null>(() => { + switch (noteSortOrder) { + case 'none': + return null + case 'updated-asc': + return (a: AssetMeta, b: AssetMeta) => a.updatedAt - b.updatedAt + case 'created-desc': + case 'updated-desc': + return (a: AssetMeta, b: AssetMeta) => b.updatedAt - a.updatedAt + case 'created-asc': + return (a: AssetMeta, b: AssetMeta) => a.updatedAt - b.updatedAt + case 'name-asc': + return (a: AssetMeta, b: AssetMeta) => + a.name.localeCompare(b.name, undefined, { sensitivity: 'base' }) + case 'name-desc': + return (a: AssetMeta, b: AssetMeta) => + b.name.localeCompare(a.name, undefined, { sensitivity: 'base' }) + default: + return (a: AssetMeta, b: AssetMeta) => b.updatedAt - a.updatedAt + } + }, [noteSortOrder]) + const orderedFiltered = useMemo(() => { if (noteSortOrder === 'none' || !sortComparator) { orderRef.current = { @@ -333,25 +410,65 @@ export function NoteList(): JSX.Element { return result }, [filtered, viewKey, sortComparator, noteSortOrder]) + const orderedFolderEntries = useMemo< + Array<{ type: 'note'; note: NoteMeta } | { type: 'asset'; asset: AssetMeta }> + >(() => { + if (view.kind === 'assets') { + return filteredAssets.map((asset) => ({ type: 'asset' as const, asset })) + } + + if (noteSortOrder === 'none') { + return [ + ...orderedFiltered.map((note) => ({ + type: 'note' as const, + note, + siblingOrder: note.siblingOrder + })), + ...filteredAssets.map((asset) => ({ + type: 'asset' as const, + asset, + siblingOrder: asset.siblingOrder + })) + ] + .sort((a, b) => a.siblingOrder - b.siblingOrder) + .map(({ siblingOrder: _siblingOrder, ...entry }) => entry) + } + + return [ + ...orderedFiltered.map((note) => ({ type: 'note' as const, note })), + ...filteredAssets + .slice() + .sort(assetSortComparator ?? ((a, b) => a.siblingOrder - b.siblingOrder)) + .map((asset) => ({ type: 'asset' as const, asset })) + ] + }, [assetSortComparator, filteredAssets, noteSortOrder, orderedFiltered, view.kind]) + const heading = view.kind === 'assets' - ? 'attachements' + ? 'Files' : view.subpath ? view.subpath.split('/').slice(-1)[0] - : folderLabels[view.folder] + : view.folder === 'inbox' && isPrimaryNotesAtRoot(vaultSettings) + ? vault?.name ?? 'Vault' + : folderLabels[view.folder] - const newTargetFolder = view.kind === 'folder' && view.folder !== 'trash' ? view.folder : 'inbox' + const newTarget = + view.kind === 'folder' && view.folder !== 'trash' + ? { folder: view.folder, subpath: view.subpath } + : { folder: 'inbox' as const, subpath: '' } const isNoteListFocused = focusedPanel === 'notelist' useEffect(() => { if (!isNoteListFocused) return const next = - orderedFiltered.length === 0 ? 0 : Math.min(noteListCursorIndex, orderedFiltered.length - 1) + orderedFolderEntries.length === 0 + ? 0 + : Math.min(noteListCursorIndex, orderedFolderEntries.length - 1) if (next !== noteListCursorIndex) { useStore.getState().setNoteListCursorIndex(next) } - }, [isNoteListFocused, noteListCursorIndex, orderedFiltered.length]) + }, [isNoteListFocused, noteListCursorIndex, orderedFolderEntries.length]) useEffect(() => { if (!isNoteListFocused || !selectedPath) return @@ -381,7 +498,7 @@ export function NoteList(): JSX.Element {

{heading}

- {view.kind === 'assets' ? assetFiles.length : filtered.length} + {view.kind === 'assets' ? assetFiles.length : orderedFolderEntries.length}
@@ -414,7 +531,7 @@ export function NoteList(): JSX.Element { @@ -433,42 +550,72 @@ export function NoteList(): JSX.Element { {view.kind === 'assets' ? ( assetFiles.length === 0 ? (
- No attachments yet. Drop an image or file into a note to populate `attachements`. + No files yet. Files anywhere inside the vault show up here.
) : assetLayout === 'grid' ? (
{assetFiles.map((asset) => ( - + { + e.preventDefault() + setAssetMenu({ x: e.clientX, y: e.clientY, path: asset.path }) + }} + /> ))}
) : (
{assetFiles.map((asset) => ( - + { + e.preventDefault() + setAssetMenu({ x: e.clientX, y: e.clientY, path: asset.path }) + }} + /> ))}
) - ) : filtered.length === 0 ? ( + ) : orderedFolderEntries.length === 0 ? (
{view.kind === 'folder' && view.folder === 'trash' ? `${folderLabels.trash} is empty.` - : 'No notes here yet.'} + : 'No files here yet.'}
) : ( - orderedFiltered.map((n, i) => ( - void selectNote(n.path)} - onContextMenu={(e) => { - e.preventDefault() - setMenu({ x: e.clientX, y: e.clientY, path: n.path }) - }} - noteListIdx={i} - vimHighlight={isNoteListFocused && noteListCursorIndex === i} - /> - )) + orderedFolderEntries.map((entry, i) => + entry.type === 'note' ? ( + void selectNote(entry.note.path)} + onContextMenu={(e) => { + e.preventDefault() + setMenu({ x: e.clientX, y: e.clientY, path: entry.note.path }) + }} + noteListIdx={i} + vimHighlight={isNoteListFocused && noteListCursorIndex === i} + /> + ) : ( + { + e.preventDefault() + setAssetMenu({ x: e.clientX, y: e.clientY, path: entry.asset.path }) + }} + noteListIdx={i} + vimHighlight={isNoteListFocused && noteListCursorIndex === i} + /> + ), + ) )}
{menu && ( @@ -479,6 +626,14 @@ export function NoteList(): JSX.Element { onClose={() => setMenu(null)} /> )} + {assetMenu && ( + setAssetMenu(null)} + /> + )} {promptModal} void + noteListIdx?: number + vimHighlight?: boolean +}): JSX.Element { + const url = assetUrl(vaultRoot, asset.path) + const extension = asset.name.includes('.') ? asset.name.split('.').pop()?.toUpperCase() ?? '' : '' + + return ( + + ) +} + function assetUrl(vaultRoot: string | null, assetPath: string): string | null { if (!vaultRoot) return null return window.zen.resolveVaultAssetUrl(vaultRoot, assetPath) @@ -555,10 +770,12 @@ function assetUrl(vaultRoot: string | null, assetPath: string): string | null { function AssetCard({ asset, - vaultRoot + vaultRoot, + onContextMenu }: { asset: AssetMeta vaultRoot: string | null + onContextMenu?: (e: React.MouseEvent) => void }): JSX.Element { const url = assetUrl(vaultRoot, asset.path) const open = (): void => { @@ -569,6 +786,7 @@ function AssetCard({ + ))} + + )} + +
+
+ Location +
+
+ {pathCrumbs.length > 0 ? ( +
+ {pathCrumbs.map((crumb, index) => ( +
+ + {index < pathCrumbs.length - 1 && ( + / + )} +
+ ))} +
+ ) : ( +
{loading ? 'Loading folders…' : 'No folder selected yet.'}
+ )} +
+
+ +
+
+
Folders
+
+ +
+
+ {showAdvancedPath && ( +
+
+ { + setDraftPath(e.target.value) + setError(null) + }} + onKeyDown={(e) => { + if (e.key === 'Enter') { + e.preventDefault() + void loadDirectory(draftPath.trim()) + } else if (e.key === 'Escape') { + e.preventDefault() + onCancel() + } + }} + className="min-w-0 flex-1 rounded-md border border-paper-300 bg-paper-50 px-2.5 py-1.5 text-sm text-ink-900 outline-none focus:border-accent" + /> + +
+
+ Optional: paste an absolute server path if you already know it. +
+
+ )} +
+ {loading ? ( +
Loading folders…
+ ) : entries.length === 0 && !parentPath ? ( +
This folder has no subfolders. You can still choose it.
+ ) : ( + <> + {parentPath && ( + + )} + {entries.map((entry) => ( + + ))} + + )} +
+
+ + {error && ( +
+ {error} +
+ )} +
+ Chosen folder: {submitPath || 'None'} +
+ + +
+ + +
+ + , + document.body + ) +} diff --git a/src/renderer/src/components/SettingsModal.tsx b/packages/app-core/src/components/SettingsModal.tsx similarity index 95% rename from src/renderer/src/components/SettingsModal.tsx rename to packages/app-core/src/components/SettingsModal.tsx index caa2ae4f..b828d8a1 100644 --- a/src/renderer/src/components/SettingsModal.tsx +++ b/packages/app-core/src/components/SettingsModal.tsx @@ -1,5 +1,6 @@ import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' import { createPortal } from 'react-dom' +import { DEFAULT_DAILY_NOTES_DIRECTORY } from '@shared/ipc' import type { AppUpdateState, VaultTextSearchBackendPreference, @@ -31,8 +32,9 @@ import { DEFAULT_SYSTEM_FOLDER_LABELS, getSystemFolderLabel } from '../lib/system-folder-labels' +import { normalizeDailyNotesDirectory } from '../lib/vault-layout' +import { getZenBridge } from '@zennotes/bridge-contract/bridge' import companyLogo from '../assets/lumary-labs-logo.svg' -import appPackage from '../../../../package.json' type SettingsCategoryId = | 'appearance' @@ -151,6 +153,7 @@ function formatReleaseNotesForDisplay(notes: string | null): string | null { } export function SettingsModal(): JSX.Element { + const appInfo = getZenBridge().getAppInfo() const setSettingsOpen = useStore((s) => s.setSettingsOpen) const vimMode = useStore((s) => s.vimMode) const setVimMode = useStore((s) => s.setVimMode) @@ -186,7 +189,10 @@ export function SettingsModal(): JSX.Element { const contentAlign = useStore((s) => s.contentAlign) const setContentAlign = useStore((s) => s.setContentAlign) const vault = useStore((s) => s.vault) + const vaultSettings = useStore((s) => s.vaultSettings) + const persistVaultSettings = useStore((s) => s.setVaultSettings) const openVaultPicker = useStore((s) => s.openVaultPicker) + const openTodayDailyNote = useStore((s) => s.openTodayDailyNote) const themeId = useStore((s) => s.themeId) const themeFamily = useStore((s) => s.themeFamily) const themeMode = useStore((s) => s.themeMode) @@ -847,9 +853,86 @@ export function SettingsModal(): JSX.Element { +
+ + void persistVaultSettings({ + ...vaultSettings, + primaryNotesLocation + }) + } + /> +
+ +
+ + void persistVaultSettings({ + ...vaultSettings, + dailyNotes: { + ...vaultSettings.dailyNotes, + enabled + } + }) + } + /> + + void persistVaultSettings({ + ...vaultSettings, + dailyNotes: { + ...vaultSettings.dailyNotes, + directory: normalizeDailyNotesDirectory(next) + } + }) + } + /> +
+
+
Open today's daily note
+
+ Opens today's note if it exists, otherwise creates it with a YYYY-MM-DD title. +
+
+ +
+
+
ZenNotes - v{appPackage.version} + v{appInfo.version}
@@ -974,7 +1057,7 @@ export function SettingsModal(): JSX.Element { )}

- {appPackage.description}. Visit{' '} + {appInfo.description}. Visit{' '} ["vaultSettings"], ): { folder: NoteFolder; subpath: string } { if (!activeNote) return { folder: "inbox", subpath: "" }; - const parts = activeNote.path.split("/"); return { folder: activeNote.folder, - subpath: parts.slice(1, -1).join("/"), + subpath: noteFolderSubpath(activeNote, vaultSettings), }; } @@ -74,6 +80,7 @@ export function Sidebar(): JSX.Element { const focusedPanel = useStore((s) => s.focusedPanel); const sidebarCursorIndex = useStore((s) => s.sidebarCursorIndex); const activeNote = useStore((s) => s.activeNote); + const vaultSettings = useStore((s) => s.vaultSettings); const view = useStore((s) => s.view); const assetFiles = useStore((s) => s.assetFiles); const setView = useStore((s) => s.setView); @@ -123,10 +130,15 @@ export function Sidebar(): JSX.Element { const moveNoteAction = useStore((s) => s.moveNote); const renameNote = useStore((s) => s.renameNote); const { prompt, modal: promptModal } = usePrompt(); + const canRevealInFileManager = window.zen.getAppInfo().runtime === "desktop"; const folderLabels = useMemo( () => resolveSystemFolderLabels(systemFolderLabels), [systemFolderLabels], ); + const primaryNotesAtRoot = useMemo( + () => isPrimaryNotesAtRoot(vaultSettings), + [vaultSettings], + ); /** * Handle a drag-drop onto a folder (top-level or subfolder). Both @@ -182,6 +194,11 @@ export function Sidebar(): JSX.Element { folder: NoteFolder; subpath: string; // "" for top-level } | null>(null); + const [assetMenu, setAssetMenu] = useState<{ + x: number; + y: number; + path: string; + } | null>(null); const [sortMenu, setSortMenu] = useState<{ x: number; y: number } | null>( null, ); @@ -220,26 +237,42 @@ export function Sidebar(): JSX.Element { () => ({ quick: buildTree( notes.filter((n) => n.folder === "quick"), + assetFiles.filter( + (asset) => folderForVaultRelativePath(asset.path, vaultSettings) === "quick", + ), "quick", allFolders.filter((f) => f.folder === "quick"), + vaultSettings, ), inbox: buildTree( notes.filter((n) => n.folder === "inbox"), + assetFiles.filter( + (asset) => folderForVaultRelativePath(asset.path, vaultSettings) === "inbox", + ), "inbox", allFolders.filter((f) => f.folder === "inbox"), + vaultSettings, ), archive: buildTree( notes.filter((n) => n.folder === "archive"), + assetFiles.filter( + (asset) => folderForVaultRelativePath(asset.path, vaultSettings) === "archive", + ), "archive", allFolders.filter((f) => f.folder === "archive"), + vaultSettings, ), trash: buildTree( notes.filter((n) => n.folder === "trash"), + assetFiles.filter( + (asset) => folderForVaultRelativePath(asset.path, vaultSettings) === "trash", + ), "trash", allFolders.filter((f) => f.folder === "trash"), + vaultSettings, ), }), - [notes, allFolders], + [notes, allFolders, assetFiles, vaultSettings], ); const treeSortComparator = useMemo< @@ -738,6 +771,53 @@ export function Sidebar(): JSX.Element { openNoteInTab, ]); + const assetMenuItems = useMemo(() => { + if (!assetMenu) return []; + const asset = assetFiles.find((entry) => entry.path === assetMenu.path); + if (!asset) return []; + + const url = vault?.root + ? window.zen.resolveVaultAssetUrl(vault.root, asset.path) + : null; + const root = vault?.root ?? ""; + const sep = root.includes("\\") ? "\\" : "/"; + const abs = [root.replace(/[\\/]+$/, ""), ...asset.path.split("/").filter(Boolean)].join( + sep, + ); + + const items: ContextMenuItem[] = [ + { + label: "Open", + onSelect: async () => { + if (url) window.open(url, "_blank"); + }, + }, + { + label: "Copy Path", + onSelect: async () => { + window.zen.clipboardWriteText(asset.path); + }, + }, + { + label: "Copy Absolute Path", + onSelect: async () => { + window.zen.clipboardWriteText(abs); + }, + }, + ]; + + if (canRevealInFileManager) { + items.push({ + label: "Reveal in File Manager", + onSelect: async () => { + await window.zen.revealNote(asset.path); + }, + }); + } + + return items; + }, [assetMenu, assetFiles, canRevealInFileManager, vault]); + const tagMenuItems = useMemo(() => { if (!tagMenu) return []; const tag = tagMenu.tag; @@ -884,9 +964,12 @@ export function Sidebar(): JSX.Element { ) as HTMLElement | null; if (noteEl) return noteEl; - const parts = selectedPath.split("/"); - const folder = parts[0] as NoteFolder; - const segments = parts.slice(1, -1); + const selectedMeta = notes.find((note) => note.path === selectedPath); + const folder = selectedMeta?.folder ?? "inbox"; + const subpath = selectedMeta + ? noteFolderSubpath(selectedMeta, vaultSettings) + : ""; + const segments = subpath ? subpath.split("/") : []; for (let i = segments.length; i >= 0; i--) { const subpath = segments.slice(0, i).join("/"); const folderEl = document.querySelector( @@ -931,6 +1014,8 @@ export function Sidebar(): JSX.Element { isSidebarFocused, quickNotesViewActive, selectedPath, + notes, + vaultSettings, unifiedSidebar, view, tagsViewActive, @@ -983,7 +1068,11 @@ export function Sidebar(): JSX.Element { { - const target = defaultNewNoteTarget(useStore.getState().activeNote); + const state = useStore.getState(); + const target = defaultNewNoteTarget( + state.activeNote, + state.vaultSettings, + ); void createAndOpen(target.folder, target.subpath); }} > @@ -1080,11 +1169,16 @@ export function Sidebar(): JSX.Element { onContextMenu={openFolderMenu} showNotes={unifiedSidebar} selectedPath={selectedPath} + vaultRoot={vault?.root ?? null} onSelectNote={(p) => void selectNote(p)} onNoteContextMenu={(e, n) => { e.preventDefault(); setNoteMenu({ x: e.clientX, y: e.clientY, path: n.path }); }} + onAssetContextMenu={(e, asset) => { + e.preventDefault(); + setAssetMenu({ x: e.clientX, y: e.clientY, path: asset.path }); + }} sortComparator={treeSortComparator} onDropOnFolder={handleDropOnFolder} idxCounter={idxCounter.current} @@ -1111,30 +1205,66 @@ export function Sidebar(): JSX.Element { } /> - } - folder="inbox" - tree={trees.inbox} - isFolderActive={isFolderActive} - collapsed={collapsed} - toggleCollapse={toggleCollapse} - setView={setView} - onContextMenu={openFolderMenu} - showNotes={unifiedSidebar} - selectedPath={selectedPath} - onSelectNote={(p) => void selectNote(p)} - onNoteContextMenu={(e, n) => { - e.preventDefault(); - setNoteMenu({ x: e.clientX, y: e.clientY, path: n.path }); - }} - sortComparator={treeSortComparator} - onDropOnFolder={handleDropOnFolder} - idxCounter={idxCounter.current} - vimCursor={vimCursor} - sidebarFocused={isSidebarFocused} - groupByKind={groupByKind} - /> + {primaryNotesAtRoot ? ( + void selectNote(p)} + onNoteContextMenu={(e, n) => { + e.preventDefault(); + setNoteMenu({ x: e.clientX, y: e.clientY, path: n.path }); + }} + onAssetContextMenu={(e, asset) => { + e.preventDefault(); + setAssetMenu({ x: e.clientX, y: e.clientY, path: asset.path }); + }} + sortComparator={treeSortComparator} + onDropOnFolder={handleDropOnFolder} + idxCounter={idxCounter.current} + vimCursor={vimCursor} + sidebarFocused={isSidebarFocused} + groupByKind={groupByKind} + /> + ) : ( + } + folder="inbox" + tree={trees.inbox} + isFolderActive={isFolderActive} + collapsed={collapsed} + toggleCollapse={toggleCollapse} + setView={setView} + onContextMenu={openFolderMenu} + showNotes={unifiedSidebar} + selectedPath={selectedPath} + vaultRoot={vault?.root ?? null} + onSelectNote={(p) => void selectNote(p)} + onNoteContextMenu={(e, n) => { + e.preventDefault(); + setNoteMenu({ x: e.clientX, y: e.clientY, path: n.path }); + }} + onAssetContextMenu={(e, asset) => { + e.preventDefault(); + setAssetMenu({ x: e.clientX, y: e.clientY, path: asset.path }); + }} + sortComparator={treeSortComparator} + onDropOnFolder={handleDropOnFolder} + idxCounter={idxCounter.current} + vimCursor={vimCursor} + sidebarFocused={isSidebarFocused} + groupByKind={groupByKind} + /> + )} {/* System folders (Archive / Trash) share the row height of * every tree row above so the vertical rhythm stays uniform. @@ -1267,7 +1397,7 @@ export function Sidebar(): JSX.Element { {hasAssetsDir && ( } - label="Assets" + label="Files" count={assetFiles.length} onClick={() => void revealAssetsDir()} sidebarIdx={idxCounter.current.value++} @@ -1322,6 +1452,14 @@ export function Sidebar(): JSX.Element { onClose={() => setNoteMenu(null)} /> )} + {assetMenu && ( + setAssetMenu(null)} + /> + )} {promptModal} {sortMenu && ( ["vaultSettings"], ): TreeNode { const root: TreeNode = { name: topFolder, subpath: "", siblingOrder: -1, notes: [], + assets: [], children: [], }; const byPath = new Map(); @@ -1410,6 +1553,7 @@ function buildTree( subpath: acc, siblingOrder: folderOrder.get(acc) ?? Number.MAX_SAFE_INTEGER, notes: [], + assets: [], children: [], }; byPath.set(acc, node); @@ -1429,15 +1573,24 @@ function buildTree( // Second pass: place every note inside its parent folder node. for (const n of notes) { - const parts = n.path.split("/"); - const segments = parts.slice(1, -1); - if (segments.length === 0) { + const parentSubpath = noteFolderSubpath(n, vaultSettings); + if (!parentSubpath) { root.notes.push(n); continue; } - const parent = ensureFolder(segments.join("/")); + const parent = ensureFolder(parentSubpath); parent.notes.push(n); } + + for (const asset of assets) { + const parentSubpath = assetFolderSubpath(asset, vaultSettings); + if (!parentSubpath) { + root.assets.push(asset); + continue; + } + const parent = ensureFolder(parentSubpath); + parent.assets.push(asset); + } return root; } @@ -1460,6 +1613,10 @@ function getTreeRenderEntries( .slice() .sort(sortComparator ?? ((a, b) => a.siblingOrder - b.siblingOrder)) .map((note) => ({ type: "note", note }) as const), + ...node.assets + .slice() + .sort((a, b) => a.siblingOrder - b.siblingOrder) + .map((asset) => ({ type: "asset", asset }) as const), ]; } @@ -1474,6 +1631,11 @@ function getTreeRenderEntries( note, siblingOrder: note.siblingOrder, })), + ...node.assets.map((asset) => ({ + type: "asset" as const, + asset, + siblingOrder: asset.siblingOrder, + })), ] .sort((a, b) => a.siblingOrder - b.siblingOrder) .map(({ siblingOrder: _siblingOrder, ...entry }) => entry); @@ -1482,6 +1644,7 @@ function getTreeRenderEntries( function countNotesInTree(node: TreeNode): number { return ( node.notes.length + + node.assets.length + node.children.reduce((s, c) => s + countNotesInTree(c), 0) ); } @@ -1506,8 +1669,10 @@ interface TreeRenderProps { ) => void; showNotes: boolean; selectedPath: string | null; + vaultRoot: string | null; onSelectNote: (path: string) => void; onNoteContextMenu: (e: React.MouseEvent, n: NoteMeta) => void; + onAssetContextMenu: (e: React.MouseEvent, asset: AssetMeta) => void; sortComparator: ((a: NoteMeta, b: NoteMeta) => number) | null; onDropOnFolder: ( payload: DragPayload, @@ -1524,6 +1689,102 @@ interface TreeRenderProps { groupByKind: boolean; } +function FolderTreeContents({ + folder, + tree, + depth, + collapsed, + toggleCollapse, + setView, + onContextMenu, + showNotes, + selectedPath, + vaultRoot, + onSelectNote, + onNoteContextMenu, + onAssetContextMenu, + sortComparator, + onDropOnFolder, + idxCounter, + vimCursor, + sidebarFocused, + groupByKind, + isFolderActive, +}: { + tree: TreeNode; + depth: number; +} & TreeRenderProps): JSX.Element { + const entries = useMemo( + () => getTreeRenderEntries(tree, showNotes, sortComparator, groupByKind), + [tree, showNotes, sortComparator, groupByKind], + ); + + return ( + <> + {entries.map((entry) => { + if (entry.type === "folder") { + return ( + + ); + } + + if (entry.type === "asset") { + const assetIdx = idxCounter.value++; + return ( + onAssetContextMenu(e, entry.asset)} + sidebarFocused={sidebarFocused} + sidebarIdx={assetIdx} + vimHighlight={vimCursor === assetIdx} + /> + ); + } + const n = entry.note; + const noteIdx = idxCounter.value++; + return ( + onSelectNote(n.path)} + onContextMenu={(e) => onNoteContextMenu(e, n)} + sidebarIdx={noteIdx} + vimHighlight={vimCursor === noteIdx} + /> + ); + })} + + ); +} + function FolderTreeRoot({ label, icon, @@ -1536,8 +1797,10 @@ function FolderTreeRoot({ onContextMenu, showNotes, selectedPath, + vaultRoot, onSelectNote, onNoteContextMenu, + onAssetContextMenu, sortComparator, onDropOnFolder, idxCounter, @@ -1605,51 +1868,28 @@ function FolderTreeRoot({ trailing={headerAction} /> {!isCollapsed && ( - <> - {entries.map((entry) => { - if (entry.type === "folder") { - return ( - - ); - } - - const n = entry.note; - const noteIdx = idxCounter.value++; - return ( - onSelectNote(n.path)} - onContextMenu={(e) => onNoteContextMenu(e, n)} - sidebarIdx={noteIdx} - vimHighlight={vimCursor === noteIdx} - /> - ); - })} - + )}

); @@ -1666,8 +1906,10 @@ function SubTree({ onContextMenu, showNotes, selectedPath, + vaultRoot, onSelectNote, onNoteContextMenu, + onAssetContextMenu, sortComparator, onDropOnFolder, idxCounter, @@ -1744,8 +1986,10 @@ function SubTree({ onContextMenu={onContextMenu} showNotes={showNotes} selectedPath={selectedPath} + vaultRoot={vaultRoot} onSelectNote={onSelectNote} onNoteContextMenu={onNoteContextMenu} + onAssetContextMenu={onAssetContextMenu} sortComparator={sortComparator} onDropOnFolder={onDropOnFolder} idxCounter={idxCounter} @@ -1756,6 +2000,22 @@ function SubTree({ ); } + if (entry.type === "asset") { + const assetIdx = idxCounter.value++; + return ( + onAssetContextMenu(e, entry.asset)} + sidebarFocused={sidebarFocused} + sidebarIdx={assetIdx} + vimHighlight={vimCursor === assetIdx} + /> + ); + } + const n = entry.note; const noteIdx = idxCounter.value++; return ( @@ -1847,8 +2107,8 @@ function NoteLeaf({ {note.title} {note.hasAttachments && ( void; + sidebarFocused: boolean; + sidebarIdx?: number; + vimHighlight?: boolean; +}): JSX.Element { + const url = vaultRoot ? window.zen.resolveVaultAssetUrl(vaultRoot, asset.path) : null; + const extension = asset.name.includes(".") + ? asset.name.split(".").pop()?.toUpperCase() ?? "" + : ""; + + return ( + + ); +} + /* ---------- Row primitives ---------- */ function TreeRow({ diff --git a/src/renderer/src/components/StatusBar.tsx b/packages/app-core/src/components/StatusBar.tsx similarity index 100% rename from src/renderer/src/components/StatusBar.tsx rename to packages/app-core/src/components/StatusBar.tsx diff --git a/src/renderer/src/components/TagView.tsx b/packages/app-core/src/components/TagView.tsx similarity index 97% rename from src/renderer/src/components/TagView.tsx rename to packages/app-core/src/components/TagView.tsx index 4a7c96c4..49652ed2 100644 --- a/src/renderer/src/components/TagView.tsx +++ b/packages/app-core/src/components/TagView.tsx @@ -4,6 +4,7 @@ import type { NoteMeta } from '@shared/ipc' import { extractTags } from '../lib/tags' import { TagIcon, CloseIcon, DocumentIcon } from './icons' import { advanceSequence, getKeymapBinding, matchesSequenceToken } from '../lib/keymaps' +import { isPrimaryNotesAtRoot, noteFolderSubpath } from '../lib/vault-layout' function formatDate(ms: number): string { const d = new Date(ms) @@ -17,11 +18,12 @@ function formatDate(ms: number): string { } function folderLabel(note: NoteMeta): string { - // `note.path` is `folder/subpath/name.md` — trim the filename so we - // show the containing breadcrumb. - const parts = note.path.split('/') - parts.pop() - return parts.join(' › ') + const vaultSettings = useStore.getState().vaultSettings + const subpath = noteFolderSubpath(note, vaultSettings) + if (note.folder === 'inbox' && isPrimaryNotesAtRoot(vaultSettings)) { + return subpath + } + return subpath ? `${note.folder} › ${subpath}` : note.folder } export function TagView(): JSX.Element { diff --git a/src/renderer/src/components/TasksRow.tsx b/packages/app-core/src/components/TasksRow.tsx similarity index 100% rename from src/renderer/src/components/TasksRow.tsx rename to packages/app-core/src/components/TasksRow.tsx diff --git a/src/renderer/src/components/TasksView.tsx b/packages/app-core/src/components/TasksView.tsx similarity index 100% rename from src/renderer/src/components/TasksView.tsx rename to packages/app-core/src/components/TasksView.tsx diff --git a/src/renderer/src/components/TitleBar.tsx b/packages/app-core/src/components/TitleBar.tsx similarity index 100% rename from src/renderer/src/components/TitleBar.tsx rename to packages/app-core/src/components/TitleBar.tsx diff --git a/src/renderer/src/components/TrashView.tsx b/packages/app-core/src/components/TrashView.tsx similarity index 100% rename from src/renderer/src/components/TrashView.tsx rename to packages/app-core/src/components/TrashView.tsx diff --git a/src/renderer/src/components/VaultBadge.tsx b/packages/app-core/src/components/VaultBadge.tsx similarity index 100% rename from src/renderer/src/components/VaultBadge.tsx rename to packages/app-core/src/components/VaultBadge.tsx diff --git a/src/renderer/src/components/VaultTextSearchPalette.tsx b/packages/app-core/src/components/VaultTextSearchPalette.tsx similarity index 100% rename from src/renderer/src/components/VaultTextSearchPalette.tsx rename to packages/app-core/src/components/VaultTextSearchPalette.tsx diff --git a/src/renderer/src/components/VimNav.tsx b/packages/app-core/src/components/VimNav.tsx similarity index 100% rename from src/renderer/src/components/VimNav.tsx rename to packages/app-core/src/components/VimNav.tsx diff --git a/src/renderer/src/components/WhichKeyOverlay.tsx b/packages/app-core/src/components/WhichKeyOverlay.tsx similarity index 100% rename from src/renderer/src/components/WhichKeyOverlay.tsx rename to packages/app-core/src/components/WhichKeyOverlay.tsx diff --git a/src/renderer/src/components/icons.tsx b/packages/app-core/src/components/icons.tsx similarity index 100% rename from src/renderer/src/components/icons.tsx rename to packages/app-core/src/components/icons.tsx diff --git a/packages/app-core/src/env.d.ts b/packages/app-core/src/env.d.ts new file mode 100644 index 00000000..0a695636 --- /dev/null +++ b/packages/app-core/src/env.d.ts @@ -0,0 +1,10 @@ +/// +import type { ZenBridge } from '@zennotes/bridge-contract/bridge' + +declare global { + interface Window { + zen: ZenBridge + } +} + +export {} diff --git a/src/renderer/src/lib/cm-code-languages.ts b/packages/app-core/src/lib/cm-code-languages.ts similarity index 100% rename from src/renderer/src/lib/cm-code-languages.ts rename to packages/app-core/src/lib/cm-code-languages.ts diff --git a/src/renderer/src/lib/cm-date-shortcuts.ts b/packages/app-core/src/lib/cm-date-shortcuts.ts similarity index 100% rename from src/renderer/src/lib/cm-date-shortcuts.ts rename to packages/app-core/src/lib/cm-date-shortcuts.ts diff --git a/src/renderer/src/lib/cm-heading-fold.ts b/packages/app-core/src/lib/cm-heading-fold.ts similarity index 100% rename from src/renderer/src/lib/cm-heading-fold.ts rename to packages/app-core/src/lib/cm-heading-fold.ts diff --git a/src/renderer/src/lib/cm-live-preview.ts b/packages/app-core/src/lib/cm-live-preview.ts similarity index 94% rename from src/renderer/src/lib/cm-live-preview.ts rename to packages/app-core/src/lib/cm-live-preview.ts index b84dcaf4..d534b26d 100644 --- a/src/renderer/src/lib/cm-live-preview.ts +++ b/packages/app-core/src/lib/cm-live-preview.ts @@ -46,6 +46,7 @@ const PREFIX_HIDE_WITH_SPACE = new Set(['HeaderMark', 'QuoteMark']) const hide = Decoration.replace({}) const imageSourceHide = Decoration.replace({}) const STANDALONE_IMAGE_RE = /^\s*!\[([^\]]*)\]\((?:<([^>]+)>|([^)]+))\)\s*$/ +const STANDALONE_OBSIDIAN_EMBED_RE = /^\s*!\[\[([^\]|]+?)(?:\|([^\]]+))?\]\]\s*$/ // Anchor-style standalone PDF link: `[Label](file.pdf)` or `[Label]()`. // Same shape as the image regex but without the leading `!`. const STANDALONE_PDF_RE = /^\s*\[([^\]]*)\]\((?:<([^>]+)>|([^)]+))\)\s*$/ @@ -119,30 +120,56 @@ function createImageDragPreview(title: string): HTMLDivElement { } function parseStandaloneLocalImage(lineText: string): ParsedImage | null { - const match = lineText.match(STANDALONE_IMAGE_RE) - if (!match) return null - const href = (match[2] ?? match[3] ?? '').trim() - if (classifyLocalAssetHref(href) !== 'image') return null const state = useStore.getState() + const fromMarkdown = lineText.match(STANDALONE_IMAGE_RE) + if (fromMarkdown) { + const href = (fromMarkdown[2] ?? fromMarkdown[3] ?? '').trim() + if (classifyLocalAssetHref(href) !== 'image') return null + const resolvedUrl = resolveLocalAssetUrl(state.vault?.root, state.activeNote?.path, href) + if (!resolvedUrl) return null + return { + alt: (fromMarkdown[1] ?? '').trim(), + href, + resolvedUrl + } + } + + const fromEmbed = lineText.match(STANDALONE_OBSIDIAN_EMBED_RE) + if (!fromEmbed) return null + const href = (fromEmbed[1] ?? '').trim() + if (classifyLocalAssetHref(href) !== 'image') return null const resolvedUrl = resolveLocalAssetUrl(state.vault?.root, state.activeNote?.path, href) if (!resolvedUrl) return null return { - alt: (match[1] ?? '').trim(), + alt: (fromEmbed[2] ?? '').trim(), href, resolvedUrl } } function parseStandaloneLocalPdf(lineText: string): ParsedPdf | null { - const match = lineText.match(STANDALONE_PDF_RE) - if (!match) return null - const href = (match[2] ?? match[3] ?? '').trim() - if (classifyLocalAssetHref(href) !== 'pdf') return null const state = useStore.getState() + const fromMarkdown = lineText.match(STANDALONE_PDF_RE) + if (fromMarkdown) { + const href = (fromMarkdown[2] ?? fromMarkdown[3] ?? '').trim() + if (classifyLocalAssetHref(href) !== 'pdf') return null + const resolvedUrl = resolveLocalAssetUrl(state.vault?.root, state.activeNote?.path, href) + if (!resolvedUrl) return null + return { + label: (fromMarkdown[1] ?? '').trim(), + href, + resolvedUrl + } + } + + const fromEmbed = lineText.match(STANDALONE_OBSIDIAN_EMBED_RE) + if (!fromEmbed) return null + const href = (fromEmbed[1] ?? '').trim() + if (classifyLocalAssetHref(href) !== 'pdf') return null const resolvedUrl = resolveLocalAssetUrl(state.vault?.root, state.activeNote?.path, href) if (!resolvedUrl) return null return { - label: (match[1] ?? '').trim(), + label: (fromEmbed[2] ?? '').trim(), href, resolvedUrl } diff --git a/src/renderer/src/lib/cm-slash-commands.ts b/packages/app-core/src/lib/cm-slash-commands.ts similarity index 100% rename from src/renderer/src/lib/cm-slash-commands.ts rename to packages/app-core/src/lib/cm-slash-commands.ts diff --git a/src/renderer/src/lib/cm-wikilinks.ts b/packages/app-core/src/lib/cm-wikilinks.ts similarity index 93% rename from src/renderer/src/lib/cm-wikilinks.ts rename to packages/app-core/src/lib/cm-wikilinks.ts index b536eac8..43d05a3e 100644 --- a/src/renderer/src/lib/cm-wikilinks.ts +++ b/packages/app-core/src/lib/cm-wikilinks.ts @@ -2,6 +2,7 @@ import type { Completion, CompletionContext, CompletionResult } from '@codemirro import type { EditorView } from '@codemirror/view' import type { NoteMeta } from '@shared/ipc' import { useStore } from '../store' +import { isPrimaryNotesAtRoot, noteFolderSubpath } from './vault-layout' function normalize(value: string): string { return value.trim().toLowerCase() @@ -24,8 +25,11 @@ function stripMdExtension(value: string): string { } function folderLabelFor(note: NoteMeta): string { - const parts = note.path.split('/') - const subpath = parts.slice(1, -1).join('/') + const vaultSettings = useStore.getState().vaultSettings + const subpath = noteFolderSubpath(note, vaultSettings) + if (note.folder === 'inbox' && isPrimaryNotesAtRoot(vaultSettings)) { + return subpath ? `${subpath}/` : '' + } return subpath ? `${subpath}/` : `${note.folder}/` } @@ -78,6 +82,9 @@ function noteTargetFor(note: NoteMeta, notes: NoteMeta[]): string { if (titleMatches.length === 1) return note.title const rel = stripMdExtension(note.path) + if (note.folder === 'inbox' && isPrimaryNotesAtRoot(useStore.getState().vaultSettings)) { + return `/${rel}` + } if (rel.startsWith('inbox/')) return `/${rel.slice('inbox/'.length)}` return rel } diff --git a/src/renderer/src/lib/commands.ts b/packages/app-core/src/lib/commands.ts similarity index 98% rename from src/renderer/src/lib/commands.ts rename to packages/app-core/src/lib/commands.ts index 39763018..5c794b16 100644 --- a/src/renderer/src/lib/commands.ts +++ b/packages/app-core/src/lib/commands.ts @@ -78,11 +78,22 @@ export function buildCommands(options?: { includeUnavailable?: boolean }): Comma }, { id: 'note.new.inbox', - title: `New Note in ${labels().inbox}`, + title: + getState().vaultSettings.primaryNotesLocation === 'root' + ? 'New Note in Vault Root' + : `New Note in ${labels().inbox}`, category: 'Note', keywords: 'create add write', run: () => getState().createAndOpen('inbox', '', { focusTitle: true }) }, + { + id: 'note.daily.today', + title: "Open Today's Daily Note", + category: 'Note', + keywords: 'daily journal date today log', + when: () => getState().vaultSettings.dailyNotes.enabled, + run: () => getState().openTodayDailyNote() + }, { id: 'note.new.here', title: 'New Note in Current Folder', @@ -710,7 +721,7 @@ export function buildCommands(options?: { includeUnavailable?: boolean }): Comma }, { id: 'nav.assets', - title: 'Go to Attachments', + title: 'Go to Files', category: 'Go', keywords: 'assets files images', run: () => getState().setView({ kind: 'assets' }) @@ -1163,7 +1174,7 @@ export function buildCommands(options?: { includeUnavailable?: boolean }): Comma }, { id: 'app.assets.reveal', - title: 'Reveal Attachments Folder', + title: 'Reveal Vault Root', category: 'App', run: () => getState().revealAssetsDir() } diff --git a/src/renderer/src/lib/confirm-trash.ts b/packages/app-core/src/lib/confirm-trash.ts similarity index 100% rename from src/renderer/src/lib/confirm-trash.ts rename to packages/app-core/src/lib/confirm-trash.ts diff --git a/src/renderer/src/lib/diagram-renderers.ts b/packages/app-core/src/lib/diagram-renderers.ts similarity index 100% rename from src/renderer/src/lib/diagram-renderers.ts rename to packages/app-core/src/lib/diagram-renderers.ts diff --git a/src/renderer/src/lib/dnd.ts b/packages/app-core/src/lib/dnd.ts similarity index 100% rename from src/renderer/src/lib/dnd.ts rename to packages/app-core/src/lib/dnd.ts diff --git a/src/renderer/src/lib/editor-drops.ts b/packages/app-core/src/lib/editor-drops.ts similarity index 100% rename from src/renderer/src/lib/editor-drops.ts rename to packages/app-core/src/lib/editor-drops.ts diff --git a/src/renderer/src/lib/editor-focus.ts b/packages/app-core/src/lib/editor-focus.ts similarity index 100% rename from src/renderer/src/lib/editor-focus.ts rename to packages/app-core/src/lib/editor-focus.ts diff --git a/src/renderer/src/lib/format-markdown.ts b/packages/app-core/src/lib/format-markdown.ts similarity index 100% rename from src/renderer/src/lib/format-markdown.ts rename to packages/app-core/src/lib/format-markdown.ts diff --git a/src/renderer/src/lib/fuzzy-score.ts b/packages/app-core/src/lib/fuzzy-score.ts similarity index 100% rename from src/renderer/src/lib/fuzzy-score.ts rename to packages/app-core/src/lib/fuzzy-score.ts diff --git a/src/renderer/src/lib/help.ts b/packages/app-core/src/lib/help.ts similarity index 98% rename from src/renderer/src/lib/help.ts rename to packages/app-core/src/lib/help.ts index a27c9a1a..5d193777 100644 --- a/src/renderer/src/lib/help.ts +++ b/packages/app-core/src/lib/help.ts @@ -185,9 +185,9 @@ export const HELP_CORE_CONCEPTS: HelpCard[] = [ 'Use [[wikilinks]] or markdown links. In normal mode, the follow-link motion opens the link under the cursor, offers to create missing notes, and pins PDFs into the reference pane.' }, { - title: 'Attachments stay local', + title: 'Files stay local', body: - 'Drop files into a note to insert local assets. ZenNotes tracks the attachments folder, can reveal it from the app, and treats PDFs specially in preview and reference workflows.' + 'Drop files into a note to insert local files. By default, ZenNotes keeps them as ordinary files in the vault root, can reveal the vault from the app, and treats PDFs specially in preview and reference workflows.' }, { title: 'Math, diagrams, and plots render from plain fences', @@ -197,7 +197,7 @@ export const HELP_CORE_CONCEPTS: HelpCard[] = [ { title: 'Footer actions expose utility views', body: - 'The sidebar footer gives you direct access to Attachments, Help, and Settings, so utility screens stay discoverable even when you are new to the app.' + 'The sidebar footer gives you direct access to Files, Help, and Settings, so utility screens stay discoverable even when you are new to the app.' }, { title: 'Destructive actions ask first', diff --git a/src/renderer/src/lib/image-block-dnd.ts b/packages/app-core/src/lib/image-block-dnd.ts similarity index 100% rename from src/renderer/src/lib/image-block-dnd.ts rename to packages/app-core/src/lib/image-block-dnd.ts diff --git a/src/renderer/src/lib/keyboard-context-menu.ts b/packages/app-core/src/lib/keyboard-context-menu.ts similarity index 100% rename from src/renderer/src/lib/keyboard-context-menu.ts rename to packages/app-core/src/lib/keyboard-context-menu.ts diff --git a/src/renderer/src/lib/keymaps.ts b/packages/app-core/src/lib/keymaps.ts similarity index 100% rename from src/renderer/src/lib/keymaps.ts rename to packages/app-core/src/lib/keymaps.ts diff --git a/src/renderer/src/lib/local-assets.ts b/packages/app-core/src/lib/local-assets.ts similarity index 83% rename from src/renderer/src/lib/local-assets.ts rename to packages/app-core/src/lib/local-assets.ts index ef3cc67d..126dd382 100644 --- a/src/renderer/src/lib/local-assets.ts +++ b/packages/app-core/src/lib/local-assets.ts @@ -1,3 +1,5 @@ +import { useStore } from '../store' + const IMAGE_EXTENSIONS = new Set([ '.apng', '.avif', @@ -14,8 +16,43 @@ const VIDEO_EXTENSIONS = new Set(['.m4v', '.mov', '.mp4', '.ogv', '.webm']) export type LocalAssetKind = 'image' | 'pdf' | 'audio' | 'video' | 'file' +function stripQueryAndHash(href: string): string { + return href.split('#')[0]?.split('?')[0] ?? href +} + +function decodeHrefPath(value: string): string { + const cleaned = stripQueryAndHash(value) + try { + return decodeURIComponent(cleaned) + } catch { + return cleaned + } +} + +function posixJoin(a: string, b: string): string { + if (!a) return b + if (!b) return a + if (a.endsWith('/')) return `${a}${b}` + return `${a}/${b}` +} + +function posixNormalize(input: string): string { + const parts = input.split('/') + const out: string[] = [] + for (const part of parts) { + if (!part || part === '.') continue + if (part === '..') { + if (out.length === 0) return '..' + out.pop() + } else { + out.push(part) + } + } + return out.join('/') +} + function assetExtension(href: string): string { - const clean = href.split('#')[0]?.split('?')[0] ?? href + const clean = stripQueryAndHash(href) const lastDot = clean.lastIndexOf('.') return lastDot === -1 ? '' : clean.slice(lastDot).toLowerCase() } @@ -37,6 +74,10 @@ export function resolveLocalAssetUrl( href: string ): string | null { if (!vaultRoot || !notePath) return null + const resolvedRel = resolveAssetVaultRelativePath(vaultRoot, notePath, href) + if (resolvedRel) { + return window.zen.resolveVaultAssetUrl(vaultRoot, resolvedRel) + } return window.zen.resolveLocalAssetUrl(vaultRoot, notePath, href) } @@ -52,20 +93,35 @@ export function resolveAssetVaultRelativePath( href: string ): string | null { if (!vaultRoot || !notePath) return null - const resolvedUrl = window.zen.resolveLocalAssetUrl(vaultRoot, notePath, href) - if (!resolvedUrl) return null - try { - const url = new URL(resolvedUrl) - const abs = url.searchParams.get('path') - if (!abs) return null - const root = vaultRoot.replace(/[/\\]+$/, '') - if (abs !== root && !abs.startsWith(`${root}/`) && !abs.startsWith(`${root}\\`)) { - return null - } - return abs.slice(root.length).replace(/^[/\\]+/, '').replace(/\\/g, '/') - } catch { - return null + const trimmed = href.trim() + if (!trimmed || trimmed.startsWith('#') || trimmed.startsWith('//')) return null + if (/^[a-zA-Z][a-zA-Z\d+.-]*:/.test(trimmed)) return null + + const noteDir = notePath.includes('/') ? notePath.slice(0, notePath.lastIndexOf('/')) : '' + const decodedHref = decodeHrefPath(trimmed) + let target = decodedHref.startsWith('/') + ? decodedHref.replace(/^\/+/, '') + : noteDir + ? posixJoin(noteDir, decodedHref) + : decodedHref + target = posixNormalize(target) + if (target.startsWith('../') || target === '..') return null + + const assets = useStore.getState().assetFiles + if (assets.some((asset) => asset.path === target)) return target + + const targetBase = target.split('/').filter(Boolean).pop()?.toLowerCase() + if (!targetBase) return null + + const basenameMatches = assets.filter((asset) => { + const assetBase = asset.path.split('/').filter(Boolean).pop()?.toLowerCase() + return assetBase === targetBase + }) + if (basenameMatches.length === 1) { + return basenameMatches[0]!.path } + + return null } function localAssetLabel(href: string, fallback: string): string { diff --git a/src/renderer/src/lib/markdown.test.ts b/packages/app-core/src/lib/markdown.test.ts similarity index 100% rename from src/renderer/src/lib/markdown.test.ts rename to packages/app-core/src/lib/markdown.test.ts diff --git a/src/renderer/src/lib/markdown.ts b/packages/app-core/src/lib/markdown.ts similarity index 91% rename from src/renderer/src/lib/markdown.ts rename to packages/app-core/src/lib/markdown.ts index f0bf074a..059e874e 100644 --- a/src/renderer/src/lib/markdown.ts +++ b/packages/app-core/src/lib/markdown.ts @@ -14,6 +14,7 @@ import { visit, SKIP } from 'unist-util-visit' import type { Root as MdRoot } from 'mdast' import type { Root as HastRoot, Element as HastElement } from 'hast' import { recordRendererPerf } from './perf' +import { classifyLocalAssetHref } from './local-assets' /** * Remark plugin: `[[target]]` and `[[target|label]]` → link nodes @@ -76,7 +77,7 @@ function remarkWikilinks() { if (p.type === 'link' || p.type === 'linkReference') return const value = (node as { value: string }).value if (!value.includes('[[')) return - const regex = /\[\[([^\]|]+?)(?:\|([^\]]+))?\]\]/g + const regex = /(!?)\[\[([^\]|]+?)(?:\|([^\]]+))?\]\]/g const next: AnyNode[] = [] let last = 0 let m: RegExpExecArray | null @@ -86,20 +87,38 @@ function remarkWikilinks() { if (m.index > last) { next.push({ type: 'text', value: value.slice(last, m.index) }) } - const target = m[1].trim() - const label = (m[2] ?? m[1]).trim() - next.push({ - type: 'link', - url: `zen://note/${encodeURIComponent(target)}`, - title: null, - data: { - hProperties: { - className: ['wikilink'], - 'data-wikilink': target - } - }, - children: [{ type: 'text', value: label }] - }) + const bang = m[1] ?? '' + const target = m[2].trim() + const label = (m[3] ?? m[2]).trim() + const assetKind = classifyLocalAssetHref(target) + if (bang === '!' && assetKind === 'image') { + next.push({ + type: 'image', + url: target, + title: null, + alt: label + }) + } else if (bang === '!' && assetKind) { + next.push({ + type: 'link', + url: target, + title: null, + children: [{ type: 'text', value: label }] + }) + } else { + next.push({ + type: 'link', + url: `zen://note/${encodeURIComponent(target)}`, + title: null, + data: { + hProperties: { + className: ['wikilink'], + 'data-wikilink': target + } + }, + children: [{ type: 'text', value: label }] + }) + } last = regex.lastIndex } if (!changed) return diff --git a/src/renderer/src/lib/move-note.ts b/packages/app-core/src/lib/move-note.ts similarity index 100% rename from src/renderer/src/lib/move-note.ts rename to packages/app-core/src/lib/move-note.ts diff --git a/src/renderer/src/lib/outline.ts b/packages/app-core/src/lib/outline.ts similarity index 100% rename from src/renderer/src/lib/outline.ts rename to packages/app-core/src/lib/outline.ts diff --git a/src/renderer/src/lib/pane-layout.ts b/packages/app-core/src/lib/pane-layout.ts similarity index 100% rename from src/renderer/src/lib/pane-layout.ts rename to packages/app-core/src/lib/pane-layout.ts diff --git a/src/renderer/src/lib/pane-mode.ts b/packages/app-core/src/lib/pane-mode.ts similarity index 100% rename from src/renderer/src/lib/pane-mode.ts rename to packages/app-core/src/lib/pane-mode.ts diff --git a/src/renderer/src/lib/pane-nav.ts b/packages/app-core/src/lib/pane-nav.ts similarity index 100% rename from src/renderer/src/lib/pane-nav.ts rename to packages/app-core/src/lib/pane-nav.ts diff --git a/src/renderer/src/lib/perf.ts b/packages/app-core/src/lib/perf.ts similarity index 100% rename from src/renderer/src/lib/perf.ts rename to packages/app-core/src/lib/perf.ts diff --git a/src/renderer/src/lib/preview-heading-fold.ts b/packages/app-core/src/lib/preview-heading-fold.ts similarity index 100% rename from src/renderer/src/lib/preview-heading-fold.ts rename to packages/app-core/src/lib/preview-heading-fold.ts diff --git a/src/renderer/src/lib/quick-note-title.ts b/packages/app-core/src/lib/quick-note-title.ts similarity index 100% rename from src/renderer/src/lib/quick-note-title.ts rename to packages/app-core/src/lib/quick-note-title.ts diff --git a/src/renderer/src/lib/system-folder-labels.ts b/packages/app-core/src/lib/system-folder-labels.ts similarity index 100% rename from src/renderer/src/lib/system-folder-labels.ts rename to packages/app-core/src/lib/system-folder-labels.ts diff --git a/src/renderer/src/lib/system-fonts.ts b/packages/app-core/src/lib/system-fonts.ts similarity index 100% rename from src/renderer/src/lib/system-fonts.ts rename to packages/app-core/src/lib/system-fonts.ts diff --git a/src/renderer/src/lib/tags.ts b/packages/app-core/src/lib/tags.ts similarity index 100% rename from src/renderer/src/lib/tags.ts rename to packages/app-core/src/lib/tags.ts diff --git a/src/renderer/src/lib/tasklists.ts b/packages/app-core/src/lib/tasklists.ts similarity index 100% rename from src/renderer/src/lib/tasklists.ts rename to packages/app-core/src/lib/tasklists.ts diff --git a/src/renderer/src/lib/tasks-filter.ts b/packages/app-core/src/lib/tasks-filter.ts similarity index 100% rename from src/renderer/src/lib/tasks-filter.ts rename to packages/app-core/src/lib/tasks-filter.ts diff --git a/src/renderer/src/lib/themes.ts b/packages/app-core/src/lib/themes.ts similarity index 100% rename from src/renderer/src/lib/themes.ts rename to packages/app-core/src/lib/themes.ts diff --git a/src/renderer/src/lib/use-rendered-markdown.ts b/packages/app-core/src/lib/use-rendered-markdown.ts similarity index 100% rename from src/renderer/src/lib/use-rendered-markdown.ts rename to packages/app-core/src/lib/use-rendered-markdown.ts diff --git a/packages/app-core/src/lib/vault-layout.ts b/packages/app-core/src/lib/vault-layout.ts new file mode 100644 index 00000000..7ade8563 --- /dev/null +++ b/packages/app-core/src/lib/vault-layout.ts @@ -0,0 +1,131 @@ +import { + DEFAULT_DAILY_NOTES_DIRECTORY, + DEFAULT_VAULT_SETTINGS, + type AssetMeta, + type NoteFolder, + type NoteMeta, + type VaultSettings +} from '@shared/ipc' + +const SYSTEM_FOLDERS = new Set(['inbox', 'quick', 'archive', 'trash']) +const RESERVED_ROOT_NAMES = new Set([ + 'inbox', + 'quick', + 'archive', + 'trash', + 'attachements', + '_assets', + '.zennotes' +]) + +function pad(n: number): string { + return n.toString().padStart(2, '0') +} + +export function normalizeDailyNotesDirectory(directory: string | null | undefined): string { + const trimmed = (directory ?? '').trim().replace(/^\/+|\/+$/g, '') + return trimmed || DEFAULT_DAILY_NOTES_DIRECTORY +} + +export function normalizeVaultSettings( + settings: VaultSettings | null | undefined +): VaultSettings { + return { + primaryNotesLocation: + settings?.primaryNotesLocation === 'root' + ? 'root' + : DEFAULT_VAULT_SETTINGS.primaryNotesLocation, + dailyNotes: { + enabled: !!settings?.dailyNotes?.enabled, + directory: normalizeDailyNotesDirectory(settings?.dailyNotes?.directory) + } + } +} + +export function isPrimaryNotesAtRoot( + settings: VaultSettings | null | undefined +): boolean { + return normalizeVaultSettings(settings).primaryNotesLocation === 'root' +} + +export function notePathWithinFolder( + path: string, + folder: NoteFolder, + settings: VaultSettings | null | undefined +): string { + if (folder === 'inbox' && isPrimaryNotesAtRoot(settings)) return path + const prefix = `${folder}/` + return path.startsWith(prefix) ? path.slice(prefix.length) : path +} + +export function noteFolderSubpath( + note: Pick, + settings: VaultSettings | null | undefined +): string { + const within = notePathWithinFolder(note.path, note.folder, settings) + const parts = within.split('/').filter(Boolean) + return parts.length > 1 ? parts.slice(0, -1).join('/') : '' +} + +export function noteBelongsToFolderView( + note: Pick, + folder: NoteFolder, + subpath: string, + settings: VaultSettings | null | undefined +): boolean { + if (note.folder !== folder) return false + if (!subpath) return true + const parent = noteFolderSubpath(note, settings) + return parent === subpath || parent.startsWith(`${subpath}/`) +} + +export function noteTitleForDate(date = new Date()): string { + return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}` +} + +export function folderForVaultRelativePath( + relPath: string, + settings: VaultSettings | null | undefined +): NoteFolder | null { + const normalized = relPath.replace(/\\/g, '/').replace(/^\/+/, '') + const top = normalized.split('/')[0] ?? '' + if (!top || top.startsWith('.')) return null + if (SYSTEM_FOLDERS.has(top as NoteFolder)) return top as NoteFolder + if (isPrimaryNotesAtRoot(settings) && !RESERVED_ROOT_NAMES.has(top)) return 'inbox' + return null +} + +export function assetPathWithinFolder( + assetPath: string, + folder: NoteFolder, + settings: VaultSettings | null | undefined +): string { + const normalized = assetPath.replace(/\\/g, '/').replace(/^\/+/, '') + if (folder === 'inbox' && isPrimaryNotesAtRoot(settings)) return normalized + const prefix = `${folder}/` + return normalized.startsWith(prefix) ? normalized.slice(prefix.length) : normalized +} + +export function assetFolderSubpath( + asset: Pick, + settings: VaultSettings | null | undefined +): string { + const folder = folderForVaultRelativePath(asset.path, settings) + if (!folder) return '' + const within = assetPathWithinFolder(asset.path, folder, settings) + const parts = within.split('/').filter(Boolean) + return parts.length > 1 ? parts.slice(0, -1).join('/') : '' +} + +export function assetBelongsToFolderView( + asset: Pick, + folder: NoteFolder, + subpath: string, + settings: VaultSettings | null | undefined +): boolean { + const assetFolder = folderForVaultRelativePath(asset.path, settings) + if (assetFolder !== folder) return false + if (!subpath) return true + const parent = assetFolderSubpath(asset, settings) + return parent === subpath || parent.startsWith(`${subpath}/`) +} diff --git a/src/renderer/src/lib/vim-nav.ts b/packages/app-core/src/lib/vim-nav.ts similarity index 100% rename from src/renderer/src/lib/vim-nav.ts rename to packages/app-core/src/lib/vim-nav.ts diff --git a/src/renderer/src/lib/wikilinks.ts b/packages/app-core/src/lib/wikilinks.ts similarity index 100% rename from src/renderer/src/lib/wikilinks.ts rename to packages/app-core/src/lib/wikilinks.ts diff --git a/packages/app-core/src/main.tsx b/packages/app-core/src/main.tsx new file mode 100644 index 00000000..576a9ec6 --- /dev/null +++ b/packages/app-core/src/main.tsx @@ -0,0 +1,21 @@ +import React from 'react' +import ReactDOM from 'react-dom/client' +import App from './App' +import { FloatingNoteApp } from './components/FloatingNoteApp' +import './styles/index.css' + +export function renderZenNotesApp(root: HTMLElement): void { + const params = new URLSearchParams(window.location.search) + const isFloating = params.get('floating') === '1' + const floatingNotePath = params.get('note') + + ReactDOM.createRoot(root).render( + + {isFloating && floatingNotePath ? ( + + ) : ( + + )} + + ) +} diff --git a/src/renderer/src/store.ts b/packages/app-core/src/store.ts similarity index 97% rename from src/renderer/src/store.ts rename to packages/app-core/src/store.ts index 59b7b751..e4bbba8b 100644 --- a/src/renderer/src/store.ts +++ b/packages/app-core/src/store.ts @@ -1,11 +1,13 @@ import { create } from 'zustand' import type { EditorView } from '@codemirror/view' +import { DEFAULT_VAULT_SETTINGS } from '@shared/ipc' import type { AssetMeta, FolderEntry, NoteContent, NoteFolder, NoteMeta, + VaultSettings, VaultTextSearchBackendPreference, VaultChangeEvent, VaultInfo @@ -21,6 +23,7 @@ import { toggleTaskAtIndex } from '@shared/tasklists' import { DEFAULT_THEME_ID, THEMES, type ThemeFamily, type ThemeMode } from './lib/themes' import { formatMarkdown } from './lib/format-markdown' import { confirmMoveToTrash } from './lib/confirm-trash' +import { pickServerDirectoryApp } from './components/ServerDirectoryPickerHost' import type { KeymapId, KeymapOverrides } from './lib/keymaps' import { normalizeKeymapOverrides } from './lib/keymaps' import { @@ -28,6 +31,12 @@ import { normalizeSystemFolderLabels } from './lib/system-folder-labels' import { recordRendererPerf } from './lib/perf' +import { + normalizeDailyNotesDirectory, + normalizeVaultSettings, + noteFolderSubpath, + noteTitleForDate +} from './lib/vault-layout' import type { Panel } from './lib/vim-nav' import { allLeaves, @@ -205,7 +214,7 @@ const DEFAULT_PREFS: Prefs = { textFont: null, monoFont: null, systemFolderLabels: {}, - sidebarWidth: 232, + sidebarWidth: 260, noteListWidth: 300, noteSortOrder: 'none', groupByKind: true, @@ -964,6 +973,7 @@ export function isQuickNotesViewActive(state: { interface Store { vault: VaultInfo | null + vaultSettings: VaultSettings notes: NoteMeta[] folders: FolderEntry[] assetFiles: AssetMeta[] @@ -1103,6 +1113,7 @@ interface Store { noteDirty: Record setVault: (v: VaultInfo | null) => void + setVaultSettings: (next: VaultSettings) => Promise setNotes: (notes: NoteMeta[]) => void setView: (view: View) => void /** Open the Tasks panel as a tab in the active pane. If the tab is @@ -1220,6 +1231,7 @@ interface Store { setPinnedRefMode: (mode: 'edit' | 'preview') => void setQuickNoteDateTitle: (on: boolean) => void + openTodayDailyNote: () => Promise setWordWrap: (on: boolean) => void setPreviewSmoothScroll: (on: boolean) => void setEditorMaxWidth: (px: number) => void @@ -1647,6 +1659,7 @@ export const useStore = create((set, get) => { return { vault: null, + vaultSettings: DEFAULT_VAULT_SETTINGS, notes: [], folders: [], assetFiles: [], @@ -1732,6 +1745,18 @@ export const useStore = create((set, get) => { noteDirty: {}, setVault: (v) => set({ vault: v }), + setVaultSettings: async (next) => { + try { + const settings = normalizeVaultSettings(await window.zen.setVaultSettings(next)) + set({ + vaultSettings: settings, + view: { kind: 'folder', folder: 'inbox', subpath: '' } + }) + await get().refreshNotes() + } catch (err) { + console.error('setVaultSettings failed', err) + } + }, setNotes: (notes) => set({ notes }), setView: (view) => set({ @@ -2723,6 +2748,26 @@ export const useStore = create((set, get) => { savePrefs(collectPrefs(get())) }, + openTodayDailyNote: async () => { + const state = get() + const settings = normalizeVaultSettings(state.vaultSettings) + if (!settings.dailyNotes.enabled) return + const title = noteTitleForDate() + const subpath = normalizeDailyNotesDirectory(settings.dailyNotes.directory) + const existing = state.notes.find( + (note) => + note.folder === 'inbox' && + note.title === title && + noteFolderSubpath(note, settings) === subpath + ) + if (existing) { + set({ view: { kind: 'folder', folder: 'inbox', subpath } }) + await get().selectNote(existing.path) + return + } + await get().createAndOpen('inbox', subpath, { title }) + }, + setWordWrap: (on) => { set({ wordWrap: on }) savePrefs(collectPrefs(get())) @@ -3349,15 +3394,16 @@ export const useStore = create((set, get) => { try { const vault = await window.zen.getCurrentVault() if (vault) { - set({ vault, workspaceRestored: false }) + const vaultSettings = normalizeVaultSettings(await window.zen.getVaultSettings()) + set({ vault, vaultSettings, workspaceRestored: false }) await get().refreshNotes() await restoreWorkspaceForVault(vault) } else { - set({ workspaceRestored: true }) + set({ workspaceRestored: true, vaultSettings: DEFAULT_VAULT_SETTINGS }) } } catch (err) { console.error('init failed', err) - set({ workspaceRestored: true }) + set({ workspaceRestored: true, vaultSettings: DEFAULT_VAULT_SETTINGS }) } // Default focus to the sidebar so j/k navigation works immediately if (get().sidebarOpen && !get().focusedPanel) { @@ -3387,11 +3433,36 @@ export const useStore = create((set, get) => { openVaultPicker: async () => { await get().flushDirtyNotes() - const vault = await window.zen.pickVault() + const capabilities = window.zen.getCapabilities() + const appInfo = window.zen.getAppInfo() + let vault: VaultInfo | null = null + + if (appInfo.runtime === 'web' && !capabilities.supportsLocalFilesystemPickers) { + const current = await window.zen.getCurrentVault() + const enteredPath = await pickServerDirectoryApp({ + title: 'Choose Vault Folder', + description: + 'Choose the folder on the server that ZenNotes should use as your vault.', + initialPath: current?.root ?? '', + confirmLabel: 'Choose Folder' + }) + if (!enteredPath) return + try { + vault = await window.zen.selectVaultPath(enteredPath.trim()) + } catch (err) { + window.alert((err as Error).message) + return + } + } else { + vault = await window.zen.pickVault() + } + if (vault) { + const vaultSettings = normalizeVaultSettings(await window.zen.getVaultSettings()) const fresh = makeLeaf() set({ vault, + vaultSettings, hasAssetsDir: false, assetFiles: [], view: { kind: 'folder', folder: 'inbox', subpath: '' }, diff --git a/src/renderer/src/styles/index.css b/packages/app-core/src/styles/index.css similarity index 99% rename from src/renderer/src/styles/index.css rename to packages/app-core/src/styles/index.css index 6674e4de..8b9440dc 100644 --- a/src/renderer/src/styles/index.css +++ b/packages/app-core/src/styles/index.css @@ -1064,6 +1064,11 @@ textarea, calc(var(--z-editor-line-height, 1.7) - 0.25), 1.45 ); + --z-heading-bottom-gap: clamp( + 0.34em, + calc(var(--z-editor-line-height, 1.7) * 0.24em), + 0.5em + ); /* Text font (editor + preview body). Falls back to SF Mono so writing feels code-ish out of the box. */ font-family: var( @@ -2396,36 +2401,43 @@ textarea, calc(var(--z-heading-fold-arrow-size) * 0.35), 10px ); + padding-bottom: var(--z-heading-bottom-gap); } .cm-editor .cm-heading-line-h1 { --z-heading-font-size: calc(var(--z-editor-font-size, 16px) * 2); font-size: calc(var(--z-editor-font-size, 16px) * 2); line-height: var(--z-heading-line-height); + padding-bottom: calc(var(--z-heading-bottom-gap) * 1.45); } .cm-editor .cm-heading-line-h2 { --z-heading-font-size: calc(var(--z-editor-font-size, 16px) * 1.6); font-size: calc(var(--z-editor-font-size, 16px) * 1.6); line-height: var(--z-heading-line-height); + padding-bottom: calc(var(--z-heading-bottom-gap) * 1.2); } .cm-editor .cm-heading-line-h3 { --z-heading-font-size: calc(var(--z-editor-font-size, 16px) * 1.3); font-size: calc(var(--z-editor-font-size, 16px) * 1.3); line-height: var(--z-heading-line-height); + padding-bottom: calc(var(--z-heading-bottom-gap) * 1.05); } .cm-editor .cm-heading-line-h4 { --z-heading-font-size: calc(var(--z-editor-font-size, 16px) * 1.15); font-size: calc(var(--z-editor-font-size, 16px) * 1.15); line-height: var(--z-heading-line-height); + padding-bottom: calc(var(--z-heading-bottom-gap) * 0.95); } .cm-editor .cm-heading-line-h5 { --z-heading-font-size: calc(var(--z-editor-font-size, 16px) * 1); font-size: calc(var(--z-editor-font-size, 16px) * 1); line-height: var(--z-heading-line-height); + padding-bottom: calc(var(--z-heading-bottom-gap) * 0.9); } .cm-editor .cm-heading-line-h6 { --z-heading-font-size: calc(var(--z-editor-font-size, 16px) * 0.92); font-size: calc(var(--z-editor-font-size, 16px) * 0.92); line-height: var(--z-heading-line-height); + padding-bottom: calc(var(--z-heading-bottom-gap) * 0.85); } .cm-editor .cm-heading-line .tok-processingInstruction, .cm-editor .cm-heading-line .tok-heading, @@ -2440,6 +2452,9 @@ textarea, font-weight: 700; letter-spacing: -0.01em; } +.cm-editor .cm-heading-line .tok-emphasis { + font-style: normal; +} .cm-heading-fold-arrow { position: absolute; left: calc( diff --git a/packages/app-core/tsconfig.json b/packages/app-core/tsconfig.json new file mode 100644 index 00000000..a9cc848c --- /dev/null +++ b/packages/app-core/tsconfig.json @@ -0,0 +1,14 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "jsx": "react-jsx", + "types": ["vite/client"], + "baseUrl": ".", + "paths": { + "@shared/*": ["../shared-domain/src/*"], + "@bridge-contract/*": ["../bridge-contract/src/*"] + } + }, + "include": ["src/**/*"] +} diff --git a/packages/bridge-contract/package.json b/packages/bridge-contract/package.json new file mode 100644 index 00000000..3fe8cc72 --- /dev/null +++ b/packages/bridge-contract/package.json @@ -0,0 +1,16 @@ +{ + "name": "@zennotes/bridge-contract", + "private": true, + "version": "1.0.4", + "type": "module", + "exports": { + "./bridge": "./src/bridge.ts", + "./ipc": "./src/ipc.ts" + }, + "scripts": { + "typecheck": "tsc --noEmit -p tsconfig.json", + "build": "tsc --noEmit -p tsconfig.json", + "test": "echo 'No bridge-contract tests yet'", + "test:run": "echo 'No bridge-contract tests yet'" + } +} diff --git a/packages/bridge-contract/src/bridge.ts b/packages/bridge-contract/src/bridge.ts new file mode 100644 index 00000000..bfc42c91 --- /dev/null +++ b/packages/bridge-contract/src/bridge.ts @@ -0,0 +1,158 @@ +import type { + AppUpdateState, + AssetMeta, + FolderEntry, + ImportedAsset, + NoteContent, + NoteFolder, + NoteMeta, + DirectoryBrowseResult, + VaultSettings, + TikzRenderResponse, + VaultChangeEvent, + VaultDemoTourResult, + VaultInfo, + VaultTextSearchBackendPreference, + VaultTextSearchCapabilities, + VaultTextSearchMatch, + VaultTextSearchToolPaths +} from './ipc' +import type { VaultTask } from '@zennotes/shared-domain/tasks' +import type { + McpClientId, + McpClientStatus, + McpInstructionsPayload, + McpServerRuntime +} from '@zennotes/shared-domain/mcp-clients' + +export interface ZenCapabilities { + supportsUpdater: boolean + supportsNativeMenus: boolean + supportsFloatingWindows: boolean + supportsLocalFilesystemPickers: boolean + supportsDesktopNotifications: boolean +} + +export interface ZenAppInfo { + name: string + productName: string + version: string + description: string + homepage?: string + runtime: 'desktop' | 'web' +} + +export interface ZenBridge { + getCapabilities(): ZenCapabilities + getAppInfo(): ZenAppInfo + + platform(): Promise + platformSync(): NodeJS.Platform + listSystemFonts(): Promise + getAppIconDataUrl(): Promise + zoomInApp(): Promise + zoomOutApp(): Promise + resetAppZoom(): Promise + getAppUpdateState(): Promise + checkForAppUpdates(): Promise + checkForAppUpdatesWithUi(): Promise + downloadAppUpdate(): Promise + installAppUpdate(): Promise + + getCurrentVault(): Promise + pickVault(): Promise + selectVaultPath(path: string): Promise + browseServerDirectories(path?: string): Promise + getVaultSettings(): Promise + setVaultSettings(next: VaultSettings): Promise + + listNotes(): Promise + listFolders(): Promise + listAssets(): Promise + hasAssetsDir(): Promise + generateDemoTour(): Promise + removeDemoTour(): Promise + getVaultTextSearchCapabilities( + paths?: VaultTextSearchToolPaths + ): Promise + searchVaultText( + query: string, + backend?: VaultTextSearchBackendPreference, + paths?: VaultTextSearchToolPaths + ): Promise + readNote(relPath: string): Promise + scanTasks(): Promise + scanTasksForPath(relPath: string): Promise + writeNote(relPath: string, body: string): Promise + createNote(folder: NoteFolder, title?: string, subpath?: string): Promise + renameNote(relPath: string, nextTitle: string): Promise + deleteNote(relPath: string): Promise + moveToTrash(relPath: string): Promise + restoreFromTrash(relPath: string): Promise + emptyTrash(): Promise + archiveNote(relPath: string): Promise + unarchiveNote(relPath: string): Promise + duplicateNote(relPath: string): Promise + revealNote(relPath: string): Promise + moveNote(relPath: string, targetFolder: NoteFolder, targetSubpath: string): Promise + importFilesToNote(notePath: string, sourcePaths: string[]): Promise + createFolder(folder: NoteFolder, subpath: string): Promise + renameFolder(folder: NoteFolder, oldSubpath: string, newSubpath: string): Promise + deleteFolder(folder: NoteFolder, subpath: string): Promise + duplicateFolder(folder: NoteFolder, subpath: string): Promise + revealFolder(folder: NoteFolder, subpath: string): Promise + revealAssetsDir(): Promise + getPathForFile(file: File): string | null + resolveLocalAssetUrl(vaultRoot: string, notePath: string, href: string): string | null + resolveVaultAssetUrl(vaultRoot: string, assetPath: string): string | null + + onVaultChange(cb: (ev: VaultChangeEvent) => void): () => void + onOpenSettings(cb: () => void): () => void + onAppUpdateState(cb: (state: AppUpdateState) => void): () => void + + windowMinimize(): void + windowToggleMaximize(): void + windowClose(): void + openNoteWindow(relPath: string): Promise + renderTikz(source: string): Promise + + mcpGetRuntime(): Promise + mcpGetStatuses(): Promise + mcpInstall(id: McpClientId): Promise + mcpUninstall(id: McpClientId): Promise + mcpGetInstructions(): Promise + mcpSetInstructions(next: string | null): Promise + clipboardWriteText(text: string): void + clipboardReadText(): string +} + +let installedBridge: ZenBridge | null = null + +function getWindowHost(): { zen: ZenBridge } | undefined { + const host = globalThis as typeof globalThis & { window?: { zen: ZenBridge } } + return typeof host.window === 'object' ? host.window : undefined +} + +export function installZenBridge(bridge: ZenBridge): ZenBridge { + installedBridge = bridge + const windowHost = getWindowHost() + if (windowHost && !windowHost.zen) { + windowHost.zen = bridge + } + return bridge +} + +export function getZenBridge(): ZenBridge { + if (installedBridge) return installedBridge + const windowHost = getWindowHost() + if (windowHost?.zen) return windowHost.zen + throw new Error('Zen bridge has not been installed') +} + +declare global { + interface Window { + zen: ZenBridge + } +} + +export {} diff --git a/packages/bridge-contract/src/ipc.ts b/packages/bridge-contract/src/ipc.ts new file mode 100644 index 00000000..01d90f13 --- /dev/null +++ b/packages/bridge-contract/src/ipc.ts @@ -0,0 +1,241 @@ +// Shared IPC channel names and types between main + renderer. +// Keeping these in one file gives us a single source of truth. + +export const IPC = { + VAULT_PICK: 'vault:pick', + VAULT_GET_CURRENT: 'vault:get-current', + VAULT_GET_SETTINGS: 'vault:get-settings', + VAULT_SET_SETTINGS: 'vault:set-settings', + VAULT_LIST_NOTES: 'vault:list-notes', + VAULT_LIST_FOLDERS: 'vault:list-folders', + VAULT_LIST_ASSETS: 'vault:list-assets', + VAULT_HAS_ASSETS_DIR: 'vault:has-assets-dir', + VAULT_GENERATE_DEMO_TOUR: 'vault:generate-demo-tour', + VAULT_REMOVE_DEMO_TOUR: 'vault:remove-demo-tour', + VAULT_TEXT_SEARCH_CAPABILITIES: 'vault:text-search-capabilities', + VAULT_SEARCH_TEXT: 'vault:search-text', + VAULT_READ_NOTE: 'vault:read-note', + VAULT_WRITE_NOTE: 'vault:write-note', + VAULT_CREATE_NOTE: 'vault:create-note', + VAULT_RENAME_NOTE: 'vault:rename-note', + VAULT_DELETE_NOTE: 'vault:delete-note', + VAULT_MOVE_TO_TRASH: 'vault:move-to-trash', + VAULT_RESTORE_FROM_TRASH: 'vault:restore-from-trash', + VAULT_EMPTY_TRASH: 'vault:empty-trash', + VAULT_ARCHIVE_NOTE: 'vault:archive-note', + VAULT_UNARCHIVE_NOTE: 'vault:unarchive-note', + VAULT_DUPLICATE_NOTE: 'vault:duplicate-note', + VAULT_REVEAL_NOTE: 'vault:reveal-note', + VAULT_MOVE_NOTE: 'vault:move-note', + VAULT_IMPORT_FILES: 'vault:import-files', + VAULT_CREATE_FOLDER: 'vault:create-folder', + VAULT_RENAME_FOLDER: 'vault:rename-folder', + VAULT_DELETE_FOLDER: 'vault:delete-folder', + VAULT_DUPLICATE_FOLDER: 'vault:duplicate-folder', + VAULT_REVEAL_FOLDER: 'vault:reveal-folder', + VAULT_REVEAL_ASSETS_DIR: 'vault:reveal-assets-dir', + VAULT_SCAN_TASKS: 'vault:scan-tasks', + VAULT_SCAN_TASKS_FOR: 'vault:scan-tasks-for', + APP_LIST_FONTS: 'app:list-fonts', + APP_ICON_DATA_URL: 'app:icon-data-url', + APP_OPEN_SETTINGS: 'app:open-settings', + APP_ZOOM_IN: 'app:zoom-in', + APP_ZOOM_OUT: 'app:zoom-out', + APP_ZOOM_RESET: 'app:zoom-reset', + APP_UPDATER_GET_STATE: 'app-updater:get-state', + APP_UPDATER_CHECK: 'app-updater:check', + APP_UPDATER_CHECK_WITH_UI: 'app-updater:check-with-ui', + APP_UPDATER_DOWNLOAD: 'app-updater:download', + APP_UPDATER_INSTALL: 'app-updater:install', + APP_UPDATER_ON_STATE: 'app-updater:on-state', + VAULT_ON_CHANGE: 'vault:on-change', + WINDOW_TOGGLE_MAXIMIZE: 'window:toggle-maximize', + WINDOW_MINIMIZE: 'window:minimize', + WINDOW_CLOSE: 'window:close', + WINDOW_OPEN_NOTE: 'window:open-note', + APP_PLATFORM: 'app:platform', + TIKZ_RENDER: 'tikz:render', + MCP_STATUS: 'mcp:status', + MCP_INSTALL: 'mcp:install', + MCP_UNINSTALL: 'mcp:uninstall', + MCP_RUNTIME: 'mcp:runtime', + MCP_GET_INSTRUCTIONS: 'mcp:get-instructions', + MCP_SET_INSTRUCTIONS: 'mcp:set-instructions' +} as const + +export interface TikzRenderResponse { + ok: boolean + svg?: string + error?: string +} + +export type AppUpdatePhase = + | 'unsupported' + | 'idle' + | 'checking' + | 'available' + | 'not-available' + | 'downloading' + | 'downloaded' + | 'error' + +export interface AppUpdateState { + phase: AppUpdatePhase + currentVersion: string + availableVersion: string | null + releaseName: string | null + releaseDate: string | null + releaseNotes: string | null + progressPercent: number | null + transferredBytes: number | null + totalBytes: number | null + bytesPerSecond: number | null + message: string +} + +export type NoteFolder = 'inbox' | 'quick' | 'archive' | 'trash' + +export type PrimaryNotesLocation = 'inbox' | 'root' + +export interface DailyNotesSettings { + enabled: boolean + directory: string +} + +export interface VaultSettings { + primaryNotesLocation: PrimaryNotesLocation + dailyNotes: DailyNotesSettings +} + +export const DEFAULT_DAILY_NOTES_DIRECTORY = 'Daily Notes' + +export const DEFAULT_VAULT_SETTINGS: VaultSettings = { + primaryNotesLocation: 'inbox', + dailyNotes: { + enabled: false, + directory: DEFAULT_DAILY_NOTES_DIRECTORY + } +} + +export interface NoteMeta { + /** Path relative to the vault root, always POSIX-style. */ + path: string + /** File name without extension. */ + title: string + folder: NoteFolder + /** Zero-based order within the parent directory as read from disk. */ + siblingOrder: number + createdAt: number + updatedAt: number + size: number + /** Extracted #tags (unique, lowercase not enforced). */ + tags: string[] + /** Outbound [[wikilink]] targets (note titles), unique. */ + wikilinks: string[] + /** True when the body references at least one local non-text asset + * (PDF, image, audio, video, generic file). Surfaced in the sidebar + * as a small paperclip hint so attachments are discoverable. */ + hasAttachments: boolean + /** First ~200 chars of the body stripped of markdown noise, for list previews. */ + excerpt: string +} + +export interface NoteContent extends NoteMeta { + /** Raw markdown body including any frontmatter. */ + body: string +} + +export type VaultTextSearchBackendPreference = 'auto' | 'builtin' | 'ripgrep' | 'fzf' +export type VaultTextSearchBackendResolved = 'builtin' | 'ripgrep' | 'fzf' + +export interface VaultTextSearchToolPaths { + ripgrepPath?: string | null + fzfPath?: string | null +} + +export interface VaultTextSearchCapabilities { + ripgrep: boolean + fzf: boolean +} + +export interface VaultDemoTourResult { + notePaths: string[] + assetPaths: string[] +} + +export interface VaultTextSearchMatch { + /** Path relative to the vault root, always POSIX-style. */ + path: string + /** File name without extension. */ + title: string + folder: NoteFolder + /** 1-based line number of the match inside the note body. */ + lineNumber: number + /** Zero-based character offset into the raw markdown body. */ + offset: number + /** Single-line preview of the matched line. */ + lineText: string +} + +export type ImportedAssetKind = 'image' | 'pdf' | 'audio' | 'video' | 'file' + +export interface AssetMeta { + /** Vault-relative path to the asset, POSIX-style. */ + path: string + /** File name only. */ + name: string + kind: ImportedAssetKind + /** Zero-based order within the parent directory as read from disk. */ + siblingOrder: number + size: number + updatedAt: number +} + +export interface ImportedAsset { + /** File name stored under the vault-root attachments directory. */ + name: string + /** Vault-relative path to the imported asset, POSIX-style. */ + path: string + /** Markdown snippet to insert into the note. */ + markdown: string + kind: ImportedAssetKind +} + +export interface VaultInfo { + root: string + name: string +} + +export interface DirectoryBrowseEntry { + name: string + path: string +} + +export interface DirectoryBrowseShortcut { + label: string + path: string +} + +export interface DirectoryBrowseResult { + currentPath: string + parentPath: string | null + entries: DirectoryBrowseEntry[] + shortcuts: DirectoryBrowseShortcut[] +} + +export interface FolderEntry { + /** Top-level folder (inbox / quick / archive / trash). */ + folder: NoteFolder + /** POSIX subpath relative to the top-level folder, "" for the top-level itself. */ + subpath: string + /** Zero-based order within the parent directory as read from disk. */ + siblingOrder: number +} + +export type VaultChangeKind = 'add' | 'change' | 'unlink' + +export interface VaultChangeEvent { + kind: VaultChangeKind + path: string + folder: NoteFolder +} diff --git a/packages/bridge-contract/tsconfig.json b/packages/bridge-contract/tsconfig.json new file mode 100644 index 00000000..4106458d --- /dev/null +++ b/packages/bridge-contract/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "composite": true, + "lib": ["ES2022", "DOM"] + }, + "include": ["src/**/*"] +} diff --git a/packages/shared-domain/package.json b/packages/shared-domain/package.json new file mode 100644 index 00000000..c0763543 --- /dev/null +++ b/packages/shared-domain/package.json @@ -0,0 +1,15 @@ +{ + "name": "@zennotes/shared-domain", + "private": true, + "version": "1.0.4", + "type": "module", + "exports": { + "./*": "./src/*.ts" + }, + "scripts": { + "typecheck": "tsc --noEmit -p tsconfig.json", + "build": "tsc --noEmit -p tsconfig.json", + "test": "echo 'No shared-domain tests yet'", + "test:run": "echo 'No shared-domain tests yet'" + } +} diff --git a/packages/shared-domain/src/archive.ts b/packages/shared-domain/src/archive.ts new file mode 100644 index 00000000..a9bfec6a --- /dev/null +++ b/packages/shared-domain/src/archive.ts @@ -0,0 +1,11 @@ +/** + * Virtual path used to identify the built-in Archive view as a tab in the + * pane layout. Uses the `zen://` scheme so it never collides with a real + * vault-relative note path. + */ +export const ARCHIVE_TAB_PATH = 'zen://archive' + +/** True when `path` points at the built-in Archive tab. */ +export function isArchiveTabPath(path: string | null | undefined): boolean { + return path === ARCHIVE_TAB_PATH +} diff --git a/packages/shared-domain/src/demo-tour.ts b/packages/shared-domain/src/demo-tour.ts new file mode 100644 index 00000000..715e3160 --- /dev/null +++ b/packages/shared-domain/src/demo-tour.ts @@ -0,0 +1,3 @@ +export const DEMO_TOUR_DIR = 'inbox/demo' +export const DEMO_TOUR_START_PATH = 'inbox/demo/00 — Start Here.md' +export const DEMO_TOUR_ASSET_PATH = 'zennotes-demo-card.svg' diff --git a/packages/shared-domain/src/help.ts b/packages/shared-domain/src/help.ts new file mode 100644 index 00000000..cd112840 --- /dev/null +++ b/packages/shared-domain/src/help.ts @@ -0,0 +1,11 @@ +/** + * Virtual path used to identify the in-app Help manual as a tab in the + * pane layout. Uses the `zen://` scheme so it never collides with a + * real vault-relative markdown path. + */ +export const HELP_TAB_PATH = 'zen://help' + +/** True when `path` points at the built-in Help tab. */ +export function isHelpTabPath(path: string | null | undefined): boolean { + return path === HELP_TAB_PATH +} diff --git a/packages/shared-domain/src/ipc.ts b/packages/shared-domain/src/ipc.ts new file mode 100644 index 00000000..5051563f --- /dev/null +++ b/packages/shared-domain/src/ipc.ts @@ -0,0 +1 @@ +export * from '@zennotes/bridge-contract/ipc' diff --git a/packages/shared-domain/src/mcp-clients.ts b/packages/shared-domain/src/mcp-clients.ts new file mode 100644 index 00000000..466f7364 --- /dev/null +++ b/packages/shared-domain/src/mcp-clients.ts @@ -0,0 +1,108 @@ +/** + * Shared descriptors for the MCP client integrations ZenNotes knows + * how to configure. Each entry describes where the client keeps its + * MCP server configuration and how its entry for our server should be + * shaped. Both the main process (read/write the files) and the + * renderer (present the UI) rely on these constants. + */ + +export type McpClientId = 'claude-code' | 'claude-desktop' | 'codex' + +export interface McpClientDescriptor { + id: McpClientId + /** Human-readable name shown in Settings. */ + label: string + /** One-line description shown below the label. */ + description: string + /** + * How the client stores its MCP server configuration. The actual + * path is resolved in the main process because it depends on the + * user's home directory and OS. + * - json: a JSON file with `mcpServers.` structure (Claude + * Desktop, Claude Code user scope). + * - toml: a TOML file with `[mcp_servers.]` tables (Codex). + */ + format: 'json' | 'toml' + /** Key ZenNotes uses inside the client's config — stable forever + * so re-runs upgrade the existing entry instead of creating a + * duplicate. */ + serverKey: string +} + +export const MCP_SERVER_KEY = 'zennotes' + +export const MCP_CLIENTS: McpClientDescriptor[] = [ + { + id: 'claude-code', + label: 'Claude Code', + description: + 'Anthropic\u2019s CLI. Installs as a user-scope MCP server in ~/.claude.json so every Claude Code session can reach your vault.', + format: 'json', + serverKey: MCP_SERVER_KEY + }, + { + id: 'claude-desktop', + label: 'Claude Desktop', + description: + 'The Claude desktop app. Registers the server in claude_desktop_config.json. Requires a full restart of Claude Desktop to take effect.', + format: 'json', + serverKey: MCP_SERVER_KEY + }, + { + id: 'codex', + label: 'Codex CLI', + description: + 'OpenAI\u2019s Codex CLI. Appends a [mcp_servers.zennotes] entry to ~/.codex/config.toml.', + format: 'toml', + serverKey: MCP_SERVER_KEY + } +] + +export function getMcpClientDescriptor(id: McpClientId): McpClientDescriptor { + const found = MCP_CLIENTS.find((c) => c.id === id) + if (!found) throw new Error(`Unknown MCP client: ${id}`) + return found +} + +/** Serialized state returned to the renderer for the settings UI. */ +export interface McpClientStatus { + id: McpClientId + /** Absolute path to the client's config file on this machine. */ + configPath: string + /** True if the config file currently contains a ZenNotes entry. */ + installed: boolean + /** Whether the installed entry matches what we would currently install + * (same command / args / env). False when the server path changed + * because the app moved, or when an older version installed a + * different shape. */ + upToDate: boolean + /** Human-readable diagnostic — surfaced beneath the row when the + * install state is ambiguous (file missing, permission error, etc). */ + note?: string +} + +export interface McpServerRuntime { + /** Absolute path to the Node binary that will run the server. */ + command: string + /** Arguments — typically `[mcpEntryPath]`. */ + args: string[] + /** Environment variables passed to the spawned server. */ + env: Record + /** Absolute path to the compiled MCP entry file. `null` when the + * build hasn\u2019t produced it yet (dev environment without a + * prior `npm run build`). */ + entryPath: string | null +} + +/** + * Shape returned when the renderer asks for the current server-side + * instructions. `defaultValue` is the compiled default; `current` is + * what the MCP server will actually send (either the user override + * or the default); `isCustom` flags whether an override is in place. + */ +export interface McpInstructionsPayload { + defaultValue: string + current: string + isCustom: boolean + filePath: string +} diff --git a/packages/shared-domain/src/quick-notes.ts b/packages/shared-domain/src/quick-notes.ts new file mode 100644 index 00000000..b79e38d4 --- /dev/null +++ b/packages/shared-domain/src/quick-notes.ts @@ -0,0 +1,11 @@ +/** + * Virtual path used to identify the built-in Quick Notes list view as a tab in + * the pane layout. Uses the `zen://` scheme so it never collides with a real + * vault-relative note path. + */ +export const QUICK_NOTES_TAB_PATH = 'zen://quick-notes' + +/** True when `path` points at the built-in Quick Notes tab. */ +export function isQuickNotesTabPath(path: string | null | undefined): boolean { + return path === QUICK_NOTES_TAB_PATH +} diff --git a/packages/shared-domain/src/tags.ts b/packages/shared-domain/src/tags.ts new file mode 100644 index 00000000..d7c140d7 --- /dev/null +++ b/packages/shared-domain/src/tags.ts @@ -0,0 +1,16 @@ +/** + * Single virtual tab path for the vault-wide Tag view. Unlike Tasks (which + * is always a solo surface), the Tag view accumulates one or more selected + * tags in the store — clicking more tag chips *adds* them to the current + * tab rather than spawning new tabs. + * + * Starts with `zen://` so the path never collides with a real vault- + * relative note path (which is always POSIX `folder/file.md`). + */ + +export const TAGS_TAB_PATH = 'zen://tags' + +/** True when `path` is the virtual Tags tab. */ +export function isTagsTabPath(path: string | null | undefined): boolean { + return path === TAGS_TAB_PATH +} diff --git a/packages/shared-domain/src/tasklists.ts b/packages/shared-domain/src/tasklists.ts new file mode 100644 index 00000000..097983ff --- /dev/null +++ b/packages/shared-domain/src/tasklists.ts @@ -0,0 +1,49 @@ +// Shared markdown task-list primitives used by both the renderer (toggling +// checkboxes in the preview) and the main-process vault-wide task scanner. +// The index convention here MUST stay in lockstep with any parser that wants +// to round-trip a toggle — see `src/shared/tasks.ts`. + +export const FENCE_RE = /^(\s*)(```|~~~)/ +export const TASK_LINE_RE = /^(\s*(?:>\s*)*(?:[-+*]|\d+[.)])\s+\[)( |x|X)(\].*)$/ + +export function toggleTaskAtIndex( + markdown: string, + taskIndex: number, + checked: boolean +): string { + if (taskIndex < 0) return markdown + + const lines = markdown.split('\n') + let currentTaskIndex = 0 + let inFence = false + let fenceMarker: string | null = null + + for (let i = 0; i < lines.length; i++) { + const line = lines[i] + const fenceMatch = line.match(FENCE_RE) + if (fenceMatch) { + const marker = fenceMatch[2] + if (!inFence) { + inFence = true + fenceMarker = marker + } else if (marker === fenceMarker) { + inFence = false + fenceMarker = null + } + continue + } + if (inFence) continue + + const taskMatch = line.match(TASK_LINE_RE) + if (!taskMatch) continue + if (currentTaskIndex !== taskIndex) { + currentTaskIndex += 1 + continue + } + + lines[i] = `${taskMatch[1]}${checked ? 'x' : ' '}${taskMatch[3]}` + return lines.join('\n') + } + + return markdown +} diff --git a/packages/shared-domain/src/tasks.ts b/packages/shared-domain/src/tasks.ts new file mode 100644 index 00000000..da8c8b1f --- /dev/null +++ b/packages/shared-domain/src/tasks.ts @@ -0,0 +1,342 @@ +import type { NoteFolder } from './ipc' +import { FENCE_RE, TASK_LINE_RE } from './tasklists' + +/** + * Virtual path used to identify the vault-wide Tasks view as a tab in the + * pane layout. Starts with `zen://` so it can never collide with a real + * vault-relative path (which is always POSIX folder/file.md). + */ +export const TASKS_TAB_PATH = 'zen://tasks' + +/** True when `path` is the virtual Tasks tab. */ +export function isTasksTabPath(path: string | null | undefined): boolean { + return path === TASKS_TAB_PATH +} + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +export type TaskPriority = 'high' | 'med' | 'low' + +export interface VaultTask { + /** Stable-ish id: `${sourcePath}#${taskIndex}`. Task index shifts only when + * tasks are added/removed above it in the same file, so this is stable + * across plain content edits. */ + id: string + /** Vault-relative POSIX path of the note containing this task. */ + sourcePath: string + /** File name without extension (for display). */ + noteTitle: string + /** Top-level vault folder the source note lives in. */ + noteFolder: NoteFolder + /** 0-based line number in the full file body (frontmatter included). */ + lineNumber: number + /** Must match `toggleTaskAtIndex` counting for round-trip edits. */ + taskIndex: number + /** Raw line as it appears on disk. */ + rawText: string + /** Display content (checkbox prefix + metadata tokens stripped). */ + content: string + checked: boolean + /** ISO YYYY-MM-DD, validated via Date round-trip. */ + due?: string + priority?: TaskPriority + /** True if `@waiting` appears anywhere on the line. */ + waiting: boolean + /** Inline `#tags` found on the line. */ + tags: string[] +} + +export interface VaultTaskGroups { + today: VaultTask[] + upcoming: VaultTask[] + waiting: VaultTask[] + done: VaultTask[] + overdueCount: number +} + +// --------------------------------------------------------------------------- +// Frontmatter (minimal — only due/priority/status) +// --------------------------------------------------------------------------- + +interface NoteDefaults { + due?: string + priority?: TaskPriority + status?: string +} + +const FRONTMATTER_RE = /^---\n([\s\S]*?)\n---\n?/ + +function unquote(v: string): string { + const trimmed = v.trim() + if (trimmed.length >= 2) { + const first = trimmed[0] + const last = trimmed[trimmed.length - 1] + if ((first === '"' || first === "'") && first === last) { + return trimmed.slice(1, -1) + } + } + return trimmed +} + +function normalizePriority(raw: string | undefined): TaskPriority | undefined { + if (!raw) return undefined + const v = raw.toLowerCase().trim() + if (v === 'high' || v === 'h') return 'high' + if (v === 'med' || v === 'medium' || v === 'm') return 'med' + if (v === 'low' || v === 'l') return 'low' + return undefined +} + +function isValidIsoDate(s: string): boolean { + if (!/^\d{4}-\d{2}-\d{2}$/.test(s)) return false + const t = Date.parse(`${s}T00:00:00Z`) + return Number.isFinite(t) +} + +function normalizeDueDate(raw: string | undefined): string | undefined { + if (!raw) return undefined + const cleaned = unquote(raw.trim()) + return isValidIsoDate(cleaned) ? cleaned : undefined +} + +/** Extract just the three keys we care about. Unparseable lines are ignored. */ +function parseNoteDefaults(body: string): { defaults: NoteDefaults; fmEndOffset: number } { + const m = body.match(FRONTMATTER_RE) + if (!m) return { defaults: {}, fmEndOffset: 0 } + const block = m[1] + const defaults: NoteDefaults = {} + for (const rawLine of block.split('\n')) { + const line = rawLine.trim() + if (!line || line.startsWith('#')) continue + const colon = line.indexOf(':') + if (colon < 1) continue + const key = line.slice(0, colon).trim().toLowerCase() + const value = unquote(line.slice(colon + 1)) + if (key === 'due') { + const d = normalizeDueDate(value) + if (d) defaults.due = d + } else if (key === 'priority') { + const p = normalizePriority(value) + if (p) defaults.priority = p + } else if (key === 'status') { + defaults.status = value.toLowerCase() + } + } + return { defaults, fmEndOffset: m[0].length } +} + +// --------------------------------------------------------------------------- +// Inline token extraction +// --------------------------------------------------------------------------- + +// Word-boundary anchored so `due:` inside a URL-ish blob won't match. +const INLINE_DUE_RE = /(?:^|\s)due:(\S+)/i +const INLINE_PRIORITY_RE = /(?:^|\s)!(high|med|medium|low|h|m|l)\b/i +const INLINE_WAITING_RE = /(?:^|\s)@waiting\b/i +// Match #tag-like tokens but only when preceded by start-of-string/whitespace. +const INLINE_TAG_RE = /(?:^|\s)#([a-z0-9][a-z0-9/_-]*)/gi + +interface ExtractedTokens { + due?: string + priority?: TaskPriority + waiting: boolean + tags: string[] + /** Tail with matched tokens stripped, for clean display. */ + stripped: string +} + +function extractTokens(tail: string): ExtractedTokens { + let due: string | undefined + let priority: TaskPriority | undefined + let waiting = false + const tags: string[] = [] + let stripped = tail + + const dueMatch = stripped.match(INLINE_DUE_RE) + if (dueMatch) { + const candidate = dueMatch[1] + if (isValidIsoDate(candidate)) due = candidate + stripped = stripped.replace(INLINE_DUE_RE, ' ') + } + + const priMatch = stripped.match(INLINE_PRIORITY_RE) + if (priMatch) { + priority = normalizePriority(priMatch[1]) + stripped = stripped.replace(INLINE_PRIORITY_RE, ' ') + } + + if (INLINE_WAITING_RE.test(stripped)) { + waiting = true + stripped = stripped.replace(INLINE_WAITING_RE, ' ') + } + + INLINE_TAG_RE.lastIndex = 0 + let tm: RegExpExecArray | null + while ((tm = INLINE_TAG_RE.exec(tail))) { + const tag = tm[1].toLowerCase() + if (!tags.includes(tag)) tags.push(tag) + } + + return { + due, + priority, + waiting, + tags, + stripped: stripped.replace(/\s+/g, ' ').trim() + } +} + +// --------------------------------------------------------------------------- +// Main parser +// --------------------------------------------------------------------------- + +export interface ParseTasksContext { + /** Vault-relative POSIX path. */ + path: string + /** File name without extension — used for the noteTitle field. */ + title: string + folder: NoteFolder +} + +/** Parse every checkbox in `body`, skipping fenced code. Index counting is + * byte-for-byte identical to `toggleTaskAtIndex` so round-trip edits stay + * stable. */ +export function parseTasksFromBody(body: string, ctx: ParseTasksContext): VaultTask[] { + const normalized = body.replace(/\r\n/g, '\n') + const { defaults } = parseNoteDefaults(normalized) + const lines = normalized.split('\n') + const tasks: VaultTask[] = [] + + let taskIndex = 0 + let inFence = false + let fenceMarker: string | null = null + + for (let i = 0; i < lines.length; i++) { + const line = lines[i] + + const fenceMatch = line.match(FENCE_RE) + if (fenceMatch) { + const marker = fenceMatch[2] + if (!inFence) { + inFence = true + fenceMarker = marker + } else if (marker === fenceMarker) { + inFence = false + fenceMarker = null + } + continue + } + if (inFence) continue + + const taskMatch = line.match(TASK_LINE_RE) + if (!taskMatch) continue + + const checkedChar = taskMatch[2] + const tail = taskMatch[3].replace(/^\]/, '') // drop the closing `]` of the checkbox + const checked = checkedChar === 'x' || checkedChar === 'X' + + const tokens = extractTokens(tail) + + tasks.push({ + id: `${ctx.path}#${taskIndex}`, + sourcePath: ctx.path, + noteTitle: ctx.title, + noteFolder: ctx.folder, + lineNumber: i, + taskIndex, + rawText: line, + content: tokens.stripped || tail.trim(), + checked, + due: tokens.due ?? defaults.due, + priority: tokens.priority ?? defaults.priority, + waiting: tokens.waiting, + tags: tokens.tags + }) + + taskIndex += 1 + } + + return tasks +} + +// --------------------------------------------------------------------------- +// Grouping +// --------------------------------------------------------------------------- + +function toIsoDate(d: Date): string { + const y = d.getFullYear() + const m = String(d.getMonth() + 1).padStart(2, '0') + const day = String(d.getDate()).padStart(2, '0') + return `${y}-${m}-${day}` +} + +/** Group using a "today" anchor — caller supplies it so tests are stable and + * the user's timezone is respected. Waiting overrides everything except Done. + * Tasks without a due date land in Today. */ +export function groupTasks(tasks: VaultTask[], today: Date): VaultTaskGroups { + const todayIso = toIsoDate(today) + const today_: VaultTask[] = [] + const upcoming: VaultTask[] = [] + const waiting: VaultTask[] = [] + const done: VaultTask[] = [] + let overdueCount = 0 + + for (const task of tasks) { + if (task.checked) { + done.push(task) + continue + } + if (task.waiting) { + waiting.push(task) + continue + } + if (!task.due) { + today_.push(task) + continue + } + if (task.due < todayIso) { + today_.push(task) + overdueCount += 1 + continue + } + if (task.due === todayIso) { + today_.push(task) + continue + } + upcoming.push(task) + } + + // Sort each bucket for stable, useful ordering. + const byDueThenPath = (a: VaultTask, b: VaultTask): number => { + const ad = a.due ?? '9999-99-99' + const bd = b.due ?? '9999-99-99' + if (ad !== bd) return ad < bd ? -1 : 1 + if (a.sourcePath !== b.sourcePath) return a.sourcePath < b.sourcePath ? -1 : 1 + return a.taskIndex - b.taskIndex + } + const priorityRank: Record = { high: 0, med: 1, low: 2 } + const byPriorityThenDue = (a: VaultTask, b: VaultTask): number => { + const ap = a.priority ? priorityRank[a.priority] : 3 + const bp = b.priority ? priorityRank[b.priority] : 3 + if (ap !== bp) return ap - bp + return byDueThenPath(a, b) + } + + today_.sort(byPriorityThenDue) + upcoming.sort(byDueThenPath) + waiting.sort(byPriorityThenDue) + done.sort((a, b) => { + if (a.sourcePath !== b.sourcePath) return a.sourcePath < b.sourcePath ? -1 : 1 + return a.taskIndex - b.taskIndex + }) + + return { today: today_, upcoming, waiting, done, overdueCount } +} + +/** Helper for UIs that need to know whether a task is overdue relative to now. */ +export function isOverdue(task: VaultTask, today: Date): boolean { + if (task.checked || task.waiting || !task.due) return false + return task.due < toIsoDate(today) +} diff --git a/packages/shared-domain/src/trash.ts b/packages/shared-domain/src/trash.ts new file mode 100644 index 00000000..3fbd6b79 --- /dev/null +++ b/packages/shared-domain/src/trash.ts @@ -0,0 +1,11 @@ +/** + * Virtual path used to identify the built-in Trash view as a tab in the + * pane layout. Uses the `zen://` scheme so it never collides with a real + * vault-relative note path. + */ +export const TRASH_TAB_PATH = 'zen://trash' + +/** True when `path` points at the built-in Trash tab. */ +export function isTrashTabPath(path: string | null | undefined): boolean { + return path === TRASH_TAB_PATH +} diff --git a/packages/shared-domain/tsconfig.json b/packages/shared-domain/tsconfig.json new file mode 100644 index 00000000..cd4013a8 --- /dev/null +++ b/packages/shared-domain/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "composite": true, + "lib": ["ES2022"] + }, + "include": ["src/**/*"] +} diff --git a/packages/shared-ui/package.json b/packages/shared-ui/package.json new file mode 100644 index 00000000..64d971f4 --- /dev/null +++ b/packages/shared-ui/package.json @@ -0,0 +1,15 @@ +{ + "name": "@zennotes/shared-ui", + "private": true, + "version": "1.0.4", + "type": "module", + "exports": { + ".": "./src/index.ts" + }, + "scripts": { + "typecheck": "tsc --noEmit -p tsconfig.json", + "build": "tsc --noEmit -p tsconfig.json", + "test": "echo 'No shared-ui tests yet'", + "test:run": "echo 'No shared-ui tests yet'" + } +} diff --git a/packages/shared-ui/src/index.ts b/packages/shared-ui/src/index.ts new file mode 100644 index 00000000..336ce12b --- /dev/null +++ b/packages/shared-ui/src/index.ts @@ -0,0 +1 @@ +export {} diff --git a/packages/shared-ui/tsconfig.json b/packages/shared-ui/tsconfig.json new file mode 100644 index 00000000..4106458d --- /dev/null +++ b/packages/shared-ui/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "composite": true, + "lib": ["ES2022", "DOM"] + }, + "include": ["src/**/*"] +} diff --git a/src/preload/index.d.ts b/src/preload/index.d.ts deleted file mode 100644 index 282c1c38..00000000 --- a/src/preload/index.d.ts +++ /dev/null @@ -1,9 +0,0 @@ -import type { ZenApi } from './index' - -declare global { - interface Window { - zen: ZenApi - } -} - -export {} diff --git a/src/renderer/src/main.tsx b/src/renderer/src/main.tsx deleted file mode 100644 index 605396e0..00000000 --- a/src/renderer/src/main.tsx +++ /dev/null @@ -1,23 +0,0 @@ -import React from 'react' -import ReactDOM from 'react-dom/client' -import App from './App' -import { FloatingNoteApp } from './components/FloatingNoteApp' -import './styles/index.css' - -// A second BrowserWindow can boot the same renderer bundle and arrive -// with `?floating=1¬e=` to pop a single note out into its own -// always-visible window. The full sidebar/tabs/splits app is too heavy -// for that — we mount a stripped FloatingNoteApp instead. -const params = new URLSearchParams(window.location.search) -const isFloating = params.get('floating') === '1' -const floatingNotePath = params.get('note') - -ReactDOM.createRoot(document.getElementById('root')!).render( - - {isFloating && floatingNotePath ? ( - - ) : ( - - )} - -) diff --git a/src/shared/demo-tour.ts b/src/shared/demo-tour.ts index c905211c..715e3160 100644 --- a/src/shared/demo-tour.ts +++ b/src/shared/demo-tour.ts @@ -1,3 +1,3 @@ export const DEMO_TOUR_DIR = 'inbox/demo' export const DEMO_TOUR_START_PATH = 'inbox/demo/00 — Start Here.md' -export const DEMO_TOUR_ASSET_PATH = 'attachements/zennotes-demo-card.svg' +export const DEMO_TOUR_ASSET_PATH = 'zennotes-demo-card.svg' diff --git a/tailwind.config.js b/tailwind.config.js index 02135f61..b0e8e6a1 100644 --- a/tailwind.config.js +++ b/tailwind.config.js @@ -1,8 +1,9 @@ /** @type {import('tailwindcss').Config} */ export default { content: [ - './src/renderer/index.html', - './src/renderer/src/**/*.{ts,tsx}' + './apps/desktop/src/renderer/index.html', + './apps/web/index.html', + './packages/app-core/src/**/*.{ts,tsx}' ], theme: { extend: { diff --git a/tooling/scripts/dev-web-stack.mjs b/tooling/scripts/dev-web-stack.mjs new file mode 100644 index 00000000..054226df --- /dev/null +++ b/tooling/scripts/dev-web-stack.mjs @@ -0,0 +1,49 @@ +import { spawn } from 'node:child_process' + +const children = [] +let shuttingDown = false + +function run(name, args) { + const child = spawn(name, args, { + stdio: 'inherit', + shell: true, + env: process.env + }) + children.push(child) + child.on('exit', (code, signal) => { + if (shuttingDown) return + shuttingDown = true + for (const other of children) { + if (other.pid && other.pid !== child.pid) { + other.kill('SIGTERM') + } + } + if (signal) process.kill(process.pid, signal) + process.exit(code ?? 0) + }) + return child +} + +function shutdown(signal) { + if (shuttingDown) return + shuttingDown = true + for (const child of children) { + if (child.pid) child.kill('SIGTERM') + } + setTimeout(() => { + for (const child of children) { + if (child.pid) child.kill('SIGKILL') + } + }, 1500).unref() + process.exit(0) +} + +process.on('SIGINT', () => shutdown('SIGINT')) +process.on('SIGTERM', () => shutdown('SIGTERM')) + +console.log('Starting ZenNotes web dev stack:') +console.log(' - server: npm run dev:server') +console.log(' - web: npm run dev:web') + +run('npm', ['run', 'dev:server']) +run('npm', ['run', 'dev:web']) diff --git a/tooling/scripts/sync-web-dist.mjs b/tooling/scripts/sync-web-dist.mjs new file mode 100644 index 00000000..9d873332 --- /dev/null +++ b/tooling/scripts/sync-web-dist.mjs @@ -0,0 +1,12 @@ +import { cp, mkdir, rm } from 'node:fs/promises' +import { dirname, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' + +const scriptDir = dirname(fileURLToPath(import.meta.url)) +const repoRoot = resolve(scriptDir, '..', '..') +const webDist = resolve(repoRoot, 'apps/web/dist') +const serverDist = resolve(repoRoot, 'apps/server/web/dist') + +await rm(serverDist, { recursive: true, force: true }) +await mkdir(serverDist, { recursive: true }) +await cp(webDist, serverDist, { recursive: true, force: true }) diff --git a/tsconfig.base.json b/tsconfig.base.json new file mode 100644 index 00000000..dc9725d6 --- /dev/null +++ b/tsconfig.base.json @@ -0,0 +1,12 @@ +{ + "compilerOptions": { + "module": "ESNext", + "moduleResolution": "Bundler", + "target": "ES2022", + "strict": true, + "esModuleInterop": true, + "resolveJsonModule": true, + "allowSyntheticDefaultImports": true, + "skipLibCheck": true + } +} diff --git a/tsconfig.json b/tsconfig.json index 155ebaa6..52803d80 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,7 +1,10 @@ { "files": [], "references": [ - { "path": "./tsconfig.node.json" }, - { "path": "./tsconfig.web.json" } + { "path": "./apps/desktop" }, + { "path": "./apps/web" }, + { "path": "./packages/app-core" }, + { "path": "./packages/bridge-contract" }, + { "path": "./packages/shared-domain" } ] } diff --git a/tsconfig.node.json b/tsconfig.node.json deleted file mode 100644 index f07f1fd6..00000000 --- a/tsconfig.node.json +++ /dev/null @@ -1,25 +0,0 @@ -{ - "compilerOptions": { - "composite": true, - "module": "ESNext", - "moduleResolution": "Bundler", - "target": "ES2022", - "lib": ["ES2022"], - "strict": true, - "esModuleInterop": true, - "resolveJsonModule": true, - "skipLibCheck": true, - "types": ["node", "electron-vite/node"], - "baseUrl": ".", - "paths": { - "@shared/*": ["src/shared/*"] - } - }, - "include": [ - "electron.vite.config.ts", - "src/main/**/*", - "src/mcp/**/*", - "src/preload/**/*", - "src/shared/**/*" - ] -} diff --git a/tsconfig.web.json b/tsconfig.web.json deleted file mode 100644 index dc8b8347..00000000 --- a/tsconfig.web.json +++ /dev/null @@ -1,30 +0,0 @@ -{ - "compilerOptions": { - "composite": true, - "module": "ESNext", - "moduleResolution": "Bundler", - "target": "ES2022", - "lib": ["ES2022", "DOM", "DOM.Iterable"], - "jsx": "react-jsx", - "strict": true, - "useDefineForClassFields": true, - "esModuleInterop": true, - "resolveJsonModule": true, - "isolatedModules": true, - "allowSyntheticDefaultImports": true, - "skipLibCheck": true, - "noUnusedLocals": false, - "noUnusedParameters": false, - "types": ["vite/client"], - "baseUrl": ".", - "paths": { - "@renderer/*": ["src/renderer/src/*"], - "@shared/*": ["src/shared/*"] - } - }, - "include": [ - "src/renderer/src/**/*", - "src/preload/*.d.ts", - "src/shared/**/*" - ] -} diff --git a/turbo.json b/turbo.json new file mode 100644 index 00000000..0b3c392c --- /dev/null +++ b/turbo.json @@ -0,0 +1,25 @@ +{ + "$schema": "https://turbo.build/schema.json", + "tasks": { + "typecheck": { + "dependsOn": ["^typecheck"], + "outputs": [] + }, + "test": { + "dependsOn": ["^test"], + "outputs": [] + }, + "test:run": { + "dependsOn": ["^test:run"], + "outputs": [] + }, + "build": { + "dependsOn": ["^build"], + "outputs": ["dist/**", "out/**", "bin/**", "web/dist/**"] + }, + "dev": { + "cache": false, + "persistent": true + } + } +} diff --git a/vault/.zennotes/server.json b/vault/.zennotes/server.json new file mode 100644 index 00000000..5bcc783d --- /dev/null +++ b/vault/.zennotes/server.json @@ -0,0 +1,5 @@ +{ + "vaultPath": "/workspace", + "bind": "0.0.0.0:7878", + "authToken": "" +} \ No newline at end of file diff --git a/vault/.zennotes/vault.json b/vault/.zennotes/vault.json new file mode 100644 index 00000000..16f81bbb --- /dev/null +++ b/vault/.zennotes/vault.json @@ -0,0 +1,7 @@ +{ + "primaryNotesLocation": "inbox", + "dailyNotes": { + "enabled": false, + "directory": "Daily Notes" + } +} \ No newline at end of file diff --git a/vault/inbox/Welcome.md b/vault/inbox/Welcome.md new file mode 100644 index 00000000..8182a632 --- /dev/null +++ b/vault/inbox/Welcome.md @@ -0,0 +1,9 @@ +# Welcome to ZenNotes + +ZenNotes keeps your notes as plain markdown files. Press `?` to see the +keybinding cheat sheet, or start typing to begin. + +- Notes live in `inbox/`, `quick/`, `archive/`, and `trash/`. +- Every word you write stays on disk, under your control. +- Vim motions are on by default. + diff --git a/vitest.config.ts b/vitest.config.ts deleted file mode 100644 index 6e533916..00000000 --- a/vitest.config.ts +++ /dev/null @@ -1,15 +0,0 @@ -import path from 'node:path' -import { defineConfig } from 'vitest/config' - -export default defineConfig({ - resolve: { - alias: { - '@shared': path.resolve(__dirname, 'src/shared'), - '@renderer': path.resolve(__dirname, 'src/renderer/src') - } - }, - test: { - environment: 'node', - include: ['src/**/*.test.ts'] - } -}) diff --git a/website/assets/zennotes-icon.png b/website/assets/zennotes-icon.png deleted file mode 100644 index e75d6014..00000000 Binary files a/website/assets/zennotes-icon.png and /dev/null differ diff --git a/website/index.html b/website/index.html deleted file mode 100644 index 429353c7..00000000 --- a/website/index.html +++ /dev/null @@ -1,353 +0,0 @@ - - - - - - ZenNotes | Keyboard-first local markdown notes - - - - - - - - -
- - -
-
-
-

For people who live in markdown all day

-

- Local markdown notes for people who think in keys, panes, and - plain files. -

-

- ZenNotes is a desktop notes app built around ordinary Markdown - files, fast Vim-aware editing, split and preview reading flows, - and a bundled MCP server so Claude Code, Claude Desktop, and Codex - can work against the same vault you do. -

- -
    -
  • macOS, Windows, and Linux builds
  • -
  • Inbox, quick notes, archive, and trash
  • -
  • Math, diagrams, tasks, tags, and backlinks
  • -
-
- - -
- -
-
-

Built from the app’s real behavior

-

- ZenNotes isn’t “another markdown renderer.” It’s a local-first - work surface. -

-

- The codebase is optimized around a few concrete ideas: notes stay - as normal files, keyboard navigation is the default, preview is - part of the writing workflow, and AI tooling should operate on the - real vault instead of a separate shadow database. -

-
- -
-
-

01

-

Plain files first

-

- Every note is a normal .md file inside a chosen - vault. ZenNotes layers search, metadata extraction, attachments, - archive, and trash on top of files you still own. -

-
- -
-

02

-

Keyboard-first by default

-

- Vim mode, leader-key flows, remappable shortcuts, buffer - switching, which-key style hints, command palette access, and - local ex prompts are all first-class features, not bolt-ons. -

-
- -
-

03

-

Preview that stays useful

-

- Edit, preview, and split modes are joined by pinned reference - panes and detached note windows, so reading, drafting, and - research can happen in the same workspace. -

-
- -
-

04

-

Real markdown power

-

- Render math and diagrams directly from notes with KaTeX, - Mermaid, TikZ, JSXGraph, function-plot, callouts, footnotes, - wiki links, code fences, and local assets. -

-
- -
-

05

-

Vault-wide structure

-

- The app ships dedicated views for tasks, tags, archive, trash, - quick notes, and help, with text search that can resolve - automatically to fzf, ripgrep, or the - built-in engine. -

-
- -
-

06

-

First-party MCP integration

-

- ZenNotes installs its bundled MCP server into Claude Code, - Claude Desktop, and Codex directly from Settings, so assistants - can read, create, move, search, and append notes safely inside - the same vault. -

-
-
-
- -
-
-

What the workflow actually looks like

-

- Capture fast, stay in context, and keep your notes portable. -

-
- -
-
- 01 -

Capture

-

- Drop quick notes into a dedicated folder, seed daily notes, or - start from the inbox. -

-
-
- 02 -

Navigate

-

- Jump with note search, outline search, pane motion, buffers, and - leader-key flows. -

-
-
- 03 -

Read and compare

-

- Use split mode, a pinned reference pane, or detached windows - when one note is not enough. -

-
-
- 04 -

Organize

-

- Move work between inbox, archive, and trash while tasks, tags, - and backlinks stay visible. -

-
-
- -
-
-

Shipped customization

-

Make the app feel like your machine.

-

- ZenNotes exposes multiple theme families, light and dark modes, - independent font controls for interface, text, and monospace - surfaces, adjustable editor sizing, and remappable keybindings - throughout the app. -

-
- -
-

Onboarding built in

-

Help and demo content are part of the product.

-

- The codebase includes an in-app help view and a generated demo - tour that seeds a full note set under inbox/demo so - first-run onboarding happens inside the product instead of a - detached docs page. -

-
-
-
- -
-
-
-

Get ZenNotes

-

A desktop notes app for serious markdown work.

-

- Download platform builds from GitHub Releases, inspect the - source, or join the Discord while the project is still early. -

-
- - - -
    -
  • macOS builds: .dmg and .zip
  • -
  • - Windows builds: installer .exe and - .zip -
  • -
  • - Linux builds: .AppImage and .deb -
  • -
-
-
-
- - -
- - diff --git a/website/styles.css b/website/styles.css deleted file mode 100644 index 73fb283c..00000000 --- a/website/styles.css +++ /dev/null @@ -1,782 +0,0 @@ -:root { - --bg: #090b09; - --bg-soft: #0f120f; - --panel: rgba(17, 21, 18, 0.84); - --panel-strong: rgba(22, 27, 23, 0.96); - --line: rgba(225, 237, 220, 0.12); - --line-strong: rgba(225, 237, 220, 0.2); - --text: #f4f3ea; - --muted: #a8b0a0; - --muted-strong: #c8cfbf; - --accent: #e7efdd; - --accent-soft: #c5d3b8; - --accent-warm: #d5bb86; - --accent-green: #89b594; - --shadow: 0 34px 90px rgba(0, 0, 0, 0.48); - --radius-xl: 32px; - --radius-lg: 24px; - --radius-md: 18px; - --max-width: 1180px; -} - -*, -*::before, -*::after { - box-sizing: border-box; -} - -html { - scroll-behavior: smooth; -} - -body { - margin: 0; - min-height: 100vh; - background: - radial-gradient( - circle at top left, - rgba(150, 180, 140, 0.18), - transparent 28% - ), - radial-gradient( - circle at 85% 18%, - rgba(213, 187, 134, 0.16), - transparent 24% - ), - linear-gradient(180deg, #0d100d 0%, #090b09 52%, #070907 100%); - color: var(--text); - font-family: "IBM Plex Sans", sans-serif; -} - -body::before, -body::after { - content: ""; - position: fixed; - inset: auto; - border-radius: 999px; - filter: blur(80px); - pointer-events: none; - opacity: 0.45; - z-index: 0; -} - -body::before { - width: 18rem; - height: 18rem; - top: 9rem; - left: -4rem; - background: rgba(148, 182, 155, 0.24); -} - -body::after { - width: 24rem; - height: 24rem; - right: -6rem; - bottom: 6rem; - background: rgba(213, 187, 134, 0.16); -} - -a { - color: inherit; - text-decoration: none; -} - -img { - max-width: 100%; - display: block; -} - -code { - font-family: "IBM Plex Mono", monospace; - font-size: 0.92em; - color: var(--accent-warm); -} - -.page-shell { - position: relative; - isolation: isolate; - overflow: clip; -} - -.site-header, -.section, -.site-footer { - width: min(var(--max-width), calc(100% - 2rem)); - margin: 0 auto; -} - -.site-header { - position: sticky; - top: 0; - z-index: 10; - display: flex; - align-items: center; - justify-content: space-between; - gap: 1.5rem; - padding: 1rem 0; - backdrop-filter: blur(18px); -} - -.brand { - display: flex; - align-items: center; - gap: 0.9rem; -} - -.brand-mark { - width: 2.75rem; - height: 2.75rem; - border-radius: 22px; - box-shadow: 0 10px 30px rgba(0, 0, 0, 0.34); -} - -.brand-name, -.brand-tag { - display: block; -} - -.brand-name { - font-family: "Fraunces", serif; - font-size: 1.15rem; - font-weight: 700; - letter-spacing: 0.02em; -} - -.brand-tag { - color: var(--muted); - font-size: 0.84rem; -} - -.site-nav { - display: flex; - align-items: center; - gap: 1.25rem; - color: var(--muted-strong); - font-size: 0.95rem; -} - -.site-nav a { - position: relative; -} - -.site-nav a::after { - content: ""; - position: absolute; - left: 0; - bottom: -0.2rem; - width: 100%; - height: 1px; - background: currentColor; - transform: scaleX(0); - transform-origin: left; - transition: transform 180ms ease; -} - -.site-nav a:hover::after, -.site-nav a:focus-visible::after { - transform: scaleX(1); -} - -.section { - padding: clamp(4rem, 8vw, 7rem) 0; -} - -.hero { - display: grid; - grid-template-columns: minmax(0, 1fr) minmax(0, 1.05fr); - gap: clamp(2rem, 4vw, 4rem); - align-items: center; - min-height: calc(100vh - 5rem); -} - -.eyebrow, -.mono-label, -.detail-kicker, -.preview-kicker, -.card-index, -.pane-mode { - font-family: "IBM Plex Mono", monospace; - letter-spacing: 0.12em; - text-transform: uppercase; -} - -.eyebrow { - margin: 0 0 1rem; - color: var(--accent-warm); - font-size: 0.78rem; -} - -.hero h1, -.section-heading h2, -.download-panel h2, -.preview-card h2, -.detail-card h3, -.feature-card h3, -.workflow-strip h3 { - font-family: "Fraunces", serif; - font-weight: 700; - line-height: 0.95; - letter-spacing: -0.03em; -} - -.hero h1 { - margin: 0; - max-width: 12ch; - font-size: clamp(3.6rem, 7vw, 6.8rem); -} - -.hero-lede, -.section-heading p, -.feature-card p, -.workflow-strip p, -.detail-card p, -.download-panel p, -.site-footer p { - color: var(--muted); - line-height: 1.72; - font-size: 1.02rem; -} - -.hero-lede { - max-width: 61ch; - margin: 1.4rem 0 0; - font-size: 1.08rem; -} - -.hero-actions, -.download-actions { - display: flex; - flex-wrap: wrap; - gap: 0.9rem; - margin-top: 2rem; -} - -.button { - display: inline-flex; - align-items: center; - justify-content: center; - min-height: 3.15rem; - padding: 0 1.25rem; - border-radius: 999px; - border: 1px solid transparent; - font-weight: 600; - transition: - transform 180ms ease, - border-color 180ms ease, - background 180ms ease, - color 180ms ease; -} - -.button:hover, -.button:focus-visible { - transform: translateY(-2px); -} - -.button-primary { - background: linear-gradient( - 135deg, - var(--accent) 0%, - var(--accent-soft) 100% - ); - color: #0a0d0a; - box-shadow: 0 18px 40px rgba(211, 228, 202, 0.2); -} - -.button-secondary { - border-color: var(--line-strong); - background: rgba(255, 255, 255, 0.02); - color: var(--text); -} - -.hero-proof { - display: flex; - flex-wrap: wrap; - gap: 0.85rem; - padding: 0; - margin: 1.5rem 0 0; - list-style: none; -} - -.hero-proof li { - padding: 0.75rem 1rem; - border: 1px solid var(--line); - border-radius: 999px; - background: rgba(255, 255, 255, 0.02); - color: var(--muted-strong); - font-size: 0.92rem; -} - -.hero-visual { - position: relative; - min-height: 42rem; -} - -.orb { - position: absolute; - border-radius: 50%; - pointer-events: none; -} - -.orb-one { - inset: 0 auto auto 10%; - width: 28rem; - aspect-ratio: 1; - border: 1px solid rgba(231, 239, 221, 0.26); - box-shadow: - 0 0 0 26px rgba(231, 239, 221, 0.04), - 0 0 90px rgba(220, 233, 206, 0.14); - animation: breathe 9s ease-in-out infinite; -} - -.orb-two { - right: 0; - bottom: 5rem; - width: 10rem; - height: 10rem; - background: radial-gradient( - circle, - rgba(213, 187, 134, 0.38), - transparent 70% - ); - filter: blur(8px); -} - -.orb-three { - left: 6%; - bottom: 2rem; - width: 12rem; - height: 12rem; - background: radial-gradient( - circle, - rgba(137, 181, 148, 0.22), - transparent 72% - ); - filter: blur(20px); -} - -.app-frame, -.feature-card, -.detail-card, -.download-panel, -.workflow-strip article { - background: var(--panel); - border: 1px solid var(--line); - box-shadow: var(--shadow); - backdrop-filter: blur(18px); -} - -.app-frame { - position: absolute; - inset: 4rem 0 0 auto; - width: min(100%, 44rem); - border-radius: var(--radius-xl); - overflow: hidden; -} - -.frame-topbar, -.tab-strip { - display: flex; - align-items: center; -} - -.frame-topbar { - gap: 0.65rem; - padding: 1rem 1.2rem; - border-bottom: 1px solid var(--line); - background: rgba(255, 255, 255, 0.015); -} - -.dot { - width: 0.65rem; - height: 0.65rem; - border-radius: 50%; - background: rgba(244, 243, 234, 0.18); -} - -.frame-pills { - display: flex; - gap: 0.45rem; - margin-left: auto; -} - -.frame-pills span, -.sidebar-chip, -.tab { - font-family: "IBM Plex Mono", monospace; - font-size: 0.75rem; -} - -.frame-pills span, -.sidebar-chip { - padding: 0.45rem 0.7rem; - border-radius: 999px; - border: 1px solid rgba(231, 239, 221, 0.12); - color: var(--muted-strong); -} - -.frame-body { - display: grid; - grid-template-columns: 12rem minmax(0, 1fr); - min-height: 33rem; -} - -.frame-sidebar { - padding: 1.3rem 1rem; - border-right: 1px solid var(--line); - background: rgba(255, 255, 255, 0.02); -} - -.mono-label { - margin: 0 0 1rem; - color: var(--accent-warm); - font-size: 0.72rem; -} - -.frame-sidebar ul, -.preview-card ul { - padding: 0; - margin: 0; - list-style: none; -} - -.frame-sidebar li { - padding: 0.6rem 0.75rem; - border-radius: 12px; - color: var(--muted); -} - -.frame-sidebar li.is-active { - background: rgba(231, 239, 221, 0.08); - color: var(--accent); -} - -.sidebar-chip { - display: inline-block; - margin-top: 1.2rem; - line-height: 1.55; -} - -.frame-main { - display: flex; - flex-direction: column; -} - -.tab-strip { - gap: 0.5rem; - padding: 0.9rem 1rem; - border-bottom: 1px solid var(--line); -} - -.tab { - padding: 0.45rem 0.7rem; - border-radius: 999px; - color: var(--muted); - background: rgba(255, 255, 255, 0.02); -} - -.tab.is-current { - color: var(--accent); - background: rgba(231, 239, 221, 0.08); -} - -.pane-grid { - display: grid; - grid-template-columns: 1.05fr 0.95fr; - gap: 1rem; - padding: 1rem; - flex: 1; -} - -.pane { - position: relative; - min-height: 21rem; - padding: 1.2rem; - border-radius: var(--radius-lg); - background: var(--panel-strong); - border: 1px solid rgba(225, 237, 220, 0.08); -} - -.pane-mode, -.card-index, -.detail-kicker, -.preview-kicker { - color: var(--accent-warm); - font-size: 0.72rem; -} - -.pane-title { - margin: 1rem 0 0.5rem; - font-family: "IBM Plex Mono", monospace; - color: var(--accent); - font-size: 1rem; -} - -.pane-copy { - margin: 0 0 0.55rem; - color: var(--muted); - line-height: 1.6; - font-size: 0.94rem; -} - -.editor-lines { - display: grid; - gap: 0.55rem; - margin-top: 1rem; -} - -.editor-lines span { - display: block; - width: 100%; - height: 0.6rem; - border-radius: 999px; - background: linear-gradient( - 90deg, - rgba(231, 239, 221, 0.18), - rgba(137, 181, 148, 0.09) - ); -} - -.editor-lines .line-short { - width: 62%; -} - -.preview-card { - height: 100%; - padding: 1.2rem; - border-radius: 20px; - background: - linear-gradient( - 180deg, - rgba(255, 255, 255, 0.03), - rgba(255, 255, 255, 0.01) - ), - rgba(10, 13, 10, 0.9); - border: 1px solid rgba(231, 239, 221, 0.08); -} - -.preview-card h2 { - margin: 0.55rem 0 1rem; - font-size: 1.8rem; -} - -.preview-card li { - padding: 0.7rem 0; - color: var(--muted); - border-top: 1px solid rgba(225, 237, 220, 0.08); -} - -.section-heading { - max-width: 50rem; - margin-bottom: 2.4rem; -} - -.section-heading h2, -.download-panel h2 { - margin: 0; - font-size: clamp(2.4rem, 4vw, 4.2rem); -} - -.section-heading p:last-child { - margin-top: 1rem; -} - -.card-grid, -.detail-grid { - display: grid; - gap: 1.15rem; -} - -.card-grid { - grid-template-columns: repeat(3, minmax(0, 1fr)); -} - -.feature-card, -.detail-card { - padding: 1.35rem; - border-radius: var(--radius-lg); -} - -.feature-card h3, -.detail-card h3, -.workflow-strip h3 { - margin: 0.7rem 0 0.8rem; - font-size: 1.6rem; -} - -.feature-card p, -.detail-card p { - margin: 0; -} - -.workflow-strip { - display: grid; - grid-template-columns: repeat(4, minmax(0, 1fr)); - gap: 1rem; -} - -.workflow-strip article { - padding: 1.25rem; - border-radius: var(--radius-lg); -} - -.workflow-strip span { - display: inline-flex; - align-items: center; - justify-content: center; - width: 2rem; - height: 2rem; - border-radius: 50%; - background: rgba(231, 239, 221, 0.08); - color: var(--accent); - font-family: "IBM Plex Mono", monospace; - font-size: 0.8rem; -} - -.workflow-strip p { - margin: 0; -} - -.detail-grid { - grid-template-columns: 1.05fr 0.95fr; - margin-top: 1rem; -} - -.detail-card-accent { - background: - radial-gradient( - circle at top right, - rgba(213, 187, 134, 0.18), - transparent 35% - ), - radial-gradient( - circle at bottom left, - rgba(137, 181, 148, 0.14), - transparent 30% - ), - var(--panel); -} - -.download-panel { - display: grid; - gap: 2rem; - grid-template-columns: minmax(0, 1fr) auto; - align-items: end; - padding: clamp(1.5rem, 3vw, 2.4rem); - border-radius: var(--radius-xl); -} - -.platform-list { - grid-column: 1 / -1; - display: grid; - gap: 0.8rem; - padding: 1.1rem 0 0; - margin: 0; - border-top: 1px solid var(--line); - list-style: none; -} - -.platform-list li { - color: var(--muted); -} - -.site-footer { - display: flex; - align-items: center; - justify-content: space-between; - gap: 1rem; - padding: 0 0 2.5rem; -} - -.footer-links { - display: flex; - gap: 1rem; - color: var(--muted-strong); -} - -.footer-links a:hover, -.footer-links a:focus-visible { - color: var(--accent); -} - -@keyframes breathe { - 0%, - 100% { - transform: scale(1); - } - 50% { - transform: scale(1.04); - } -} - -@media (max-width: 1080px) { - .hero, - .card-grid, - .workflow-strip, - .detail-grid, - .download-panel { - grid-template-columns: 1fr; - } - - .hero-visual { - min-height: 36rem; - } - - .app-frame { - position: relative; - inset: 0; - margin-top: 1.5rem; - width: 100%; - } - - .download-actions { - margin-top: 0; - } -} - -@media (max-width: 760px) { - .site-header, - .site-footer, - .frame-topbar, - .tab-strip, - .pane-grid, - .frame-body { - width: 100%; - } - - .site-header, - .site-footer { - flex-direction: column; - align-items: flex-start; - } - - .site-nav { - flex-wrap: wrap; - } - - .hero { - min-height: auto; - } - - .hero h1 { - max-width: none; - font-size: clamp(2.9rem, 12vw, 4.4rem); - } - - .frame-body, - .pane-grid { - grid-template-columns: 1fr; - } - - .frame-sidebar { - border-right: 0; - border-bottom: 1px solid var(--line); - } - - .hero-proof { - flex-direction: column; - } - - .section { - padding: 4.25rem 0; - } -}