Compare commits

...
15 Commits
Author SHA1 Message Date
Your NameandClaude Haiku 5.5 5f4a1c4fb9 build: homepage at the new Gitea address
build / linux (push) Successful in 5m39s
build / windows (push) Successful in 2m30s
Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
2026-10-15 14:16:02 +02:00
Your NameandClaude Haiku 5.5 2134678485 build: homepage for the deb package
Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
2026-10-15 13:53:06 +02:00
Your NameandClaude Haiku 5.5 3613df7772 ci: build AppImage and deb in CI; flatpak stays local
flatpak-builder cannot run inside the job container.

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
2026-10-15 13:50:05 +02:00
Your NameandClaude Haiku 5.5 f8acd14be5 build: use the npm build of @electron/node-gyp
The GitHub pin of electron/node-gyp cannot be fetched on Gitea Actions.

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
2026-10-15 13:26:04 +02:00
Your NameandClaude Haiku 5.5 6993ce6449 ci: drop sudo, the job container runs as root
Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
2026-10-15 13:05:02 +02:00
Your NameandClaude Sonnet 5.5 1c16b743d8 build: flatpak and deb targets, Gitea Actions workflow
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-15 12:49:11 +02:00
Your NameandClaude Sonnet 5.5 d5d25eaf46 perf: faster startup and long pages
- open the window while the notes folder loads; rebuild the search index after the page list is sent
- load mermaid and katex on demand
- vault.list reads files in parallel batches
- resolveLink uses a per-page-list lookup table
- very long pages (>300 blocks) skip layout of off-screen blocks
- perf-large: add a long-page case, fix the tasks poll

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-15 12:25:45 +02:00
Your NameandClaude Sonnet 5.5 fcad23afa9 test: daily e2e opens the Notes tab of Settings
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-15 12:01:00 +02:00
Your NameandClaude Sonnet 5.5 469eedf593 test: open a folder before reaching its rows (the tree draws them lazily)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-15 12:00:39 +02:00
Your NameandClaude Sonnet 5.5 9b00e21c44 test: unit and end-to-end suites
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-15 11:57:09 +02:00
Your NameandClaude Sonnet 5.5 27fafa0bcd feat(ui): components, routes and styles
Sidebar (lazy tree, keyboard navigation, saved searches), page view with
typed properties, tasks, tags, calendar and clip dialogs, settings with
shortcut rebinding, canvas, database and board views.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-15 11:57:09 +02:00
Your NameandClaude Sonnet 5.5 c0587cb267 feat(app): editor engine, markdown, store and domain modules
Block editor, markdown round trip, formulas/databases/kanban/canvas
models, graph simulation (time-budgeted ticks for big vaults), shortcut
registry, dialog focus handling, HTML to Markdown for paste and clipping.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-15 11:57:09 +02:00
Your NameandClaude Sonnet 5.5 313c41f867 feat(electron): main process, vault, search index, links, import, capture
Vault on plain .md files with FTS5 index, link rewriting, canvas files,
history/trash/backup, link previews, image fetch, tasks and unlinked
mentions, Notion/Obsidian importer, quick-capture window and shortcut.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-15 11:57:09 +02:00
Your NameandClaude Sonnet 5.5 0b5f8bfa8b chore: project scaffold, build config, scripts and README
Bun + SvelteKit (static adapter) + Electron project files, icon resources,
dev/e2e/bench scripts and the README documenting the app.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-15 11:57:09 +02:00
Your Name 437887207c feat: Add Focus Mode toggle for distraction-free writing 2026-10-10 11:57:11 +02:00
162 changed files with 26552 additions and 233 deletions

No files matched your search

+54
View File
@@ -0,0 +1,54 @@
# Builds Ink for Linux and Windows on every v* tag (or by hand) on a Gitea Actions runner.
# macOS needs a Mac runner labelled "macos"; the job below stays off until you add one.
name: build
on:
push:
tags: ['v*']
workflow_dispatch:
jobs:
linux:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Tools
run: |
apt-get update
apt-get install -y fakeroot dpkg rpm libarchive-tools
npm i -g bun
- run: bun install --frozen-lockfile --ignore-scripts
- run: bun run build
- run: bunx electron-builder --linux AppImage deb
- uses: actions/upload-artifact@v3
with:
name: linux
path: |
dist/*.AppImage
dist/*.deb
windows:
runs-on: ubuntu-latest
container: electronuserland/builder:wine
steps:
- uses: actions/checkout@v4
- run: npm i -g bun
- run: bun install --frozen-lockfile --ignore-scripts
- run: bun run build
- run: bunx electron-builder --win nsis
- uses: actions/upload-artifact@v3
with:
name: windows
path: dist/*.exe
# macos:
# runs-on: macos
# steps:
# - uses: actions/checkout@v4
# - run: npm i -g bun
# - run: bun install --frozen-lockfile --ignore-scripts
# - run: bun run build
# - run: bunx electron-builder --mac dmg
# - uses: actions/upload-artifact@v3
# with:
# name: macos
# path: dist/*.dmg
+10
View File
@@ -0,0 +1,10 @@
node_modules/
.svelte-kit/
build/
dist/
.DS_Store
*.log
# one-off patch scripts, kept out of the way
old-patches/
*.bak
+161
View File
@@ -0,0 +1,161 @@
# Ink
A calm, offline Markdown notes app. Electron + SvelteKit (Svelte 5), plain `.md` files on disk, SQLite full-text search.
## Run it
Requires [Bun](https://bun.sh) and Node 20.19+ (22 LTS recommended; tests run on Node's test runner).
```sh
bun install # also downloads Electron and rebuilds better-sqlite3 for it
bun run dev # Vite dev server + Electron; the page hot-reloads, Electron restarts when electron/ changes
```
Other scripts:
| Script | What it does |
| ----------------- | ---------------------------------------------------------------- |
| `bun run dev:web` | UI only, in a browser, with an in-memory mock instead of files |
| `bun run start` | Production build of the UI, then Electron loads it |
| `bun run dist` | Packaged app in `dist/` (dmg / nsis / AppImage via electron-builder) |
| `bun run icons` | Re-render `resources/icon.svg` into every icon size (needs `rsvg-convert` and ImageMagick) |
| `bun run test` | Markdown round-trip, vault, front matter and search index tests |
If `bun install` fails while rebuilding `better-sqlite3`, there was no prebuilt binary for your Electron version and it tried to compile one. Install build tools (Xcode CLT on macOS, "Desktop development with C++" on Windows, `build-essential` + `python3` on Linux) and run `bunx electron-rebuild -f -w better-sqlite3`.
## How it fits together
```
renderer (SvelteKit SPA, sandboxed) preload main process (Node)
───────────────────────────────── ───────── ────────────────────
Editor (contenteditable blocks)
└─ onChange ─> store.edit()
└─ 600 ms ─> toMarkdown() ─> window.vault.write() ─> ipcMain ─> vault.cjs ─> ~/Notes/*.md
└─> db.cjs ─> .notes/index.sqlite (FTS5)
Palette ─> window.vault.search() ───────────────────────────> ipcMain ─> db.cjs
store <─ 'vault:changed' <───────────────────────────────── fs.watch (edits from other editors)
```
- **Files are the source of truth.** `Projects.md` is a page; `Projects/Roadmap.md` is its child. A folder without a matching `.md` still shows as a page. Delete `.notes/` any time and the index rebuilds on launch.
- **Front matter** holds `icon`, `cover`, `tags`, `created`. Unknown keys are preserved, so Obsidian vaults are safe to open.
- **Renaming** a page renames the file and its folder. **Moving** (drag it onto another page in the sidebar, or onto empty space for the top level) moves both, plus the images it shows. **Deleting** moves both to `.notes/trash/`, where the Trash dialog (sidebar footer, or `Ctrl/⌘ K` → Open trash) can restore them; the toast after a delete also has **Undo**. Entries older than 30 days, or deleted from the Trash dialog, go to the OS trash.
- **Web links** `[text](https://…)` and bare `https://…` URLs are clickable (`Ctrl/⌘`-click, or the bubble that shows while the caret is in one). Pasting a URL over selected text links it; a typed URL becomes a link at the next space. Only `http(s)` and `mailto` links open; others stay plain text.
- **Tables** are GitHub-style Markdown tables, edited cell by cell (right-click a cell for rows, columns and alignment; `/table` makes one). A table you don't touch is saved exactly as it was written; an edited one is written back with aligned columns.
- **Links** are Obsidian-style `[[Page]]`, `[[Folder/Page]]` or `[[Page|label]]`. A bare title matches the page with that title (shortest path wins). Renaming or moving a page rewrites the links that point to it. Each page lists its backlinks at the bottom.
- **Canvas** (like Obsidian's): an endless surface of cards, notes, pictures, web links and groups, joined by arrows. Make one with `Ctrl/⌘ N` → **Canvas**, "New canvas" in the sidebar's right-click menu, or the search palette. It is a `.canvas` file in the JSON Canvas format Obsidian uses, written the way Obsidian writes it, so canvases open in both apps (unknown fields are kept; a file that can't be read is shown as such and never saved over).
- **Move around**: scroll (Shift+scroll sideways), Space+drag or middle-drag; `Ctrl/⌘`+scroll or `Ctrl/⌘ +/−` zooms, `Ctrl/⌘ 0` is 100%, `Shift+1` shows everything. Each canvas remembers where you were looking.
- **Add**: double-click empty space for a card (Markdown, shown rendered; double-click or Enter to edit, Escape or click away to finish; a new card left empty disappears), or use the bar at the bottom: card, a note from your notes (shown as a live preview; `[[links]]` in cards and notes open), a picture (or drop pictures from the file manager, or paste one), a group (around the selection, if any). Pasting a web address makes a link card.
- **Arrange**: drag to move (snaps to the grid; Alt for free), the corner to resize; dragging a group takes what's inside along. Drag over empty space to select with a box, Shift+click to add; `Ctrl/⌘ A`, arrow keys nudge, `Delete` removes, `Ctrl/⌘ D` duplicates, `Ctrl/⌘ C/X/V` copy, cut and paste (as canvas text, like Obsidian). The bar over a selection sets one of six colours, opens a note, edits an arrow's label, or deletes.
- **Arrows**: drag one of the dots on a card's sides onto another card; let go on empty space to make a new card there. Double-click an arrow to label it.
- Undo/redo (`Ctrl/⌘ Z`, `Ctrl/⌘ Shift Z`). Canvases count as links: the notes on them list the canvas under backlinks, they appear in the graph, their text is searchable, and renaming or moving a note (or renaming one of its headings) updates every canvas showing it. `[[Name.canvas]]` links to a canvas; `[[Name]]` never means one.
- **New page** (`Ctrl/⌘ N`, the + buttons) opens a picker: **Blank page**, or a template. Templates are Markdown files in `<notes folder>/.notes/templates/` (front matter such as `icon` and `tags` carries over, and the file name becomes the page's title); each shows as a card with its icon and a preview. The first card is focused, so `Ctrl/⌘ N` then `Enter` gives a blank page; arrow keys move, **Open folder** opens the templates folder (creating it), and the list refreshes when you come back to the window.
- **Right-click** opens the app's own menu instead of the system one: spelling suggestions and "Add to dictionary" for a misspelled word; open / copy / edit / remove for a web link (open for a page link); a row of format buttons for selected text in a note; Cut, Copy, Paste and Select all; and "Search notes for …" for a short selection. It doesn't take focus, so the selection stays; arrow keys, Enter and Escape work. (Electron's main process reports what was clicked, since only it has the spelling suggestions, and does the clipboard and spelling actions; `TextMenu.svelte` draws the menu.) Pages in the sidebar and table cells keep their own menus.
- **Math and diagrams**: a `$$ … $$` block is a LaTeX formula (KaTeX) and a ```` ```mermaid ```` block is a Mermaid diagram (`/math`, `/mermaid`). Each shows the rendered result on top and its source underneath, and the result updates as you type; a mistake shows an error message instead. Diagrams follow the light/dark theme when they're drawn. Embeds show math but show a diagram's source.
- **Highlight**: `==text==` shows highlighted. Select text and use the toolbar's **H** button or `Ctrl/⌘ Shift H` to add or remove it.
- **Footnotes**: `[^id]` marks a reference and a `[^id]: text` line holds its text (indented lines continue it); `^[text]` is an inline note. References are numbered in reading order, hovering one shows its text, clicking it jumps to the text, and clicking a footnote's number jumps back. `/footnote` adds one with the next free number and takes you to its text; Enter at the end of a footnote text takes you back to the sentence, just after the reference (Shift+Enter for a new line inside the footnote). Footnote texts stay at the bottom: clicking below the page adds a line above them; typing `[^id]` or `^[…]` turns into a reference as you type, and `[^id]: ` at the start of a line starts a footnote text. A reference with no text is shown grey (click it to write one); a text nothing points to keeps its id. HTML export numbers and links them.
- **Heading links**: `[[Page#Heading]]` opens the page and scrolls to that heading (matched ignoring case and spacing); `[[#Heading]]` jumps within the page you're on. They're shown as `Page › Heading`, and typing `#` inside `[[` suggests the page's headings. Renaming a page keeps the `#Heading` part. **Renaming a heading** updates them too: once you stop typing for ~2 seconds (or at once when you leave the page or window), every `[[Page#Heading]]` link and `![[Page#Heading]]` embed in your notes, including those in board cards, and the `[[#Heading]]` links in the page itself, are re-pointed at the new name, keeping aliases, and a toast says how many. Only the first heading of a given name counts (links point at it), a change of case alone leaves links alone, a brand-new or emptied heading isn't a rename, and characters that would break a link (`# | ^ [ ]`) become spaces in the link. Ctrl+Z on the heading takes the links back.
- **Embeds**: `![[Page]]` on a line of its own shows that note's content inline, read-only, under its title (click the title to open it); `![[Page#Heading]]` shows just that section, down to the next heading of the same or higher level. Long ones fold with a "Show more" button. Type `![[` for a page picker, or use `/embed`. Embeds count as links for backlinks, the graph, and rename/move rewriting. `![[photo.png]]` still shows a picture; other files (pdf, audio…) stay plain text. A note can't embed itself, boards aren't shown, and an embed inside an embed appears as a link.
- **Cover pictures**: a page's cover is a gradient, or your own picture. Hover the cover → **Change cover** → **Upload an image…** or **From a link…** (paste an image address; the app downloads it, public http(s) addresses only), or drop an image file onto the cover. The picture is saved next to the page and named in its front matter (`cover: header.png`); it moves with the page when you move it, and database galleries show it on the page's card. "Remove the image" goes back to a gradient (the file stays where it is).
- **Images** pasted, dropped or added with `/image` are saved next to the note and written as `![](name.png)`; `![[name.png]]` is read too. They are served to the page through a `notes-media://` protocol that only reads inside the notes folder.
- **Split view**: a second page beside the one you're on, editable, with its own saving. Right-click a page → **Open in split**, Alt+click it in the sidebar, or drag it from the sidebar onto the right half of the page (the left half makes it the main page and moves the current one to the side), or use the top bar's columns button / `Ctrl/⌘ Shift \` (which opens the page you were on before). The side pane's title swaps the panes; ✕ closes it. Boards, databases and canvases work in the side pane too; links clicked in either pane open on the left.
- **Favorites** (the star in the top bar, or right-click a page) and **recent pages** (shown in `Ctrl/⌘ K` before you type) are kept per notes folder in the app settings.
- **Kanban boards** are pages with `kanban-plugin: basic` in their front matter, in the Obsidian Kanban plugin's format: each `## Heading` is a column, each `- [ ]` item a card (indented lines continue it), and a trailing `%% kanban:settings %%` block is kept. Create one with **New board** (`Ctrl/⌘ K`, or right-click in the sidebar), or turn any page into a board and back from the page's `⋯` menu. Cards and columns drag around; `Ctrl/⌘ Z` undoes board changes. Cards are drawn with the editor's own Markdown, so bold / italic / highlight / `code` / math, `[[links]]`, bullet and numbered lists, `>` quotes, headings, fenced code, tables and images (put them on the lines indented under the card) look as they do on a page; clicking a card edits its Markdown as written. Column titles are colour pills: the column's `⋯` menu picks one of eight colours (or leaves it automatic, from the name). Colours are kept in the page's front matter (`ink-colors`), never in the board text, so the file stays what the Kanban plugin writes; renaming a column keeps its colour.
- **Databases** are pages whose sub-pages are *records*, with typed properties and several views of the same records. Make one with **Database** in the new-page dialog (`Ctrl/⌘ N`), "New database" in the sidebar's right-click menu, or the search palette. A record is an ordinary note: its property values are keys in its own front matter (`status: Doing`, `due: 2026-03-01`, `done: true`, `owner: [Ana, Ben]`), so Obsidian reads them too, and a record can hold text like any page. The database page holds the schema (properties and views) in a `%% ink-database … %%` comment block and shows the records instead of a text editor; its records aren't listed under it in the sidebar.
- **Properties**: text, number, select, multi-select, date, checkbox, people, files and URL. Add one with **+** in the table header; its menu renames it, changes its type, edits select options (renaming an option updates the records), sorts, hides, moves or deletes it. Deleting a property or option leaves the values in the files. Files are copied next to the record and open with the system's app.
- **Views**: table, board (set up like a Kanban board: drag cards between groups; each group's header has its colour pill and a menu to rename it, pick its colour, add a record or delete it; drag a header to reorder the groups, "Add a group" adds an option, a card's `⋯` menu moves, renames or deletes it. New databases start with a Table and a Calendar; add a board with the `+` beside the view tabs), calendar (drag a record to another day, `+` on a day adds one), gallery (cover picture from a Files property, else the record's cover), timeline (bars from a start date to an optional end date; drag a bar to move it) and list. Each view keeps its own sort, filters, hidden properties and grouping or date property. Right-click a view's tab to rename, change its layout, duplicate or delete it.
- **Table**: drag a column's edge to resize it (kept per view) and its header to reorder; arrow keys move between cells (hold Alt where the arrows mean something in the field, e.g. a text caret or a number), Enter finishes and drops down. Tick rows (Shift-click for a range) for a bar that sets one property on all of them or trashes them in one Undo-able step.
- **Option colours** (select and multi-select): in the property menu, click an option's dot. **Number formats**: number with commas, percent, dollar, euro or a progress bar (set what a full bar is); the file always holds the plain number.
- **Calendar** has Month and Week; drag the right edge of a record to give it an end date (an "End" date property is added if there is none). **Timeline**: drag a bar's edges to change its start or end. A **Relation** property holds the titles of other records; point a timeline's *Dependencies* at one and it draws arrows (red when the earlier record ends after the later one starts). Renaming a record updates relations that name it.
- **In a page**: `![[Tasks]]` shows the database live, in place: filter, sort, switch views and edit values right there. `![[Tasks#Board]]` picks the saved view called Board (typing `#` after the name lists the views). An embed's own sort, filters and hidden properties are kept in its alias, e.g. `![[Tasks#Board|sort=due:desc; filter=status:is:Done; hide=owner]]`; they sit on top of the saved view's and never change the database. Embeds can't add properties or views; open the database for that.
- **CSV**: **Export as CSV** (page `⋯` menu, or the search palette) writes a Name column and one per property, every record. **Import CSV…** (same menu, the palette, or the sidebar's right-click menu for a new database) reads `,`, `;` or tab separated files with quoted cells and lets you choose what each column becomes: the name, an existing property (matched by header), a new property of a guessed type (number, date, checkbox, select, multi-select, URL, text) or nothing. Dates like `3/9/2025` and amounts like `$5,200` are understood; select options are collected from the values.
- **Search and graph**: property values are indexed. Plain words match them (`Ana`), `property:value` filters (`status:Done`, `status:"In progress"`, `owner:!Ana`, `points:>=5`, `due:2026-03`, `has:due`) and combines with words, `tag:` and `in:`. With only filters, every matching record is listed with the values that matched. `[[links]]` written in a property value count as links (graph, backlinks, renames), and so do relation properties between records.
- **Moving a record** out of its database (the record page's `⋯` menu has "Move out of …"; dragging a page onto a database makes it a record) says which of its properties stayed in the file, with a **Remove** button; moving between databases only mentions the ones the new database doesn't have.
- **Formulas**: a *Formula* property is worked out from the record's other values and never stored in the file. `prop("Price") * prop("Quantity")` (or `{Price}`), `if(cond, a, b)`, `concat(…)`, `round(x, 2)`, `dateBetween(prop("Expiry"), today(), "days")`, `dateAdd(d, 1, "months")`, `contains`, `min/max/sum/average` and more; `+ - * / % ^`, comparisons and `and / or / not`. An empty number counts as 0. Dates work with `+` and `-` (`date - date` is days). The property's menu has the editor with a live result for the first record, a number format (euro, dollar, percent, commas), and a list of functions; a mistake shows what is wrong and `Error` in the cells. Formulas can use each other, sort and filter numerically (`>`, `≥` …) and show on boards, galleries, record pages and in CSV exports.
- **Totals**: the table's bottom row has a **Calculate** cell under every column (hover it): count, count values / empty / unique, percent filled, and for numbers and formulas sum, average, median, min, max and range; earliest / latest / span for dates; checked and percent checked for checkboxes. They follow the view's filters, use the column's number format and are kept in the view.
- **Undo** (`Ctrl/⌘ Z`, `Ctrl/⌘ Shift Z`) steps through changes to the schema: properties, options, views, sorts, filters, widths. Changes to record values aren't part of it (use the editor's own undo, or version history on the record).
- **Records** open as pages with the database's properties on top, edited with the same controls. In the table, rename a record in place and use the trash button to delete one (Undo is in the toast).
- **Outline** (the list button in the top bar, `Ctrl/⌘ Shift O`, or Ctrl+K) docks a list of the page's `#`/`##`/`###` headings on the right, indented by level. Click one to scroll there; the section you're reading stays highlighted, and the list updates as you type. Pages only, not boards. Whether it's open is remembered.
- **Graph view** draws every note as a dot and every `[[link]]` as a line, laid out by a small force simulation (`src/lib/graph.js`: springs for links, Barnes–Hut repulsion, a pull to the centre). Drag dots, drag the background to pan, scroll to zoom, hover to light a note's neighbours, click to open. The gear shows filters: highlight, this page only (1–3 links away), notes without links, links to missing pages, page hierarchy, spacing.
- **Tasks** (checklist button in the sidebar footer, `Ctrl/⌘ Shift T`, or Ctrl+K → Open tasks) lists every `- [ ]` / `- [x]` item in your notes, grouped by page. Filter by open/done, due date (`📅 2026-03-01` or `due:2026-03-01` in the task: overdue, today, next 7 days, none), tag (the page's tags and `#tags` in the task) and text. Ticking one edits the line in its file; if the line changed on disk since the list was made, nothing is written. Code blocks, boards and canvases are skipped.
- **Unlinked mentions**: under *Linked from*, "Mentioned without a link" lists pages that write this page's title in plain text (whole words, not in code, links or URLs; titles under 3 letters are ignored). **Link it** turns the first mention on that page into `[[Page]]` (or `[[Page|as written]]`).
- **Saved searches**: type a query in the palette (`tag:work in:plan status:Done`) and choose *Save this search to the sidebar*. The saved search is listed under Favorites, per notes folder; clicking it opens the palette with the query, and ✕ removes it.
- **Quick capture**: a global shortcut (default `Ctrl/⌘ Alt N`, change it in Settings → General; also Ctrl+K → Quick capture) opens a small window. Enter adds what you typed to today's note or to a top-level **Inbox** page (your choice, also in the window): a single line becomes a bullet, several lines are added as written. Some Wayland desktops don't let apps register global shortcuts; the palette command still works.
- **Daily notes: previous / next and calendar.** On a daily note the top bar shows ‹ 📅 ›: the arrows go to the nearest existing daily note before or after (past the last one: today), the calendar marks days that have a note and opens or creates any day (also Ctrl+K → *Open a daily note by date*). A template for new daily notes is in Settings → Notes (`{{date}}`, `{{weekday}}`).
- **Properties on any page** are typed by their value: `true`/`false` is a checkbox, a number a number, `YYYY-MM-DD` a date, a list a multi-select (choices come from the same property on other pages), an `https://` address a link. The icon before a property's name changes its type; **Add property** asks for the type first. Only the value is written to the front matter.
- **Tag manager** (the gear by *Tags*, a tag's right-click menu, or Ctrl+K → *Manage tags*): every tag with its page count; rename (into an existing tag it merges) or remove a tag from all pages.
- **Import**: Settings → General → *Import notes*, or Ctrl+K. An Obsidian vault or a Notion "Markdown & CSV" export (folder or .zip) goes into a new top-level folder: Notion's ids are removed from names, its relative links become `[[wiki links]]`, pictures keep working, `.obsidian` is skipped. **Clip a web page** (Ctrl+K) saves a public page's main text as a note with `source:` in its front matter. **Pasting** from a web page or rich editor keeps headings, lists, tables, links and bold as Markdown (Settings → Editor turns it off).
- **Export**: Markdown, HTML and PDF per page (page `⋯` menu); *Export all notes as a zip* (Settings → General, or Ctrl+K) writes the whole folder, without history and trash.
- **Daily notes** live under `Daily/` as `YYYY-MM-DD.md`; `Ctrl/⌘ Shift D` or **Today** in the sidebar opens or creates today's.
- **Selecting across blocks** works the way it does in a text editor: drag from one block into another (up or down; the page scrolls along at its edges), Shift+click to extend, Shift+arrows, Ctrl/⌘+Shift+arrows (words, paragraphs), Shift+Home/End and Ctrl/⌘+Shift+Home/End (start or end of the page). Delete, typing and copy then act on the whole selection.
- **Undo** (`Ctrl/⌘ Z`, `Ctrl/⌘ Shift Z` or `Ctrl Y`) works across blocks: typing in one block is one step, and every new, deleted, converted or moved block is another.
- **Edits made elsewhere** (another editor, a sync tool, `git`) show up in the open page as soon as the file changes, unless you have unsaved edits there. If you do, and the file changed on disk too, your version is saved and the other one is first put into the page's version history, with a toast that opens it. Nothing is overwritten without a copy. (Notes folders are watched folder by folder; dot folders such as `.notes` and `.git` are ignored.)
- **Saving** is debounced (600 ms) and flushed on page switch, window blur, `Ctrl/⌘ S`, and before the window closes.
- **Security:** `contextIsolation`, `sandbox`, no `nodeIntegration`; the UI is served from a custom `app://` protocol with a hashed CSP and no network access. Fonts are bundled.
## Layout
```
electron/
main.cjs window, app:// and notes-media:// protocols, IPC handlers, safe close
preload.cjs window.vault bridge (the only API the page has)
vault.cjs read / write / create / rename / move / remove / watch Markdown files, save images
links.cjs find, rewrite and backlink [[page links]] on disk
canvas.cjs .canvas files on disk: read/write, text for search, keeping them in step with renames
frontmatter.cjs minimal YAML front matter
db.cjs SQLite FTS5 search index
settings.cjs theme, window size, last page (in the app's userData folder)
seed.cjs "Getting started" page for an empty folder
src/
routes/ +layout (SPA, fonts, css) and +page (the app shell)
lib/
editor.js block editor engine (plain DOM, mounted by Editor.svelte)
markdown.js Markdown <-> blocks, HTML export
links.js how a [[link]] finds its page (same rules as electron/links.cjs)
kanban.js Kanban board <-> Markdown
database.js database schema <-> Markdown, property values, filters, sorting, calendar and timeline maths
canvas.js .canvas format (same as electron/canvas.cjs), arrow and zoom geometry
graph.js force simulation for the graph view
embed.js headings, sections and read-only HTML for ![[embeds]]
scroll.js scroll a block into view and flash it (outline, heading links, footnotes)
footnotes.js number footnote references and texts on screen
state.svelte.js the store (Svelte 5 runes)
api.js / mock.js Electron bridge, browser mock
components/ Sidebar, TreeNode, Topbar, PageView, Editor, Backlinks, Board, Database, PropCell, CanvasView, FindBar, Outline, GraphView, SettingsDialog, Palette, ContextMenu, TrashDialog, ExportDialog, Toast, Welcome, WindowControls
resources/
icon.svg the app icon (source); icon.png, icon.ico and icons/NxN.png are rendered from it
scripts/icons.sh renders the icon files
test/ node --test suites
```
## Keyboard
| Keys | Action |
| ---- | ------ |
| `Ctrl/⌘ K` | Search notes and run commands |
| `Ctrl/⌘ N` | New page |
| `Ctrl/⌘ Shift D` | Today's daily note |
| `Ctrl/⌘ G` | Graph view (`Esc` goes back) |
| `Ctrl/⌘ Shift H` | Highlight the selection (`==text==`) |
| `Ctrl/⌘ Shift O` | Show / hide the page outline |
| `Ctrl/⌘ ,` | Settings: theme, text size, page width, notes folder, spellcheck |
| `Ctrl/⌘ \` | Show or hide the sidebar |
| `Ctrl/⌘ S` | Save now |
| `Ctrl/⌘ Z`, `Ctrl/⌘ Shift Z` | Undo, redo (across blocks) |
| `Ctrl/⌘ F` | Find in page (`Enter` / `Shift Enter` to step, `Esc` to close) |
| `Ctrl/⌘ B`, `I`, `E`, `Shift S` | Bold, italic, code, strikethrough (a toolbar also appears over selected text) |
| `Ctrl/⌘ K` with text selected | Link the selection to a web address (on a link: edit it) |
| `Ctrl/⌘` click a link | Open it in the browser |
| `Tab`, `Shift Tab`, `Enter` in a table | Next / previous cell, cell below (adding a row at the end) |
| `Ctrl/⌘ D` | Duplicate the current block |
| Click `⋮⋮` | Block menu: turn into, duplicate, delete |
| `/` | Block menu |
| `[[` | Link to a page (or create one) |
| `#`, `##`, `###`, `-`, `1.`, `[]`, `>`, ```` ``` ````, `---` | Markdown shortcuts at the start of a line |
## Packaging
`bun run dist` builds for the current OS into `dist/`: an AppImage on Linux (`Ink-<version>-x86_64.AppImage`, with a desktop entry and icons), an NSIS installer on Windows (lets you pick the install folder, adds Start menu and desktop shortcuts), a dmg on macOS. Installer settings live in the `build` field of `package.json`; `resources/` holds their icons (`build/` is Vite's output, so it can't be used for that).
On Linux, docks find a window's icon through a `.desktop` file named after its app id (`ink`, from `desktopName` in `package.json`). An AppImage installs nothing itself, so when Ink runs from one it writes `~/.local/share/applications/ink.desktop` (pointing at the AppImage's current location) and `~/.local/share/icons/hicolor/*/apps/ink.png`. Delete those to remove it.
The app was called "Notes" before; on first launch Ink copies the old settings (notes folder, favorites, window size) from the `Notes` settings folder.
+1371
View File
File diff suppressed because it is too large. Load diff
+134
View File
@@ -0,0 +1,134 @@
'use strict';
// Backups: the notes folder as one .zip, written without any dependency (deflate from zlib, a small zip writer).
// Version history and the trash are left out (they are large and can be rebuilt or are meant to be gone),
// and so is the search index.
const fs = require('node:fs');
const fsp = require('node:fs/promises');
const path = require('node:path');
const zlib = require('node:zlib');
const SKIP = ['.notes/history', '.notes/trash', '.notes/index.sqlite', '.notes/index.sqlite-wal', '.notes/index.sqlite-shm'];
let table = null;
function crc32(buf) {
if (!table) {
table = new Uint32Array(256);
for (let n = 0; n < 256; n++) {
let c = n;
for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
table[n] = c >>> 0;
}
}
let c = 0xffffffff;
for (let i = 0; i < buf.length; i++) c = table[(c ^ buf[i]) & 0xff] ^ (c >>> 8);
return (c ^ 0xffffffff) >>> 0;
}
function dosTime(d) {
const year = Math.max(1980, d.getFullYear());
return {
time: (d.getHours() << 11) | (d.getMinutes() << 5) | (d.getSeconds() >> 1),
date: ((year - 1980) << 9) | ((d.getMonth() + 1) << 5) | d.getDate()
};
}
async function* walk(root, rel = '') {
let entries;
try { entries = await fsp.readdir(path.join(root, rel), { withFileTypes: true }); } catch { return; }
for (const e of entries) {
const r = rel ? `${rel}/${e.name}` : e.name;
if (SKIP.includes(r)) continue;
if (e.isDirectory()) yield* walk(root, r);
else if (e.isFile()) yield r;
}
}
// Zip `root` into `out`. Returns the number of files.
async function zipFolder(root, out) {
await fsp.mkdir(path.dirname(out), { recursive: true });
const tmp = out + '.part';
const fd = await fsp.open(tmp, 'w');
const central = [];
let offset = 0, count = 0;
try {
for await (const rel of walk(root)) {
const file = path.join(root, rel);
let data, st;
try { data = await fsp.readFile(file); st = await fsp.stat(file); } catch { continue; } // changed while we ran
const packed = zlib.deflateRawSync(data);
const useDeflate = packed.length < data.length;
const body = useDeflate ? packed : data;
const name = Buffer.from(rel, 'utf8');
const { time, date } = dosTime(st.mtime);
const crc = crc32(data);
const head = Buffer.alloc(30);
head.writeUInt32LE(0x04034b50, 0);
head.writeUInt16LE(20, 4);
head.writeUInt16LE(0x0800, 6); // UTF-8 names
head.writeUInt16LE(useDeflate ? 8 : 0, 8);
head.writeUInt16LE(time, 10);
head.writeUInt16LE(date, 12);
head.writeUInt32LE(crc, 14);
head.writeUInt32LE(body.length, 18);
head.writeUInt32LE(data.length, 22);
head.writeUInt16LE(name.length, 26);
await fd.write(head);
await fd.write(name);
await fd.write(body);
central.push({ name, crc, size: data.length, packed: body.length, method: useDeflate ? 8 : 0, time, date, offset });
offset += 30 + name.length + body.length;
count++;
}
let size = 0;
for (const c of central) {
const h = Buffer.alloc(46);
h.writeUInt32LE(0x02014b50, 0);
h.writeUInt16LE(20, 4);
h.writeUInt16LE(20, 6);
h.writeUInt16LE(0x0800, 8);
h.writeUInt16LE(c.method, 10);
h.writeUInt16LE(c.time, 12);
h.writeUInt16LE(c.date, 14);
h.writeUInt32LE(c.crc, 16);
h.writeUInt32LE(c.packed, 20);
h.writeUInt32LE(c.size, 24);
h.writeUInt16LE(c.name.length, 28);
h.writeUInt32LE(c.offset, 42);
await fd.write(h);
await fd.write(c.name);
size += 46 + c.name.length;
}
const end = Buffer.alloc(22);
end.writeUInt32LE(0x06054b50, 0);
end.writeUInt16LE(count, 8);
end.writeUInt16LE(count, 10);
end.writeUInt32LE(size, 12);
end.writeUInt32LE(offset, 16);
await fd.write(end);
} finally {
await fd.close();
}
await fsp.rename(tmp, out);
return count;
}
const pad = (n) => String(n).padStart(2, '0');
function stamp(d = new Date()) {
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}${pad(d.getMinutes())}`;
}
// Back up the folder into `dir` as "<folder name> <date time>.zip", then keep only the newest `keep` of this folder's.
async function backup(root, dir, keep) {
const base = path.basename(root);
const out = path.join(dir, `${base} ${stamp()}.zip`);
const count = await zipFolder(root, out);
const mine = (await fsp.readdir(dir)).filter((f) => f.startsWith(base + ' ') && /\d{4}-\d{2}-\d{2} \d{4}\.zip$/.test(f)).sort().reverse();
for (const old of mine.slice(Math.max(1, keep))) await fsp.unlink(path.join(dir, old)).catch(() => {});
return { file: out, count };
}
const EVERY = { daily: 20 * 3600e3, weekly: 6.5 * 24 * 3600e3 };
// Whether a backup is due: every = 'off' | 'daily' | 'weekly'; last = time of the previous one (ms) or 0.
const due = (every, last, now = Date.now()) => !!EVERY[every] && now - (last || 0) >= EVERY[every];
module.exports = { zipFolder, backup, due, crc32, stamp };
+91
View File
@@ -0,0 +1,91 @@
'use strict';
// Canvas files (".canvas") on disk, in the JSON Canvas format Obsidian uses (https://jsoncanvas.org):
// { "nodes": [{ id, type: text|file|link|group, x, y, width, height, text|file|url|label, color? }],
// "edges": [{ id, fromNode, fromSide?, toNode, toSide?, label?, color? }] }
// Written the way Obsidian writes it (one node per line, tabs), so files changed by both apps diff cleanly.
// The page-side twin is src/lib/canvas.js; keep parse/serialize in step.
const { rewriteLinks, renameHeadingRefs, resolveLink, matchesHeading, linkHeading } = require('./links.cjs');
const isCanvasId = (id) => /\.canvas$/i.test(String(id || ''));
// { nodes, edges, extra } from the file's text. Empty text is an empty canvas; anything unreadable throws.
function parseCanvas(text) {
const s = String(text || '').trim();
if (!s) return { nodes: [], edges: [], extra: {} };
const data = JSON.parse(s);
if (!data || typeof data !== 'object' || Array.isArray(data)) throw new Error('This is not a canvas file.');
const { nodes, edges, ...extra } = data;
return { nodes: Array.isArray(nodes) ? nodes : [], edges: Array.isArray(edges) ? edges : [], extra };
}
function serializeCanvas({ nodes = [], edges = [], extra = {} }) {
const list = (arr) => (arr.length ? '[\n' + arr.map((o) => '\t\t' + JSON.stringify(o)).join(',\n') + '\n\t]' : '[]');
const parts = [`\t"nodes":${list(nodes)}`, `\t"edges":${list(edges)}`];
for (const [k, v] of Object.entries(extra)) parts.push(`\t${JSON.stringify(k)}:${JSON.stringify(v)}`);
return '{\n' + parts.join(',\n') + '\n}';
}
const EMPTY_CANVAS = serializeCanvas({});
// A note's file node points at "Folder/Note.md"; its page id has no extension. Other files keep theirs.
const fileToId = (file) => String(file || '').replace(/\.md$/i, '');
const isNoteFile = (file) => /\.md$/i.test(String(file || ''));
const isCanvasFile = (file) => isCanvasId(file);
// The canvas as Markdown-ish text: what search indexes, and where backlinks and the graph find its links
// (a note or canvas on it counts as a [[link]]).
function canvasText(text) {
let c;
try { c = parseCanvas(text); } catch { return ''; }
const out = [];
for (const n of c.nodes) {
if (n.type === 'text' && n.text) out.push(String(n.text));
else if (n.type === 'group' && n.label) out.push(String(n.label));
else if (n.type === 'link' && n.url) out.push(String(n.url));
else if (n.type === 'file' && (isNoteFile(n.file) || isCanvasFile(n.file))) out.push(`[[${fileToId(n.file)}${n.subpath || ''}]]`);
}
for (const e of c.edges) if (e.label) out.push(String(e.label));
return out.join('\n\n');
}
// Pages moved or renamed (see main.cjs relink): point file nodes and [[links]] in text nodes at the new places.
// Returns the new file text, or null when nothing changed.
function rewriteCanvas(text, before, after, moved) {
let c;
try { c = parseCanvas(text); } catch { return null; }
let changed = false;
for (const n of c.nodes) {
if (n.type === 'file' && n.file) {
const id = fileToId(n.file);
const now = moved(id);
if (now && now !== id) { n.file = isNoteFile(n.file) ? now + '.md' : now; changed = true; }
} else if (n.type === 'text' && n.text) {
const t = rewriteLinks(n.text, before, after, moved);
if (t !== null) { n.text = t; changed = true; }
}
}
return changed ? serializeCanvas(c) : null;
}
// A heading of page `pageId` was renamed: notes shown at that heading ("subpath") and [[Page#Heading]] links in
// text nodes follow. Returns { body, count } or null.
function renameHeadingInCanvas(text, pages, pageId, from, to) {
let c;
try { c = parseCanvas(text); } catch { return null; }
const safe = linkHeading(to);
if (!safe) return null;
let count = 0;
for (const n of c.nodes) {
if (n.type === 'file' && isNoteFile(n.file) && n.subpath && resolveLink(fileToId(n.file), pages) === pageId && matchesHeading(String(n.subpath).replace(/^#/, ''), from)) {
n.subpath = '#' + safe;
count++;
} else if (n.type === 'text' && n.text) {
const r = renameHeadingRefs(n.text, pages, pageId, from, to);
if (r) { n.text = r.body; count += r.count; }
}
}
return count ? { body: serializeCanvas(c), count } : null;
}
module.exports = { isCanvasId, parseCanvas, serializeCanvas, EMPTY_CANVAS, canvasText, rewriteCanvas, renameHeadingInCanvas, fileToId };
+9
View File
@@ -0,0 +1,9 @@
'use strict';
// The quick-capture window's only bridge (electron/capture.html).
const { contextBridge, ipcRenderer } = require('electron');
contextBridge.exposeInMainWorld('capture', {
info: () => ipcRenderer.invoke('capture:info'),
save: (text, to) => ipcRenderer.invoke('capture:save', text, to),
close: () => ipcRenderer.send('capture:close')
});
+52
View File
@@ -0,0 +1,52 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src 'unsafe-inline'; script-src 'unsafe-inline'" />
<title>Quick capture</title>
<style>
:root { --bg:#161617; --raise:#222224; --line:rgba(255,255,255,.13); --ink:#ececed; --ink-2:#a1a1a6; --ink-3:#6c6c71; --blush:#e4ada5; --blush-ink:#2a1714; color-scheme:dark; }
:root[data-theme="light"] { --bg:#fbfbfb; --raise:#f3f3f4; --line:rgba(0,0,0,.14); --ink:#1c1c1e; --ink-2:#5c5c61; --ink-3:#9a9a9f; --blush:#b8695f; --blush-ink:#fff8f5; color-scheme:light; }
* { box-sizing:border-box; }
html, body { height:100%; margin:0; }
body { background:var(--bg); color:var(--ink); font:14px/1.5 'Inter Variable', Inter, ui-sans-serif, system-ui, sans-serif; -webkit-font-smoothing:antialiased; display:flex; flex-direction:column; padding:14px; gap:10px; border:1px solid var(--line); border-radius:10px; }
textarea { flex:1; resize:none; border:0; outline:0; background:transparent; color:inherit; font:inherit; font-size:15px; }
textarea::placeholder { color:var(--ink-3); }
.bar { display:flex; align-items:center; gap:8px; color:var(--ink-3); font-size:12px; }
.bar span { flex:1; }
.seg { display:flex; background:var(--raise); border-radius:7px; padding:2px; }
.seg button { border:0; background:none; color:var(--ink-2); font:inherit; font-size:12px; padding:3px 10px; border-radius:5px; cursor:pointer; }
.seg button.on { background:var(--bg); color:var(--ink); box-shadow:0 0 0 1px var(--line); }
.go { border:0; background:var(--blush); color:var(--blush-ink); font:inherit; font-size:12.5px; font-weight:600; padding:5px 12px; border-radius:7px; cursor:pointer; }
kbd { font:inherit; color:var(--ink-2); }
</style>
</head>
<body>
<textarea id="t" placeholder="Capture a thought…" spellcheck="true" autofocus></textarea>
<div class="bar">
<div class="seg" role="radiogroup" aria-label="Where it goes">
<button data-to="daily" role="radio">Today's note</button>
<button data-to="inbox" role="radio">Inbox</button>
</div>
<span><kbd>Enter</kbd> save · <kbd>Shift</kbd>+<kbd>Enter</kbd> new line · <kbd>Esc</kbd> close</span>
<button class="go" id="go">Save</button>
</div>
<script>
let to = 'daily';
const t = document.getElementById('t');
const mark = () => document.querySelectorAll('.seg button').forEach((b) => { b.classList.toggle('on', b.dataset.to === to); b.setAttribute('aria-checked', String(b.dataset.to === to)); });
document.querySelectorAll('.seg button').forEach((b) => b.addEventListener('click', () => { to = b.dataset.to; mark(); t.focus(); }));
async function save() {
if (!t.value.trim()) return window.capture.close();
await window.capture.save(t.value, to);
}
document.getElementById('go').addEventListener('click', save);
t.addEventListener('keydown', (e) => {
if (e.key === 'Enter' && !e.shiftKey && !e.isComposing) { e.preventDefault(); save(); }
if (e.key === 'Escape') { e.preventDefault(); window.capture.close(); }
});
window.capture.info().then((i) => { document.documentElement.dataset.theme = i.theme; to = i.to; mark(); t.focus(); });
mark();
</script>
</body>
</html>
+99 -14
View File
@@ -12,40 +12,100 @@ function loadDriver() {
const likeEscape = (s) => s.replace(/[\\%_]/g, (c) => '\\' + c);
// A page's own front matter keys; any other key is a database property (src/lib/database.js).
const NOT_PROPS = new Set(['icon', 'cover', 'tags', 'created', 'ink-database', 'kanban-plugin']);
// meta (or a page's `props`) -> [[key, value]], one pair per list item, values trimmed, empty ones dropped
function propPairs(props) {
const out = [];
for (const [k, v] of Object.entries(props || {})) {
if (NOT_PROPS.has(k)) continue;
for (const x of Array.isArray(v) ? v : [v]) {
const t = String(x ?? '').trim();
if (t) out.push([k.toLowerCase(), t]);
}
}
return out;
}
function openDb(file) {
const Database = loadDriver();
const db = new Database(file);
// An index made before properties were indexed has no `props` column: it's only a cache, so start it again.
try {
const cols = db.prepare('PRAGMA table_info(notes)').all().map((c) => c.name);
if (cols.length && !cols.includes('props')) db.exec('DROP TABLE notes');
} catch { /* no table yet */ }
db.exec(`CREATE VIRTUAL TABLE IF NOT EXISTS notes USING fts5(
id UNINDEXED, title, tags, body, tokenize = 'unicode61 remove_diacritics 2'
id UNINDEXED, title, tags, body, props, tokenize = 'unicode61 remove_diacritics 2'
)`);
// property values as rows, for key:value searches
db.exec(`CREATE TABLE IF NOT EXISTS props (id TEXT NOT NULL, key TEXT NOT NULL, value TEXT NOT NULL)`);
db.exec('CREATE INDEX IF NOT EXISTS props_key ON props (key, value)');
db.exec('CREATE INDEX IF NOT EXISTS props_id ON props (id)');
const del = db.prepare('DELETE FROM notes WHERE id = ?');
const delTree = db.prepare("DELETE FROM notes WHERE id = ? OR id LIKE ? ESCAPE '\\'");
const ins = db.prepare('INSERT INTO notes (id, title, tags, body) VALUES (?, ?, ?, ?)');
const find = db.prepare(`SELECT id, title, snippet(notes, 3, char(1), char(2), '…', 14) AS snip
FROM notes WHERE notes MATCH ? ORDER BY bm25(notes, 0.0, 8.0, 4.0, 1.0) LIMIT ?`);
const row = (p) => [p.id, p.title || '', (p.tags || []).join(' '), p.body || ''];
const ins = db.prepare('INSERT INTO notes (id, title, tags, body, props) VALUES (?, ?, ?, ?, ?)');
const find = db.prepare(`SELECT id, title, snippet(notes, -1, char(1), char(2), '…', 14) AS snip
FROM notes WHERE notes MATCH ? ORDER BY bm25(notes, 0.0, 8.0, 4.0, 1.0, 3.0) LIMIT ?`);
const delProps = db.prepare('DELETE FROM props WHERE id = ?');
const delPropsTree = db.prepare("DELETE FROM props WHERE id = ? OR id LIKE ? ESCAPE '\\'");
const insProp = db.prepare('INSERT INTO props (id, key, value) VALUES (?, ?, ?)');
const hasKey = db.prepare('SELECT 1 FROM props WHERE key = ? LIMIT 1');
const propsOfId = db.prepare('SELECT key, value FROM props WHERE id = ? LIMIT 6');
const titleOf = db.prepare('SELECT title FROM notes WHERE id = ?');
const row = (p) => {
const pairs = propPairs(p.props);
return [p.id, p.title || '', (p.tags || []).join(' '), p.body || '', pairs.map(([, v]) => v).join(' ')];
};
const putProps = (p) => { for (const [k, v] of propPairs(p.props)) insProp.run(p.id, k, v.toLowerCase()); };
// ids of pages that satisfy a `key:value` filter ({ key, op, value }); ops: '' (equals or starts with), '!', '>', '<', '>=', '<='
const idsFor = (f) => {
const num = f.op === '>' || f.op === '<' || f.op === '>=' || f.op === '<=';
if (num) {
const n = Number(f.value);
if (!Number.isFinite(n)) return new Set();
const sql = `SELECT DISTINCT id FROM props WHERE key = ? AND value GLOB '*[0-9]*' AND CAST(value AS REAL) ${f.op} ?`;
return new Set(db.prepare(sql).all(f.key, n).map((r) => r.id));
}
const v = f.value.toLowerCase();
const sql = "SELECT DISTINCT id FROM props WHERE key = ? AND (value = ? OR value LIKE ? ESCAPE '\\')";
return new Set(db.prepare(sql).all(f.key, v, likeEscape(v) + '%').map((r) => r.id));
};
return {
upsert(p) { del.run(p.id); ins.run(...row(p)); },
remove(id) { delTree.run(id, likeEscape(id) + '/%'); },
upsert(p) { del.run(p.id); delProps.run(p.id); ins.run(...row(p)); putProps(p); },
remove(id) { delTree.run(id, likeEscape(id) + '/%'); delPropsTree.run(id, likeEscape(id) + '/%'); },
rebuild(pages) {
db.exec('BEGIN');
try {
db.exec('DELETE FROM notes');
for (const p of pages) if (p.exists) ins.run(...row(p));
db.exec('DELETE FROM props');
for (const p of pages) if (p.exists) { ins.run(...row(p)); putProps(p); }
db.exec('COMMIT');
} catch (err) {
db.exec('ROLLBACK');
throw err;
}
},
// Words, `tag:x`, `in:title`, and `property:value` for a database property (`status:Done`, `due:2026-03`,
// `points:>=5`, `owner:!Ana`, `status:"In progress"`, `has:due`). Property filters narrow what the words find;
// with only filters, every matching page is listed.
search(q, limit = 20) {
const parts = String(q).match(/(?:tag:|in:|)\S+/g) || [];
const parts = String(q).match(/[^\s:"]+:"[^"]*"|"[^"]*"|\S+/g) || [];
const terms = [];
const fields = [];
const filters = [];
for (const p of parts) {
if (p.startsWith('tag:')) { fields.push('tags: ' + p.slice(4).replace(/[^\w-]/g, '')); }
else if (p.startsWith('in:')) { fields.push('title: ' + p.slice(3).replace(/[^\w-]/g, '')); }
const m = p.match(/^([^\s:"]+):(.*)$/);
const key = m ? m[1].toLowerCase() : '';
const val = m ? m[2].replace(/^"|"$/g, '').trim() : '';
if (key === 'tag') { fields.push('tags: ' + val.replace(/[^\w-]/g, '')); }
else if (key === 'in') { fields.push('title: ' + val.replace(/[^\w-]/g, '')); }
else if (key === 'has' && val && hasKey.get(val.toLowerCase())) { filters.push({ key: val.toLowerCase(), has: true }); }
else if (m && val && hasKey.get(key)) {
const o = val.match(/^(>=|<=|>|<|!|=)\s*(.*)$/);
filters.push({ key, op: o ? (o[1] === '=' ? '' : o[1]) : '', value: o ? o[2] : val, not: o && o[1] === '!' });
}
else { terms.push(p); }
}
@@ -54,9 +114,34 @@ function openDb(file) {
if (fields.length) {
match = fields.map(f => `${f.split(':')[0]}: "${f.split(':')[1].trim()}"*`).join(' ') + (match ? ' ' + match : '');
}
if (!match) return [];
if (!match && !filters.length) return [];
// property filters -> the ids that pass all of them
let allowed = null;
for (const f of filters) {
let ids;
if (f.has) ids = new Set(db.prepare('SELECT DISTINCT id FROM props WHERE key = ?').all(f.key).map((r) => r.id));
else if (f.not) {
// everything that has properties at all (the records), except those with that value
const hit = idsFor({ ...f, op: '' });
ids = new Set(db.prepare('SELECT DISTINCT id FROM props').all().map((r) => r.id).filter((id) => !hit.has(id)));
} else ids = idsFor(f);
allowed = allowed ? new Set([...allowed].filter((id) => ids.has(id))) : ids;
}
try {
return find.all(match, limit).map((r) => ({ id: r.id, title: r.title, snip: r.snip }));
if (match) {
const rows = find.all(match, allowed ? 1000 : limit).map((r) => ({ id: r.id, title: r.title, snip: r.snip }));
return (allowed ? rows.filter((r) => allowed.has(r.id)) : rows).slice(0, limit);
}
// only property filters: list the matching pages, showing the values that matched
const out = [];
for (const id of allowed) {
const t = titleOf.get(id);
if (!t) continue;
const shown = propsOfId.all(id).map((r) => `${r.key}: ${r.value}`).join(' · ');
out.push({ id, title: t.title, snip: shown });
}
return out.sort((a, b) => a.title.localeCompare(b.title)).slice(0, limit);
} catch {
return [];
}
@@ -65,4 +150,4 @@ function openDb(file) {
};
}
module.exports = { openDb };
module.exports = { openDb, propPairs };
+68
View File
@@ -0,0 +1,68 @@
'use strict';
// Minimal YAML front matter: flat `key: value`, inline `[a, b]` lists and `- item` lists.
// Unknown keys are kept and written back, so notes from Obsidian survive a round trip.
const FM = /^?---\r?\n([\s\S]*?)\r?\n---[ \t]*(?:\r?\n|$)/;
function unquote(v) {
v = v.trim();
if (v.length >= 2 && v.startsWith('"') && v.endsWith('"')) {
try { return JSON.parse(v); } catch { return v.slice(1, -1); }
}
if (v.length >= 2 && v.startsWith("'") && v.endsWith("'")) return v.slice(1, -1).replace(/''/g, "'");
return v;
}
function parseFrontMatter(raw) {
const m = raw.match(FM);
if (!m) return { meta: {}, body: raw };
const meta = {};
let last = null;
for (const line of m[1].split(/\r?\n/)) {
const kv = line.match(/^([A-Za-z0-9_-]+):\s*(.*)$/);
if (kv) {
last = kv[1];
const v = kv[2].trim();
if (v.startsWith('[') && v.endsWith(']')) {
meta[last] = v.slice(1, -1).split(',').map(unquote).filter(Boolean);
} else {
meta[last] = v === '' ? [] : unquote(v);
}
continue;
}
const item = line.match(/^\s*-\s+(.*)$/);
if (item && last) {
if (!Array.isArray(meta[last])) meta[last] = [];
meta[last].push(unquote(item[1]));
}
}
// An empty `key:` with no list items below it is just an empty string.
for (const k of Object.keys(meta)) if (Array.isArray(meta[k]) && !meta[k].length && k !== 'tags') meta[k] = '';
return { meta, body: raw.slice(m[0].length).replace(/^\r?\n/, '') };
}
function quote(s) {
s = String(s);
return /^[\p{L}\p{N}_ .\/@#+-]+$/u.test(s) && s.trim() === s && !/^[-#@]/.test(s) ? s : JSON.stringify(s);
}
const ORDER = ['icon', 'cover', 'tags', 'created'];
function serializeFrontMatter(meta = {}) {
const keys = Object.keys(meta).filter((k) => {
const v = meta[k];
return v !== undefined && v !== null && v !== '' && !(Array.isArray(v) && !v.length);
});
if (!keys.length) return '';
keys.sort((a, b) => {
const ia = ORDER.indexOf(a), ib = ORDER.indexOf(b);
return (ia < 0 ? 99 : ia) - (ib < 0 ? 99 : ib);
});
const lines = keys.map((k) => {
const v = meta[k];
return Array.isArray(v) ? `${k}: [${v.map(quote).join(', ')}]` : `${k}: ${quote(v)}`;
});
return `---\n${lines.join('\n')}\n---\n\n`;
}
module.exports = { parseFrontMatter, serializeFrontMatter };
+79
View File
@@ -0,0 +1,79 @@
'use strict';
// Download a picture from a web address (for a page's cover). Runs in the main process, and has the same rule as
// link previews: only public http(s) addresses, every redirect checked again (see linkpreview.cjs).
const path = require('node:path');
const { allowed, hooks } = require('./linkpreview.cjs');
const MAX_BYTES = 50 * 1024 * 1024;
const TIMEOUT = 25000;
const MAX_REDIRECTS = 5;
const EXT = { 'image/png': 'png', 'image/jpeg': 'jpg', 'image/jpg': 'jpg', 'image/gif': 'gif', 'image/webp': 'webp', 'image/avif': 'avif', 'image/svg+xml': 'svg', 'image/bmp': 'bmp' };
const KNOWN = /\.(png|jpe?g|gif|webp|avif|svg|bmp)$/i;
// A file name for it: the address's own (when it looks like one), else "cover" with the right extension.
function nameFor(u, type) {
let base = '';
try { base = decodeURIComponent(path.posix.basename(u.pathname)); } catch { base = path.posix.basename(u.pathname); }
base = base.replace(/[\\/:*?"<>|\u0000-\u001f]/g, '-').trim().slice(0, 80);
if (KNOWN.test(base)) return base;
const ext = EXT[type] || 'jpg';
return `${base && !base.startsWith('.') ? base.replace(/\.[a-z0-9]{1,5}$/i, '') : 'cover'}.${ext}`;
}
async function readLimited(res, max) {
const chunks = [];
let size = 0;
const reader = res.body.getReader();
try {
for (;;) {
const { done, value } = await reader.read();
if (done) break;
size += value.length;
if (size > max) throw new Error('That image is too big (50 MB at most).');
chunks.push(Buffer.from(value));
}
} finally {
reader.cancel().catch(() => {});
}
return Buffer.concat(chunks);
}
// -> { bytes: Buffer, name, type }. Throws an Error whose message is meant for the person.
async function fetchImage(url, { fetch: doFetch, lookup } = {}) {
const get = doFetch || hooks.fetch || globalThis.fetch;
const look = lookup || hooks.lookup;
const signal = AbortSignal.timeout(TIMEOUT);
let u = await allowed(String(url || '').trim(), look);
if (!u) throw new Error('That is not a public web address (http or https).');
for (let hop = 0; hop <= MAX_REDIRECTS; hop++) {
let res;
try {
res = await get(u.href, {
redirect: 'manual',
signal,
headers: { 'user-agent': 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0 Safari/537.36', accept: 'image/*,*/*;q=0.1' }
});
} catch {
throw new Error('Could not reach that address.');
}
if (res.status >= 300 && res.status < 400) {
const loc = res.headers.get('location');
const next = loc ? await allowed(new URL(loc, u).href, look) : null;
if (!next) throw new Error('That address redirects somewhere that is not allowed.');
u = next;
continue;
}
if (!res.ok) throw new Error(`The server answered ${res.status}.`);
const type = String(res.headers.get('content-type') || '').split(';')[0].trim().toLowerCase();
if (!type.startsWith('image/')) throw new Error('That address is not a picture.');
const len = Number(res.headers.get('content-length'));
if (len > MAX_BYTES) throw new Error('That image is too big (50 MB at most).');
if (!res.body) throw new Error('That address sent nothing.');
const bytes = await readLimited(res, MAX_BYTES);
if (!bytes.length) throw new Error('That address sent nothing.');
return { bytes, name: nameFor(u, type), type };
}
throw new Error('Too many redirects.');
}
module.exports = { fetchImage, nameFor };
+148
View File
@@ -0,0 +1,148 @@
'use strict';
// Bringing notes in from another app: an Obsidian vault or a Notion "Markdown & CSV" export, as a folder or a .zip.
// Everything goes into one new top-level folder of the notes, so nothing existing is touched.
// - Notion's 32-character ids are taken out of file and folder names, and its relative Markdown links
// ([Title](Folder/Page%20abc….md)) become [[wiki links]] to the imported pages.
// - Obsidian's .obsidian folder and Notion's _all.csv files are left out; other files (pictures, PDFs) are copied.
const fs = require('node:fs');
const fsp = fs.promises;
const path = require('node:path');
const zlib = require('node:zlib');
const SKIP_DIR = /^(\.obsidian|\.trash|\.git|\.notes|__MACOSX|node_modules)$/;
const MAX_FILES = 50000;
const MAX_FILE = 200 * 1024 * 1024;
/* zip reading: central directory, stored and deflated entries */
function readZip(file) {
const buf = fs.readFileSync(file);
let end = -1;
for (let i = buf.length - 22; i >= Math.max(0, buf.length - 65557); i--) if (buf.readUInt32LE(i) === 0x06054b50) { end = i; break; }
if (end < 0) throw new Error('That file is not a zip archive.');
const count = buf.readUInt16LE(end + 10);
let at = buf.readUInt32LE(end + 16);
const out = [];
for (let n = 0; n < count && n < MAX_FILES; n++) {
if (buf.readUInt32LE(at) !== 0x02014b50) break;
const method = buf.readUInt16LE(at + 10);
const csize = buf.readUInt32LE(at + 20);
const size = buf.readUInt32LE(at + 24);
const nameLen = buf.readUInt16LE(at + 28), extraLen = buf.readUInt16LE(at + 30), commentLen = buf.readUInt16LE(at + 32);
const local = buf.readUInt32LE(at + 42);
const name = buf.toString('utf8', at + 46, at + 46 + nameLen);
at += 46 + nameLen + extraLen + commentLen;
if (name.endsWith('/') || size > MAX_FILE) continue;
const dataAt = local + 30 + buf.readUInt16LE(local + 26) + buf.readUInt16LE(local + 28);
out.push({
rel: name,
read: () => {
const raw = buf.subarray(dataAt, dataAt + csize);
return method === 0 ? raw : zlib.inflateRawSync(raw);
}
});
}
return out;
}
async function* walkDir(root, rel = '') {
let entries;
try { entries = await fsp.readdir(path.join(root, rel), { withFileTypes: true }); } catch { return; }
for (const e of entries) {
if (e.isSymbolicLink()) continue;
const r = rel ? `${rel}/${e.name}` : e.name;
if (e.isDirectory()) { if (!SKIP_DIR.test(e.name)) yield* walkDir(root, r); }
else if (e.isFile()) yield r;
}
}
// "Page 0a1b2c3d4e5f60718293a4b5c6d7e8f9.md" -> "Page.md"; a plain name is left alone.
const NOTION_ID = /\s+[0-9a-f]{32}(?=\.[^.]+$|$)/i;
const stripId = (seg) => seg.replace(NOTION_ID, '');
const badChars = /[<>:"|?*\u0000-\u001f]/g;
const safeSeg = (s) => s.replace(badChars, '-').replace(/^\.+/, '').trim() || 'Untitled';
// Where each source path ends up: { 'a/b abc.md': 'a/b.md' }, names made unique.
function plan(files) {
const map = new Map();
const taken = new Set();
for (const rel of files.slice().sort()) {
const parts = rel.split('/').map((p) => safeSeg(stripId(p)));
let target = parts.join('/');
const ext = path.posix.extname(target);
const stem = target.slice(0, target.length - ext.length);
for (let n = 2; taken.has(target.toLowerCase()); n++) target = `${stem} ${n}${ext}`;
taken.add(target.toLowerCase());
map.set(rel, target);
}
return map;
}
// Rewrite relative links in one note: to other notes -> [[wiki links]], to files -> the new relative path.
function rewriteBody(body, fromRel, map, rootName) {
const dir = path.posix.dirname(fromRel);
const resolve = (href) => {
let h = href.split('#')[0];
try { h = decodeURIComponent(h); } catch { /* leave as written */ }
if (!h || /^[a-z][a-z0-9+.-]*:/i.test(h) || h.startsWith('/') || h.startsWith('#')) return null;
const joined = path.posix.normalize(dir === '.' ? h : `${dir}/${h}`);
return joined.startsWith('..') ? null : joined;
};
return body.replace(/(!?)\[([^\]\n]*)\]\(([^)\n]+)\)/g, (all, bang, text, href) => {
const target = resolve(href.trim().replace(/^<|>$/g, ''));
if (!target || !map.has(target)) return all;
const to = map.get(target);
if (!bang && /\.md$/i.test(to)) {
const id = `${rootName}/${to.replace(/\.md$/i, '')}`;
const title = id.split('/').pop();
return text && text !== title ? `[[${id}|${text}]]` : `[[${id}]]`;
}
// a picture or file: its path from this note's new folder
const fromDir = path.posix.dirname(map.get(fromRel));
const rel = path.posix.relative(fromDir === '.' ? '' : fromDir, to);
return `${bang}[${text}](${encodeURI(rel).replace(/\(/g, '%28').replace(/\)/g, '%29')})`;
});
}
// files: [{ rel, read() -> Buffer }]. Writes them under <vaultRoot>/<name>. Returns { folder, notes, files }.
async function importFiles(vaultRoot, name, files) {
const kept = files.filter((f) => !f.rel.split('/').some((p) => SKIP_DIR.test(p)) && !/_all\.csv$/i.test(f.rel) && !/(^|\/)\.DS_Store$/.test(f.rel));
// a zip often has one folder around everything: look inside it
const first = new Set(kept.map((f) => f.rel.split('/')[0]));
const strip = first.size === 1 && kept.every((f) => f.rel.includes('/')) ? [...first][0] + '/' : '';
const items = kept.map((f) => ({ ...f, rel: f.rel.slice(strip.length) })).filter((f) => f.rel);
if (!items.length) throw new Error('There was nothing to import in that.');
const map = plan(items.map((f) => f.rel));
let folder = safeSeg(name);
for (let n = 2; fs.existsSync(path.join(vaultRoot, folder)) || fs.existsSync(path.join(vaultRoot, folder + '.md')); n++) folder = `${safeSeg(name)} ${n}`;
let notes = 0, others = 0;
for (const f of items) {
const to = path.join(vaultRoot, folder, ...map.get(f.rel).split('/'));
await fsp.mkdir(path.dirname(to), { recursive: true });
let data = f.read();
if (/\.md$/i.test(f.rel)) {
data = Buffer.from(rewriteBody(data.toString('utf8'), f.rel, map, folder), 'utf8');
notes++;
} else others++;
await fsp.writeFile(to, data);
}
return { folder, notes, files: others };
}
async function importFolder(vaultRoot, dir) {
const files = [];
for await (const rel of walkDir(dir)) {
if (files.length >= MAX_FILES) break;
const full = path.join(dir, rel);
files.push({ rel, read: () => fs.readFileSync(full) });
}
return importFiles(vaultRoot, path.basename(dir), files);
}
async function importZip(vaultRoot, file) {
const entries = readZip(file).filter((e) => !e.rel.includes('..'));
return importFiles(vaultRoot, path.basename(file).replace(/\.zip$/i, ''), entries);
}
module.exports = { importFolder, importZip, importFiles, readZip, stripId, plan, rewriteBody };
+166
View File
@@ -0,0 +1,166 @@
'use strict';
// What a web link card shows: the page's title and description, read from the start of its HTML.
// Runs in the main process (no CORS there). Only public http(s) addresses are fetched: a canvas file that
// someone else made must not be able to make this app read an address on your own network.
const dns = require('node:dns/promises');
const net = require('node:net');
const MAX_BYTES = 512 * 1024; // of HTML read; the tags we want are at the top
const TIMEOUT = 8000;
const MAX_REDIRECTS = 5;
// Replaceable for tests: { fetch, lookup }
const hooks = { fetch: null, lookup: null };
// Addresses that are not on the public internet (this machine, the local network, link-local, multicast, …).
const PRIVATE = new net.BlockList();
for (const [ip, bits] of [['0.0.0.0', 8], ['10.0.0.0', 8], ['100.64.0.0', 10], ['127.0.0.0', 8], ['169.254.0.0', 16], ['172.16.0.0', 12],
['192.0.0.0', 24], ['192.168.0.0', 16], ['198.18.0.0', 15], ['224.0.0.0', 3]]) PRIVATE.addSubnet(ip, bits, 'ipv4');
for (const [ip, bits] of [['::', 128], ['::1', 128], ['fc00::', 7], ['fe80::', 10], ['ff00::', 8]]) PRIVATE.addSubnet(ip, bits, 'ipv6');
function privateAddress(ip) {
const v = net.isIP(ip);
if (!v) return true;
const x = ip.toLowerCase();
if (v === 6) {
// IPv4 inside IPv6 (::ffff:a.b.c.d, which URL parsing turns into ::ffff:aabb:ccdd, or ::a.b.c.d): judge the IPv4 part
const dotted = x.match(/^::(?:ffff:)?(\d+\.\d+\.\d+\.\d+)$/);
const hex = x.match(/^::(?:ffff:)?([0-9a-f]{1,4}):([0-9a-f]{1,4})$/);
let v4 = dotted ? dotted[1] : null;
if (hex) {
const hi = parseInt(hex[1], 16), lo = parseInt(hex[2], 16);
v4 = `${hi >> 8}.${hi & 255}.${lo >> 8}.${lo & 255}`;
}
if (v4 && PRIVATE.check(v4, 'ipv4')) return true;
}
return PRIVATE.check(x, v === 4 ? 'ipv4' : 'ipv6');
}
// The URL if it may be fetched, else null.
async function allowed(raw, lookup) {
let u;
try { u = new URL(raw); } catch { return null; }
if (u.protocol !== 'http:' && u.protocol !== 'https:') return null;
if (u.username || u.password) return null;
const host = u.hostname.replace(/^\[|\]$/g, '');
if (!host) return null;
if (net.isIP(host)) return privateAddress(host) ? null : u;
if (/(^|\.)localhost$/i.test(host)) return null;
try {
const addrs = await (lookup || ((h) => dns.lookup(h, { all: true })))(host);
if (!addrs.length || addrs.some((a) => privateAddress(a.address))) return null;
} catch { return null; }
return u;
}
const ENTITIES = { amp: '&', lt: '<', gt: '>', quot: '"', apos: "'", nbsp: ' ', ndash: '–', mdash: '—', hellip: '…', rsquo: '’', lsquo: '‘', rdquo: '”', ldquo: '“', copy: '©' };
const decode = (s) =>
s.replace(/&(#x[0-9a-f]+|#\d+|[a-z]+);/gi, (m, e) => {
if (e[0] === '#') {
const n = e[1].toLowerCase() === 'x' ? parseInt(e.slice(2), 16) : parseInt(e.slice(1), 10);
try { return n > 0 && n <= 0x10ffff ? String.fromCodePoint(n) : m; } catch { return m; }
}
return ENTITIES[e.toLowerCase()] ?? m;
});
const tidy = (s, max) => {
const t = decode(String(s || '')).replace(/\s+/g, ' ').trim();
return t.length > max ? t.slice(0, max - 1).trimEnd() + '…' : t;
};
// { title, description, site } from a page's HTML (empty strings where it says nothing)
function parsePreview(html) {
const head = String(html).slice(0, MAX_BYTES);
const meta = {};
for (const tag of head.match(/<meta\b[^>]*>/gi) || []) {
const at = {};
for (const m of tag.matchAll(/([\w:-]+)\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'>]+))/g)) at[m[1].toLowerCase()] = m[2] ?? m[3] ?? m[4] ?? '';
const key = (at.property || at.name || '').toLowerCase();
if (key && at.content !== undefined && !(key in meta)) meta[key] = at.content;
}
const t = head.match(/<title\b[^>]*>([\s\S]*?)<\/title>/i);
return {
title: tidy(meta['og:title'] || meta['twitter:title'] || (t ? t[1] : ''), 200),
description: tidy(meta['og:description'] || meta['twitter:description'] || meta.description, 400),
site: tidy(meta['og:site_name'], 80)
};
}
function charsetOf(contentType, bytes) {
const h = /charset\s*=\s*["']?([\w-]+)/i.exec(contentType || '');
if (h) return h[1];
const m = /<meta[^>]+charset\s*=\s*["']?([\w-]+)/i.exec(Buffer.from(bytes.subarray(0, 4096)).toString('latin1'));
return m ? m[1] : 'utf-8';
}
async function readLimited(res, limit = MAX_BYTES) {
const chunks = [];
let size = 0;
const reader = res.body.getReader();
try {
while (size < limit) {
const { done, value } = await reader.read();
if (done) break;
chunks.push(value);
size += value.length;
}
} finally {
reader.cancel().catch(() => {});
}
return Buffer.concat(chunks.map((c) => Buffer.from(c))).subarray(0, limit);
}
// { title, description, site } for a web address, or null when there is nothing to show.
async function fetchPreview(url, { fetch: doFetch, lookup } = {}) {
const get = doFetch || hooks.fetch || globalThis.fetch;
const look = lookup || hooks.lookup;
const signal = AbortSignal.timeout(TIMEOUT);
let u = await allowed(url, look);
for (let hop = 0; u && hop <= MAX_REDIRECTS; hop++) {
let res;
try {
res = await get(u.href, {
redirect: 'manual',
signal,
headers: { 'user-agent': 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0 Safari/537.36', accept: 'text/html,application/xhtml+xml;q=0.9,*/*;q=0.1', 'accept-language': 'en;q=0.8' }
});
} catch { return null; }
if (res.status >= 300 && res.status < 400) {
const loc = res.headers.get('location');
u = loc ? await allowed(new URL(loc, u).href, look) : null; // every hop is checked again
continue;
}
if (!res.ok || !/html/i.test(res.headers.get('content-type') || '') || !res.body) return null;
const bytes = await readLimited(res);
let text;
try { text = new TextDecoder(charsetOf(res.headers.get('content-type'), bytes)).decode(bytes); } catch { text = bytes.toString('utf8'); }
const p = parsePreview(text);
return p.title || p.description ? p : null;
}
return null;
}
// { html, url } for a whole web page (up to 3 MB), under the same rules as previews; null if it can't be had.
async function fetchHtml(url, { fetch: doFetch, lookup } = {}) {
const get = doFetch || hooks.fetch || globalThis.fetch;
const look = lookup || hooks.lookup;
const signal = AbortSignal.timeout(15000);
let u = await allowed(url, look);
for (let hop = 0; u && hop <= MAX_REDIRECTS; hop++) {
let res;
try {
res = await get(u.href, { redirect: 'manual', signal, headers: { 'user-agent': 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0 Safari/537.36', accept: 'text/html,application/xhtml+xml;q=0.9,*/*;q=0.1' } });
} catch { return null; }
if (res.status >= 300 && res.status < 400) {
const loc = res.headers.get('location');
u = loc ? await allowed(new URL(loc, u).href, look) : null;
continue;
}
if (!res.ok || !/html/i.test(res.headers.get('content-type') || '') || !res.body) return null;
const bytes = await readLimited(res, 3 * 1024 * 1024);
let text;
try { text = new TextDecoder(charsetOf(res.headers.get('content-type'), bytes)).decode(bytes); } catch { text = bytes.toString('utf8'); }
return { html: text, url: u.href };
}
return null;
}
module.exports = { fetchHtml, fetchPreview, parsePreview, allowed, hooks };
+290
View File
@@ -0,0 +1,290 @@
'use strict';
// [[Wiki links]] on disk: find them, resolve them, rewrite them after a rename or move,
// and list backlinks. Lookup rules match src/lib/links.js.
const refPage = (ref) => String(ref || '').split('#')[0].replace(/\.md$/i, '').trim();
// Lookups over a page list, built once per list (a vault of thousands of pages is searched for every link).
const indexes = new WeakMap();
function indexOf(pages) {
let ix = indexes.get(pages);
if (ix && ix.size === pages.length) return ix;
const exact = new Map();
const byName = new Map();
for (const p of pages) {
const id = p.id.toLowerCase();
if (!exact.has(id)) exact.set(id, p.id);
const name = id.slice(id.lastIndexOf('/') + 1);
const list = byName.get(name);
if (list) list.push(p); else byName.set(name, [p]);
}
// by name: a note's title, or a canvas's file name ("Map.canvas"), so [[Map]] never means a canvas; shortest path wins
for (const list of byName.values()) if (list.length > 1) list.sort((a, b) => a.id.length - b.id.length);
ix = { size: pages.length, exact, byName };
indexes.set(pages, ix);
return ix;
}
function resolveLink(ref, pages) {
const want = refPage(ref).toLowerCase();
if (!want) return null;
const ix = indexOf(pages);
const hit = ix.exact.get(want);
if (hit) return hit;
const named = ix.byName.get(want);
return named ? named[0].id : null;
}
const normHeading = (s) => String(s || '').replace(/\s+/g, ' ').trim().toLowerCase();
const linkHeading = (s) => String(s || '').replace(/[#|^\[\]]/g, ' ').replace(/\s+/g, ' ').trim();
const matchesHeading = (linkText, headingText) => {
const a = normHeading(linkText);
return !!a && (a === normHeading(headingText) || a === normHeading(linkHeading(headingText)));
};
// The heading a link points at ('' if none): the last #part, and #^blockid isn't a heading.
const headingOf = (ref) => {
const s = String(ref || '');
const i = s.lastIndexOf('#');
if (i < 0) return '';
const h = s.slice(i + 1).trim();
return h.startsWith('^') ? '' : h;
};
function refFor(id, pages) {
const title = id.slice(id.lastIndexOf('/') + 1);
const same = indexOf(pages).byName.get(title.toLowerCase()) || [];
return same.length <= 1 ? title : id;
}
// Blank out code (fenced and inline) so links inside it are left alone; offsets stay the same.
function maskCode(body) {
const blank = (s) => s.replace(/[^\n]/g, ' ');
let fence = false;
return body
.split('\n')
.map((line) => {
if (/^```/.test(line)) { fence = !fence; return blank(line); }
return fence ? blank(line) : line.replace(/`[^`\n]+`/g, blank);
})
.join('\n');
}
// [[Page]] links and ![[Page]] embeds (groups: 1 = "!" for an embed, 2 = target, 3 = |alias).
const LINK = /(!?)\[\[([^\[\]\n|]+)(\|[^\[\]\n]*)?\]\]/g;
// ![[photo.png]] and ![[slides.pdf]] embed files, not pages. (Same lists as src/lib/links.js.)
const FILE = /\.(png|jpe?g|gif|webp|avif|svg|bmp|pdf|mp3|mp4|wav|m4a|ogg|webm|mov|mkv|zip|docx?|xlsx?|pptx?|csv)$/i;
// [{ index, length, ref, alias, embed }] for every page link and page embed in a Markdown body.
function findLinks(body) {
const masked = maskCode(String(body || ''));
const out = [];
for (const m of masked.matchAll(LINK)) {
if (m[1] && FILE.test(m[2].trim())) continue;
out.push({ index: m.index, length: m[0].length, embed: !!m[1], ref: body.substr(m.index + m[1].length + 2, m[2].length), alias: m[3] || '' });
}
return out;
}
/* database properties
A database record's property values are front matter (page.props). Links written in them ([[Page]]) count as
links from the record, and a relation property (src/lib/database.js) points at sibling records by title. */
const NOT_PROPS = new Set(['icon', 'cover', 'tags', 'created', 'ink-database', 'kanban-plugin']);
const valuesOf = (v) => (Array.isArray(v) ? v : [v]).filter((x) => typeof x === 'string');
// The property values of a page that hold [[links]], one per line.
function propText(p) {
const out = [];
for (const [k, v] of Object.entries(p.props || {})) {
if (NOT_PROPS.has(k)) continue;
for (const x of valuesOf(v)) if (x.includes('[[')) out.push(x);
}
return out.join('\n');
}
// [{ key, name }] of the relation properties in database page `db` (read from the schema comment in its body).
function relationKeys(db) {
const m = String((db && db.body) || '').match(/^[ \t]*%%[ \t]*ink-database[ \t]*\r?\n([\s\S]*?)\r?\n[ \t]*%%[ \t]*$/m);
if (!m) return [];
try {
return (JSON.parse(m[1]).properties || []).filter((p) => p && p.type === 'relation' && p.key).map((p) => ({ key: p.key, name: p.name || p.key }));
} catch { return []; }
}
// [{ id, name }]: the sibling records that record `rec` names in its relation properties.
function relatedIds(rec, pages) {
if (!rec.parent || !rec.props) return [];
const db = pages.find((x) => x.id === rec.parent);
const keys = db ? relationKeys(db) : [];
if (!keys.length) return [];
const siblings = pages.filter((x) => x.parent === rec.parent && x.exists && x.id !== rec.id);
const out = [];
for (const { key, name } of keys) {
for (const title of valuesOf(rec.props[key])) {
const hit = siblings.find((x) => x.title.toLowerCase() === title.trim().toLowerCase());
if (hit) out.push({ id: hit.id, name });
}
}
return out;
}
// { key: newValue } for the property values whose [[links]] moved, or null.
function rewriteProps(props, before, after, moved) {
const patch = {};
for (const [k, v] of Object.entries(props || {})) {
if (NOT_PROPS.has(k)) continue;
const next = (x) => (typeof x === 'string' && x.includes('[[') ? rewriteLinks(x, before, after, moved) : null);
if (Array.isArray(v)) {
const out = v.map((x) => next(x));
if (out.some((x) => x !== null)) patch[k] = v.map((x, i) => (out[i] === null ? x : out[i]));
} else {
const n = next(v);
if (n !== null) patch[k] = n;
}
}
return Object.keys(patch).length ? patch : null;
}
// Rewrite links whose target moved. `before` is the page list before the change, `after` the list after,
// `moved(oldId)` returns the new id or null. Returns the new body, or null when nothing changed.
function rewriteLinks(body, before, after, moved) {
const links = findLinks(body);
let out = '', at = 0, changed = false;
for (const l of links) {
const was = resolveLink(l.ref, before);
const now = was && moved(was);
if (!now || now === was) continue;
const hash = l.ref.includes('#') ? l.ref.slice(l.ref.indexOf('#')) : '';
out += body.slice(at, l.index) + (l.embed ? '!' : '') + '[[' + refFor(now, after) + hash + l.alias + ']]';
at = l.index + l.length;
changed = true;
}
return changed ? out + body.slice(at) : null;
}
// A heading of page `pageId` was renamed from `from` to `to`: point the [[Page#from]] links and ![[Page#from]]
// embeds in `body` at the new name (page part and |alias stay). Returns { body, count }, or null if none matched.
// ([[#from]] with no page part points inside its own page, so it belongs to that page and is left alone.)
function renameHeadingRefs(body, pages, pageId, from, to) {
const safe = linkHeading(to);
if (!safe) return null;
let out = '', at = 0, count = 0;
for (const l of findLinks(body)) {
if (!refPage(l.ref) || resolveLink(l.ref, pages) !== pageId || !matchesHeading(headingOf(l.ref), from)) continue;
const ref = l.ref.slice(0, l.ref.lastIndexOf('#') + 1) + safe;
out += body.slice(at, l.index) + (l.embed ? '!' : '') + '[[' + ref + l.alias + ']]';
at = l.index + l.length;
count++;
}
return count ? { body: out + body.slice(at), count } : null;
}
// Pages that link to `id`, each with the line the first link sits on.
function backlinks(id, pages) {
const out = [];
for (const p of pages) {
if (!p.exists || p.id === id) continue;
const text = p.body + '\n' + propText(p); // links in property values count too
const hit = findLinks(text).find((l) => resolveLink(l.ref, pages) === id);
if (!hit) {
const rel = relatedIds(p, pages).find((r) => r.id === id);
if (rel) out.push({ id: p.id, title: p.title, icon: p.icon, snip: rel.name });
continue;
}
const start = text.lastIndexOf('\n', hit.index) + 1;
let end = text.indexOf('\n', hit.index);
if (end < 0) end = text.length;
const line = text.slice(start, end)
.replace(LINK, (_, _bang, ref, alias) => (alias ? alias.slice(1) : refPage(ref).split('/').pop()))
.replace(/^\s*(?:[-*+]\s+(?:\[[ xX]\]\s*)?|#{1,6}\s+|>\s?(?:\[!\w+\]\s*)?)/, '')
.replace(/\*\*|__|[*_`]/g, '')
.trim();
out.push({ id: p.id, title: p.title, icon: p.icon, snip: line.length > 160 ? line.slice(0, 159) + '…' : line });
}
return out.sort((a, b) => a.title.localeCompare(b.title));
}
/* unlinked mentions: pages that say a page's title in plain text without linking to it */
const esc = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
// Blank out what is not plain prose (code, [[links]], [text](url), bare URLs); offsets stay the same.
function maskProse(body) {
const blank = (s) => s.replace(/[^\n]/g, ' ');
return maskCode(body)
.replace(/!?\[\[[^\]\n]*\]\]/g, blank)
.replace(/!?\[[^\]\n]*\]\([^)\n]*\)/g, blank)
.replace(/\b(?:https?:\/\/|mailto:)\S+/g, blank);
}
const mentionRe = (title) => new RegExp('(?<![\\p{L}\\p{N}_])' + esc(title) + '(?![\\p{L}\\p{N}_])', 'iu');
const MIN_MENTION = 3;
function firstMention(body, title) {
if (String(title || '').trim().length < MIN_MENTION) return null;
const m = mentionRe(title.trim()).exec(maskProse(String(body || '')));
return m ? { index: m.index, length: m[0].length } : null;
}
// Pages that mention the title of `id` without linking to it, each with the line of the first mention.
function unlinked(id, pages) {
const me = pages.find((p) => p.id === id);
if (!me || !me.exists || /\.canvas$/i.test(id)) return [];
const out = [];
for (const p of pages) {
if (!p.exists || p.id === id || p.kind === 'canvas' || /\.canvas$/i.test(p.id)) continue;
if (p.props && p.props['kanban-plugin']) continue;
const hit = firstMention(p.body, me.title);
if (!hit) continue;
if (findLinks(p.body + '\n' + propText(p)).some((l) => resolveLink(l.ref, pages) === id)) continue;
const start = p.body.lastIndexOf('\n', hit.index) + 1;
let end = p.body.indexOf('\n', hit.index);
if (end < 0) end = p.body.length;
const line = p.body.slice(start, end)
.replace(/^\s*(?:[-*+]\s+(?:\[[ xX]\]\s*)?|#{1,6}\s+|>\s?(?:\[!\w+\]\s*)?)/, '')
.replace(/\*\*|__|[*_`]/g, '').trim();
out.push({ id: p.id, title: p.title, icon: p.icon, snip: line.length > 160 ? line.slice(0, 159) + '…' : line });
}
return out.sort((a, b) => a.title.localeCompare(b.title)).slice(0, 200);
}
// `body` with the first plain mention of page `id`'s title turned into a link; null if there is none.
function linkFirstMention(body, id, pages) {
const me = pages.find((p) => p.id === id);
if (!me) return null;
const hit = firstMention(body, me.title);
if (!hit) return null;
const said = body.substr(hit.index, hit.length);
const ref = refFor(id, pages);
const link = said === me.title.trim() && ref.toLowerCase() === said.toLowerCase() ? `[[${ref}]]` : `[[${ref}|${said}]]`;
return body.slice(0, hit.index) + link + body.slice(hit.index + hit.length);
}
// Everything the graph view draws: pages, links between them, and links to pages that don't exist yet.
// nodes: [{ id, title, icon, parent }] edges: [[from, to]] (each pair once) ghosts: [{ id: '?name', title }]
function graph(pages) {
const nodes = pages.filter((p) => p.exists).map((p) => ({ id: p.id, title: p.title, icon: p.icon || null, parent: p.parent || null, tags: p.tags || [] }));
const seen = new Set();
const edges = [];
const ghosts = new Map();
const add = (from, to) => {
const key = from + '\u0000' + to;
if (to === from || seen.has(key)) return;
seen.add(key);
edges.push([from, to]);
};
for (const p of pages) {
if (!p.exists) continue;
for (const l of findLinks(p.body + '\n' + propText(p))) {
let to = resolveLink(l.ref, pages);
if (!to) {
const name = refPage(l.ref);
if (!name) continue;
to = '?' + name.toLowerCase();
if (!ghosts.has(to)) ghosts.set(to, { id: to, title: name.split('/').pop() });
}
add(p.id, to);
}
for (const r of relatedIds(p, pages)) add(p.id, r.id);
}
return { nodes, edges, ghosts: [...ghosts.values()] };
}
module.exports = { unlinked, linkFirstMention, refPage, resolveLink, refFor, findLinks, rewriteLinks, rewriteProps, propText, relatedIds, renameHeadingRefs, backlinks, graph, headingOf, linkHeading, matchesHeading };
+787
View File
@@ -0,0 +1,787 @@
'use strict';
const { app, BrowserWindow, ipcMain, dialog, shell, protocol, net, Menu, session, nativeTheme, globalShortcut, screen } = require('electron');
const fs = require('node:fs');
const fsp = fs.promises;
const path = require('node:path');
const { pathToFileURL } = require('node:url');
const { Vault, titleOf, setLimits } = require('./vault.cjs');
const { backup, due: backupDue } = require('./backup.cjs');
const links = require('./links.cjs');
const tasks = require('./tasks.cjs');
const importer = require('./importer.cjs');
const canvas = require('./canvas.cjs');
const linkpreview = require('./linkpreview.cjs');
const imagefetch = require('./imagefetch.cjs');
// What search indexes for a page: a canvas's text, not its JSON.
const indexed = (id, body) => (canvas.isCanvasId(id) ? canvas.canvasText(body) : body);
const { openDb } = require('./db.cjs');
const { isEmpty, seedVault } = require('./seed.cjs');
const settings = require('./settings.cjs');
const DEV = process.argv.includes('--dev');
const DEV_URL = process.env.VITE_DEV_SERVER_URL || 'http://localhost:5173';
const BUILD = path.join(__dirname, '..', 'build');
const MAC = process.platform === 'darwin';
protocol.registerSchemesAsPrivileged([
{ scheme: 'app', privileges: { standard: true, secure: true, supportFetchAPI: true, corsEnabled: true } },
// Images inside the notes folder: notes-media://vault/<page id>?src=<path as written in the page>
{ scheme: 'notes-media', privileges: { standard: true, secure: true, supportFetchAPI: true, corsEnabled: true, stream: true } }
]);
// The app used to be called "Notes"; its settings (notes folder, favorites, window size) lived under that name.
(function moveOldSettings() {
const now = path.join(app.getPath('userData'), 'settings.json');
const old = path.join(app.getPath('appData'), 'Notes', 'settings.json');
try {
if (!fs.existsSync(now) && fs.existsSync(old)) {
fs.mkdirSync(path.dirname(now), { recursive: true });
fs.copyFileSync(old, now);
}
} catch (err) { console.error('Could not bring over old settings', err); }
})();
if (process.platform === 'win32') app.setAppUserModelId('app.ink.desktop');
if (!app.requestSingleInstanceLock()) app.quit();
let win = null;
let vault = null;
let db = null;
let stopWatch = null;
let allowClose = false;
let quitting = false;
/* ---------- vault ---------- */
const strip = (pages) => pages.map(({ body, ...p }) => p);
async function snapshot(pages) {
if (!vault) return null;
return { path: vault.root, name: path.basename(vault.root), pages: strip(pages || (await vault.list())) };
}
async function openVault(dir) {
if (stopWatch) stopWatch();
if (db) db.close();
await fsp.mkdir(path.join(dir, '.notes'), { recursive: true });
vault = new Vault(dir, { trash: (p) => shell.trashItem(p) });
db = openDb(path.join(dir, '.notes', 'index.sqlite'));
const pages = await vault.list();
// The index is only a cache of the files, so refresh it once the window has its page list.
const opened = vault;
setTimeout(async () => {
try { if (vault === opened) db.rebuild(await vault.list()); } catch (err) { console.error('Could not rebuild the search index', err); }
}, 300).unref();
const trashDays = prefs().trashDays;
if (trashDays > 0) vault.purgeOlderThan(trashDays).catch((err) => console.error('Could not clear old trash', err));
stopWatch = vault.watch(async (ids) => {
for (const id of ids) {
try {
const r = await vault.read(id);
if (r.exists) db.upsert({ id, title: r.title, tags: r.meta.tags, props: r.meta, body: indexed(id, r.body) });
else db.remove(id);
} catch { /* folder events and the like */ }
}
if (win && !win.isDestroyed()) win.webContents.send('vault:changed', ids);
});
settings.set({ vault: vault.root });
return snapshot(pages);
}
async function reindexTree(id) {
const all = await vault.list();
for (const p of all) if (p.exists && (p.id === id || p.id.startsWith(id + '/'))) db.upsert(p);
}
// After page `from` became `to` (rename or move), point every [[link]] to it, or to a page under it, at the new place.
async function relink(before, from, to) {
const moved = (id) => (id === from ? to : id.startsWith(from + '/') ? to + id.slice(from.length) : null);
const after = await vault.list();
const touched = [];
for (const p of after) {
if (!p.exists) continue;
if (canvas.isCanvasId(p.id)) {
// (p.body is the canvas's text; the file itself is JSON)
const next = canvas.rewriteCanvas((await vault.read(p.id)).body, before, after, moved);
if (next === null) continue;
await vault.write(p.id, {}, next);
db.upsert({ id: p.id, title: p.title, tags: [], body: canvas.canvasText(next) });
touched.push(p.id);
continue;
}
const rewritten = links.rewriteLinks(p.body, before, after, moved);
const patch = links.rewriteProps(p.props, before, after, moved); // [[links]] in a record's property values
if (rewritten === null && !patch) continue;
const { meta, body: raw } = await vault.read(p.id);
const body = rewritten === null ? raw : rewritten;
await vault.write(p.id, patch ? { ...meta, ...patch } : meta, body);
db.upsert({ id: p.id, title: p.title, tags: p.tags, props: { ...(p.props || {}), ...(patch || {}) }, body });
touched.push(p.id);
}
if (touched.length && win && !win.isDestroyed()) win.webContents.send('vault:changed', touched);
}
// A heading of page `id` was renamed: point other pages' [[Page#from]] links and ![[Page#from]] embeds at `to`.
// (The page itself is left to the editor, which has its unsaved text.) Returns { pages: [ids changed], count: links changed }.
async function renameHeading(id, from, to) {
const pages = await need().list();
const touched = [];
let count = 0;
for (const p of pages) {
if (!p.exists || p.id === id) continue;
const isCanvas = canvas.isCanvasId(p.id);
const r = isCanvas
? canvas.renameHeadingInCanvas((await vault.read(p.id)).body, pages, id, String(from), String(to))
: links.renameHeadingRefs(p.body, pages, id, String(from), String(to));
if (!r) continue;
const { meta } = await vault.read(p.id);
await vault.write(p.id, meta, r.body);
db.upsert({ id: p.id, title: p.title, tags: p.tags, props: p.props, body: indexed(p.id, r.body) });
touched.push(p.id);
count += r.count;
}
if (touched.length && win && !win.isDestroyed()) win.webContents.send('vault:changed', touched);
return { pages: touched, count };
}
async function relocate(id, op) {
const before = await need().list();
const next = await op();
if (next !== id) {
db.remove(id);
await reindexTree(next);
await relink(before, id, next);
}
return next;
}
const IMAGE_TYPES = [{ name: 'Images', extensions: ['png', 'jpg', 'jpeg', 'gif', 'webp', 'avif', 'svg', 'bmp'] }];
function need() {
if (!vault) throw new Error('No notes folder is open.');
return vault;
}
/* ---------- IPC ---------- */
const handle = (channel, fn) => ipcMain.handle(channel, (_event, ...args) => fn(...args));
// The window is created while the folder is still opening; the page's first question waits for it.
let opening = null;
handle('vault:get', async () => { if (opening) await opening.catch(() => {}); return snapshot(); });
handle('vault:list', () => snapshot());
handle('vault:pick', async (kind) => {
let dir;
if (kind === 'default') {
dir = path.join(app.getPath('home'), 'Notes');
} else {
const r = await dialog.showOpenDialog(win, {
title: 'Choose a folder for your notes',
buttonLabel: 'Use this folder',
properties: ['openDirectory', 'createDirectory']
});
if (r.canceled || !r.filePaths[0]) return null;
dir = r.filePaths[0];
}
if (await isEmpty(dir)) await seedVault(dir);
return openVault(dir);
});
handle('vault:read', (id) => need().read(id));
handle('vault:keepVersion', (id) => need().keepCurrentVersion(id));
handle('vault:listHistory', (id) => need().listHistory(id));
handle('vault:readHistory', (id, ts) => need().readHistory(id, ts));
handle('vault:listTemplates', () => need().listTemplates());
handle('vault:getTemplate', (name) => need().getTemplate(name));
handle('vault:openTemplates', async () => shell.openPath(await need().ensureTemplatesDir())); // '' on success, else an error text
handle('vault:restoreHistory', async (id, ts) => {
const mtime = await need().restoreHistory(id, ts);
const r = await vault.read(id);
db.upsert({ id, title: titleOf(id), tags: r.meta.tags || [], props: r.meta, body: indexed(id, r.body) });
return mtime;
});
handle('vault:write', async (id, meta, body) => {
const mtime = await need().write(id, meta || {}, body || '');
db.upsert({ id, title: titleOf(id), tags: (meta && meta.tags) || [], props: meta, body: indexed(id, body) });
return mtime;
});
handle('vault:create', async (parent, title, meta, body = '') => {
const id = await need().create(parent || null, title || 'Untitled', meta || {}, body);
db.upsert({ id, title: titleOf(id), tags: (meta && meta.tags) || [], props: meta, body: indexed(id, body) });
return id;
});
// Many records at once (a CSV import): [{ title, meta }] -> their ids, in order.
handle('vault:createMany', async (parent, items) => {
const v = need();
const ids = [];
for (const it of Array.isArray(items) ? items.slice(0, 20000) : []) {
const id = await v.create(parent || null, String((it && it.title) || 'Untitled'), (it && it.meta) || {}, '');
db.upsert({ id, title: titleOf(id), tags: [], props: it && it.meta, body: '' });
ids.push(id);
}
return ids;
});
handle('vault:createCanvas', async (parent, title) => {
const id = await need().createCanvas(parent || null, title || 'Untitled canvas');
db.upsert({ id, title: titleOf(id), tags: [], body: '' });
return id;
});
handle('vault:duplicate', async (id) => {
const next = await need().duplicate(id);
await reindexTree(next);
return next;
});
handle('vault:rename', (id, title) => relocate(id, () => vault.rename(id, title)));
handle('vault:move', (id, parent) => relocate(id, () => vault.move(id, parent)));
handle('vault:renameHeading', (id, from, to) => renameHeading(id, from, to));
handle('vault:backlinks', async (id) => links.backlinks(id, await need().list()));
// Every checklist item in the notes; ticking one edits the line in its file (refused if the line has changed).
handle('vault:tasks', async () => tasks.findTasks(await need().list()));
handle('vault:setTask', async (id, line, text, done) => {
const v = need();
const r = await v.read(id);
const body = tasks.setTask(r.body, line, text, !!done);
if (body === null) throw new Error('That task changed on disk. Reopen the list.');
await v.write(id, r.meta, body);
db.upsert({ id, title: titleOf(id), tags: r.meta.tags || [], props: r.meta, body });
if (win && !win.isDestroyed()) win.webContents.send('vault:changed', [id]);
});
// Pages that say this page's title without linking to it, and turning one such mention into a link.
handle('vault:unlinked', async (id) => links.unlinked(id, await need().list()));
handle('vault:linkMention', async (id, target) => {
const v = need();
const pages = await v.list();
const r = await v.read(id);
const body = links.linkFirstMention(r.body, target, pages);
if (body === null) throw new Error('That mention is gone.');
await v.write(id, r.meta, body);
db.upsert({ id, title: titleOf(id), tags: r.meta.tags || [], props: r.meta, body });
if (win && !win.isDestroyed()) win.webContents.send('vault:changed', [id]);
});
// Tags live in front matter: rename (or, into an existing tag, merge) or remove (to === '') one in every page.
handle('vault:renameTag', async (from, to) => {
const v = need();
const norm = (t) => String(t || '').trim().toLowerCase().replace(/^#/, '').replace(/\s+/g, '-');
const a = norm(from), b = norm(to);
if (!a) throw new Error('Which tag?');
const touched = [];
for (const p of await v.list()) {
if (!p.exists || !(p.tags || []).some((t) => norm(t) === a)) continue;
const r = await v.read(p.id);
const next = [];
for (const t of Array.isArray(r.meta.tags) ? r.meta.tags : []) {
const x = norm(t) === a ? b : String(t);
if (x && !next.some((y) => norm(y) === norm(x))) next.push(x);
}
const meta = { ...r.meta, tags: next };
if (!next.length) delete meta.tags;
await v.write(p.id, meta, r.body);
db.upsert({ id: p.id, title: p.title, tags: next, props: meta, body: indexed(p.id, r.body) });
touched.push(p.id);
}
if (touched.length && win && !win.isDestroyed()) win.webContents.send('vault:changed', touched);
return touched.length;
});
// The whole notes folder as one .zip (the same archive the backups make).
handle('export:vaultZip', async () => {
const v = need();
const stamp = new Date().toISOString().slice(0, 10);
const r = await dialog.showSaveDialog(win, { defaultPath: path.join(app.getPath('documents'), `${path.basename(v.root)} ${stamp}.zip`), filters: [{ name: 'Zip archive', extensions: ['zip'] }] });
if (r.canceled || !r.filePath) return null;
const count = await require('./backup.cjs').zipFolder(v.root, r.filePath);
return { path: r.filePath, count };
});
// Import an Obsidian vault or a Notion export (a folder or a .zip) into a new folder of the notes.
handle('vault:import', async (kind) => {
const v = need();
const zip = kind === 'zip';
const r = await dialog.showOpenDialog(win, zip
? { title: 'Import a .zip export', properties: ['openFile'], filters: [{ name: 'Zip archive', extensions: ['zip'] }] }
: { title: 'Import a folder of notes', properties: ['openDirectory'] });
if (r.canceled || !r.filePaths[0]) return null;
const res = zip ? await importer.importZip(v.root, r.filePaths[0]) : await importer.importFolder(v.root, r.filePaths[0]);
db.rebuild(await v.list());
return res;
});
// A web page's HTML for the clipper (public addresses only).
handle('web:clip', async (url) => {
const page = await linkpreview.fetchHtml(String(url || '').trim(), { fetch: linkpreview.hooks.fetch || net.fetch.bind(net) });
if (!page) throw new Error('Could not read that page.');
return page;
});
handle('vault:graph', async () => links.graph(await need().list()));
handle('vault:saveImage', async (id, name, bytes) => {
if (!(bytes instanceof Uint8Array) || bytes.length > 50 * 1024 * 1024) throw new Error('That image is too big (50 MB at most).');
return need().saveImage(id, name, Buffer.from(bytes));
});
// A picture from a web address, saved next to page `id`; returns its file name.
handle('vault:saveImageFromUrl', async (id, url) => {
const v = need();
const img = await imagefetch.fetchImage(url, { fetch: linkpreview.hooks.fetch || net.fetch.bind(net) });
return v.saveImage(id, img.name, img.bytes);
});
handle('vault:pickImage', async (id) => {
const v = need();
const r = await dialog.showOpenDialog(win, { title: 'Add an image', properties: ['openFile'], filters: IMAGE_TYPES });
if (r.canceled || !r.filePaths[0]) return null;
return v.saveImage(id, path.basename(r.filePaths[0]), await fsp.readFile(r.filePaths[0]));
});
// Any file, for a database record's Files property: copied next to the record, its name returned.
handle('vault:pickFile', async (id) => {
const v = need();
const r = await dialog.showOpenDialog(win, { title: 'Attach files', properties: ['openFile', 'multiSelections'] });
if (r.canceled || !r.filePaths.length) return [];
const names = [];
for (const f of r.filePaths) {
if ((await fsp.stat(f)).size > 50 * 1024 * 1024) throw new Error(`${path.basename(f)} is too big (50 MB at most).`);
names.push(await v.saveImage(id, path.basename(f), await fsp.readFile(f)));
}
return names;
});
// Open a file attached to a record with the system's app for it (only files inside the notes folder).
handle('vault:openFile', async (id, src) => {
const file = need().imagePath(id, src);
if (!file) throw new Error('That file is not in the notes folder.');
const err = await shell.openPath(file); // '' on success
if (err) throw new Error(err);
});
handle('vault:remove', async (id) => {
const entry = await need().remove(id);
db.remove(id);
return entry;
});
handle('vault:trash', () => need().listTrash());
handle('vault:restore', async (entry) => {
const id = await need().restore(entry);
await reindexTree(id);
return id;
});
handle('vault:purge', (entry) => need().purge(entry || null));
handle('vault:search', (q) => (db ? db.search(q) : []));
handle('vault:reveal', async (id) => {
const v = need();
if (!id) return shell.openPath(v.root);
const file = v.abs(id);
shell.showItemInFolder(fs.existsSync(file) ? file : v.dir(id));
});
handle('vault:filePath', (id) => need().abs(id));
handle('export:save', async (name, content) => {
const r = await dialog.showSaveDialog(win, { defaultPath: path.join(app.getPath('documents'), name) });
if (r.canceled || !r.filePath) return null;
await fsp.writeFile(r.filePath, content, 'utf8');
return r.filePath;
});
handle('export:pdf', async (name, title, htmlContent) => {
const r = await dialog.showSaveDialog(win, { defaultPath: path.join(app.getPath('documents'), name) });
if (r.canceled || !r.filePath) return null;
// We create a hidden offscreen window to render the pristine HTML document
const pdfWin = new BrowserWindow({ show: false, webPreferences: { nodeIntegration: false } });
await pdfWin.loadURL('data:text/html;charset=utf-8,' + encodeURIComponent(htmlContent));
const data = await pdfWin.webContents.printToPDF({
printBackground: true,
margins: { marginType: 'default' }
});
pdfWin.destroy();
await fsp.writeFile(r.filePath, data);
return r.filePath;
});
// The title and description of a web page, for link cards on a canvas ({ off: true } when the user turned that off).
const previewCache = new Map(); // url -> { at, value }
handle('web:preview', async (url) => {
if (settings.get().linkPreviews === false) return { off: true };
const key = String(url || '');
const hit = previewCache.get(key);
if (hit && Date.now() - hit.at < (hit.value ? 3600e3 : 300e3)) return hit.value;
const value = await linkpreview.fetchPreview(key, { fetch: linkpreview.hooks.fetch || net.fetch.bind(net) });
previewCache.set(key, { at: Date.now(), value });
return value;
});
// A canvas as a picture or a PDF: `html` is a self-contained page (see src/lib/canvasExport.js), `width` × `height` its size.
handle('export:canvas', async (name, kind, html, width, height) => {
const ext = kind === 'pdf' ? 'pdf' : 'png';
const r = await dialog.showSaveDialog(win, {
defaultPath: path.join(app.getPath('documents'), name),
filters: [{ name: ext.toUpperCase(), extensions: [ext] }]
});
if (r.canceled || !r.filePath) return null;
const page = path.join(app.getPath('temp'), `ink-canvas-${process.pid}-${Date.now()}.html`);
await fsp.writeFile(page, html, 'utf8');
const w = Math.max(1, Math.ceil(width)), h = Math.max(1, Math.ceil(height));
const out = new BrowserWindow({
show: false, width: w, height: h, useContentSize: true, frame: false, enableLargerThanScreen: true,
webPreferences: { sandbox: true, backgroundThrottling: false }
});
try {
await out.loadFile(page);
// fonts and pictures in place before it is taken
await out.webContents.executeJavaScript(
'document.fonts.ready.then(() => Promise.all([...document.images].map((i) => i.decode().catch(() => null))))'
);
const data = ext === 'pdf'
? await out.webContents.printToPDF({ printBackground: true, preferCSSPageSize: true, margins: { marginType: 'none' } })
: (await out.webContents.capturePage()).toPNG();
await fsp.writeFile(r.filePath, data);
return r.filePath;
} finally {
out.destroy();
fsp.rm(page, { force: true }).catch(() => {});
}
});
ipcMain.on('export:print', () => {
win.webContents.print({
printBackground: true,
margins: { marginType: 'default' }
});
});
handle('settings:get', () => settings.get());
handle('settings:set', (patch) => {
const next = settings.set(patch || {});
if (patch && patch.prefs) applyPrefs();
return next;
});
/* prefs (see src/lib/prefs.js): the ones only this side can act on */
function prefs() {
const p = settings.get().prefs;
return {
trashDays: 30, historyKeep: 100, historyGapMin: 5, alwaysOnTop: false, startMinimized: false,
backupEvery: 'off', backupKeep: 5, backupFolder: '',
captureShortcut: 'CommandOrControl+Alt+N', captureTo: 'daily',
...(p && typeof p === 'object' ? p : {})
};
}
function applyPrefs() {
const p = prefs();
setLimits(p);
if (win && !win.isDestroyed()) win.setAlwaysOnTop(!!p.alwaysOnTop);
applyCaptureShortcut(p.captureShortcut);
}
/* quick capture: a global shortcut opens a small window; what is typed is added to today's note (or an Inbox page)
by the main window, which knows the daily-note settings. Captures wait in a queue until it takes them. */
let captureWin = null;
let captureKey = '';
const captures = [];
function applyCaptureShortcut(accel) {
const want = String(accel || '').trim();
if (want === captureKey) return;
if (captureKey) { try { globalShortcut.unregister(captureKey); } catch { /* not registered */ } }
captureKey = '';
if (!want) return;
try {
if (globalShortcut.register(want, showCapture)) captureKey = want;
else console.error('Quick capture shortcut not available:', want);
} catch (err) { console.error('Quick capture shortcut not valid:', want, err.message); }
}
function showCapture() {
if (!vault) return;
if (captureWin && !captureWin.isDestroyed()) { captureWin.show(); captureWin.focus(); return; }
const at = screen.getDisplayNearestPoint(screen.getCursorScreenPoint()).workArea;
const w = 560, h = 190;
captureWin = new BrowserWindow({
width: w, height: h, x: Math.round(at.x + (at.width - w) / 2), y: Math.round(at.y + at.height * 0.22),
frame: false, resizable: false, minimizable: false, maximizable: false, skipTaskbar: true, alwaysOnTop: true,
show: false, title: 'Quick capture', backgroundColor: '#161617',
webPreferences: { preload: path.join(__dirname, 'capture-preload.cjs'), contextIsolation: true, sandbox: true, nodeIntegration: false }
});
captureWin.loadFile(path.join(__dirname, 'capture.html'));
captureWin.once('ready-to-show', () => captureWin.show());
captureWin.on('blur', () => { if (captureWin && !captureWin.isDestroyed() && !captureWin.webContents.isDevToolsOpened()) captureWin.close(); });
captureWin.on('closed', () => { captureWin = null; });
}
ipcMain.handle('capture:info', () => {
const s = settings.get();
const dark = s.theme === 'dark' || (s.theme !== 'light' && nativeTheme.shouldUseDarkColors);
return { theme: dark ? 'dark' : 'light', to: prefs().captureTo === 'inbox' ? 'inbox' : 'daily', vault: vault ? path.basename(vault.root) : '' };
});
ipcMain.handle('capture:save', (_e, text, to) => {
const t = String(text || '').trim().slice(0, 20000);
if (!t) return false;
captures.push({ text: t, to: to === 'inbox' ? 'inbox' : 'daily' });
if (win && !win.isDestroyed()) {
win.webContents.send('capture:ping');
} else if (MAC && app.isReady()) {
allowClose = false;
createWindow(); // the page takes the queue when it starts
}
if (captureWin && !captureWin.isDestroyed()) captureWin.close();
return true;
});
ipcMain.on('capture:close', () => { if (captureWin && !captureWin.isDestroyed()) captureWin.close(); });
handle('capture:take', () => captures.splice(0));
handle('capture:open', () => showCapture());
/* backups */
const backupDir = () => prefs().backupFolder || path.join(app.getPath('userData'), 'backups');
async function runBackup() {
if (!vault) throw new Error('Open a notes folder first.');
const r = await backup(vault.root, backupDir(), prefs().backupKeep);
settings.set({ lastBackup: Date.now() });
return r;
}
async function backupIfDue() {
const p = prefs();
if (!vault || !backupDue(p.backupEvery, settings.get().lastBackup)) return;
try { await runBackup(); } catch (err) { console.error('Could not back up', err); }
}
handle('backup:now', async () => {
const r = await runBackup();
return { file: r.file, count: r.count };
});
handle('backup:info', () => ({ folder: backupDir(), last: settings.get().lastBackup || 0 }));
handle('backup:pickFolder', async () => {
const r = await dialog.showOpenDialog(win, { title: 'Folder for backups', properties: ['openDirectory', 'createDirectory'] });
return r.canceled || !r.filePaths[0] ? null : r.filePaths[0];
});
handle('backup:reveal', async () => {
const dir = backupDir();
await fsp.mkdir(dir, { recursive: true });
shell.openPath(dir);
return true;
});
/* custom dictionary */
handle('spell:words', async () => (await session.defaultSession.listWordsInSpellCheckerDictionary()).sort((a, b) => a.localeCompare(b)));
handle('spell:addWord', (word) => session.defaultSession.addWordToSpellCheckerDictionary(String(word).trim().slice(0, 80)));
handle('spell:removeWord', (word) => session.defaultSession.removeWordFromSpellCheckerDictionary(String(word)));
/* spellcheck: on/off and languages (macOS always uses the system's own languages) */
function applySpellcheck() {
const s = settings.get();
const ses = session.defaultSession;
if (!MAC) {
const langs = (Array.isArray(s.spellLangs) ? s.spellLangs : []).filter((l) => ses.availableSpellCheckerLanguages.includes(l));
if (langs.length) ses.setSpellCheckerLanguages(langs);
}
// After the languages: setting them switches spellcheck back on.
ses.setSpellCheckerEnabled(s.spellcheck !== false);
}
handle('spell:get', () => {
const ses = session.defaultSession;
return {
enabled: ses.isSpellCheckerEnabled(),
languages: MAC ? [] : ses.getSpellCheckerLanguages(),
available: MAC ? [] : ses.availableSpellCheckerLanguages,
system: MAC
};
});
handle('spell:set', (enabled, langs) => {
settings.set({ spellcheck: !!enabled, spellLangs: Array.isArray(langs) ? langs.slice(0, 3) : [] });
applySpellcheck();
return true;
});
handle('win:isMaximized', () => !!win && win.isMaximized());
ipcMain.on('win:minimize', () => win && win.minimize());
ipcMain.on('win:toggleMaximize', () => win && (win.isMaximized() ? win.unmaximize() : win.maximize()));
ipcMain.on('win:close', () => win && win.close());
// What the custom right-click menu asks for.
const EDIT_ACTIONS = new Set(['cut', 'copy', 'paste', 'selectAll']);
ipcMain.on('edit:do', (e, action) => { if (EDIT_ACTIONS.has(action)) e.sender[action](); });
ipcMain.on('edit:copyImageAt', (e, x, y) => e.sender.copyImageAt(Math.round(x), Math.round(y)));
ipcMain.on('edit:replaceMisspelling', (e, word) => e.sender.replaceMisspelling(String(word)));
ipcMain.on('edit:addToDictionary', (e, word) => e.sender.session.addWordToSpellCheckerDictionary(String(word)));
ipcMain.on('app:flushed', () => {
allowClose = true;
if (quitting) app.quit();
else if (win && !win.isDestroyed()) win.close();
});
/* ---------- window ---------- */
function createWindow() {
const s = settings.get();
const b = s.bounds || {};
win = new BrowserWindow({
width: b.width || 1280,
height: b.height || 840,
x: b.x,
y: b.y,
minWidth: 720,
minHeight: 480,
show: false,
title: 'Ink',
icon: MAC ? undefined : path.join(__dirname, '..', 'resources', 'icons', '512x512.png'),
backgroundColor: /^#[0-9a-f]{6}$/i.test(s.ground) ? s.ground : (s.theme === 'system' ? !nativeTheme.shouldUseDarkColors : s.theme === 'light') ? '#e7e7e9' : '#0e0e0f',
// macOS keeps its traffic lights; elsewhere the page draws its own controls (WindowControls.svelte).
frame: MAC,
titleBarStyle: MAC ? 'hiddenInset' : 'default',
trafficLightPosition: { x: 22, y: 24 },
autoHideMenuBar: !MAC,
webPreferences: {
preload: path.join(__dirname, 'preload.cjs'),
contextIsolation: true,
sandbox: true,
nodeIntegration: false,
spellcheck: true
}
});
win.once('ready-to-show', () => {
win.show();
if (prefs().startMinimized) win.minimize();
});
win.on('maximize', () => win.webContents.send('win:maximized', true));
win.on('unmaximize', () => win.webContents.send('win:maximized', false));
win.on('app-command', (e, cmd) => {
if (cmd === 'browser-backward') {
e.preventDefault();
win.webContents.send('nav:go', 'back');
} else if (cmd === 'browser-forward') {
e.preventDefault();
win.webContents.send('nav:go', 'forward');
}
});
// Let the page save pending edits before the window goes away.
win.on('close', (e) => {
settings.set({ bounds: win.getBounds() });
if (allowClose) return;
e.preventDefault();
win.webContents.send('app:flush');
setTimeout(() => {
allowClose = true;
if (quitting) app.quit();
else if (!win.isDestroyed()) win.close();
}, 1500);
});
win.webContents.setWindowOpenHandler(({ url }) => {
if (/^(https?|mailto):/i.test(url)) shell.openExternal(url);
return { action: 'deny' };
});
win.webContents.on('will-navigate', (e, url) => {
if (!url.startsWith(DEV ? DEV_URL : 'app://')) {
e.preventDefault();
if (/^https?:/i.test(url)) shell.openExternal(url);
}
});
// Spelling suggestions and copy/paste on right click.
// Right-click: the page draws its own menu (TextMenu.svelte). Only this side knows the spelling suggestions,
// so pass along what was clicked; the page's choice comes back through 'edit:*' below.
win.webContents.on('context-menu', (_e, p) => {
win.webContents.send('app:context-menu', {
x: p.x, y: p.y,
isEditable: p.isEditable,
selectionText: p.selectionText || '',
misspelledWord: p.misspelledWord || '',
suggestions: (p.dictionarySuggestions || []).slice(0, 5),
linkURL: p.linkURL || '',
mediaType: p.mediaType || 'none',
can: { cut: p.editFlags.canCut, copy: p.editFlags.canCopy, paste: p.editFlags.canPaste, selectAll: p.editFlags.canSelectAll }
});
});
if (DEV) {
win.loadURL(DEV_URL);
win.webContents.openDevTools({ mode: 'detach' });
} else {
win.loadURL('app://notes/');
}
}
/* ---------- Linux desktop integration ---------- */
// Docks and launchers (GNOME, KDE…) find a window's icon through an installed .desktop file whose name matches
// the window's app id ("ink", from desktopName in package.json). An AppImage installs nothing by itself, so when
// we run from one, put a launcher and the icons in the user's data folder, and keep the launcher pointing at
// wherever the AppImage is now.
function integrateAppImage() {
const image = process.env.APPIMAGE;
if (process.platform !== 'linux' || !image) return;
const data = process.env.XDG_DATA_HOME || path.join(app.getPath('home'), '.local', 'share');
const launcher = path.join(data, 'applications', 'ink.desktop');
const icons = path.join(__dirname, '..', 'resources', 'icons');
// A fingerprint of the icon, so a new icon gets copied even when nothing else in the launcher changed.
let iconId = '';
try { iconId = require('node:crypto').createHash('sha1').update(fs.readFileSync(path.join(icons, '512x512.png'))).digest('hex').slice(0, 12); } catch { /* no icon */ }
const entry = [
'[Desktop Entry]',
'Type=Application',
'Name=Ink',
'Comment=A calm, offline Markdown notes app',
`Exec="${image.replace(/(["`$\\])/g, '\\$1')}" %U`,
'Icon=ink',
'Terminal=false',
'Categories=Office;',
'Keywords=notes;markdown;writing;kanban;journal;',
'StartupWMClass=ink',
`X-AppImage-Version=${app.getVersion()}`,
`X-Ink-Icon=${iconId}`,
''
].join('\n');
try {
if (fs.existsSync(launcher) && fs.readFileSync(launcher, 'utf8') === entry) return;
for (const f of fs.readdirSync(icons)) {
const size = f.replace(/\.png$/, '');
if (!/^\d+x\d+$/.test(size)) continue;
const dir = path.join(data, 'icons', 'hicolor', size, 'apps');
fs.mkdirSync(dir, { recursive: true });
fs.copyFileSync(path.join(icons, f), path.join(dir, 'ink.png'));
}
fs.mkdirSync(path.dirname(launcher), { recursive: true });
fs.writeFileSync(launcher, entry);
} catch (err) {
console.error('Could not add the desktop launcher', err);
}
}
app.whenReady().then(async () => {
integrateAppImage();
applySpellcheck();
protocol.handle('app', (req) => {
const { pathname } = new URL(req.url);
const rel = decodeURIComponent(pathname).replace(/^\/+/, '');
let file = path.normalize(path.join(BUILD, rel));
if (file !== BUILD && !file.startsWith(BUILD + path.sep)) return new Response('Not found', { status: 404 });
let isFile = false;
try { isFile = fs.statSync(file).isFile(); } catch { /* missing */ }
if (!isFile) {
if (rel.startsWith('_app/')) return new Response('Not found', { status: 404 });
file = path.join(BUILD, 'index.html'); // SPA fallback
}
return net.fetch(pathToFileURL(file).toString());
});
protocol.handle('notes-media', (req) => {
const u = new URL(req.url);
const id = decodeURIComponent(u.pathname.replace(/^\/+/, ''));
const file = vault && id && vault.imagePath(id, u.searchParams.get('src') || '');
if (!file || !fs.existsSync(file)) return new Response('Not found', { status: 404 });
return net.fetch(pathToFileURL(file).toString());
});
const s = settings.get();
if (s.vault && fs.existsSync(s.vault)) {
opening = openVault(s.vault).catch((err) => { console.error('Could not open notes folder', err); });
}
createWindow();
applyPrefs();
backupIfDue();
setInterval(backupIfDue, 3600e3).unref();
app.on('activate', () => { if (!BrowserWindow.getAllWindows().length) { allowClose = false; createWindow(); } });
});
app.on('second-instance', () => {
if (!win) return;
if (win.isMinimized()) win.restore();
win.focus();
});
app.on('window-all-closed', () => { if (!MAC) app.quit(); });
app.on('before-quit', () => { quitting = true; });
app.on('will-quit', () => {
globalShortcut.unregisterAll();
if (stopWatch) stopWatch();
if (db) db.close();
settings.flush();
});
+109
View File
@@ -0,0 +1,109 @@
'use strict';
// The only bridge between the page and the file system. Everything goes through ipcMain handlers in main.cjs.
const { contextBridge, ipcRenderer } = require('electron');
const invoke = (channel, ...args) => ipcRenderer.invoke(channel, ...args);
contextBridge.exposeInMainWorld('vault', {
platform: process.platform,
get: () => invoke('vault:get'),
list: () => invoke('vault:list'),
pick: (kind) => invoke('vault:pick', kind),
read: (id) => invoke('vault:read', id),
write: (id, meta, body) => invoke('vault:write', id, meta, body),
duplicate: (id) => invoke('vault:duplicate', id),
create: (parent, title, meta, body) => invoke('vault:create', parent, title, meta, body),
createMany: (parent, items) => invoke('vault:createMany', parent, items),
createCanvas: (parent, title) => invoke('vault:createCanvas', parent, title),
listTemplates: () => invoke('vault:listTemplates'),
getTemplate: (name) => invoke('vault:getTemplate', name),
openTemplatesFolder: () => invoke('vault:openTemplates'),
rename: (id, title) => invoke('vault:rename', id, title),
move: (id, parent) => invoke('vault:move', id, parent),
renameHeading: (id, from, to) => invoke('vault:renameHeading', id, from, to),
tasks: () => invoke('vault:tasks'),
setTask: (id, line, text, done) => invoke('vault:setTask', id, line, text, done),
unlinked: (id) => invoke('vault:unlinked', id),
linkMention: (id, target) => invoke('vault:linkMention', id, target),
openCapture: () => invoke('capture:open'),
takeCaptures: () => invoke('capture:take'),
onCapture(cb) {
const listener = () => cb();
ipcRenderer.on('capture:ping', listener);
return () => ipcRenderer.removeListener('capture:ping', listener);
},
renameTag: (from, to) => invoke('vault:renameTag', from, to),
exportVaultZip: () => invoke('export:vaultZip'),
importNotes: (kind) => invoke('vault:import', kind),
clipPage: (url) => invoke('web:clip', url),
backlinks: (id) => invoke('vault:backlinks', id),
graph: () => invoke('vault:graph'),
keepVersion: (id) => invoke('vault:keepVersion', id),
listHistory: (id) => invoke('vault:listHistory', id),
readHistory: (id, ts) => invoke('vault:readHistory', id, ts),
restoreHistory: (id, ts) => invoke('vault:restoreHistory', id, ts),
saveImage: (id, name, bytes) => invoke('vault:saveImage', id, name, bytes),
saveImageFromUrl: (id, url) => invoke('vault:saveImageFromUrl', id, url),
pickImage: (id) => invoke('vault:pickImage', id),
pickFile: (id) => invoke('vault:pickFile', id),
openFile: (id, src) => invoke('vault:openFile', id, src),
mediaUrl: (id, src) => `notes-media://vault/${encodeURIComponent(id)}?src=${encodeURIComponent(src)}`,
remove: (id) => invoke('vault:remove', id),
trash: () => invoke('vault:trash'),
restore: (entry) => invoke('vault:restore', entry),
purge: (entry) => invoke('vault:purge', entry),
search: (q) => invoke('vault:search', q),
reveal: (id) => invoke('vault:reveal', id),
filePath: (id) => invoke('vault:filePath', id),
saveExport: (name, content) => invoke('export:save', name, content),
savePdf: (name, title, html) => invoke('export:pdf', name, title, html),
print: () => ipcRenderer.send('export:print'),
linkPreview: (url) => invoke('web:preview', url),
saveCanvasImage: (name, kind, html, width, height) => invoke('export:canvas', name, kind, html, width, height),
getSettings: () => invoke('settings:get'),
setSettings: (patch) => invoke('settings:set', patch),
getSpellcheck: () => invoke('spell:get'),
setSpellcheck: (enabled, langs) => invoke('spell:set', enabled, langs),
spellWords: () => invoke('spell:words'),
addSpellWord: (word) => invoke('spell:addWord', word),
removeSpellWord: (word) => invoke('spell:removeWord', word),
backupNow: () => invoke('backup:now'),
backupInfo: () => invoke('backup:info'),
pickBackupFolder: () => invoke('backup:pickFolder'),
revealBackups: () => invoke('backup:reveal'),
onNav(cb) {
const listener = (_e, dir) => cb(dir);
ipcRenderer.on('nav:go', listener);
return () => ipcRenderer.removeListener('nav:go', listener);
},
onChanged(cb) {
const listener = (_e, ids) => cb(ids);
ipcRenderer.on('vault:changed', listener);
return () => ipcRenderer.removeListener('vault:changed', listener);
},
minimize: () => ipcRenderer.send('win:minimize'),
toggleMaximize: () => ipcRenderer.send('win:toggleMaximize'),
close: () => ipcRenderer.send('win:close'),
isMaximized: () => invoke('win:isMaximized'),
onMaximized(cb) {
const listener = (_e, v) => cb(v);
ipcRenderer.on('win:maximized', listener);
return () => ipcRenderer.removeListener('win:maximized', listener);
},
onContextMenu(cb) {
const listener = (_e, info) => cb(info);
ipcRenderer.on('app:context-menu', listener);
return () => ipcRenderer.removeListener('app:context-menu', listener);
},
editAction: (action) => ipcRenderer.send('edit:do', action),
copyImageAt: (x, y) => ipcRenderer.send('edit:copyImageAt', x, y),
replaceMisspelling: (word) => ipcRenderer.send('edit:replaceMisspelling', word),
addToDictionary: (word) => ipcRenderer.send('edit:addToDictionary', word),
onFlush(cb) {
const listener = async () => {
try { await cb(); } finally { ipcRenderer.send('app:flushed'); }
};
ipcRenderer.on('app:flush', listener);
return () => ipcRenderer.removeListener('app:flush', listener);
}
});
+50
View File
@@ -0,0 +1,50 @@
'use strict';
// First-run content for an empty notes folder.
const fsp = require('node:fs').promises;
const path = require('node:path');
const { today } = require('./vault.cjs');
const GETTING_STARTED = `Everything here is plain Markdown in this folder. No account, no sync, nothing leaves this computer.
A quiet place to write. Pages nest like folders, tags live in each file's front matter, and search runs against a local index.
## Write the way you think
- Type \`/\` on any line to add a heading, to-do, quote, toggle or code block.
- Start a line with \`#\`, \`-\`, \`[]\`, \`>\` or three backticks and it turns into that block as you type.
- Hover a block and drag the handle on its left to move it.
- Press <kbd>Ctrl</kbd> <kbd>K</kbd> to search every note or run a command.
## First steps
- [x] Pick a folder for your notes
- [ ] Write one page, however short
- [ ] Try the light theme from the switch in the sidebar
<details>
<summary>Where do my files actually live?</summary>
Each page is a \`.md\` file in this folder. A page with sub-pages gets a folder of the same name. The search index sits in \`.notes/index.sqlite\`; delete it any time and it rebuilds on launch.
</details>
> Notes are for thinking, not for filing.
`;
async function isEmpty(dir) {
try {
const entries = await fsp.readdir(dir);
return entries.filter((n) => !n.startsWith('.')).length === 0;
} catch (err) {
if (err.code === 'ENOENT') return true;
throw err;
}
}
async function seedVault(dir) {
await fsp.mkdir(dir, { recursive: true });
const fm = `---\nicon: sparkle\ncover: blush\ntags: [guide]\ncreated: ${today()}\n---\n\n`;
await fsp.writeFile(path.join(dir, 'Getting started.md'), fm + GETTING_STARTED, 'utf8');
}
module.exports = { isEmpty, seedVault, GETTING_STARTED };
+39
View File
@@ -0,0 +1,39 @@
'use strict';
// App settings (not notes): last vault, theme, window size, folded pages.
const fs = require('node:fs');
const path = require('node:path');
let file = null;
let cache = null;
function location() {
if (!file) file = path.join(require('electron').app.getPath('userData'), 'settings.json');
return file;
}
function get() {
if (cache) return cache;
try { cache = JSON.parse(fs.readFileSync(location(), 'utf8')); } catch { cache = {}; }
return cache;
}
let timer = null;
function set(patch) {
cache = { ...get(), ...patch };
clearTimeout(timer);
timer = setTimeout(flush, 200);
return cache;
}
function flush() {
clearTimeout(timer);
if (!cache) return;
try {
fs.mkdirSync(path.dirname(location()), { recursive: true });
fs.writeFileSync(location(), JSON.stringify(cache, null, 2));
} catch (err) {
console.error('Could not write settings', err);
}
}
module.exports = { get, set, flush };
+55
View File
@@ -0,0 +1,55 @@
'use strict';
// Checklist items (`- [ ] text`) across the notes, and ticking one from outside its page.
// A task is identified by its page and line number in the page's body (what vault.read returns), plus its text,
// so a file that changed since the list was made is never edited blindly.
const TASK = /^(\s*(?:>\s*)*[-*+]\s+)\[([ xX])\](\s+)(.*)$/;
const DUE = /(?:📅|\bdue:|\[due::)\s*(\d{4}-\d{2}-\d{2})/;
const TAG = /(?:^|\s)#([\p{L}\p{N}_/-]*[\p{L}_/-][\p{L}\p{N}_/-]*)/gu;
// Line indexes inside ``` / ~~~ fences are not tasks.
function* lines(body) {
let fence = null;
const all = String(body || '').split('\n');
for (let i = 0; i < all.length; i++) {
const f = all[i].match(/^\s*(```|~~~)/);
if (f) { fence = fence === f[1] ? null : fence || f[1]; continue; }
if (!fence) yield [i, all[i]];
}
}
// [{ id, title, icon, line, done, text, due, tags, depth }] for every task on every page (boards and canvases excluded:
// a board's cards are its own thing).
function findTasks(pages) {
const out = [];
for (const p of pages) {
if (!p.exists || p.kind === 'canvas' || /\.canvas$/i.test(p.id)) continue;
if (p.props && p.props['kanban-plugin']) continue;
for (const [line, text] of lines(p.body)) {
const m = text.match(TASK);
if (!m) continue;
const body = m[4].trim();
if (!body) continue;
const tags = new Set((p.tags || []).map(String));
for (const t of body.matchAll(TAG)) tags.add(t[1]);
const due = body.match(DUE);
out.push({
id: p.id, title: p.title, icon: p.icon || null, line,
done: m[2] !== ' ', text: body, due: due ? due[1] : '', tags: [...tags],
depth: Math.floor((m[1].match(/^\s*/)[0].replace(/\t/g, ' ').length) / 2)
});
}
}
return out;
}
// The body with the task at `line` ticked or unticked; null if the line is no longer that task.
function setTask(body, line, text, done) {
const all = String(body || '').split('\n');
const m = all[line] && all[line].match(TASK);
if (!m || m[4].trim() !== String(text).trim()) return null;
all[line] = m[1] + (done ? '[x]' : '[ ]') + m[3] + m[4];
return all.join('\n');
}
module.exports = { findTasks, setTask };
+546
View File
@@ -0,0 +1,546 @@
'use strict';
// The vault is a plain folder of Markdown files.
// "Projects.md" -> page "Projects"
// "Projects/Note app roadmap.md" -> child page of "Projects"
// A folder without a matching .md file still shows up as an (empty) page.
// Page ids are paths relative to the vault, without ".md", always with "/".
// Canvases ("Ideas/Map.canvas", see canvas.cjs) are pages too; their id keeps the ".canvas", as links to them do.
const fs = require('node:fs');
const fsp = fs.promises;
const path = require('node:path');
const { parseFrontMatter, serializeFrontMatter } = require('./frontmatter.cjs');
const { isCanvasId, canvasText, EMPTY_CANVAS } = require('./canvas.cjs');
const ILLEGAL = /[\\/:*?"<>|\u0000-\u001f]/g;
const SKIP_DIRS = new Set(['node_modules']);
function cleanTitle(t) {
const s = String(t || '').replace(ILLEGAL, '-').replace(/\s+/g, ' ').trim().replace(/^\.+/, '').slice(0, 120).trim();
return s || 'Untitled';
}
const today = (d = new Date()) => {
const z = (n) => String(n).padStart(2, '0');
return `${d.getFullYear()}-${z(d.getMonth() + 1)}-${z(d.getDate())}`;
};
const parentOf = (id) => (id.includes('/') ? id.slice(0, id.lastIndexOf('/')) : null);
const PAGE_KEYS = new Set(['icon', 'cover', 'tags', 'created']);
const titleOf = (id) => id.slice(id.lastIndexOf('/') + 1).replace(/\.canvas$/i, '');
const extOf = (id) => (isCanvasId(id) ? '.canvas' : '');
const noCanvasParent = (parent) => { if (parent && isCanvasId(parent)) throw new Error('A canvas cannot hold pages.'); };
const asArray = (v) => (Array.isArray(v) ? v : typeof v === 'string' && v ? [v] : []).map(String);
async function exists(p) {
try { await fsp.access(p); return true; } catch { return false; }
}
// Version history limits (Settings): at most one version per `gap` of editing, the newest `keep` per page.
const limits = { gap: 5 * 60 * 1000, keep: 100 };
function setLimits({ historyKeep, historyGapMin } = {}) {
if (Number.isFinite(historyKeep) && historyKeep >= 1) limits.keep = Math.floor(historyKeep);
if (Number.isFinite(historyGapMin) && historyGapMin >= 1) limits.gap = Math.floor(historyGapMin) * 60 * 1000;
}
const isLocalPath = (src) => !!src && !/^[a-z][a-z0-9+.-]*:/i.test(src) && !src.startsWith('/') && !src.startsWith('\\');
const cleanFileName = (name) => {
const ext = (path.extname(String(name)).toLowerCase().match(/^\.[a-z0-9]{1,5}$/) || ['.png'])[0];
const base = path.basename(String(name), path.extname(String(name))).replace(ILLEGAL, '-').replace(/\s+/g, ' ').trim().replace(/^\.+/, '').slice(0, 80);
return (base || 'image') + ext;
};
async function freeFile(dir, name) {
const ext = path.extname(name), base = name.slice(0, name.length - ext.length);
for (let n = 1; n < 1000; n++) {
const f = n === 1 ? name : `${base} ${n}${ext}`;
if (!(await exists(path.join(dir, f)))) return f;
}
throw new Error('Could not find a free file name.');
}
class Vault {
constructor(root, { trash } = {}) {
this.root = path.resolve(root);
this.trash = trash;
this.recent = new Map(); // id -> time of our own write, to ignore watcher echoes
this.cache = new Map(); // fullPath -> { mtime, page }
}
#inside(p) {
if (p !== this.root && !p.startsWith(this.root + path.sep)) throw new Error('That path is outside the notes folder.');
return p;
}
abs(id) { return this.#inside(path.resolve(this.root, isCanvasId(id) ? id : id + '.md')); }
dir(id) { return this.#inside(path.resolve(this.root, id || '.')); }
#touch(id) { this.recent.set(id, Date.now()); }
isRecent(id) { const t = this.recent.get(id); return t !== undefined && Date.now() - t < 1500; }
async list() {
// Each folder yields its pages (files first, then its subfolders, in directory order). Files are read in
// parallel batches and subfolders are walked together; the order of the result stays the same.
const readPage = async (dir, parent, e) => {
const canvas = /\.canvas$/i.test(e.name);
const title = canvas ? e.name.slice(0, -7) : e.name.slice(0, -3);
const id = parent ? `${parent}/${canvas ? e.name : title}` : canvas ? e.name : title;
const full = path.join(dir, e.name);
try {
const st = await fsp.stat(full);
const cached = this.cache.get(full);
let page;
if (cached && cached.mtime === st.mtimeMs) {
page = cached.page;
// update ID and parent in case it moved/renamed but mtime stayed the same
page.id = id;
page.parent = parent || null;
page.title = title;
} else {
const raw = await fsp.readFile(full, 'utf8');
// A canvas's "body" is its text and the notes on it as [[links]], for search, backlinks and the graph.
const { meta, body } = canvas ? { meta: {}, body: canvasText(raw) } : parseFrontMatter(raw);
// Front matter beyond the page's own keys: a database record's property values (see src/lib/database.js).
const props = {};
for (const k of Object.keys(meta)) if (!PAGE_KEYS.has(k)) props[k] = meta[k];
page = {
...(canvas ? { kind: 'canvas' } : {}),
props,
...(meta['ink-database'] ? { db: true } : {}),
id, parent: parent || null, title, exists: true, mtime: st.mtimeMs,
icon: meta.icon || null, cover: meta.cover || null, tags: asArray(meta.tags),
created: meta.created || today(st.birthtime && st.birthtime.getTime() ? st.birthtime : st.mtime),
body
};
this.cache.set(full, { mtime: st.mtimeMs, page });
}
return page;
} catch { return null; /* unreadable file: skip */ }
};
const walk = async (dir, parent) => {
let entries;
try { entries = await fsp.readdir(dir, { withFileTypes: true }); } catch { return []; }
const files = entries.filter((e) => !e.name.startsWith('.') && e.isFile() && /\.(md|canvas)$/i.test(e.name));
const out = [];
const taken = new Set();
for (let i = 0; i < files.length; i += 64) {
const batch = await Promise.all(files.slice(i, i + 64).map((e) => readPage(dir, parent, e)));
for (const page of batch) if (page) { out.push(page); taken.add(page.id); }
}
const dirs = entries.filter((e) => !e.name.startsWith('.') && e.isDirectory() && !SKIP_DIRS.has(e.name));
const subs = await Promise.all(dirs.map((e) => walk(path.join(dir, e.name), parent ? `${parent}/${e.name}` : e.name)));
dirs.forEach((e, i) => {
const id = parent ? `${parent}/${e.name}` : e.name;
if (!taken.has(id)) {
out.push({ id, parent: parent || null, title: e.name, exists: false, mtime: 0, icon: null, cover: null, tags: [], created: null, body: '' });
taken.add(id);
}
for (const p of subs[i]) out.push(p);
});
return out;
};
const pages = new Map();
for (const p of await walk(this.root, '')) pages.set(p.id, p);
const active = new Set([...pages.values()].filter(p => p.exists).map(p => this.abs(p.id)));
for (const k of this.cache.keys()) {
if (!active.has(k)) this.cache.delete(k);
}
return [...pages.values()];
}
async read(id) {
const file = this.abs(id);
try {
const [raw, st] = await Promise.all([fsp.readFile(file, 'utf8'), fsp.stat(file)]);
const { meta, body } = isCanvasId(id) ? { meta: {}, body: raw } : parseFrontMatter(raw);
if (meta.tags !== undefined) meta.tags = asArray(meta.tags);
return { id, title: titleOf(id), meta, body, exists: true, mtime: st.mtimeMs };
} catch (err) {
if (err.code !== 'ENOENT') throw err;
return { id, title: titleOf(id), meta: {}, body: '', exists: false, mtime: 0 };
}
}
async write(id, meta, body) {
const file = this.abs(id);
await fsp.mkdir(path.dirname(file), { recursive: true });
const text = isCanvasId(id) ? String(body || '') : serializeFrontMatter(meta) + String(body || '');
const tmp = path.join(path.dirname(file), `.${path.basename(file)}.${process.pid}.tmp`);
this.#touch(id);
await fsp.writeFile(tmp, text, 'utf8');
await fsp.rename(tmp, file);
this.#touch(id);
await this.snapshot(id, text);
return (await fsp.stat(file)).mtimeMs;
}
/* version history
.notes/history/<page id>/<time>.md holds copies of a page as it was. Saves happen every few
hundred ms while typing, so a copy is kept at most every limits.gap (and never twice the same),
the newest limits.keep per page. */
get historyRoot() { return path.join(this.root, '.notes', 'history'); }
#historyDir(id) {
const dir = path.resolve(this.historyRoot, id);
if (!dir.startsWith(this.historyRoot + path.sep)) throw new Error('That path is outside the notes folder.');
return dir;
}
async #versions(id) {
try {
return (await fsp.readdir(this.#historyDir(id))).filter((v) => /^\d+\.md$/.test(v)).map((v) => parseInt(v, 10)).sort((a, b) => b - a);
} catch { return []; }
}
// Keep `text` (the page's full file) as a version. force: skip the time gap (used before a restore).
async snapshot(id, text, { force = false } = {}) {
try {
const dir = this.#historyDir(id);
const [newest] = await this.#versions(id);
if (newest && !force) {
if (Date.now() - newest < limits.gap) return;
}
if (newest && (await fsp.readFile(path.join(dir, `${newest}.md`), 'utf8').catch(() => null)) === text) return;
await fsp.mkdir(dir, { recursive: true });
await fsp.writeFile(path.join(dir, `${Math.max(Date.now(), (newest || 0) + 1)}.md`), text, 'utf8');
const all = await this.#versions(id);
for (const v of all.slice(limits.keep)) await fsp.unlink(path.join(dir, `${v}.md`)).catch(() => {});
} catch (err) {
console.error('Could not save a version', err);
}
}
// Keep the file as it is on disk right now as a version (before something else overwrites it).
async keepCurrentVersion(id) {
const text = await fsp.readFile(this.abs(id), 'utf8');
await this.snapshot(id, text, { force: true });
}
listHistory(id) { return this.#versions(id); }
async readHistory(id, ts) {
if (!/^\d+$/.test(String(ts))) throw new Error('Unknown version.');
const text = await fsp.readFile(path.join(this.#historyDir(id), `${ts}.md`), 'utf8');
return isCanvasId(id) ? { meta: {}, body: text } : parseFrontMatter(text); // a canvas has no front matter
}
// Put version `ts` back. What's there now is kept as a version first, so a restore can itself be undone.
async restoreHistory(id, ts) {
const { meta, body } = await this.readHistory(id, ts);
try { await this.snapshot(id, await fsp.readFile(this.abs(id), 'utf8'), { force: true }); } catch { /* page file gone */ }
return this.write(id, meta, body);
}
// History follows a page (and its sub-pages) when it's renamed or moved.
async #moveHistory(id, next) {
const from = this.#historyDir(id), to = this.#historyDir(next);
if (!(await exists(from)) || (await exists(to))) return;
await fsp.mkdir(path.dirname(to), { recursive: true });
await fsp.rename(from, to).catch((err) => console.error('Could not move history', err));
}
async #freeName(parent, wanted, except, ext = '') {
const base = cleanTitle(wanted);
for (let n = 1; n < 1000; n++) {
const title = (n === 1 ? base : `${base} ${n}`) + ext;
const id = parent ? `${parent}/${title}` : title;
if (except && id.toLowerCase() === except.toLowerCase()) return id;
if (!(await exists(this.abs(id))) && !(await exists(this.dir(id)))) return id;
}
throw new Error('Could not find a free file name.');
}
async create(parent, title, meta = {}, body = '') {
noCanvasParent(parent);
if (parent) await fsp.mkdir(this.dir(parent), { recursive: true });
const id = await this.#freeName(parent, title || 'Untitled');
await this.write(id, { created: today(), ...meta }, body);
return id;
}
async createCanvas(parent, title) {
noCanvasParent(parent);
if (parent) await fsp.mkdir(this.dir(parent), { recursive: true });
const id = await this.#freeName(parent, title || 'Untitled canvas', undefined, '.canvas');
await this.write(id, {}, EMPTY_CANVAS);
return id;
}
// A copy of a page, board or canvas next to the original ("Name copy", "Name copy 2", …), with its sub-pages and
// pictures (the page's folder) when it has any. Returns the copy's id.
async duplicate(id) {
const src = this.abs(id);
if (!(await exists(src))) throw new Error('That page has no file to copy.');
const next = await this.#freeName(parentOf(id), titleOf(id) + ' copy', undefined, extOf(id));
await fsp.mkdir(path.dirname(this.abs(next)), { recursive: true });
if (!isCanvasId(id) && (await exists(this.dir(id)))) await fsp.cp(this.dir(id), this.dir(next), { recursive: true });
this.#touch(next);
await fsp.copyFile(src, this.abs(next));
this.#touch(next);
await this.snapshot(next, await fsp.readFile(this.abs(next), 'utf8'));
return next;
}
// The folder templates live in (made on demand, so there's something to open).
async ensureTemplatesDir() {
const dir = path.join(this.root, '.notes', 'templates');
await fsp.mkdir(dir, { recursive: true });
return dir;
}
async listTemplates() {
try {
const tplDir = path.join(this.root, '.notes', 'templates');
if (!(await exists(tplDir))) return [];
const files = await fsp.readdir(tplDir);
return files.filter(f => f.endsWith('.md')).map(f => f.slice(0, -3));
} catch {
return [];
}
}
async getTemplate(name) {
try {
const tplFile = path.join(this.root, '.notes', 'templates', name + '.md');
if (!(await exists(tplFile))) return null;
const raw = await fsp.readFile(tplFile, 'utf8');
return parseFrontMatter(raw);
} catch {
return null;
}
}
async rename(id, title) {
const parent = parentOf(id);
if (cleanTitle(title) === titleOf(id)) return id;
const next = await this.#freeName(parent, title, id, extOf(id));
if (next === id) return id;
this.#touch(id); this.#touch(next);
if (await exists(this.abs(id))) await fsp.rename(this.abs(id), this.abs(next));
if (!isCanvasId(id) && (await exists(this.dir(id)))) await fsp.rename(this.dir(id), this.dir(next));
await this.#moveHistory(id, next);
return next;
}
// Move a page (and its sub-pages) under another page, or to the top level when newParent is null.
// Images the page shows from its own folder go with it, so its relative links keep working.
async move(id, newParent) {
newParent = newParent || null;
if (newParent === parentOf(id)) return id;
if (newParent && (newParent === id || newParent.startsWith(id + '/'))) throw new Error('A page cannot go inside itself.');
noCanvasParent(newParent);
if (newParent) await fsp.mkdir(this.dir(newParent), { recursive: true });
const next = await this.#freeName(newParent, titleOf(id), undefined, extOf(id));
const from = path.dirname(this.abs(id)), to = path.dirname(this.abs(next));
this.#touch(id); this.#touch(next);
if (await exists(this.abs(id))) {
await fsp.rename(this.abs(id), this.abs(next));
if (!isCanvasId(next)) await this.#moveImages(next, from, to);
}
if (!isCanvasId(id) && (await exists(this.dir(id)))) await fsp.rename(this.dir(id), this.dir(next));
await this.#moveHistory(id, next);
return next;
}
async #moveImages(id, from, to) {
const { meta, body } = await this.read(id);
let changed = false;
const done = new Map(); // old path -> new name, for images shown more than once
const relocate = async (src) => {
if (!isLocalPath(src)) return src;
const old = path.resolve(from, src);
if (path.dirname(old) !== from) return src;
let name = done.get(old);
if (!name) {
if (!(await exists(old))) return src;
name = await freeFile(to, path.basename(old));
await fsp.rename(old, path.join(to, name));
done.set(old, name);
}
if (name !== path.basename(old)) changed = true;
return name;
};
// a picture used as the page's cover goes with it too
let nextMeta = meta;
if (typeof meta.cover === 'string' && /\.(png|jpe?g|gif|webp|avif|svg|bmp)$/i.test(meta.cover)) {
const name = await relocate(meta.cover);
if (name !== meta.cover) nextMeta = { ...meta, cover: name };
}
const out = [];
for (const line of body.split('\n')) {
let m = line.match(/^(\s*!\[[^\]]*\]\(\s*)(<[^>]+>|[^)\s]+)(.*)$/);
if (m) {
const raw = m[2].replace(/^<|>$/g, '');
let src = raw;
try { src = decodeURI(raw); } catch { /* as written */ }
const name = await relocate(src);
out.push(name === src ? line : m[1] + name.replace(/[ ()<>]/g, encodeURIComponent) + m[3]);
continue;
}
m = line.match(/^(\s*!\[\[)([^\]|]+)(.*)$/);
if (m) {
const name = await relocate(m[2].trim());
out.push(name === m[2].trim() ? line : m[1] + name + m[3]);
continue;
}
out.push(line);
}
if (changed) await this.write(id, nextMeta, out.join('\n'));
}
// Store image bytes next to page `id`; returns the file name to use in ![](name).
async saveImage(id, name, bytes) {
const dir = path.dirname(this.abs(id));
await fsp.mkdir(dir, { recursive: true });
const file = await freeFile(dir, cleanFileName(name));
await fsp.writeFile(path.join(dir, file), bytes);
return file;
}
// Absolute path of an image `src` as written in page `id`, or null if it isn't a file in the vault.
imagePath(id, src) {
if (!isLocalPath(src)) return null;
try { return this.#inside(path.resolve(path.dirname(this.abs(id)), src)); } catch { return null; }
}
/* trash
Deleted pages go to .notes/trash/<entry>/ so the app can list and restore them:
info.json { id, title, icon, pages, deletedAt }
files/ the page's .md and its folder of sub-pages, as they were
Deleting from there (or after 30 days) hands the entry to the system trash. */
get trashRoot() { return path.join(this.root, '.notes', 'trash'); }
// Move a page and its sub-pages to the trash. Returns the trash entry, or null if there was nothing to move.
async remove(id) {
const targets = isCanvasId(id) ? [this.abs(id)] : [this.abs(id), this.dir(id)]; // a canvas has no folder of sub-pages
const found = [];
for (const t of targets) if (await exists(t)) found.push(t);
if (!found.length) return null;
const page = await this.read(id);
const below = (await this.list()).filter((p) => p.exists && p.id.startsWith(id + '/')).length;
const entry = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 7)}`;
const files = path.join(this.trashRoot, entry, 'files');
await fsp.mkdir(files, { recursive: true });
this.#touch(id);
for (const t of found) await fsp.rename(t, path.join(files, path.basename(t)));
const info = { id, title: titleOf(id), icon: page.meta.icon || null, pages: below + (page.exists ? 1 : 0), deletedAt: Date.now() };
await fsp.writeFile(path.join(this.trashRoot, entry, 'info.json'), JSON.stringify(info, null, 2));
return entry;
}
async listTrash() {
let names;
try { names = await fsp.readdir(this.trashRoot); } catch { return []; }
const out = [];
for (const entry of names) {
try {
const info = JSON.parse(await fsp.readFile(path.join(this.trashRoot, entry, 'info.json'), 'utf8'));
out.push({ entry, ...info });
} catch { /* half-written entry: skip */ }
}
return out.sort((a, b) => b.deletedAt - a.deletedAt);
}
#entry(entry) {
if (!/^[a-z0-9-]+$/i.test(String(entry))) throw new Error('Unknown trash item.');
return path.join(this.trashRoot, entry);
}
// Put a trashed page back where it was (with a number added if that name is taken now). Returns its id.
async restore(entry) {
const base = this.#entry(entry);
const info = JSON.parse(await fsp.readFile(path.join(base, 'info.json'), 'utf8'));
const parent = parentOf(info.id);
if (parent) await fsp.mkdir(this.dir(parent), { recursive: true });
const next = await this.#freeName(parent, titleOf(info.id), undefined, extOf(info.id));
const title = titleOf(info.id);
this.#touch(next);
const md = path.join(base, 'files', path.basename(this.abs(info.id))), folder = path.join(base, 'files', title);
if (await exists(md)) await fsp.rename(md, this.abs(next));
if (await exists(folder)) await fsp.rename(folder, this.dir(next));
await fsp.rm(base, { recursive: true, force: true });
return next;
}
// Delete for good: to the system trash when there is one. No entry = everything.
async purge(entry) {
const entries = entry ? [entry] : (await this.listTrash()).map((t) => t.entry);
for (const e of entries) {
const base = this.#entry(e);
if (!(await exists(base))) continue;
if (this.trash) await this.trash(base);
else await fsp.rm(base, { recursive: true, force: true });
}
}
async purgeOlderThan(days) {
const cutoff = Date.now() - days * 864e5;
for (const t of await this.listTrash()) if (t.deletedAt < cutoff) await this.purge(t.entry);
}
// Calls back with the ids of pages changed outside the app.
// Calls onChange(ids) (debounced) when pages change on disk behind the app's back.
// One non-recursive watcher per note folder: Node's recursive watcher on Linux stopped delivering events once
// the app had created folders of its own (.notes/history), and it would also report all the churn inside .notes.
// Dot folders and the app's own are never watched.
watch(onChange) {
const pending = new Set();
const watchers = new Map(); // absolute folder -> FSWatcher
let timer = null;
let resyncTimer = null;
let closed = false;
const flush = () => {
const ids = [...pending].filter((id) => !this.isRecent(id));
pending.clear();
if (ids.length) onChange(ids);
};
const note = (id) => {
pending.add(id);
clearTimeout(timer);
timer = setTimeout(flush, 250);
};
const add = async (dir) => {
if (closed || watchers.has(dir)) return;
let w;
try {
w = fs.watch(dir, (evt, name) => onEvent(dir, evt, name));
} catch {
return;
}
w.on('error', () => { try { w.close(); } catch { /* gone */ } watchers.delete(dir); });
watchers.set(dir, w);
let entries = [];
try { entries = await fsp.readdir(dir, { withFileTypes: true }); } catch { return; }
for (const e of entries) {
if (e.isDirectory() && !e.name.startsWith('.') && !SKIP_DIRS.has(e.name)) await add(path.join(dir, e.name));
}
};
// Folders that appeared, moved or vanished: look at the whole tree again (a watched folder that is renamed
// keeps reporting under its old name).
const resync = () => {
clearTimeout(resyncTimer);
resyncTimer = setTimeout(async () => {
if (closed) return;
for (const w of watchers.values()) { try { w.close(); } catch { /* gone */ } }
watchers.clear();
await add(this.root);
}, 200);
};
const onEvent = (dir, evt, name) => {
if (!name) return;
name = String(name);
if (name.startsWith('.')) return; // temp files, .notes, .obsidian, .git
const full = path.join(dir, name);
const rel = path.relative(this.root, full).split(path.sep).join('/');
if (SKIP_DIRS.has(name)) return;
note(rel.replace(/\.md$/i, ''));
if (evt === 'rename') {
fsp.stat(full).then((st) => { if (st.isDirectory()) resync(); }, () => { if (watchers.has(full) || !/\.[a-z0-9]+$/i.test(name)) resync(); });
}
};
add(this.root);
return () => {
closed = true;
clearTimeout(timer);
clearTimeout(resyncTimer);
for (const w of watchers.values()) { try { w.close(); } catch { /* gone */ } }
watchers.clear();
};
}
}
module.exports = { setLimits, Vault, cleanTitle, today, titleOf, parentOf, isCanvasId };
+8
View File
@@ -0,0 +1,8 @@
{
"extends": "./.svelte-kit/tsconfig.json",
"compilerOptions": {
"allowJs": true,
"checkJs": false,
"moduleResolution": "bundler"
}
}
+135
View File
@@ -0,0 +1,135 @@
{
"name": "ink",
"productName": "Ink",
"desktopName": "ink.desktop",
"version": "1.0.0-beta",
"description": "A calm, offline Markdown notes app.",
"homepage": "https://git.joji.li/joji/ink-note-app",
"private": true,
"type": "module",
"main": "electron/main.cjs",
"scripts": {
"dev": "concurrently -k -n web,app -c magenta,cyan \"vite dev\" \"wait-on http://localhost:5173 && node scripts/dev-app.mjs\"",
"dev:web": "vite dev",
"build": "vite build",
"start": "vite build && electron .",
"dist": "vite build && electron-builder",
"test": "ELECTRON_RUN_AS_NODE=1 electron --test test/*.test.js",
"e2e": "node scripts/e2e.mjs",
"postinstall": "bun node_modules/electron/install.js && svelte-kit sync && electron-rebuild -f -w better-sqlite3",
"icons": "./scripts/icons.sh",
"test:e2e": "node scripts/e2e.mjs",
"dist:flatpak": "vite build && electron-builder --linux flatpak"
},
"trustedDependencies": [
"electron"
],
"dependencies": {
"better-sqlite3": "^11.9.1",
"katex": "^0.19.0",
"mermaid": "^12.1.0"
},
"devDependencies": {
"@electron/rebuild": "^3.7.1",
"@fontsource-variable/inter": "^5.2.5",
"@fontsource/jetbrains-mono": "^5.2.5",
"@sveltejs/adapter-static": "^3.0.8",
"@sveltejs/kit": "^2.20.2",
"@sveltejs/vite-plugin-svelte": "^5.0.3",
"concurrently": "^9.1.2",
"electron": "^35.1.2",
"electron-builder": "^25.1.8",
"svelte": "^5.25.3",
"vite": "^6.2.3",
"wait-on": "^8.0.3"
},
"build": {
"appId": "app.ink.desktop",
"productName": "Ink",
"copyright": "Copyright © 2026",
"directories": {
"output": "dist",
"buildResources": "resources"
},
"files": [
"build/**/*",
"electron/**/*",
"resources/icons/*.png",
"package.json"
],
"asarUnpack": [
"node_modules/better-sqlite3/**/*"
],
"artifactName": "${productName}-${version}-${arch}.${ext}",
"mac": {
"category": "public.app-category.productivity",
"icon": "resources/icon.png",
"target": [
"dmg"
]
},
"dmg": {
"title": "${productName} ${version}"
},
"win": {
"icon": "resources/icon.ico",
"target": [
"nsis"
]
},
"nsis": {
"oneClick": false,
"perMachine": false,
"allowToChangeInstallationDirectory": true,
"createDesktopShortcut": true,
"createStartMenuShortcut": true,
"shortcutName": "Ink",
"installerIcon": "resources/icon.ico",
"uninstallerIcon": "resources/icon.ico",
"uninstallDisplayName": "Ink",
"deleteAppDataOnUninstall": false
},
"linux": {
"icon": "resources/icons",
"target": [
"AppImage",
"deb",
"flatpak"
],
"category": "Office",
"executableName": "ink",
"synopsis": "A calm, offline Markdown notes app",
"desktop": {
"Name": "Ink",
"Comment": "A calm, offline Markdown notes app",
"Keywords": "notes;markdown;writing;kanban;journal;",
"StartupWMClass": "ink"
}
},
"flatpak": {
"runtime": "org.freedesktop.Platform",
"runtimeVersion": "25.08",
"sdk": "org.freedesktop.Sdk",
"base": "org.electronjs.Electron2.BaseApp",
"baseVersion": "25.08",
"finishArgs": [
"--socket=wayland",
"--socket=x11",
"--share=ipc",
"--device=dri",
"--socket=pulseaudio",
"--share=network",
"--filesystem=home",
"--talk-name=org.freedesktop.Notifications",
"--talk-name=org.freedesktop.FileManager1"
]
}
},
"author": {
"name": "Joel Muenster",
"email": "joel.muenster@proton.me"
},
"overrides": {
"@electron/node-gyp": "10.2.0-electron.2"
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 364 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 492 KiB

+63
View File
@@ -0,0 +1,63 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1024" height="1024" viewBox="0 0 1024 1024">
<!-- Ink app icon: a blush fountain-pen nib on the app's neutral dark ground.
Source for every size in resources/ (run `bun run icons` after editing). -->
<defs>
<linearGradient id="bg" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#2a2a2d"/>
<stop offset="1" stop-color="#121213"/>
</linearGradient>
<radialGradient id="glow" cx="0.5" cy="0.5" r="0.45">
<stop offset="0" stop-color="#e4ada5" stop-opacity="0.22"/>
<stop offset="1" stop-color="#e4ada5" stop-opacity="0"/>
</radialGradient>
<linearGradient id="nib" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#f7d3c9"/>
<stop offset="0.6" stop-color="#e4ada5"/>
<stop offset="1" stop-color="#c97a6d"/>
</linearGradient>
<linearGradient id="rim" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#ffffff" stop-opacity="0.14"/>
<stop offset="1" stop-color="#ffffff" stop-opacity="0.02"/>
</linearGradient>
<filter id="tileShadow" x="-20%" y="-20%" width="140%" height="150%">
<feDropShadow dx="0" dy="18" stdDeviation="22" flood-color="#000" flood-opacity="0.45"/>
</filter>
<filter id="nibShadow" x="-30%" y="-30%" width="160%" height="160%">
<feDropShadow dx="0" dy="14" stdDeviation="18" flood-color="#0b0807" flood-opacity="0.55"/>
</filter>
<filter id="grain" x="0" y="0" width="100%" height="100%">
<feTurbulence type="fractalNoise" baseFrequency="0.9" numOctaves="2" stitchTiles="stitch"/>
<feColorMatrix values="0 0 0 0 0.5 0 0 0 0 0.5 0 0 0 0 0.5 0 0 0 0.9 0"/>
</filter>
<clipPath id="tile"><rect x="100" y="100" width="824" height="824" rx="190"/></clipPath>
<clipPath id="nibShape">
<path d="M330 340 C330 296 362 266 406 266 H618 C662 266 694 296 694 340 C694 448 646 528 598 600 C560 656 530 724 512 806 C494 724 464 656 426 600 C378 528 330 448 330 340 Z"/>
</clipPath>
</defs>
<!-- tile -->
<g filter="url(#tileShadow)">
<rect x="100" y="100" width="824" height="824" rx="190" fill="url(#bg)"/>
</g>
<rect x="100" y="100" width="824" height="824" fill="url(#glow)" clip-path="url(#tile)"/>
<!-- the same fine grain as the app's background; also hides gradient banding -->
<rect x="100" y="100" width="824" height="824" filter="url(#grain)" opacity="0.07" clip-path="url(#tile)"/>
<rect x="101.5" y="101.5" width="821" height="821" rx="188.5" fill="none" stroke="url(#rim)" stroke-width="3"/>
<!-- nib -->
<g filter="url(#nibShadow)">
<path d="M330 340 C330 296 362 266 406 266 H618 C662 266 694 296 694 340 C694 448 646 528 598 600 C560 656 530 724 512 806 C494 724 464 656 426 600 C378 528 330 448 330 340 Z" fill="url(#nib)"/>
</g>
<g clip-path="url(#nibShape)">
<!-- right half a little darker, so the nib reads as two folded tines -->
<rect x="512" y="250" width="200" height="580" fill="#5a2a22" opacity="0.16"/>
<!-- collar where the nib meets the pen -->
<rect x="320" y="250" width="400" height="92" fill="#5a2a22" opacity="0.22"/>
<rect x="320" y="340" width="400" height="5" fill="#fff6f0" opacity="0.35"/>
<!-- slit and breather hole -->
<path d="M512 520 V800" stroke="#2a1a16" stroke-width="15" stroke-linecap="round"/>
<circle cx="512" cy="500" r="30" fill="#2a1a16"/>
<!-- shine on the left tine -->
<path d="M378 378 C382 444 408 506 446 562" stroke="#fffaf6" stroke-width="16" stroke-linecap="round" fill="none" opacity="0.5"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 663 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 139 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

+43
View File
@@ -0,0 +1,43 @@
// Timing of the vault-side work on a big synthetic vault: node scripts/bench.cjs [notes=10000]
// Run with the Electron binary as node so better-sqlite3 loads: ELECTRON_RUN_AS_NODE=1 electron scripts/bench.cjs
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const { Vault } = require('../electron/vault.cjs');
const { openDb } = require('../electron/db.cjs');
const links = require('../electron/links.cjs');
const tasks = require('../electron/tasks.cjs');
const N = Number(process.argv[2]) || 10000;
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ink-bench-'));
const words = 'alpha beta gamma delta project roadmap meeting notes idea review plan budget design draft summary weekly sync'.split(' ');
const rnd = (n) => Math.floor(Math.random() * n);
const sentence = () => Array.from({ length: 8 + rnd(10) }, () => words[rnd(words.length)]).join(' ');
const t0 = Date.now();
for (let i = 0; i < N; i++) {
const folder = path.join(dir, `Area ${i % 40}`, `Topic ${i % 400}`);
fs.mkdirSync(folder, { recursive: true });
let body = `# Note ${i}\n\n`;
for (let p = 0; p < 6; p++) body += sentence() + (rnd(3) === 0 ? ` [[Note ${rnd(N)}]]` : '') + '.\n\n';
body += `- [ ] task ${i} #work\n`;
fs.writeFileSync(path.join(folder, `Note ${i}.md`), `---\ntags: [t${i % 50}]\n---\n\n${body}`);
}
console.log(`generated ${N} notes in ${Date.now() - t0} ms`);
const time = async (label, fn) => { const s = process.hrtime.bigint(); const r = await fn(); console.log(label.padEnd(34), String(Number(process.hrtime.bigint() - s) / 1e6 | 0).padStart(7), 'ms'); return r; };
(async () => {
const v = new Vault(dir, { trash: async () => {} });
const pages = await time('vault.list (cold)', () => v.list());
await time('vault.list (warm cache)', () => v.list());
const db = openDb(path.join(dir, 'index.sqlite'));
await time('db.rebuild', async () => db.rebuild(pages));
await time('db.search "roadmap budget"', async () => db.search('roadmap budget'));
await time('links.graph', async () => links.graph(pages));
const target = pages.find((p) => p.title === 'Note 5').id;
await time('links.backlinks (one page)', async () => links.backlinks(target, pages));
await time('links.unlinked (one page)', async () => links.unlinked(target, pages));
await time('tasks.findTasks', async () => tasks.findTasks(pages));
await time('JSON size of page list (no body)', async () => JSON.stringify(pages.map(({ body, ...p }) => p)).length);
db.close();
fs.rmSync(dir, { recursive: true, force: true });
})();
+33
View File
@@ -0,0 +1,33 @@
// `bun run dev` runs this: Electron with --dev, restarted whenever a file in electron/ changes.
// (Vite hot-reloads the page by itself, but the main process only loads its code once.)
import { spawn } from 'node:child_process';
import { watch } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const root = path.join(path.dirname(fileURLToPath(import.meta.url)), '..');
const electron = path.join(root, 'node_modules', '.bin', 'electron');
let child = null;
let restarting = false;
let timer = null;
function start() {
child = spawn(electron, ['.', '--dev', ...process.argv.slice(2)], { cwd: root, stdio: 'inherit' });
child.on('exit', (code) => {
if (restarting) { restarting = false; start(); return; }
process.exit(code ?? 0); // the window was closed: stop dev mode, as before
});
}
watch(path.join(root, 'electron'), { recursive: true }, (_event, file) => {
if (!file || !/\.(c?js|json)$/.test(file)) return;
clearTimeout(timer);
timer = setTimeout(() => {
console.log(`[app] ${file} changed, restarting Electron`);
restarting = true;
child.kill('SIGTERM');
}, 250);
});
for (const sig of ['SIGINT', 'SIGTERM']) process.on(sig, () => { restarting = false; child?.kill(sig); });
start();
+40
View File
@@ -0,0 +1,40 @@
// Runs the end-to-end suites. No arguments: all of them, in parallel. Names: only those, e.g. `bun run e2e canvas tags`.
import { spawn } from 'node:child_process';
import { readdirSync } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const electron = path.join(root, 'node_modules/.bin/electron');
const want = process.argv.slice(2);
const all = readdirSync(path.join(root, 'test')).filter((f) => /^e2e.*\.test\.cjs$/.test(f)).sort();
const files = want.length ? all.filter((f) => want.some((w) => f === `e2e-${w}.test.cjs` || f === `e2e.test.cjs` && w === 'e2e')) : all;
if (!files.length) { console.error(`No suite matches: ${want.join(', ')}`); process.exit(2); }
// Timing-sensitive suites (they measure frame rate) run alone, after the rest, so the others can't slow them down.
const SERIAL = ['e2e-board-drag.test.cjs'];
const started = Date.now();
const run = (file) => new Promise((resolve) => {
const t0 = Date.now();
const child = spawn(electron, [path.join('test', file), '--ozone-platform=headless'], { cwd: root });
let out = '';
child.stdout.on('data', (d) => (out += d));
child.stderr.on('data', (d) => (out += d));
child.on('close', (code) => {
const lines = out.split('\n').filter((l) => /^(PASS|FAIL)|passed!|failed|harness/.test(l) && !/Fontconfig/.test(l));
const fails = lines.filter((l) => l.startsWith('FAIL') || /failed|harness/.test(l));
resolve({ file, code, secs: ((Date.now() - t0) / 1000).toFixed(0), fails, checks: lines.filter((l) => l.startsWith('PASS')).length });
});
});
const parallel = files.filter((f) => !SERIAL.includes(f));
const results = await Promise.all(parallel.map(run));
for (const f of files.filter((f) => SERIAL.includes(f))) results.push(await run(f));
for (const r of results) {
const ok = r.code === 0;
console.log(`${ok ? 'ok ' : 'FAIL'} ${r.file.padEnd(28)} ${String(r.checks).padStart(3)} checks ${r.secs}s`);
if (!ok) for (const l of r.fails) console.log(' ' + l);
}
console.log(`\n${results.filter((r) => r.code === 0).length}/${results.length} suites passed in ${((Date.now() - started) / 1000).toFixed(0)}s`);
process.exit(results.every((r) => r.code === 0) ? 0 : 1);
+16
View File
@@ -0,0 +1,16 @@
#!/bin/sh
# Render resources/icon.svg into every icon file the app and its installers use.
# Needs rsvg-convert (librsvg) and ImageMagick. The results are committed, so building doesn't need these.
set -e
cd "$(dirname "$0")/../resources"
mkdir -p icons
for s in 16 24 32 48 64 128 256 512 1024; do
rsvg-convert -w $s -h $s icon.svg -o icons/${s}x${s}.png
done
cp icons/1024x1024.png icon.png # electron-builder: macOS .icns is made from this
magick icons/16x16.png icons/24x24.png icons/32x32.png icons/48x48.png \
icons/64x64.png icons/128x128.png icons/256x256.png icon.ico # Windows
rm icons/1024x1024.png # Linux sizes stop at 512
cp icon.svg ../static/icon.svg # the welcome screen shows it
cp icons/64x64.png ../static/favicon.png
echo "icons written to resources/ and static/"
+88
View File
@@ -0,0 +1,88 @@
// How the app feels on a big vault. Not part of `bun run e2e` (it is slow): electron scripts/perf-large.cjs [notes=10000]
const { app, BrowserWindow } = require('electron');
const path = require('path');
const os = require('os');
const fs = require('fs');
const N = Number(process.argv.find((a) => /^\d+$/.test(a))) || 10000;
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ink-perf-'));
const vault = path.join(tmp, 'Vault');
const words = 'alpha beta gamma delta project roadmap meeting notes idea review plan budget design draft summary weekly sync'.split(' ');
const rnd = (n) => Math.floor(Math.random() * n);
const sentence = () => Array.from({ length: 8 + rnd(10) }, () => words[rnd(words.length)]).join(' ');
for (let i = 0; i < N; i++) {
const folder = path.join(vault, `Area ${i % 40}`);
fs.mkdirSync(folder, { recursive: true });
let body = `# Note ${i}\n\n`;
for (let p = 0; p < 6; p++) body += sentence() + (rnd(3) === 0 ? ` [[Note ${rnd(N)}]]` : '') + '.\n\n';
fs.writeFileSync(path.join(folder, `Note ${i}.md`), `---\ntags: [t${i % 50}]\n---\n\n${body}`);
}
for (let a = 0; a < 40; a++) fs.writeFileSync(path.join(vault, `Area ${a}.md`), `Area ${a}\n`);
// one very long page: headings, paragraphs, bullets, a table
fs.writeFileSync(path.join(vault, 'Long.md'), Array.from({ length: 1500 }, (_, i) => `## Section ${i}\n\n${sentence()} **bold** and a [[Note ${i}]] link. ${sentence()}\n\n- ${sentence()}\n- ${sentence()}\n`).join('\n'));
app.setPath('userData', tmp);
fs.writeFileSync(path.join(tmp, 'settings.json'), JSON.stringify({ vault, lastPage: 'Area 1', theme: 'dark', bounds: { width: 1300, height: 850 } }));
const started = Date.now();
require('../electron/main.cjs');
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
app.whenReady().then(async () => {
let win;
for (let i = 0; i < 200 && !(win = BrowserWindow.getAllWindows()[0]); i++) await sleep(50);
const wc = win.webContents;
if (wc.isLoading()) await new Promise((r) => wc.once('did-finish-load', r));
const js = (code) => wc.executeJavaScript(`(async () => { ${code} })()`);
const report = (label, ms, extra = '') => console.log(`${label.padEnd(40)} ${String(Math.round(ms)).padStart(6)} ms ${extra}`);
// frames the page could not draw: longest gap between animation frames over a period
const jank = (ms) => js(`let last = performance.now(), worst = 0; const end = last + ${ms}; await new Promise((res) => { const f = (t) => { worst = Math.max(worst, t - last); last = t; t < end ? requestAnimationFrame(f) : res(); }; requestAnimationFrame(f); }); return worst`);
const timeIn = (code) => js(`const t = performance.now(); ${code}; return performance.now() - t`);
try {
for (let i = 0; i < 300; i++) { if (await js(`return !!document.querySelector('.tree .row')`)) break; await sleep(100); }
report('launch to sidebar shown', Date.now() - started);
report('sidebar rows (folded)', 0, String(await js(`return document.querySelectorAll('.tree .row').length`)));
// open every top-level folder
const t0 = Date.now();
await js(`for (const r of document.querySelectorAll('.tree > .node > .row')) { r.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowRight', bubbles: true })); }`);
await sleep(200);
report('open all 40 folders', Date.now() - t0, 'rows now: ' + (await js(`return document.querySelectorAll('.tree .row').length`)));
report('worst frame gap after that (1s)', await jank(1000));
// open a page that has many backlinks / mentions
const o = Date.now();
await js(`document.querySelector('.tree .row[data-id="Area 2/Note 2"]')?.click()`);
for (let i = 0; i < 100; i++) { if ((await js(`return document.querySelector('.title')?.textContent`)) === 'Note 2') break; await sleep(20); }
report('open a page', Date.now() - o);
// a very long page
const L = Date.now();
await js(`document.querySelector('.tree .row[data-id="Long"]')?.click()`);
for (let i = 0; i < 300; i++) { if ((await js(`return document.querySelectorAll('.block').length`)) > 4000) break; await sleep(20); }
report('open a 4500-block page', Date.now() - L);
report('worst frame gap after that (1s)', await jank(1000));
report('scroll that page 20 steps', await js(`const sc = [...document.querySelectorAll('*')].find((e) => e.scrollHeight > e.clientHeight + 5000); if (!sc) return -1; const t = performance.now(); for (let i = 0; i < 20; i++) { sc.scrollTop += 1500; await new Promise((r) => requestAnimationFrame(r)); } return performance.now() - t`));
// search
const s = Date.now();
await js(`document.querySelector('.sidebar button[aria-label="Search"]').click()`);
await sleep(100);
await js(`const i = document.querySelector('.pal-top input'); i.value = 'roadmap budget'; i.dispatchEvent(new Event('input', { bubbles: true }))`);
for (let i = 0; i < 100; i++) { if (await js(`return document.querySelectorAll('.pal-list .pi').length > 3`)) break; await sleep(20); }
report('search shows results', Date.now() - s);
await js(`document.querySelector('.overlay')?.dispatchEvent(new MouseEvent('mousedown', { bubbles: true }))`);
await sleep(400);
// graph
const g = Date.now();
await js(`document.querySelector('.sidebar button[aria-label="Graph view"]').click()`);
for (let i = 0; i < 200; i++) { if (await js(`return !!document.querySelector('canvas, .graph svg, .gv')`)) break; await sleep(25); }
report('graph view appears', Date.now() - g);
report('worst frame gap while it settles (3s)', await jank(3000));
await js(`document.querySelector('.sidebar button[aria-label="Graph view"]').click()`);
await sleep(500);
// tasks list
const k = Date.now();
await js(`document.querySelector('.sidebar button[aria-label="Tasks"]').click()`);
for (let i = 0; i < 100; i++) { if (await js(`const p = document.querySelector('.tasks-panel'); return !!p && !/Loading/.test(p.textContent)`)) break; await sleep(20); }
report('tasks dialog', Date.now() - k);
} catch (err) {
console.log('harness', err.stack);
}
fs.rmSync(tmp, { recursive: true, force: true });
app.exit(0);
});
+18
View File
@@ -0,0 +1,18 @@
const { run } = require('node:test');
const { spec } = require('node:test/reporters');
const fs = require('fs');
const path = require('path');
const { app } = require('electron');
app.whenReady().then(async () => {
const dir = path.join(__dirname, '../test');
const files = fs.readdirSync(dir)
.filter(f => f.endsWith('.test.js'))
.map(f => path.join(dir, f));
const stream = run({ files });
stream.on('test:fail', () => { process.exitCode = 1; });
stream.compose(new spec()).pipe(process.stdout);
stream.on('end', () => app.quit());
});
+992 -81
View File
File diff suppressed because it is too large. Load diff
+14
View File
@@ -0,0 +1,14 @@
<!doctype html>
<html lang="en" data-theme="dark">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="color-scheme" content="dark light" />
<title>Ink</title>
<link rel="icon" type="image/png" href="%sveltekit.assets%/favicon.png" />
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="off">
<div style="display: contents">%sveltekit.body%</div>
</body>
</html>
+65
View File
@@ -0,0 +1,65 @@
// Keyboard and screen-reader behaviour shared by every dialog, so the dialogs themselves stay simple:
// a [role=dialog] gets aria-modal, takes focus when it opens, keeps Tab inside it, and gives focus back to what had it.
// (Menus and popovers are not dialogs; they handle their own keys.)
const FOCUSABLE = 'a[href],button:not([disabled]),input:not([disabled]),select:not([disabled]),textarea:not([disabled]),[tabindex]:not([tabindex="-1"])';
const visible = (el) => !el.closest('[inert],[hidden]') && el.getClientRects().length > 0;
const topDialog = () => {
const all = [...document.querySelectorAll('[role="dialog"]')].filter((d) => !d.closest('.closing'));
return all[all.length - 1] || null;
};
export function installDialogFocus() {
if (typeof document === 'undefined') return () => {};
const returnTo = new WeakMap();
const opened = (d) => {
if (d.dataset.a11y) return;
d.dataset.a11y = '1';
d.setAttribute('aria-modal', 'true');
returnTo.set(d, document.activeElement);
if (!d.contains(document.activeElement)) {
// an input in it (a search box) takes focus on its own a moment later; this is for the ones without
requestAnimationFrame(() => {
if (d.contains(document.activeElement)) return;
d.tabIndex = -1;
d.focus({ preventScroll: true });
});
}
};
const closed = (d) => {
const back = returnTo.get(d);
if (back && document.contains(back) && !topDialog()) requestAnimationFrame(() => back.focus?.({ preventScroll: true }));
};
const mo = new MutationObserver((records) => {
for (const r of records) {
for (const n of r.addedNodes) {
if (n.nodeType !== 1) continue;
if (n.matches?.('[role="dialog"]')) opened(n);
n.querySelectorAll?.('[role="dialog"]').forEach(opened);
}
for (const n of r.removedNodes) {
if (n.nodeType !== 1) continue;
if (n.matches?.('[role="dialog"]')) closed(n);
n.querySelectorAll?.('[role="dialog"]').forEach(closed);
}
}
});
mo.observe(document.body, { childList: true, subtree: true });
document.querySelectorAll('[role="dialog"]').forEach(opened);
const onkeydown = (e) => {
if (e.key !== 'Tab') return;
const d = topDialog();
if (!d) return;
const items = [...d.querySelectorAll(FOCUSABLE)].filter(visible);
if (!items.length) { e.preventDefault(); return; }
const first = items[0], last = items[items.length - 1];
if (!d.contains(document.activeElement)) { e.preventDefault(); first.focus(); }
else if (e.shiftKey && (document.activeElement === first || document.activeElement === d)) { e.preventDefault(); last.focus(); }
else if (!e.shiftKey && document.activeElement === last) { e.preventDefault(); first.focus(); }
};
document.addEventListener('keydown', onkeydown, true);
return () => { mo.disconnect(); document.removeEventListener('keydown', onkeydown, true); };
}
+12
View File
@@ -0,0 +1,12 @@
// In Electron, window.vault comes from electron/preload.cjs.
// In a plain browser (`npm run dev:web`) an in-memory mock stands in, so the UI can be worked on without Electron.
import { createMock } from './mock.js';
export const isDesktop = typeof window !== 'undefined' && !!window.vault;
export const api = isDesktop ? window.vault : createMock();
export const platform = isDesktop ? window.vault.platform : 'web';
export const errorText = (err) =>
String((err && err.message) || err || 'Something went wrong')
.replace(/^Error invoking remote method '[^']+': /, '')
.replace(/^Error: /, '');
+341
View File
@@ -0,0 +1,341 @@
// Canvases: ".canvas" files in the JSON Canvas format Obsidian uses (https://jsoncanvas.org).
// nodes: { id, type: 'text' | 'file' | 'link' | 'group', x, y, width, height, color?, ...
// text (text) | file, subpath? (file) | url (link) | label?, background? (group) }
// edges: { id, fromNode, fromSide?, toNode, toSide?, fromEnd?, toEnd?, label?, color? }
// Fields this app doesn't know are kept as they are. parse/serialize match electron/canvas.cjs.
export const isCanvasId = (id) => /\.canvas$/i.test(String(id || ''));
export function parseCanvas(text) {
const s = String(text || '').trim();
if (!s) return { nodes: [], edges: [], extra: {} };
const data = JSON.parse(s);
if (!data || typeof data !== 'object' || Array.isArray(data)) throw new Error('This is not a canvas file.');
const { nodes, edges, ...extra } = data;
return { nodes: Array.isArray(nodes) ? nodes : [], edges: Array.isArray(edges) ? edges : [], extra };
}
// Written the way Obsidian writes it: one node or edge per line, tabs.
export function serializeCanvas({ nodes = [], edges = [], extra = {} }) {
const list = (arr) => (arr.length ? '[\n' + arr.map((o) => '\t\t' + JSON.stringify(o)).join(',\n') + '\n\t]' : '[]');
const parts = [`\t"nodes":${list(nodes)}`, `\t"edges":${list(edges)}`];
for (const [k, v] of Object.entries(extra)) parts.push(`\t${JSON.stringify(k)}:${JSON.stringify(v)}`);
return '{\n' + parts.join(',\n') + '\n}';
}
// 16 hex digits, like Obsidian's ids.
export const newId = () => Array.from({ length: 16 }, () => '0123456789abcdef'[Math.floor(Math.random() * 16)]).join('');
// Obsidian's six preset colours ("1"-"6"); any "#rrggbb" is used as is.
export const PRESETS = { 1: '#e5534b', 2: '#e8883a', 3: '#d9b130', 4: '#44ad6e', 5: '#3aa6c4', 6: '#9a6de0' };
export const colorOf = (c) => (c ? PRESETS[c] || (/^#[0-9a-f]{3,8}$/i.test(c) ? c : null) : null);
export const SIZES = { text: [250, 60], note: [400, 400], file: [400, 300], link: [400, 220], group: [480, 320] };
export const MIN_W = 80, MIN_H = 40;
/* files on a canvas: paths are relative to the notes folder, with extension ("Ideas/Plan.md", "img/a.png") */
export const IMAGE_EXT = /\.(png|jpe?g|gif|webp|avif|svg|bmp)$/i;
export const fileKind = (file) => (/\.md$/i.test(file) ? 'note' : isCanvasId(file) ? 'canvas' : IMAGE_EXT.test(file) ? 'image' : 'other');
export const fileToId = (file) => String(file || '').replace(/\.md$/i, '');
export const idToFile = (id) => (isCanvasId(id) ? id : id + '.md');
export const fileName = (file) => String(file || '').slice(String(file || '').lastIndexOf('/') + 1).replace(/\.md$/i, '');
// `file` as seen from the folder `fromDir` ('' = the top), e.g. for an image next to some other page.
export function relPath(fromDir, file) {
const a = fromDir ? fromDir.split('/') : [];
const b = String(file).split('/');
let i = 0;
while (i < a.length && i < b.length - 1 && a[i] === b[i]) i++;
return [...a.slice(i).map(() => '..'), ...b.slice(i)].join('/');
}
export const dirOf = (id) => (id.includes('/') ? id.slice(0, id.lastIndexOf('/')) : '');
/* geometry (canvas coordinates) */
const NORMAL = { top: [0, -1], right: [1, 0], bottom: [0, 1], left: [-1, 0] };
export const SIDES = Object.keys(NORMAL);
export function sidePoint(n, side) {
switch (side) {
case 'top': return [n.x + n.width / 2, n.y];
case 'bottom': return [n.x + n.width / 2, n.y + n.height];
case 'left': return [n.x, n.y + n.height / 2];
default: return [n.x + n.width, n.y + n.height / 2];
}
}
// The side of `n` facing the point (x, y).
export function nearestSide(n, x, y) {
const dx = (x - (n.x + n.width / 2)) / (n.width / 2 || 1);
const dy = (y - (n.y + n.height / 2)) / (n.height / 2 || 1);
return Math.abs(dx) > Math.abs(dy) ? (dx > 0 ? 'right' : 'left') : dy > 0 ? 'bottom' : 'top';
}
// Sides for an edge that doesn't say which: the ones facing each other.
export function autoSides(a, b) {
return [nearestSide(a, b.x + b.width / 2, b.y + b.height / 2), nearestSide(b, a.x + a.width / 2, a.y + a.height / 2)];
}
export const SHAPES = ['curved', 'straight', 'orthogonal'];
export const shapeOf = (e) => (SHAPES.includes(e && e.style) ? e.style : 'curved');
// The line of an edge from (p1 on side s1) to (p2 on side s2, or a loose end when s2 is null), as a curve, a
// straight line or right-angled segments (`shape`).
// Returns { d (SVG path), mid ([x, y] for the label), end: { at, dir } for the arrow head, start: { at, dir } },
// plus `pts` (the corners) for right angles.
export function edgePath(p1, s1, p2, s2, shape = 'curved') {
const dist = Math.hypot(p2[0] - p1[0], p2[1] - p1[1]);
const n1 = NORMAL[s1] || [0, 0];
const n2 = s2 ? NORMAL[s2] : [(p1[0] - p2[0]) / (dist || 1), (p1[1] - p2[1]) / (dist || 1)];
if (shape === 'straight') {
const u = [(p2[0] - p1[0]) / (dist || 1), (p2[1] - p1[1]) / (dist || 1)];
return {
d: `M${p1[0]},${p1[1]} L${p2[0]},${p2[1]}`,
mid: [(p1[0] + p2[0]) / 2, (p1[1] + p2[1]) / 2],
end: { at: p2, dir: u },
start: { at: p1, dir: [0 - u[0], 0 - u[1]] }
};
}
if (shape === 'orthogonal') return orthogonal(p1, n1, p2, s2 ? n2 : null);
const k = Math.max(30, Math.min(220, dist * 0.45));
const c1 = [p1[0] + n1[0] * k, p1[1] + n1[1] * k];
const c2 = [p2[0] + n2[0] * k, p2[1] + n2[1] * k];
const f = (a, b, c, d) => 0.125 * a + 0.375 * b + 0.375 * c + 0.125 * d;
return {
d: `M${p1[0]},${p1[1]} C${c1[0]},${c1[1]} ${c2[0]},${c2[1]} ${p2[0]},${p2[1]}`,
mid: [f(p1[0], c1[0], c2[0], p2[0]), f(p1[1], c1[1], c2[1], p2[1])],
end: { at: p2, dir: [0 - n2[0], 0 - n2[1]] },
start: { at: p1, dir: [0 - n1[0], 0 - n1[1]] }
};
}
const STUB = 24; // how far a right-angled line leaves a card before it turns
const RADIUS = 10; // rounded corners
// Right angles: leave p1 along n1, arrive at p2 along n2 (null = a loose end), turning on the way.
function orthogonal(p1, n1, p2, n2) {
const horiz = (n) => Math.abs(n[0]) > Math.abs(n[1]);
const h1 = horiz(n1);
// a loose end arrives from whichever direction is further
const h2 = n2 ? horiz(n2) : Math.abs(p2[0] - p1[0]) >= Math.abs(p2[1] - p1[1]);
const a1 = [p1[0] + n1[0] * STUB, p1[1] + n1[1] * STUB];
const a2 = n2 ? [p2[0] + n2[0] * STUB, p2[1] + n2[1] * STUB] : p2;
let via;
if (h1 && h2) { const mx = (a1[0] + a2[0]) / 2; via = [[mx, a1[1]], [mx, a2[1]]]; }
else if (!h1 && !h2) { const my = (a1[1] + a2[1]) / 2; via = [[a1[0], my], [a2[0], my]]; }
else if (h1) via = [[a2[0], a1[1]]];
else via = [[a1[0], a2[1]]];
const raw = [p1, a1, ...via, ...(n2 ? [a2] : []), p2];
// no repeated points, and no point in the middle of a straight run
const pts = [];
for (const p of raw) {
const last = pts[pts.length - 1];
if (last && Math.abs(last[0] - p[0]) < 0.01 && Math.abs(last[1] - p[1]) < 0.01) continue;
const prev = pts[pts.length - 2];
if (prev && last && ((prev[0] === last[0] && last[0] === p[0]) || (prev[1] === last[1] && last[1] === p[1]))) pts.pop();
pts.push(p);
}
const last = pts[pts.length - 1], before = pts[pts.length - 2] || p1;
const len = Math.hypot(last[0] - before[0], last[1] - before[1]) || 1;
const end = n2 ? [0 - n2[0], 0 - n2[1]] : [(last[0] - before[0]) / len, (last[1] - before[1]) / len];
return { d: roundedPath(pts, RADIUS), mid: middleOf(pts), end: { at: p2, dir: end }, start: { at: p1, dir: [0 - n1[0], 0 - n1[1]] }, pts };
}
// A polyline with its corners rounded off.
function roundedPath(pts, r) {
let d = `M${pts[0][0]},${pts[0][1]}`;
for (let i = 1; i < pts.length - 1; i++) {
const [px, py] = pts[i - 1], [x, y] = pts[i], [nx, ny] = pts[i + 1];
const l1 = Math.hypot(x - px, y - py), l2 = Math.hypot(nx - x, ny - y);
const k = Math.min(r, l1 / 2, l2 / 2);
if (k < 0.5) { d += ` L${x},${y}`; continue; }
d += ` L${x + ((px - x) / l1) * k},${y + ((py - y) / l1) * k} Q${x},${y} ${x + ((nx - x) / l2) * k},${y + ((ny - y) / l2) * k}`;
}
const e = pts[pts.length - 1];
return d + ` L${e[0]},${e[1]}`;
}
// The point half way along a polyline.
function middleOf(pts) {
const lens = pts.slice(1).map((p, i) => Math.hypot(p[0] - pts[i][0], p[1] - pts[i][1]));
let half = lens.reduce((a, b) => a + b, 0) / 2;
for (let i = 0; i < lens.length; i++) {
if (half <= lens[i] || i === lens.length - 1) {
const t = lens[i] ? half / lens[i] : 0;
return [pts[i][0] + (pts[i + 1][0] - pts[i][0]) * t, pts[i][1] + (pts[i + 1][1] - pts[i][1]) * t];
}
half -= lens[i];
}
return pts[0];
}
// An arrow head at `at` pointing along `dir`, as SVG polygon points.
export function arrowHead(at, dir, size = 11) {
const [x, y] = at, [dx, dy] = dir;
const bx = x - dx * size, by = y - dy * size;
const w = size * 0.55;
return `${x},${y} ${bx - dy * w},${by + dx * w} ${bx + dy * w},${by - dx * w}`;
}
export const inside = (n, g) => n !== g && n.x >= g.x && n.y >= g.y && n.x + n.width <= g.x + g.width && n.y + n.height <= g.y + g.height;
export const overlaps = (n, r) => n.x < r.x + r.width && n.x + n.width > r.x && n.y < r.y + r.height && n.y + n.height > r.y;
export function bounds(nodes) {
if (!nodes.length) return null;
let x1 = Infinity, y1 = Infinity, x2 = -Infinity, y2 = -Infinity;
for (const n of nodes) {
x1 = Math.min(x1, n.x); y1 = Math.min(y1, n.y);
x2 = Math.max(x2, n.x + n.width); y2 = Math.max(y2, n.y + n.height);
}
return { x: x1, y: y1, width: x2 - x1, height: y2 - y1 };
}
/* lining up and spreading out a selection: each returns { id -> { x, y } } for the nodes that would change */
export const ALIGNS = ['left', 'centerX', 'right', 'top', 'centerY', 'bottom'];
export function alignNodes(nodes, how) {
const b = bounds(nodes);
const out = new Map();
if (!b || nodes.length < 2 || !ALIGNS.includes(how)) return out;
for (const n of nodes) {
const x = how === 'left' ? b.x : how === 'right' ? b.x + b.width - n.width : how === 'centerX' ? Math.round(b.x + (b.width - n.width) / 2) : n.x;
const y = how === 'top' ? b.y : how === 'bottom' ? b.y + b.height - n.height : how === 'centerY' ? Math.round(b.y + (b.height - n.height) / 2) : n.y;
if (x !== n.x || y !== n.y) out.set(n.id, { x, y });
}
return out;
}
// Equal gaps between the nodes along `axis` ('x' or 'y'); the outermost two stay where they are.
export function distributeNodes(nodes, axis) {
const out = new Map();
if (nodes.length < 3) return out;
const [pos, size] = axis === 'y' ? ['y', 'height'] : ['x', 'width'];
const sorted = [...nodes].sort((a, c) => a[pos] + a[size] / 2 - (c[pos] + c[size] / 2));
const first = sorted[0], last = sorted[sorted.length - 1];
const span = last[pos] + last[size] - first[pos];
const gap = (span - sorted.reduce((t, n) => t + n[size], 0)) / (sorted.length - 1);
let at = first[pos];
for (const n of sorted) {
const p = Math.round(at);
if (p !== n[pos]) out.set(n.id, axis === 'y' ? { x: n.x, y: p } : { x: p, y: n.y });
at += n[size] + gap;
}
return out;
}
/* alignment guides while dragging
`use` says which lines of the dragged box count: fractions along the axis (0 = left/top, .5 = centre, 1 = right/bottom). */
export const ALL_LINES = { x: [0, 0.5, 1], y: [0, 0.5, 1] };
const EPS = 0.5;
// How far to nudge `box` so a line of it sits on a line of another node, if one is within `tol`:
// { dx, dy, sx, sy } (sx / sy: whether that axis found something to snap to).
export function guideSnap(box, others, tol, use = ALL_LINES) {
const best = (axis) => {
const [pos, size] = axis === 'x' ? ['x', 'width'] : ['y', 'height'];
let d = null;
for (const f of use[axis]) {
const v = box[pos] + box[size] * f;
for (const n of others) for (const g of ALL_LINES[axis]) {
const diff = n[pos] + n[size] * g - v;
if (Math.abs(diff) <= tol && (d === null || Math.abs(diff) < Math.abs(d))) d = diff;
}
}
return d;
};
const dx = best('x'), dy = best('y');
return { dx: dx ?? 0, dy: dy ?? 0, sx: dx !== null, sy: dy !== null };
}
// The lines to draw for `box` where it is: { axis, at, from, to } for each line of it that sits on a line of another node.
export function guideLines(box, others, use = ALL_LINES) {
const out = [];
for (const axis of ['x', 'y']) {
const [pos, size, cross, crossSize] = axis === 'x' ? ['x', 'width', 'y', 'height'] : ['y', 'height', 'x', 'width'];
for (const f of use[axis]) {
const v = box[pos] + box[size] * f;
let from = box[cross], to = box[cross] + box[crossSize], hit = false;
for (const n of others) {
if (!ALL_LINES[axis].some((g) => Math.abs(n[pos] + n[size] * g - v) <= EPS)) continue;
hit = true;
from = Math.min(from, n[cross]);
to = Math.max(to, n[cross] + n[crossSize]);
}
if (hit && !out.some((l) => l.axis === axis && Math.abs(l.at - v) <= EPS)) out.push({ axis, at: v, from, to });
}
}
return out;
}
/* card <-> note */
const HEADING = /^#{1,3}\s+(.*\S)\s*#*\s*$/;
// A page name for a card's text: its first line with the Markdown taken off.
export function titleFromText(text) {
for (const raw of String(text || '').split(/\r?\n/)) {
const t = raw
.replace(/^\s*(#{1,6}\s+|>\s*|[-*+]\s+(\[[ xX]\]\s+)?|\d+[.)]\s+)+/, '')
.replace(/!?\[\[([^\]|]*\|)?([^\]]*)\]\]/g, '$2') // [[Page|label]] -> label
.replace(/!?\[([^\]]*)\]\([^)]*\)/g, '$1') // [label](url) -> label
.replace(/[*_~`]+/g, '')
.replace(/\s+/g, ' ')
.trim();
if (!t || /^[-=_*]{3,}$/.test(t)) continue;
return t.length > 60 ? t.slice(0, 60).replace(/\s+\S*$/, '') || t.slice(0, 60) : t;
}
return 'Untitled';
}
// The page text for a card: all of it, except a heading on the first line, which becomes the page's name.
export function bodyAfterTitle(text) {
const lines = String(text || '').split(/\r?\n/);
const first = lines.findIndex((l) => l.trim());
if (first >= 0 && HEADING.test(lines[first])) lines.splice(0, first + 1);
const body = lines.join('\n').replace(/^\s*\n/, '').trimEnd();
return body ? body + '\n' : '';
}
/* groups: membership is where things sit (a node fully inside a group is in it), so a group can also fold away */
export const COLLAPSED_H = 44;
const area = (n) => n.width * n.height;
// Every node hidden by a collapsed group (the outermost one that holds it): Map id -> group.
export function hiddenMap(nodes) {
const out = new Map();
for (const g of nodes) {
if (g.type !== 'group' || !g.collapsed) continue;
for (const x of nodes) {
if (x === g || !inside(x, g)) continue;
const cur = out.get(x.id);
if (!cur || area(g) > area(cur)) out.set(x.id, g);
}
}
return out;
}
// A node as drawn: a collapsed group is a strip as wide as it was.
export const displayBox = (n) => (n.type === 'group' && n.collapsed ? { x: n.x, y: n.y, width: n.width, height: COLLAPSED_H } : n);
// The group a card was dropped into: the smallest open group holding the middle of it (null if it's in none).
export function groupFor(card, nodes) {
const cx = card.x + card.width / 2, cy = card.y + card.height / 2;
let best = null;
for (const g of nodes) {
if (g.type !== 'group' || g === card || g.collapsed || g.id === card.id) continue;
if (cx < g.x || cx > g.x + g.width || cy < g.y || cy > g.y + g.height) continue;
if (!best || area(g) < area(best)) best = g;
}
return best;
}
// What it takes for `card` to be fully inside the group it was dropped on: null if it already is,
// { x, y } to move the card in, or { group: {x, y, width, height} } when the card is too big and the group grows.
export function fitInto(card, group, pad = 20) {
if (inside(card, group)) return null;
if (card.width + pad * 2 <= group.width && card.height + pad * 2 <= group.height) {
const clamp = (v, lo, hi) => Math.min(Math.max(v, lo), hi);
return {
x: clamp(card.x, group.x + pad, group.x + group.width - card.width - pad),
y: clamp(card.y, group.y + pad, group.y + group.height - card.height - pad)
};
}
const x1 = Math.min(group.x, card.x - pad), y1 = Math.min(group.y, card.y - pad);
const x2 = Math.max(group.x + group.width, card.x + card.width + pad), y2 = Math.max(group.y + group.height, card.y + card.height + pad);
return { group: { x: x1, y: y1, width: x2 - x1, height: y2 - y1 } };
}
export const MIN_ZOOM = 0.1, MAX_ZOOM = 3;
export const clampZoom = (z) => Math.min(MAX_ZOOM, Math.max(MIN_ZOOM, z));
// The view { x, y, z } that shows `box` in a viewport of w × h (never zooming in past 100%).
export function fitView(box, w, h, pad = 60) {
if (!box) return { x: w / 2, y: h / 2, z: 1 };
const z = clampZoom(Math.min(1, (w - pad * 2) / (box.width || 1), (h - pad * 2) / (box.height || 1)));
return { x: w / 2 - (box.x + box.width / 2) * z, y: h / 2 - (box.y + box.height / 2) * z, z };
}
+106
View File
@@ -0,0 +1,106 @@
// Turns what a canvas shows into a picture or a document: the live canvas is copied (cards, notes, arrows exactly as
// they look), laid out at its own size with the app's styles, fonts and images inlined, so it stands on its own.
// html: a full page for the main process to print to PNG or PDF; svg: a vector file (the page inside a foreignObject)
import { bounds } from './canvas.js';
const PAD = 48; // canvas units of space around the items
const MAX_SIDE = 8000; // longest side in pixels, so a huge canvas still renders
const XHTML = 'http://www.w3.org/1999/xhtml';
const OWN = new Set(['--gs', '--gx', '--gy', '--iz']);
function dataUrl(url) {
return fetch(url)
.then((r) => { if (!r.ok) throw new Error(String(r.status)); return r.blob(); })
.then((b) => new Promise((resolve, reject) => {
const f = new FileReader();
f.onload = () => resolve(f.result);
f.onerror = reject;
f.readAsDataURL(b);
}));
}
// A stylesheet rule's text with the files it names (fonts) written into it.
async function inlineUrls(text, base) {
const found = [...text.matchAll(/url\((["']?)(?!data:)([^"')]+)\1\)/g)];
for (const m of found) {
try { text = text.replace(m[0], `url("${await dataUrl(new URL(m[2], base).href)}")`); } catch { /* left as it was */ }
}
return text;
}
async function pageCss() {
let css = '';
const names = new Set();
const noteNames = (rule) => {
if (rule.style) for (const n of rule.style) if (n.startsWith('--')) names.add(n);
if (rule.cssRules) for (const r of rule.cssRules) noteNames(r);
};
for (const sheet of document.styleSheets) {
let rules;
try { rules = sheet.cssRules; } catch { continue; }
const base = sheet.href || document.baseURI;
for (const r of rules) {
noteNames(r);
css += (r instanceof CSSFontFaceRule ? await inlineUrls(r.cssText, base) : r.cssText) + '\n';
}
}
return { css, names };
}
const clear = (c) => !c || c === 'transparent' || /rgba?\([^)]*,\s*0\)$/.test(c);
function backdrop(el) {
for (let e = el; e; e = e.parentElement) {
const c = getComputedStyle(e).backgroundColor;
if (!clear(c)) return c;
}
return '#ffffff';
}
// nodes: the canvas items to show (all of them); scale: pixels per canvas unit (cut down for very large canvases)
export async function buildCanvasExport(view, nodes, { scale = 1 } = {}) {
const b = bounds(nodes);
if (!b) return null;
const s = Math.max(0.05, Math.min(scale, MAX_SIDE / Math.max(b.width + PAD * 2, b.height + PAD * 2)));
const W = Math.ceil((b.width + PAD * 2) * s), H = Math.ceil((b.height + PAD * 2) * s);
const world = view.querySelector('.cv-world').cloneNode(true);
world.style.transform = `translate(${(PAD - b.x) * s}px,${(PAD - b.y) * s}px) scale(${s})`;
for (const e of world.querySelectorAll('.cv-dot,.cv-resize,.cv-box,.cv-guide')) e.remove();
for (const e of world.querySelectorAll('.sel,.target,.editing,.found,.found-cur')) e.classList.remove('sel', 'target', 'editing', 'found', 'found-cur');
await Promise.all([...world.querySelectorAll('img')].map(async (img) => {
const src = img.getAttribute('src');
if (!src || src.startsWith('data:')) return;
try { img.setAttribute('src', await dataUrl(src)); } catch { /* shows as the broken image it was */ }
}));
const { css, names } = await pageCss();
const live = getComputedStyle(view);
const vars = [...names].filter((n) => !OWN.has(n)).map((n) => [n, live.getPropertyValue(n).trim()]).filter(([, v]) => v).map(([n, v]) => `${n}:${v}`);
const bg = backdrop(view);
const wrap = document.createElement('div');
wrap.className = 'cv';
wrap.setAttribute('style', `${vars.join(';')};--iz:${1 / s};position:relative;width:${W}px;height:${H}px;flex:none;overflow:hidden;background:${bg};` +
`font-family:${live.fontFamily};font-size:${live.fontSize};color:${live.color}`);
wrap.append(world);
const page = `<!doctype html><html><head><meta charset="utf-8"><style>${css}</style>` +
`<style>html,body{margin:0;padding:0;width:${W}px;height:${H}px;overflow:hidden;background:${bg}}@page{size:${W}px ${H}px;margin:0}</style>` +
`</head><body>${wrap.outerHTML}</body></html>`;
// The same, as one SVG file: the styles and the copied canvas sit in a foreignObject.
const style = document.createElement('style');
style.textContent = css;
const inner = document.createElementNS(XHTML, 'div');
inner.append(style, wrap.cloneNode(true));
const fo = document.createElementNS('http://www.w3.org/2000/svg', 'foreignObject');
fo.setAttribute('width', String(W));
fo.setAttribute('height', String(H));
fo.append(inner);
const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
svg.setAttribute('xmlns', 'http://www.w3.org/2000/svg');
svg.setAttribute('width', String(W));
svg.setAttribute('height', String(H));
svg.setAttribute('viewBox', `0 0 ${W} ${H}`);
svg.append(fo);
return { html: page, svg: new XMLSerializer().serializeToString(svg), width: W, height: H, scale: s };
}
+71
View File
@@ -0,0 +1,71 @@
<script>
import Icon from './Icon.svelte';
import { store, iconOf } from '$lib/state.svelte.js';
import { api, errorText } from '$lib/api.js';
import { toast } from '$lib/toast.svelte.js';
let { id } = $props();
let items = $state([]);
let loose = $state([]); // plain-text mentions of this page's title that are not links
let openLoose = $state(false);
let busy = $state(null);
// Reload when the page changes or anything in the folder does (store.pages is replaced on every refresh).
$effect(() => {
const target = id;
store.pages;
let stale = false;
api.backlinks(target).then((r) => { if (!stale) items = r || []; }, () => {});
api.unlinked?.(target).then((r) => { if (!stale) loose = r || []; }, () => {});
return () => { stale = true; };
});
async function link(p) {
if (busy) return;
busy = p.id;
try {
await store.flush();
await api.linkMention(p.id, id);
loose = loose.filter((x) => x.id !== p.id);
await store.refresh();
api.backlinks(id).then((r) => (items = r || []), () => {});
} catch (err) {
toast(errorText(err), 'x');
} finally {
busy = null;
}
}
</script>
{#if items.length}
<section class="backlinks" aria-label="Linked from">
<h2><Icon name="enter" />Linked from {items.length} {items.length === 1 ? 'page' : 'pages'}</h2>
{#each items as p (p.id)}
<button class="bl" onclick={() => store.openPage(p.id)}>
<span class="row-ico"><Icon name={iconOf(p)} /></span>
<span class="bl-t"><b>{p.title}</b>{#if p.snip}<small>{p.snip}</small>{/if}</span>
</button>
{/each}
</section>
{/if}
{#if loose.length}
<section class="backlinks unlinked" aria-label="Unlinked mentions">
<h2>
<button class="sec-toggle" aria-expanded={openLoose} onclick={() => (openLoose = !openLoose)}>
<span class="chev" class:open={openLoose}><Icon name="chev" /></span>Mentioned without a link<em>{loose.length}</em>
</button>
</h2>
{#if openLoose}
{#each loose as p (p.id)}
<div class="bl bl-loose">
<button class="bl-open" onclick={() => store.openPage(p.id)}>
<span class="row-ico"><Icon name={iconOf(p)} /></span>
<span class="bl-t"><b>{p.title}</b>{#if p.snip}<small>{p.snip}</small>{/if}</span>
</button>
<button class="btn" disabled={busy === p.id} title="Turn the first mention into a link" onclick={() => link(p)}><Icon name="link" />Link it</button>
</div>
{/each}
{/if}
</section>
{/if}
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+35
View File
@@ -0,0 +1,35 @@
<script>
import Icon from './Icon.svelte';
import { store } from '$lib/state.svelte.js';
let closing = $state(false);
let url = $state('');
let busy = $state(false);
async function go() {
if (!url.trim() || busy) return;
busy = true;
await store.clipWeb(url);
busy = false;
close();
}
function close() {
closing = true;
setTimeout(() => { store.clipOpen = false; closing = false; }, 150);
}
const focusOnMount = (el) => el.focus();
</script>
<svelte:window onkeydown={(e) => e.key === 'Escape' && close()} />
<div class="overlay" class:closing role="presentation" onmousedown={(e) => e.target === e.currentTarget && close()}>
<div class="panel" role="dialog" aria-label="Clip a web page">
<div class="pal-top">
<Icon name="globe" />
<input bind:value={url} placeholder="Paste a web address to save as a note" autocomplete="off" spellcheck="false" use:focusOnMount
onkeydown={(e) => { if (e.key === 'Enter') { e.preventDefault(); go(); } }} />
<button class="btn primary" disabled={busy || !url.trim()} onclick={go}>{busy ? 'Clipping…' : 'Clip'}</button>
</div>
<div class="pal-foot"><span>Saves the page’s main text as Markdown, with its address in the page’s front matter. Public web pages only.</span></div>
</div>
</div>
+192
View File
@@ -0,0 +1,192 @@
<script>
import { isCanvasId } from '$lib/canvas.js';
import Icon from './Icon.svelte';
import { store } from '$lib/state.svelte.js';
import { api, isDesktop } from '$lib/api.js';
import { toast } from '$lib/toast.svelte.js';
import { TAG_COLORS } from '$lib/tagColors.js';
let el = $state();
let armed = $state(false);
let subOpen = $state(false);
let subTimer;
let armTimer;
let renamingTag = $state(false);
let flip = $state(false);
let pos = $state({ x: store.menu.x, y: store.menu.y });
let page = $derived(store.menu.id ? store.pages.find((p) => p.id === store.menu.id) : null);
let tag = $derived(store.menu.tag || null);
let kids = $derived(page ? store.pages.filter((p) => p.id.startsWith(page.id + '/')).length : 0);
// Keep the menu inside the window.
$effect(() => {
if (!el) return;
const r = el.getBoundingClientRect();
pos = {
x: Math.max(8, Math.min(store.menu.x, innerWidth - r.width - 8)),
y: Math.max(8, Math.min(store.menu.y, innerHeight - r.height - 8))
};
el.querySelector('.mi')?.focus();
});
// Open the submenu on the other side when there is no room to its right.
function placeSub(node) {
const r = node.parentElement.getBoundingClientRect();
flip = r.right + node.offsetWidth + 8 > innerWidth;
// Near the bottom of the window the side menu moves up so that it stays whole.
const over = r.top - 6 + node.offsetHeight + 8 - innerHeight;
node.style.top = over > 0 ? `${-6 - over}px` : '';
}
const close = () => (store.menu = null);
function run(kind, arg) {
const id = page && page.id;
const forTag = tag; // read before the menu closes
if (kind === 'delete') {
if (!armed) {
armed = true;
clearTimeout(armTimer);
armTimer = setTimeout(() => (armed = false), 3000);
return;
}
close();
store.removePage(id);
return;
}
close();
if (kind === 'open') store.openPage(id);
if (kind === 'split') store.openSplit(id);
if (kind === 'tagDelete') store.deleteTag(forTag);
if (kind === 'tagManage') store.tagManagerOpen = true;
if (kind === 'new') store.templatesOpen = { parent: null };
if (kind === 'child') store.templatesOpen = { parent: id };
if (kind === 'childBoard') store.newBoard(id);
if (kind === 'newBoard') store.newBoard(null);
if (kind === 'newDatabase') store.newDatabase(null);
if (kind === 'importCsv') store.pickCsv(null);
if (kind === 'childDatabase') store.newDatabase(id);
if (kind === 'newCanvas') store.newCanvas(null);
if (kind === 'childCanvas') store.newCanvas(id);
if (kind === 'rename') store.renaming = id;
if (kind === 'duplicate') store.duplicatePage(id);
if (kind === 'top') store.movePage(id, null);
if (kind === 'pin') store.togglePin(id);
if (kind === 'trash') store.trashOpen = true;
if (kind === 'tagColor') store.setTagColor(forTag, arg);
if (kind === 'reveal') api.reveal(id);
if (kind === 'path') {
api.filePath(id).then((p) =>
navigator.clipboard.writeText(p).then(() => toast('File path copied', 'copy'), () => toast(p, 'file'))
);
}
}
// Hovering opens the submenu at once; leaving waits a moment so a diagonal mouse path does not close it.
function subEnter() { clearTimeout(subTimer); subOpen = true; }
function subLeave() { clearTimeout(subTimer); subTimer = setTimeout(() => (subOpen = false), 150); }
function finishTagRename(input, save) {
const name = input.value.trim();
close();
if (save && name && name !== tag) store.renameTag(tag, name);
}
const focusSelect = (input) => { input.focus(); input.select(); };
function onpointerdown(e) {
if (el && !el.contains(e.target)) close();
}
function onkeydown(e) {
if (e.key === 'Escape') {
e.preventDefault();
if (subOpen) subOpen = false; // the innermost layer first
else close();
return;
}
if (e.key !== 'ArrowDown' && e.key !== 'ArrowUp') return;
e.preventDefault();
const items = [...el.querySelectorAll('.mi')];
const i = items.indexOf(document.activeElement);
items[(i + (e.key === 'ArrowDown' ? 1 : -1) + items.length) % items.length].focus();
}
</script>
<svelte:window {onpointerdown} {onkeydown} onblur={close} onresize={close} />
<div class="pop menu ctx" bind:this={el} role="menu" tabindex="-1" style="left:{pos.x}px;top:{pos.y}px" oncontextmenu={(e) => e.preventDefault()}>
{#if tag}
<div class="tag-swatches" role="group" aria-label="Tag colour">
{#each TAG_COLORS as [name, hex] (name)}
<button class="cv-swatch" class:on={store.tagColors[tag] === name} style="--sw:{hex}" data-color={name} aria-label={name} title={name[0].toUpperCase() + name.slice(1)} onclick={() => run('tagColor', name)}></button>
{/each}
<button class="cv-swatch" class:on={!store.tagColors[tag]} data-color="none" aria-label="No colour" title="No colour" onclick={() => run('tagColor', null)}></button>
</div>
<div class="sepline"></div>
{#if renamingTag}
<input class="ctx-input" value={tag} aria-label="New tag name" use:focusSelect
onkeydown={(e) => {
e.stopPropagation();
if (e.key === 'Enter') { e.preventDefault(); finishTagRename(e.currentTarget, true); }
if (e.key === 'Escape') { e.preventDefault(); close(); }
}} />
{:else}
<button class="mi" role="menuitem" onclick={() => (renamingTag = true)}><Icon name="pen" />Rename tag</button>
<button class="mi" role="menuitem" onclick={() => run('tagDelete')}><Icon name="trash" />Remove tag from all pages</button>
<button class="mi" role="menuitem" onclick={() => run('tagManage')}><Icon name="hash" />Manage tags…</button>
{/if}
{:else if page && store.menu.add}
<button class="mi" role="menuitem" onclick={() => run('child')}><Icon name="file" />Page</button>
<button class="mi" role="menuitem" onclick={() => run('childBoard')}><Icon name="board" />Board</button>
<button class="mi" role="menuitem" onclick={() => run('childDatabase')}><Icon name="table" />Database</button>
<button class="mi" role="menuitem" onclick={() => run('childCanvas')}><Icon name="canvas" />Canvas</button>
{:else if page}
<button class="mi" role="menuitem" onclick={() => run('open')}><Icon name="file" />Open</button>
{#if page.exists && page.id !== store.currentId}
<button class="mi" role="menuitem" onclick={() => run('split')}><Icon name="columns" />Open in split</button>
{/if}
{#if !isCanvasId(page.id)}
<div class="mi-sub" role="none" onpointerenter={subEnter} onpointerleave={subLeave}>
<button class="mi" role="menuitem" aria-haspopup="menu" aria-expanded={subOpen} onclick={() => (subOpen = !subOpen)}
onkeydown={(e) => { if (e.key === 'ArrowRight') { e.preventDefault(); e.stopPropagation(); subOpen = true; queueMicrotask(() => e.currentTarget.parentElement.querySelector('.sub .mi')?.focus()); } }}>
<Icon name="plus" /><span class="mi-label">Add inside</span><Icon name="chevRight" />
</button>
{#if subOpen}
<div class="pop menu sub" class:left={flip} role="menu" use:placeSub
onkeydown={(e) => { if (e.key === 'ArrowLeft' || e.key === 'Escape') { e.preventDefault(); e.stopPropagation(); subOpen = false; e.currentTarget.parentElement.querySelector('.mi')?.focus(); } }}>
<button class="mi" role="menuitem" onclick={() => run('child')}><Icon name="file" />Page</button>
<button class="mi" role="menuitem" onclick={() => run('childBoard')}><Icon name="board" />Board</button>
<button class="mi" role="menuitem" onclick={() => run('childDatabase')}><Icon name="table" />Database</button>
<button class="mi" role="menuitem" onclick={() => run('childCanvas')}><Icon name="canvas" />Canvas</button>
</div>
{/if}
</div>
{/if}
<button class="mi" role="menuitem" onclick={() => run('pin')}>
<Icon name="star" />{store.isPinned(page.id) ? 'Remove from favorites' : 'Add to favorites'}
</button>
<button class="mi" role="menuitem" onclick={() => run('rename')}><Icon name="pen" />Rename</button>
{#if page.exists}
<button class="mi" role="menuitem" onclick={() => run('duplicate')}><Icon name="layers" />Duplicate</button>
{/if}
{#if page.parent}
<button class="mi" role="menuitem" onclick={() => run('top')}><Icon name="folder" />Move to top level</button>
{/if}
<div class="sepline"></div>
{#if isDesktop}
<button class="mi" role="menuitem" onclick={() => run('reveal')}><Icon name="folder" />Show in folder</button>
{/if}
<button class="mi" role="menuitem" onclick={() => run('path')}><Icon name="copy" />Copy file path</button>
<div class="sepline"></div>
<button class="mi danger" class:armed role="menuitem" onclick={() => run('delete')}>
<Icon name="trash" /><span>{armed ? (kids ? `Click again to trash ${kids + 1} pages` : 'Click again to move to trash') : 'Delete page'}</span>
</button>
{:else}
<button class="mi" role="menuitem" onclick={() => run('new')}><Icon name="plus" />New page</button>
<button class="mi" role="menuitem" onclick={() => run('newBoard')}><Icon name="board" />New board</button>
<button class="mi" role="menuitem" onclick={() => run('newDatabase')}><Icon name="table" />New database</button>
<button class="mi" role="menuitem" onclick={() => run('importCsv')}><Icon name="download" />Import CSV as database</button>
<button class="mi" role="menuitem" onclick={() => run('newCanvas')}><Icon name="canvas" />New canvas</button>
<button class="mi" role="menuitem" onclick={() => run('trash')}><Icon name="trash" />Open trash</button>
{/if}
</div>
+126
View File
@@ -0,0 +1,126 @@
<script>
import { onMount } from 'svelte';
import Icon from './Icon.svelte';
import { COVERS } from '$lib/icons.js';
// onupload(): choose a picture from the computer; onurl(address) -> Promise: take one from the web (rejects with
// a message); hasImage: the cover is one now, so it can be taken away
let { anchor, current, onpick, onclose, onupload, onurl, hasImage = false } = $props();
let linking = $state(false);
let address = $state('');
let busy = $state(false);
let problem = $state('');
async function useAddress() {
const a = address.trim();
if (!a || busy) return;
busy = true;
problem = '';
try {
await onurl(a);
onclose();
} catch (err) {
problem = String((err && err.message) || err).replace(/^Error invoking remote method '[^']+': /, '').replace(/^Error: /, '');
}
busy = false;
}
const focusOnMount = (el) => el.focus();
// a pasted address goes straight in
function onPaste(e) {
const t = (e.clipboardData && e.clipboardData.getData('text')) || '';
if (/^https?:\/\/\S+$/i.test(t.trim())) setTimeout(useAddress, 0);
}
let el = $state();
let pos = $state({ x: 0, y: 0 });
function place() {
if (!anchor || !el) return;
const a = anchor.getBoundingClientRect(), r = el.getBoundingClientRect();
let y = a.bottom + 8;
if (y + r.height > innerHeight - 8) y = Math.max(8, a.top - r.height - 8);
let x = a.left;
if (x + r.width > innerWidth - 8) x = Math.max(8, innerWidth - r.width - 8);
pos = { x, y };
}
onMount(() => {
place();
window.addEventListener('resize', place);
return () => window.removeEventListener('resize', place);
});
</script>
<svelte:window onmousedown={(e) => el && !el.contains(e.target) && anchor && !anchor.contains(e.target) && onclose()} />
<div class="pop cover-picker" bind:this={el} style="left:{pos.x}px; top:{pos.y}px;" role="dialog" aria-label="Pick cover">
{#each Object.entries(COVERS) as [key, colors]}
<button
class="c-opt"
class:on={key === current}
style="--c1:{colors[0]};--c2:{colors[1]}"
onclick={() => { onpick(key); onclose(); }}
aria-label={key}
></button>
{/each}
{#if onupload}
<button class="c-up" onclick={() => { onclose(); onupload(); }}><Icon name="image" />Upload an image…</button>
{/if}
{#if onurl}
{#if linking}
<div class="c-link">
<input
class="c-url"
type="url"
placeholder="Paste an image address"
aria-label="Image address"
spellcheck="false"
bind:value={address}
use:focusOnMount
disabled={busy}
onpaste={onPaste}
onkeydown={(e) => { if (e.key === 'Enter') { e.preventDefault(); useAddress(); } if (e.key === 'Escape') { e.stopPropagation(); linking = false; problem = ''; } }}
/>
<button class="c-go" disabled={busy || !address.trim()} onclick={useAddress}>{busy ? '…' : 'Add'}</button>
{#if problem}<p class="c-problem">{problem}</p>{/if}
</div>
{:else}
<button class="c-up" onclick={() => (linking = true)}><Icon name="link" />From a link…</button>
{/if}
{/if}
{#if hasImage}
<button class="c-up" onclick={() => { onpick(''); onclose(); }}><Icon name="x" />Remove the image</button>
{/if}
</div>
<style>
.cover-picker { padding: 8px; display: flex; gap: 8px; flex-wrap: wrap; width: 250px; }
.c-up {
flex: 1 0 100%; display: flex; align-items: center; gap: 8px; height: 30px; padding: 0 8px; border-radius: 6px;
color: var(--ink-2); font-size: 12.5px; text-align: left; transition: background 0.15s, color 0.15s;
}
.c-up:hover { background: var(--hover); color: var(--ink); }
.c-up :global(.i) { width: 14px; height: 14px; }
.c-up:first-of-type { margin-top: 2px; border-top: 1px solid var(--line); padding-top: 6px; height: 36px; }
.c-link { flex: 1 0 100%; display: flex; flex-wrap: wrap; gap: 6px; align-items: center; }
.c-url {
flex: 1; min-width: 0; height: 30px; border: 0; outline: 0; border-radius: 6px; padding: 0 9px;
background: var(--hover); color: var(--ink); font: 12.5px var(--f-ui);
}
.c-url:focus { box-shadow: inset 0 0 0 1px var(--blush-line); }
.c-go {
height: 30px; padding: 0 12px; border-radius: 6px; background: var(--blush); color: var(--blush-ink);
font-size: 12.5px; font-weight: 550; transition: filter 0.15s, opacity 0.15s;
}
.c-go:hover { filter: brightness(1.06); }
.c-go:disabled { opacity: 0.5; pointer-events: none; }
.c-problem { flex: 1 0 100%; margin: 0; color: var(--danger); font-size: 12px; line-height: 1.4; }
.c-opt {
width: 24px; height: 24px; border-radius: 50%;
background: linear-gradient(135deg, var(--c1), var(--c2));
border: 2px solid transparent;
transition: transform 0.2s var(--ease), border-color 0.2s;
}
.c-opt:hover { transform: scale(1.1); }
.c-opt.on { border-color: var(--ink); transform: scale(1.1); }
</style>
+122
View File
@@ -0,0 +1,122 @@
<script>
// Import a CSV file: choose what each column becomes, then create the records (into a database, or as a new one).
import { untrack } from 'svelte';
import Icon from './Icon.svelte';
import SelectMenu from './SelectMenu.svelte';
import { store } from '$lib/state.svelte.js';
import { parseCsv, autoMap, planImport } from '$lib/csv.js';
import { TYPES, typeInfo } from '$lib/database.js';
const job = untrack(() => store.csvImport);
const parsed = parseCsv(job.text);
const [headers = [], ...rows] = parsed.rows;
let schema = $state(null); // the target database's properties, once read
let cols = $state([]);
let name = $state(job.name);
let busy = $state(false);
let closing = $state(false);
// Match columns to the database's existing properties (or to nothing, for a new database).
(async () => {
let existing = [];
if (job.target) {
try { existing = (await store.readSchema(job.target))?.properties || []; } catch { existing = []; }
}
schema = existing;
cols = autoMap(headers, rows, existing);
})();
const target = $derived(job.target ? store.pages.find((p) => p.id === job.target) : null);
const plan = $derived(schema ? planImport({ rows, columns: $state.snapshot(cols), existing: schema }) : null);
const sample = (i) => rows.slice(0, 3).map((r) => String(r[i] ?? '')).filter(Boolean).join(' · ');
function choices(c) {
return [
{ value: 'title', label: 'Name' },
...(schema || []).map((p) => ({ value: 'p:' + p.key, label: p.name })),
{ value: 'new', label: 'New property' },
{ value: 'skip', label: 'Don’t import' }
];
}
const valueOf = (c) => (c.target === 'existing' ? 'p:' + c.key : c.target);
function choose(c, v) {
// only one column can be the name
if (v === 'title') for (const o of cols) if (o.target === 'title' && o !== c) o.target = 'skip';
if (v.startsWith('p:')) {
const p = schema.find((x) => x.key === v.slice(2));
// one column per property
for (const o of cols) if (o.target === 'existing' && o.key === p.key && o !== c) o.target = 'skip';
Object.assign(c, { target: 'existing', key: p.key, type: p.type });
} else {
c.target = v;
if (v === 'new' && !c.name) c.name = c.header || `Column ${c.index + 1}`;
}
}
const noName = $derived(cols.length > 0 && !cols.some((c) => c.target === 'title'));
const used = $derived(cols.filter((c) => c.target !== 'skip').length);
function close() {
closing = true;
setTimeout(() => { store.csvImport = null; }, 150);
}
async function go() {
if (!plan || busy) return;
busy = true;
const id = await store.importCsv(plan, { target: job.target, name: name.trim() || 'Imported' });
busy = false;
if (id) close();
}
</script>
<svelte:window onkeydown={(e) => e.key === 'Escape' && close()} />
<div class="overlay" class:closing role="presentation" onmousedown={(e) => e.target === e.currentTarget && close()}>
<div class="panel csv" role="dialog" aria-label="Import CSV">
<div class="trash-top">
<Icon name="download" />
<h2>Import CSV</h2>
<button class="btn-icon" aria-label="Close" onclick={close}><Icon name="x" /></button>
</div>
<div class="csv-body">
<p class="csv-lead">
{rows.length} row{rows.length === 1 ? '' : 's'}, {headers.length} column{headers.length === 1 ? '' : 's'}
{#if target}into <b>{target.title}</b>{:else}as a new database{/if}.
</p>
{#if !target}
<label class="csv-name">Database name <input class="db-find" bind:value={name} placeholder="Imported" /></label>
{/if}
{#if !schema}
<p class="csv-lead">Reading…</p>
{:else}
<div class="csv-cols">
{#each cols as c (c.index)}
<div class="csv-col" class:skip={c.target === 'skip'}>
<div class="csv-src">
<b>{c.header || `Column ${c.index + 1}`}</b>
<small>{sample(c.index) || 'empty'}</small>
</div>
<Icon name="chev" />
<div class="csv-dst">
<SelectMenu options={choices(c)} value={valueOf(c)} label="Import {c.header || 'column'} as" onchange={(v) => choose(c, v)} />
{#if c.target === 'new'}
<input class="db-in csv-pname" bind:value={c.name} aria-label="Property name" placeholder="Property name" />
<SelectMenu options={TYPES.map((t) => ({ value: t.type, label: t.label }))} value={c.type} label="Type" onchange={(t) => (c.type = t)} />
{:else if c.target === 'existing'}
<span class="csv-type"><Icon name={typeInfo(c.type).icon} />{typeInfo(c.type).label}</span>
{/if}
</div>
</div>
{/each}
</div>
{/if}
</div>
<div class="trash-foot">
<span>{#if noName}No column is the name, so records will be called “Untitled”.{:else}{used} column{used === 1 ? '' : 's'} imported.{/if}</span>
<span class="csv-actions">
<button class="btn" onclick={close}>Cancel</button>
<button class="btn primary" disabled={!plan || busy || !rows.length} onclick={go}>{busy ? 'Importing…' : `Import ${rows.length} record${rows.length === 1 ? '' : 's'}`}</button>
</span>
</div>
</div>
</div>
+68
View File
@@ -0,0 +1,68 @@
<script>
import Icon from './Icon.svelte';
import { store } from '$lib/state.svelte.js';
let closing = $state(false);
const start = store.currentDay ? new Date(store.currentDay + 'T12:00:00') : new Date();
let year = $state(start.getFullYear());
let month = $state(start.getMonth());
const iso = (y, m, d) => `${y}-${String(m + 1).padStart(2, '0')}-${String(d).padStart(2, '0')}`;
const now = new Date();
const today = iso(now.getFullYear(), now.getMonth(), now.getDate());
const mondayFirst = $derived(store.prefs.weekStart !== 'sunday');
const locale = $derived(store.prefs.locale || undefined);
let title = $derived(new Date(year, month, 1).toLocaleDateString(locale, { month: 'long', year: 'numeric' }));
let heads = $derived(Array.from({ length: 7 }, (_, i) => new Date(2024, 0, (mondayFirst ? 1 : 7) + i).toLocaleDateString(locale, { weekday: 'short' }).slice(0, 2)));
let cells = $derived.by(() => {
const first = new Date(year, month, 1);
const lead = (first.getDay() + (mondayFirst ? 6 : 0)) % 7;
const count = new Date(year, month + 1, 0).getDate();
const out = [];
for (let i = 0; i < lead; i++) out.push(null);
for (let d = 1; d <= count; d++) out.push(iso(year, month, d));
return out;
});
const have = $derived(store.dailyDays);
function go(delta) {
const d = new Date(year, month + delta, 1);
year = d.getFullYear();
month = d.getMonth();
}
function pick(day) {
close();
setTimeout(() => store.openDaily(new Date(day + 'T12:00:00')), 120);
}
function close() {
closing = true;
setTimeout(() => { store.calendarOpen = false; closing = false; }, 150);
}
</script>
<svelte:window onkeydown={(e) => e.key === 'Escape' && close()} />
<div class="overlay" class:closing role="presentation" onmousedown={(e) => e.target === e.currentTarget && close()}>
<div class="panel cal-panel" role="dialog" aria-label="Daily notes calendar">
<div class="cal-top">
<button class="btn-icon" aria-label="Previous month" onclick={() => go(-1)}><Icon name="chevLeft" /></button>
<b>{title}</b>
<button class="btn-icon" aria-label="Next month" onclick={() => go(1)}><Icon name="chevRight" /></button>
</div>
<div class="cal-grid">
{#each heads as h}<span class="cal-h">{h}</span>{/each}
{#each cells as day, i (i)}
{#if day}
<button class="cal-d" class:today={day === today} class:has={have.has(day)} class:cur={day === store.currentDay} onclick={() => pick(day)} title={have.has(day) ? 'Open this day' : 'Create this day’s note'}>
{Number(day.slice(8))}
</button>
{:else}<span></span>{/if}
{/each}
</div>
<div class="cal-foot">
<span><i class="cal-dot"></i> has a note</span>
<button class="btn" onclick={() => pick(today)}>Today</button>
</div>
</div>
</div>
File diff suppressed because it is too large. Load diff
+34
View File
@@ -0,0 +1,34 @@
<script>
import { onMount } from 'svelte';
import { createEditor } from '$lib/editor.js';
import { store } from '$lib/state.svelte.js';
import { editorHooks } from '$lib/editorHooks.js';
let { blocks, onexittop } = $props();
let host = $state();
let ed = $state.raw(null);
onMount(() => {
const instance = createEditor(host, {
blocks,
animate: true,
onChange: (b) => store.edit(b),
onExitTop: () => onexittop?.(),
...editorHooks()
});
ed = instance;
store.editor = instance;
return () => {
instance.destroy();
if (store.editor === instance) store.editor = null;
};
});
// Links turn dim or bright as pages come and go.
$effect(() => {
store.pages;
ed?.refreshLinks();
});
</script>
<div bind:this={host}></div>
+85
View File
@@ -0,0 +1,85 @@
<script>
import Icon from './Icon.svelte';
import { store } from '$lib/state.svelte.js';
import { api, errorText } from '$lib/api.js';
import { toast } from '$lib/toast.svelte.js';
let closing = $state(false);
let format = $derived(store.exportFormat || 'md');
let content = $derived(format === 'md' || format === 'html' ? store.exportText(format) : null);
let fileName = $derived(`${store.doc?.title || 'Untitled'}.${format === 'md' ? 'md' : format === 'html' ? 'html' : 'pdf'}`);
function close() {
closing = true;
setTimeout(() => { store.exportFormat = null; closing = false; }, 150);
}
function copy() {
if (!content) return;
navigator.clipboard.writeText(content).then(
() => toast(format === 'md' ? 'Markdown copied' : 'HTML copied', 'copy'),
() => toast('Copying is blocked here. Select the text instead.', 'x')
);
}
async function save() {
try {
let where = null;
if (format === 'pdf') {
// Just directly save the PDF through main process
// Generate the exact HTML export string
const htmlDoc = store.exportText('html');
// Render it into PDF offscreen
where = await api.savePdf(fileName, store.doc?.title, htmlDoc);
} else if (format === 'print') {
api.print();
close();
return;
} else {
where = await api.saveExport(fileName, content);
}
if (where) { toast('Exported', 'save'); close(); }
} catch (err) {
toast('Could not export: ' + errorText(err), 'x');
}
}
</script>
<svelte:window onkeydown={(e) => e.key === 'Escape' && close()} />
<div class="overlay" class:closing role="presentation" onmousedown={(e) => e.target === e.currentTarget && close()}>
<div class="panel" role="dialog" aria-label="Export page">
<div class="exp-top">
<div class="seg {format}" style="width: auto">
<span class="seg-pill"></span>
<button class:on={format === 'md'} onclick={() => (store.exportFormat = 'md')}>Markdown</button>
<button class:on={format === 'html'} onclick={() => (store.exportFormat = 'html')}>HTML</button>
<button class:on={format === 'pdf'} onclick={() => (store.exportFormat = 'pdf')}>PDF</button>
</div>
{#if format !== 'print'}
<span class="exp-name">{fileName}</span>
{/if}
<button class="btn-icon" aria-label="Close" onclick={close}><Icon name="x" /></button>
</div>
{#key format}
{#if format === 'pdf'}
<div class="exp-out swap" style="display: flex; align-items: center; justify-content: center; color: var(--ink-3);">
The PDF will be generated exactly as this page currently looks.
</div>
{:else}
<pre class="exp-out swap">{content}</pre>
{/if}
{/key}
<div class="exp-foot">
{#if content}
<button class="btn" onclick={copy}><Icon name="copy" />Copy</button>
{:else}
<div></div>
{/if}
<button class="btn primary" onclick={save}>
<Icon name={format === 'print' ? 'export' : 'save'} />
{format === 'print' ? 'Print…' : 'Save as…'}
</button>
</div>
</div>
</div>
+161
View File
@@ -0,0 +1,161 @@
<script>
// Find in page (Ctrl+F). Matches are painted with the CSS Custom Highlight API,
// so the editable text itself is never touched.
import { onMount, tick } from 'svelte';
import Icon from './Icon.svelte';
import { store } from '$lib/state.svelte.js';
let input = $state();
let q = $state('');
let count = $state(0);
let index = $state(0);
let ranges = [];
let timer = 0;
const hl = typeof CSS !== 'undefined' && CSS.highlights ? CSS.highlights : null;
const scroller = () => document.querySelector('.main .scroller');
// Every match as a Range. Each editable area is searched as one string, so a match may cross bold/italic.
function collect(query) {
const out = [];
const needle = query.toLowerCase();
if (!needle) return out;
for (const area of document.querySelectorAll('.main .page .title, .main .blocks .c, .main .blocks .c2, .main .blocks .tcell, .main .blocks .ecell, .main .board .kh-title, .main .board .kc-text')) {
const nodes = [];
let text = '';
const w = document.createTreeWalker(area, NodeFilter.SHOW_TEXT);
for (let n; (n = w.nextNode()); ) { nodes.push([n, text.length]); text += n.data; }
const hay = text.toLowerCase();
for (let at = hay.indexOf(needle); at >= 0; at = hay.indexOf(needle, at + needle.length)) {
const r = document.createRange();
const place = (pos, end) => {
for (let i = nodes.length - 1; i >= 0; i--) {
const [n, start] = nodes[i];
if (pos > start || (pos === start && !end)) {
end ? r.setEnd(n, Math.min(pos - start, n.length)) : r.setStart(n, pos - start);
return;
}
}
end ? r.setEnd(nodes[0][0], 0) : r.setStart(nodes[0][0], 0);
};
place(at, false);
place(at + needle.length, true);
out.push(r);
}
}
return out;
}
function paint() {
if (!hl) return;
hl.set('find', new Highlight(...ranges));
if (ranges[index]) hl.set('find-current', new Highlight(ranges[index]));
else hl.delete('find-current');
}
function reveal() {
const r = ranges[index];
if (!r) return;
const el = r.startContainer.parentElement;
// A match inside a closed toggle: open it first.
const tg = el && el.closest('.tg:not(.open)');
if (tg) tg.querySelector('.tg-chev')?.click();
const sc = scroller();
if (!sc) return;
requestAnimationFrame(() => {
const box = r.getBoundingClientRect(), view = sc.getBoundingClientRect();
if (box.top < view.top + 40 || box.bottom > view.bottom - 40) {
sc.scrollBy({ top: box.top - view.top - view.height / 3, behavior: 'smooth' });
}
});
}
function run(keepPlace = false) {
const before = ranges[index];
ranges = collect(q);
count = ranges.length;
if (!keepPlace || !before) index = 0;
else {
// Stay on the same spot after an edit: first match at or after where we were.
const i = ranges.findIndex((r) => r.compareBoundaryPoints(Range.START_TO_START, before) >= 0);
index = i < 0 ? 0 : i;
}
paint();
if (!keepPlace) reveal();
}
function step(dir) {
if (!count) return;
index = (index + dir + count) % count;
paint();
reveal();
}
function close(selectMatch) {
const r = ranges[index];
hl?.delete('find');
hl?.delete('find-current');
store.findOpen = 0;
if (selectMatch && r && r.startContainer.isConnected) {
const area = r.startContainer.parentElement?.closest('[contenteditable="true"]');
area?.focus();
const s = getSelection();
s.removeAllRanges();
s.addRange(r);
}
}
function onkeydown(e) {
if (e.key === 'Enter') { e.preventDefault(); step(e.shiftKey ? -1 : 1); }
if (e.key === 'Escape') { e.preventDefault(); e.stopPropagation(); close(true); }
}
// Ctrl+F again (store.findOpen bumps): back to the input with the text selected.
$effect(() => {
store.findOpen;
tick().then(() => { input?.focus(); input?.select(); });
});
// New page: search it from the top.
$effect(() => {
store.doc?.version;
clearTimeout(timer);
timer = setTimeout(() => run(false), 120);
});
onMount(() => {
// Typing in the page moves matches around: re-search shortly after.
const onEdit = (e) => {
if (e.target === input) return;
clearTimeout(timer);
timer = setTimeout(() => run(true), 150);
};
document.addEventListener('input', onEdit, true);
return () => {
document.removeEventListener('input', onEdit, true);
clearTimeout(timer);
hl?.delete('find');
hl?.delete('find-current');
};
});
</script>
<div class="findbar pop" role="search">
<Icon name="search" />
<input
bind:this={input}
bind:value={q}
placeholder="Find in page"
aria-label="Find in page"
spellcheck="false"
oninput={() => run(false)}
{onkeydown}
/>
<span class="find-n">{q ? (count ? `${index + 1} of ${count}` : 'No results') : ''}</span>
<button class="btn-icon" aria-label="Previous match" title="Previous (Shift+Enter)" disabled={!count} onclick={() => step(-1)}>
<Icon name="chev" class="up" />
</button>
<button class="btn-icon" aria-label="Next match" title="Next (Enter)" disabled={!count} onclick={() => step(1)}>
<Icon name="chev" class="down" />
</button>
<button class="btn-icon" aria-label="Close" title="Close (Esc)" onclick={() => close(false)}><Icon name="x" /></button>
</div>
+139
View File
@@ -0,0 +1,139 @@
<script>
// A text field for a page path with a list of existing pages under it, as you type: for choosing a folder.
import Icon from './Icon.svelte';
import { store, iconOf } from '$lib/state.svelte.js';
import { cleanFolder, autoFolder } from '$lib/daily.js';
// value: the chosen path ('' = automatic); onchange(path or '') is called when the choice is made
let { value = '', onchange, label = 'Folder' } = $props();
let input = $state();
let open = $state(false);
let text = $state(value);
let active = $state(0);
let at = $state({ left: 0, top: 0, bottom: null, width: 260, max: 300 });
$effect(() => { if (!open) text = value; });
const auto = $derived(autoFolder(store.pages));
const pages = $derived(store.pages.filter((p) => p.exists && !/\.canvas$/i.test(p.id)));
const q = $derived(text.trim().toLowerCase());
// best matches first: names starting with it, then names containing it, then paths containing it
const found = $derived.by(() => {
if (!q) return pages.slice().sort((a, b) => a.id.localeCompare(b.id)).slice(0, 40);
const rank = (p) => {
const t = p.title.toLowerCase();
return t.startsWith(q) ? 0 : t.includes(q) ? 1 : p.id.toLowerCase().includes(q) ? 2 : 3;
};
return pages.map((p) => [rank(p), p]).filter(([r]) => r < 3).sort((a, b) => a[0] - b[0] || a[1].id.localeCompare(b[1].id)).map(([, p]) => p).slice(0, 40);
});
const typed = $derived(cleanFolder(text));
const isNew = $derived(!!typed && !pages.some((p) => p.id.toLowerCase() === typed.toLowerCase()));
// rows: the automatic choice, matching pages, and "use what I typed" for a folder that doesn't exist yet
const rows = $derived([
...(q ? [] : [{ kind: 'auto' }]),
...found.map((p) => ({ kind: 'page', p })),
...(isNew ? [{ kind: 'new', id: typed }] : [])
]);
function show() {
const r = input.getBoundingClientRect();
const width = Math.max(r.width, 280);
// below the field if there's room (or more room than above), otherwise above it
const below = innerHeight - r.bottom - 14, above = r.top - 14;
const up = below < 240 && above > below;
const left = Math.max(8, Math.min(r.right - width, innerWidth - width - 8));
at = up
? { left, top: null, bottom: innerHeight - r.top + 6, width, max: Math.min(300, above) }
: { left, top: r.bottom + 6, bottom: null, width, max: Math.min(300, below) };
active = 0;
open = true;
}
function choose(row) {
open = false;
if (!row) return;
const next = row.kind === 'auto' ? '' : row.kind === 'page' ? row.p.id : row.id;
text = next;
if (next !== value) onchange?.(next);
input?.blur();
}
function commitTyped() {
open = false;
const next = typed;
if (next !== value) { text = next; onchange?.(next); }
else text = value;
}
function onkeydown(e) {
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
e.preventDefault();
if (!open) return show();
const n = rows.length || 1;
active = (active + (e.key === 'ArrowDown' ? 1 : -1) + n) % n;
document.querySelector('.fp-list .mi.act')?.scrollIntoView({ block: 'nearest' });
} else if (e.key === 'Enter') {
e.preventDefault();
// a highlighted row if the list was used; otherwise exactly what was typed
if (open && rows[active] && !(text.trim() && rows[active].kind === 'auto')) choose(rows[active]);
else { commitTyped(); input.blur(); }
} else if (e.key === 'Escape' && open) {
e.preventDefault();
e.stopPropagation(); // the dialog stays
open = false;
text = value;
}
}
const mark = (s) => {
const i = q ? s.toLowerCase().indexOf(q) : -1;
return i < 0 ? [s, '', ''] : [s.slice(0, i), s.slice(i, i + q.length), s.slice(i + q.length)];
};
</script>
<svelte:window onresize={() => (open = false)} />
<div class="fp">
<Icon name="folder" />
<input
bind:this={input}
bind:value={text}
class="fp-input"
role="combobox"
aria-label={label}
aria-expanded={open}
aria-autocomplete="list"
placeholder={auto}
spellcheck="false"
autocomplete="off"
onfocus={(e) => { show(); e.currentTarget.select(); }}
onclick={(e) => { if (!open) show(); e.currentTarget.select(); }}
oninput={() => { open = true; active = 0; }}
onblur={() => { if (open) commitTyped(); }}
{onkeydown}
/>
<button class="fp-chev" tabindex="-1" aria-label="Show folders" onmousedown={(e) => { e.preventDefault(); open ? (open = false) : (input.focus(), show()); }}>
<Icon name="chev" />
</button>
</div>
{#if open}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="pop fp-list" role="listbox" style="left:{at.left}px;{at.top !== null ? `top:${at.top}px;` : `bottom:${at.bottom}px;`}width:{at.width}px;max-height:{at.max}px" class:up={at.top === null} onmousedown={(e) => e.preventDefault()}>
{#each rows as row, i (row.kind + (row.p ? row.p.id : row.id || ''))}
{#if row.kind === 'auto'}
<button class="mi" class:act={i === active} role="option" aria-selected={!value} onmousemove={() => (active = i)} onclick={() => choose(row)}>
<Icon name="sparkle" /><span class="mi-label">Find automatically</span><em>{auto}</em>{#if !value}<Icon name="tick" />{/if}
</button>
{:else if row.kind === 'page'}
{@const [a, m, b] = mark(row.p.title)}
<button class="mi" class:act={i === active} role="option" aria-selected={value === row.p.id} onmousemove={() => (active = i)} onclick={() => choose(row)}>
<Icon name={iconOf(row.p)} /><span class="mi-label">{a}{#if m}<mark>{m}</mark>{/if}{b}</span>{#if row.p.parent}<em>{row.p.parent}</em>{/if}{#if value === row.p.id}<Icon name="tick" />{/if}
</button>
{:else}
<button class="mi" class:act={i === active} role="option" aria-selected="false" onmousemove={() => (active = i)} onclick={() => choose(row)}>
<Icon name="plus" /><span class="mi-label">Use “{row.id}”</span><em>new folder</em>
</button>
{/if}
{:else}
<p class="fp-none">No pages yet.</p>
{/each}
</div>
{/if}
+23 -11
View File
@@ -5,6 +5,7 @@
import Icon from './Icon.svelte';
import WindowControls from './WindowControls.svelte';
import { store } from '$lib/state.svelte.js';
import { nodeColor } from '$lib/graphColors.js';
import { api, errorText } from '$lib/api.js';
import { toast } from '$lib/toast.svelte.js';
import { createSimulation, neighbourhood } from '$lib/graph.js';
@@ -49,6 +50,18 @@
const ids = new Set(nodes.map((n) => n.id));
let edges = data.edges.filter(([a, b]) => ids.has(a) && ids.has(b)).map((e) => [...e, 'link']);
if (g.folders) for (const n of nodes) if (n.parent && ids.has(n.parent)) edges.push([n.id, n.parent, 'tree']);
if (g.tags) {
// each tag becomes a node its pages hang from
const tagNodes = new Map();
for (const n of nodes) {
if (n.ghost) continue;
for (const t of n.tags || []) {
if (!tagNodes.has(t)) tagNodes.set(t, { id: '#' + t, title: '#' + t, tags: [], ghost: true, tag: t });
edges.push([n.id, '#' + t, 'tree']);
}
}
nodes = nodes.concat([...tagNodes.values()]);
}
if (g.local && cur && ids.has(cur)) {
const keep = neighbourhood(cur, edges, g.depth || 1);
nodes = nodes.filter((n) => keep.has(n.id));
@@ -62,12 +75,14 @@
sim.params.repel = (store.graph.repel || 420) * g.spacing;
sim.params.linkDistance = (store.graph.linkDistance || 96) * g.spacing;
sim.setData(nodes, edges);
// a big graph cools faster (about 120 ticks to rest instead of 300)
sim.alphaDecay = 1 - Math.pow(0.002, 1 / (nodes.length > 3000 ? 120 : 300));
const kind = new Map(edges.map(([a, b, k]) => [a + '\u0000' + b, k]));
for (const l of sim.links) l.kind = kind.get(l.source.id + '\u0000' + l.target.id) || 'link';
counts = { nodes: nodes.filter((n) => !n.ghost).length, links: sim.links.filter((l) => l.kind === 'link').length };
if (!fitted) {
// A head start so the first frame isn't a tangle; then frame it.
sim.tick(Math.min(220, Math.max(60, 120000 / Math.max(1, nodes.length))));
sim.tickFor(Math.min(220, Math.max(60, 120000 / Math.max(1, nodes.length))), 350);
fitted = fit(false);
}
sim.reheat(0.5);
@@ -86,17 +101,12 @@
};
}
$effect(() => { store.theme; readColors(); draw(); });
// colouring a tag while the graph is open
$effect(() => { store.tagColors; redraw(); });
const colorForNode = (n) => {
if (n.tags && n.tags.length > 0) {
// Pick a consistent color based on the first tag's name
let hash = 0;
for (let i = 0; i < n.tags[0].length; i++) hash = (hash << 5) - hash + n.tags[0].charCodeAt(i);
return colors.tags[Math.abs(hash) % colors.tags.length];
}
return colors.ink3; // Default
};
// A tag with a colour (see the sidebar's tag menu) gives its pages that colour here; the rest are plain.
const colorForNode = (n) => nodeColor(n.tags, store.tagColors, colors.ink3);
const radius = (n) => (n.ghost ? 3.5 : 3.6 + Math.sqrt(n.degree) * 1.7);
@@ -185,7 +195,7 @@
/* animation: run the simulation while it's warm or something is being dragged */
function frame() {
raf = 0;
if (sim.running) sim.tick(sim.nodes.length > 1500 ? 1 : 2);
if (sim.running) sim.tickFor(sim.nodes.length > 1500 ? 1 : 2, 14);
draw();
if (sim.running || gesture?.kind === 'drag') start();
}
@@ -279,6 +289,7 @@
}
}
function open(n) {
if (n.tag) { store.filterTag = n.tag; store.closeGraph(); return; }
if (n.ghost) store.openLink(n.title);
else store.openPage(n.id);
}
@@ -375,6 +386,7 @@
{/if}
<label class="gp-row"><span>Notes without links</span><button class="tgl" class:on={store.graph.orphans} role="switch" aria-checked={store.graph.orphans} aria-label="Notes without links" onclick={() => set({ orphans: !store.graph.orphans })}><span></span></button></label>
<label class="gp-row"><span>Links to missing pages</span><button class="tgl" class:on={store.graph.ghosts} role="switch" aria-checked={store.graph.ghosts} aria-label="Links to missing pages" onclick={() => set({ ghosts: !store.graph.ghosts })}><span></span></button></label>
<label class="gp-row"><span>Tags as nodes</span><button class="tgl" class:on={store.graph.tags} role="switch" aria-checked={!!store.graph.tags} aria-label="Tags as nodes" onclick={() => set({ tags: !store.graph.tags })}><span></span></button></label>
<label class="gp-row"><span>Page hierarchy</span><button class="tgl" class:on={store.graph.folders} role="switch" aria-checked={store.graph.folders} aria-label="Page hierarchy" onclick={() => set({ folders: !store.graph.folders })}><span></span></button></label>
<div class="gp-row">
<span>Spacing</span>
+85
View File
@@ -0,0 +1,85 @@
<script>
import { onMount } from 'svelte';
import Icon from './Icon.svelte';
import { store } from '$lib/state.svelte.js';
import { api, errorText } from '$lib/api.js';
import { toast } from '$lib/toast.svelte.js';
import { isCanvasId, parseCanvas, fileKind } from '$lib/canvas.js';
let items = $state(null);
let closing = $state(false);
let pageId = store.historyOpen;
const canvas = isCanvasId(pageId);
let about = $state({}); // canvas versions: time -> what was on it
async function load() {
try { items = (await api.listHistory(pageId)) || []; } catch (err) { items = []; toast(errorText(err), 'x'); }
}
// What a canvas held at that time, since a bare date says little about a picture.
function describe(text) {
const { nodes, edges } = parseCanvas(text);
const count = (f) => nodes.filter(f).length;
const parts = [
[count((n) => n.type === 'text'), 'card'],
[count((n) => n.type === 'file' && fileKind(n.file) === 'note'), 'note'],
[count((n) => n.type === 'file' && fileKind(n.file) === 'image'), 'picture'],
[count((n) => n.type === 'link'), 'link'],
[count((n) => n.type === 'group'), 'group'],
[edges.length, 'arrow']
].filter(([n]) => n).map(([n, w]) => `${n} ${w}${n === 1 ? '' : 's'}`);
return parts.length ? parts.join(' · ') : 'Empty';
}
async function loadAbout() {
for (const ts of items || []) {
try { about[ts] = describe((await api.readHistory(pageId, ts)).body); } catch { about[ts] = 'Could not be read'; }
}
}
onMount(async () => {
await load();
if (canvas) loadAbout();
});
function close() {
closing = true;
setTimeout(() => { store.historyOpen = null; closing = false; }, 150);
}
// The page as it is now is kept as a version first, so this can be undone from here too.
function restore(ts) {
close();
store.restoreVersion(pageId, ts);
}
const formatDate = (ts) => new Date(ts).toLocaleString(store.prefs.locale || undefined);
</script>
<svelte:window onkeydown={(e) => e.key === 'Escape' && close()} />
<div class="overlay" class:closing role="presentation" onmousedown={(e) => e.target === e.currentTarget && close()}>
<div class="panel" role="dialog" aria-label="Version History">
<div class="trash-top">
<Icon name="clock" />
<h2>Version history</h2>
<button class="btn-icon" aria-label="Close" onclick={close}><Icon name="x" /></button>
</div>
<div class="trash-list scroller">
{#if items === null}
<div class="trash-empty">Loading…</div>
{:else if items.length === 0}
<div class="trash-empty">No versions found.</div>
{:else}
{#each items as ts}
<div class="tr">
<Icon name={canvas ? 'canvas' : 'file'} />
<span class="tr-t">
<b>{formatDate(ts)}</b>
{#if canvas && about[ts]}<small>{about[ts]}</small>{/if}
</span>
<button class="btn" onclick={() => restore(ts)}><Icon name="undo" />Restore</button>
</div>
{/each}
{/if}
</div>
</div>
</div>
+13
View File
@@ -0,0 +1,13 @@
<script>
import { paths, PATHS, isEmoji } from '$lib/icons.js';
let { name, class: cls = '' } = $props();
</script>
{#if !PATHS[name] && isEmoji(name)}
<span class="i emo {cls}" aria-hidden="true">{name}</span>
{:else}
<svg class="i {cls}" viewBox="0 0 24 24" aria-hidden="true">
{#each paths(name) as d, i (i)}<path {d} />{/each}
</svg>
{/if}
+110
View File
@@ -0,0 +1,110 @@
<script>
// Popover for choosing a page icon: stroke icons or emoji, with search.
import { onMount, tick, untrack } from 'svelte';
import Icon from './Icon.svelte';
import { PICKER_ICONS, PICKER_EMOJI, isEmoji } from '$lib/icons.js';
let { anchor, current, onpick, onremove, onclose } = $props();
const COLS = 8;
let tab = $state(untrack(() => (isEmoji(current) ? 'emoji' : 'icons')));
let q = $state('');
let el = $state();
let gridEl = $state();
let input = $state();
let pos = $state({ x: 0, y: 0 });
let items = $derived.by(() => {
const list = tab === 'emoji' ? PICKER_EMOJI : PICKER_ICONS;
const ql = q.trim().toLowerCase();
return ql ? list.filter(([v, words]) => (v + ' ' + words).toLowerCase().includes(ql)) : list;
});
function place() {
if (!anchor || !el) return;
const a = anchor.getBoundingClientRect(), r = el.getBoundingClientRect();
let y = a.bottom + 8;
if (y + r.height > innerHeight - 8) y = Math.max(8, a.top - r.height - 8);
pos = { x: Math.max(8, Math.min(a.left, innerWidth - r.width - 8)), y };
}
onMount(() => {
place();
input?.focus();
});
function pick(v) { onpick(v); }
function random() {
const list = tab === 'emoji' ? PICKER_EMOJI : PICKER_ICONS;
const options = list.filter(([v]) => v !== current);
onpick(options[Math.floor(Math.random() * options.length)][0]);
}
async function switchTab(t) {
tab = t;
await tick();
input?.focus();
}
function onSearchKey(e) {
if (e.key === 'Enter' && items.length) { e.preventDefault(); pick(items[0][0]); }
if (e.key === 'ArrowDown' && items.length) { e.preventDefault(); gridEl?.querySelector('button')?.focus(); }
}
// Arrow keys move around the grid; up from the first row goes back to the search box.
function onGridKey(e) {
const buttons = [...gridEl.querySelectorAll('button')];
const i = buttons.indexOf(document.activeElement);
if (i < 0) return;
const step = { ArrowRight: 1, ArrowLeft: -1, ArrowDown: COLS, ArrowUp: -COLS }[e.key];
if (!step) return;
e.preventDefault();
const n = i + step;
if (n < 0) input?.focus();
else buttons[Math.min(n, buttons.length - 1)]?.focus();
}
function onKey(e) {
if (e.key === 'Escape') { e.preventDefault(); e.stopPropagation(); onclose(); }
}
function onDown(e) {
if (el && !el.contains(e.target) && !anchor?.contains(e.target)) onclose();
}
</script>
<svelte:window onkeydown={onKey} onpointerdown={onDown} onresize={place} />
<div class="pop ipick" bind:this={el} role="dialog" aria-label="Choose an icon" style="left:{pos.x}px;top:{pos.y}px">
<div class="ip-top">
<div class="ip-tabs" role="tablist">
<button class="ip-tab" class:on={tab === 'icons'} role="tab" aria-selected={tab === 'icons'} onclick={() => switchTab('icons')}>Icons</button>
<button class="ip-tab" class:on={tab === 'emoji'} role="tab" aria-selected={tab === 'emoji'} onclick={() => switchTab('emoji')}>Emoji</button>
</div>
<button class="btn-icon" aria-label="Random icon" title="Random" onclick={random}><Icon name="shuffle" /></button>
<button class="btn-icon" aria-label="Remove icon" title="Remove (use the default)" onclick={onremove}><Icon name="trash" /></button>
</div>
<div class="ip-search">
<Icon name="search" />
<input
bind:this={input}
bind:value={q}
placeholder={tab === 'emoji' ? 'Search emoji' : 'Search icons'}
aria-label="Search"
spellcheck="false"
onkeydown={onSearchKey}
/>
</div>
<div class="ip-grid" class:emoji={tab === 'emoji'} bind:this={gridEl} role="listbox" tabindex="-1" onkeydown={onGridKey}>
{#each items as [v, words] (v)}
<button
class="ip-cell"
class:on={v === current}
role="option"
aria-selected={v === current}
aria-label={tab === 'emoji' ? words.split(' ')[0] : v}
title={tab === 'emoji' ? words.split(' ')[0] : v}
onclick={() => pick(v)}
>
{#if tab === 'emoji'}<span class="ip-emo">{v}</span>{:else}<Icon name={v} />{/if}
</button>
{:else}
<p class="ip-none">Nothing matches “{q}”.</p>
{/each}
</div>
</div>
+103
View File
@@ -0,0 +1,103 @@
<script>
// Outline of the open page: its headings, indented by level. Click one to scroll there;
// the entry for the section you're reading stays highlighted as you scroll.
import { tick } from 'svelte';
import Icon from './Icon.svelte';
import { store } from '$lib/state.svelte.js';
import { scrollToBlock } from '$lib/scroll.js';
let { scroller } = $props(); // the page's scrolling element
let active = $state(null);
let lockUntil = 0; // ignore scroll updates while a click's smooth scroll is still running
let listEl = $state();
// Indent relative to the highest level actually used, so a page of only ## and ### starts flush left.
let base = $derived(store.headings.reduce((m, h) => Math.min(m, h.level), 3));
const blockEl = (id) => scroller?.querySelector(`.block[data-id="${id}"]`);
// The last heading whose top has passed a line just under the top of the page.
function findActive() {
if (!scroller || Date.now() < lockUntil) return;
const line = scroller.getBoundingClientRect().top + 28;
let cur = null;
for (const h of store.headings) {
const el = blockEl(h.id);
if (!el) continue;
if (el.getBoundingClientRect().top <= line) cur = h.id;
else break;
}
active = cur;
}
let frame = 0;
const onScroll = () => { if (!frame) frame = requestAnimationFrame(() => { frame = 0; findActive(); }); };
$effect(() => {
if (!scroller) return;
scroller.addEventListener('scroll', onScroll, { passive: true });
return () => { scroller.removeEventListener('scroll', onScroll); cancelAnimationFrame(frame); frame = 0; };
});
// Headings changed (typing, another page): recompute once the page has rendered.
$effect(() => {
store.headings;
store.doc?.version;
let stale = false;
tick().then(() => setTimeout(() => { if (!stale) findActive(); }, 60));
return () => { stale = true; };
});
// Up/Down move between entries.
$effect(() => {
if (!listEl) return;
listEl.addEventListener('keydown', onKey);
return () => listEl.removeEventListener('keydown', onKey);
});
// Keep the highlighted entry in view when the outline itself scrolls (long pages).
$effect(() => {
if (!active || !listEl) return;
listEl.querySelector('.ol-item.on')?.scrollIntoView({ block: 'nearest' });
});
function jump(h) {
const el = blockEl(h.id);
if (!el || !scroller) return;
active = h.id;
lockUntil = Date.now() + scrollToBlock(scroller, el);
}
function onKey(e) {
if (e.key !== 'ArrowDown' && e.key !== 'ArrowUp') return;
const items = [...listEl.querySelectorAll('.ol-item')];
const i = items.indexOf(document.activeElement);
if (i < 0) return;
e.preventDefault();
items[Math.max(0, Math.min(items.length - 1, i + (e.key === 'ArrowDown' ? 1 : -1)))].focus();
}
</script>
<aside class="outline" aria-label="Outline">
<header class="ol-head">
<span>Outline</span>
{#if store.headings.length}<em>{store.headings.length}</em>{/if}
<button class="btn-icon" aria-label="Close outline" title="Close outline" onclick={() => store.toggleOutline(false)}><Icon name="x" /></button>
</header>
<div class="ol-list" bind:this={listEl}>
{#each store.headings as h (h.id)}
<button
class="ol-item"
class:on={active === h.id}
class:top={h.level === base}
style="--lv:{h.level - base}"
title={h.text}
aria-current={active === h.id ? 'location' : undefined}
onclick={() => jump(h)}
><span>{h.text}</span></button>
{:else}
<p class="ol-empty">No headings yet.<br />Start a line with <kbd>#</kbd>, <kbd>##</kbd> or <kbd>###</kbd> to make one.</p>
{/each}
</div>
</aside>
+202 -22
View File
@@ -6,9 +6,14 @@
import Board from './Board.svelte';
import Outline from './Outline.svelte';
import { store, iconOf, coverOf } from '$lib/state.svelte.js';
import { COVERS } from '$lib/icons.js';
import { COVERS, isImageCover } from '$lib/icons.js';
import { tagHex } from '$lib/tagColors.js';
import IconPicker from './IconPicker.svelte';
import { api } from '$lib/api.js';
import Database from './Database.svelte';
import PropCell from './PropCell.svelte';
import { api, errorText } from '$lib/api.js';
import { toast } from '$lib/toast.svelte.js';
import { read, write, typeInfo, computeFormulas, TYPES } from '$lib/database.js';
let doc = $derived(store.doc);
let page = $derived(store.current);
@@ -17,6 +22,27 @@
let tags = $derived(Array.isArray(doc?.meta.tags) ? doc.meta.tags : []);
let created = $derived(doc?.meta.created || page?.created || null);
// When this page is a record of a database: that database's properties, shown here with their own editors.
let schema = $state(null); // { dbId, properties }
$effect(() => {
const parent = page && page.parent;
const owner = parent && store.pages.find((x) => x.id === parent);
const mine = doc && doc.id;
if (!owner || !owner.db || store.isDatabase) { schema = null; return; }
store.readSchema(parent).then((s) => { if (store.doc && store.doc.id === mine) schema = s ? { dbId: parent, properties: s.properties } : null; });
});
const schemaKeys = $derived(new Set((schema ? schema.properties : []).map((p) => p.key)));
// formula properties of the database, worked out for this record
const formulas = $derived(schema && doc ? computeFormulas(schema.properties, { title: doc.title, props: doc.meta }) : {});
async function addSchemaOption(p, name) {
const s = await store.readSchema(schema.dbId);
const q = s && s.properties.find((x) => x.key === p.key);
if (!q || (q.options || []).includes(name)) return;
(q.options ||= []).push(name);
await store.saveSchema(schema.dbId, s);
schema = { dbId: schema.dbId, properties: s.properties };
}
let scrollerEl = $state();
let titleEl = $state();
let iconEl = $state();
@@ -40,7 +66,7 @@
const fmtDate = (d) => {
if (!d) return '—';
const t = new Date(String(d).length === 10 ? d + 'T12:00:00' : d);
return isNaN(t) ? String(d) : t.toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' });
return isNaN(t) ? String(d) : t.toLocaleDateString(store.prefs.locale || undefined, { month: 'short', day: 'numeric', year: 'numeric' });
};
function restart(el, cls) {
@@ -55,6 +81,37 @@
restart(iconEl, 'spin');
iconEl?.focus();
}
let coverBroken = $state(false);
const coverImage = $derived(isImageCover(doc?.meta.cover) ? doc.meta.cover : null);
$effect(() => { coverImage; coverBroken = false; });
// Pick a picture with the file dialog, or take a dropped one: it is saved next to the page like any image.
async function uploadCover() {
try {
const name = await api.pickImage(doc.id);
if (name) setCover(name);
} catch (err) {
toast(errorText(err), 'x');
}
}
// A picture from a web address: downloaded by the app and saved next to the page. Throws a message for the picker.
async function coverFromUrl(address) {
const name = await api.saveImageFromUrl(doc.id, address);
if (name) setCover(name);
}
async function dropCover(e) {
const file = [...(e.dataTransfer?.files || [])].find((f) => /^image\//.test(f.type));
if (!file) return;
e.preventDefault();
coverOver = false;
try {
if (file.size > 50 * 1024 * 1024) throw new Error('That image is too big (50 MB at most).');
const name = await api.saveImage(doc.id, file.name || 'cover.png', new Uint8Array(await file.arrayBuffer()));
if (name) setCover(name);
} catch (err) {
toast(errorText(err), 'x');
}
}
let coverOver = $state(false);
function setCover(v) {
pickingCover = false;
store.setMeta({ cover: v });
@@ -113,6 +170,52 @@
let addingProp = $state(false);
let newPropEl = $state();
/* A plain page's own properties are typed by their value: true/false is a checkbox, a number a number, YYYY-MM-DD a
date, a list a multi-select, an address a link. Nothing but the value is stored, so Obsidian reads them as is. */
const PLAIN_TYPES = ['text', 'number', 'date', 'checkbox', 'multiselect', 'url'];
const HIDDEN_KEYS = ['icon', 'cover', 'tags', 'created', 'ink-colors', 'kanban-plugin', 'ink-database', 'source'];
function inferType(v) {
if (typeof v === 'boolean' || /^(true|false)$/i.test(String(v))) return 'checkbox';
if (Array.isArray(v)) return 'multiselect';
if (typeof v === 'number' || /^-?\d+(\.\d+)?$/.test(String(v).trim())) return 'number';
if (/^\d{4}-\d{2}-\d{2}/.test(String(v))) return 'date';
if (/^https?:\/\/\S+$/i.test(String(v).trim())) return 'url';
return 'text';
}
const customKeys = $derived(doc ? Object.keys(doc.meta).filter((k) => doc.meta[k] !== undefined && !HIDDEN_KEYS.includes(k) && !schemaKeys.has(k)) : []);
// the values other pages give this key, as the choices of a list property
function optionsFor(k) {
const seen = new Set();
for (const p of store.pages) {
const v = p.props && p.props[k];
for (const x of Array.isArray(v) ? v : []) if (typeof x === 'string' && x.trim()) seen.add(x.trim());
}
const mine = doc.meta[k];
for (const x of Array.isArray(mine) ? mine : []) seen.add(String(x));
return [...seen].sort((a, b) => a.localeCompare(b));
}
const propFor = (k) => {
const type = inferType(doc.meta[k]);
return { key: k, name: k, type, options: type === 'multiselect' ? optionsFor(k) : [] };
};
const DEFAULTS = { text: '', number: '0', date: () => new Date().toISOString().slice(0, 10), checkbox: 'false', multiselect: [], url: '' };
const startValue = (t) => (typeof DEFAULTS[t] === 'function' ? DEFAULTS[t]() : DEFAULTS[t]);
function convertValue(v, from, to) {
if (to === 'multiselect') return Array.isArray(v) ? v : String(v ?? '').split(',').map((x) => x.trim()).filter(Boolean);
const plain = Array.isArray(v) ? v.join(', ') : String(v ?? '');
if (to === 'checkbox') return /^(true|yes|1|x|on)$/i.test(plain) ? 'true' : 'false';
if (to === 'number') { const n = Number(plain.replace(',', '.')); return Number.isFinite(n) && plain.trim() ? String(n) : '0'; }
if (to === 'date') return /^\d{4}-\d{2}-\d{2}/.test(plain) ? plain.slice(0, 10) : startValue('date');
return plain;
}
let typeMenu = $state(null); // { key, x, y } for changing a property's type
let newType = $state('text');
function setType(k, to) {
const from = inferType(doc.meta[k]);
typeMenu = null;
if (from !== to) store.setMeta({ [k]: convertValue(doc.meta[k], from, to) });
}
function updateMeta(k, v) {
if (Array.isArray(doc.meta[k])) {
store.setMeta({ [k]: v.split(',').map(s => s.trim()).filter(Boolean) });
@@ -141,13 +244,15 @@
function commitNewProp(k) {
k = k.trim().replace(/[^a-zA-Z0-9_-]/g, '-');
if (k && !doc.meta[k] && !['icon', 'cover', 'tags', 'created'].includes(k)) {
store.setMeta({ [k]: '' });
if (k && doc.meta[k] === undefined && !HIDDEN_KEYS.includes(k)) {
store.setMeta({ [k]: startValue(newType) });
}
addingProp = false;
}
</script>
<svelte:window onclick={(e) => { if (typeMenu && !e.target.closest?.('.ptype, .pk-type')) typeMenu = null; }} />
<div class="pagewrap">
<div class="scroller" bind:this={scrollerEl}>
{#if doc}
@@ -157,12 +262,26 @@
{/each}
</datalist>
{#key doc.version}
<div class="cover enter" bind:this={coverEl} style="--c1:{COVERS[cover][0]};--c2:{COVERS[cover][1]}">
<div
class="cover enter"
class:photo={coverImage && !coverBroken}
class:over={coverOver}
bind:this={coverEl}
style="--c1:{COVERS[cover][0]};--c2:{COVERS[cover][1]}"
role="presentation"
ondragover={(e) => { if ([...(e.dataTransfer?.items || [])].some((i) => i.kind === 'file')) { e.preventDefault(); coverOver = true; } }}
ondragleave={() => (coverOver = false)}
ondrop={dropCover}
>
{#if coverImage}
<img class="cover-img" src={api.mediaUrl(doc.id, coverImage)} alt="" draggable="false" onerror={() => (coverBroken = true)} />
{/if}
<button class="cover-btn" bind:this={coverBtnEl} onclick={() => (pickingCover = !pickingCover)}><Icon name="image" />Change cover</button>
</div>
<article class="page enter" class:wide={store.isBoard}>
<article class="page enter" class:wide={store.isBoard || store.isDatabase}>
<div class="head">
<button class="p-icon" bind:this={iconEl} aria-label="Change icon" title="Change icon" aria-expanded={picking} onclick={() => (picking = !picking)}>
<button class="p-icon" bind:this={iconEl} aria-label="Change icon" title="Change icon" aria-expanded={picking} onclick={() => (picking = !picking)}
oncontextmenu={(e) => { e.preventDefault(); store.menu = { x: e.clientX, y: e.clientY, id: doc.id }; }}>
<Icon name={icon} />
</button>
<h1
@@ -176,12 +295,39 @@
onblur={() => store.commitTitle(titleEl.textContent)}
onpaste={onTitlePaste}
></h1>
{#if !store.isDatabase}
<div class="props">
{#if schema}
{#each schema.properties as p (p.key)}
<div class="prop">
<span class="pk"><Icon name={typeInfo(p.type).icon} />{p.name}</span>
<div class="pv db-pv">
<PropCell
prop={p}
value={p.type === 'formula' ? formulas[p.key]?.text ?? '' : read(doc.meta[p.key], p.type)}
error={p.type === 'formula' ? formulas[p.key]?.error || '' : ''}
recordId={doc.id}
choices={p.type === 'relation' ? store.records(schema.dbId).filter((r) => r.id !== doc.id).map((r) => r.title) : []}
plain
onchange={(v) => store.setMeta({ [p.key]: write(v, p.type) })}
onaddoption={(n) => addSchemaOption(p, n)}
/>
</div>
</div>
{/each}
{/if}
<div class="prop">
<span class="pk"><Icon name="hash" />Tags</span>
<div class="pv tagv">
{#each tags as t (t)}
<span class="chip in" class:out={removing === t}>
<span
class="chip in"
class:tinted={!!tagHex(store.tagColors[t])}
class:out={removing === t}
style={tagHex(store.tagColors[t]) ? `--tc:${tagHex(store.tagColors[t])}` : undefined}
role="presentation"
oncontextmenu={(e) => { e.preventDefault(); store.menu = { x: e.clientX, y: e.clientY, tag: t }; }}
>
{t}<button class="chip-x" aria-label="Remove tag {t}" onclick={() => removeTag(t)}><Icon name="x" /></button>
</span>
{/each}
@@ -212,44 +358,75 @@
<span class="pk"><Icon name="file" />File</span>
<button class="pv mono pv-link" title="Show in folder" onclick={() => api.reveal(doc.id)}>{store.relPath(doc.id)}</button>
</div>
{#each Object.keys(doc.meta).filter(k => doc.meta[k] !== undefined && !['icon', 'cover', 'tags', 'created'].includes(k)) as pk (pk)}
{#each customKeys as pk (pk)}
{@const cp = propFor(pk)}
<div class="prop">
<span class="pk">
<Icon name="list" />
<button class="pk-type" aria-label="Change type of {pk}" title="Type: {typeInfo(cp.type).label}"
onclick={(e) => { const r = e.currentTarget.getBoundingClientRect(); typeMenu = typeMenu && typeMenu.key === pk ? null : { key: pk, x: r.left, y: r.bottom + 4 }; }}>
<Icon name={typeInfo(cp.type).icon} />
</button>
<input class="custom-pk" value={pk} onchange={(e) => renameMeta(pk, e.target.value)} />
</span>
<div style="display:flex; gap: 4px; align-items: center; width: 100%;">
<input
class="pv custom-pv"
value={Array.isArray(doc.meta[pk]) ? doc.meta[pk].join(', ') : doc.meta[pk]}
onchange={(e) => updateMeta(pk, e.target.value)}
placeholder="Empty"
{#if cp.type === 'text'}
<input class="pv custom-pv" value={doc.meta[pk]} onchange={(e) => updateMeta(pk, e.target.value)} placeholder="Empty" />
{:else}
<div class="pv db-pv">
<PropCell
prop={cp}
value={read(doc.meta[pk], cp.type)}
recordId={doc.id}
plain
onchange={(v) => store.setMeta({ [pk]: write(v, cp.type) })}
onaddoption={() => {}}
/>
</div>
{/if}
<button class="chip-x" aria-label="Remove property" onclick={() => removeMeta(pk)}><Icon name="x" /></button>
</div>
</div>
{/each}
{#if addingProp}
<div class="prop">
<span class="pk"><Icon name="list" /><input class="custom-pk" bind:this={newPropEl} placeholder="Key" onkeydown={(e) => { if (e.key === 'Enter') commitNewProp(e.target.value); if (e.key === 'Escape') addingProp = false; }} onblur={(e) => commitNewProp(e.target.value)} /></span>
<span class="pk"><Icon name={typeInfo(newType).icon} /><input class="custom-pk" bind:this={newPropEl} placeholder="{typeInfo(newType).label} property name" onkeydown={(e) => { if (e.key === 'Enter') commitNewProp(e.target.value); if (e.key === 'Escape') addingProp = false; }} onblur={(e) => { if (!e.relatedTarget?.classList?.contains('type-chip')) commitNewProp(e.target.value); }} /></span>
<span class="pv custom-pv"></span>
</div>
{/if}
<div class="prop">
<span class="pk"></span>
<div class="pv"><button class="chip-add" onclick={() => { addingProp = true; setTimeout(() => newPropEl?.focus(), 0); }}><Icon name="plus" />Add property</button></div>
<div class="pv tagv">
{#if addingProp}
{#each PLAIN_TYPES as t (t)}
<button class="chip-add type-chip" class:on={newType === t} onclick={() => { newType = t; newPropEl?.focus(); }}><Icon name={typeInfo(t).icon} />{typeInfo(t).label}</button>
{/each}
{:else}
<button class="chip-add" onclick={() => { addingProp = true; setTimeout(() => newPropEl?.focus(), 0); }}><Icon name="plus" />Add property</button>
{/if}
</div>
</div>
</div>
{#if !store.isBoard}
{/if}
</div>
{#if !store.isBoard && !store.isDatabase}
<Editor blocks={doc.blocks} onexittop={toTitleEnd} />
<Backlinks id={doc.id} />
{/if}
</article>
{#if typeMenu}
<div class="ptype" role="menu" style="left:{typeMenu.x}px;top:{typeMenu.y}px">
{#each PLAIN_TYPES as t (t)}
<button class="mi" class:on={inferType(doc.meta[typeMenu.key]) === t} role="menuitem" onclick={() => setType(typeMenu.key, t)}><Icon name={typeInfo(t).icon} />{typeInfo(t).label}</button>
{/each}
</div>
{/if}
{#if pickingCover}
<CoverPicker
anchor={coverBtnEl}
current={cover}
current={coverImage ? '' : cover}
hasImage={!!coverImage}
onupload={uploadCover}
onurl={coverFromUrl}
onpick={setCover}
onclose={() => { pickingCover = false; coverBtnEl?.focus(); }}
/>
@@ -263,7 +440,10 @@
onclose={() => { picking = false; iconEl?.focus(); }}
/>
{/if}
{#if store.isBoard}
{#if store.isDatabase}
<Database id={doc.id} body={doc.body} />
<div class="page wide"><Backlinks id={doc.id} /></div>
{:else if store.isBoard}
<Board body={doc.body} />
<div class="page wide"><Backlinks id={doc.id} /></div>
{/if}
@@ -275,5 +455,5 @@
</div>
{/if}
</div>
{#if store.outlineOpen && doc && !store.isBoard}<Outline scroller={scrollerEl} />{/if}
{#if store.outlineOpen && doc && !store.isBoard && !store.isDatabase}<Outline scroller={scrollerEl} />{/if}
</div>
+170
View File
@@ -0,0 +1,170 @@
<script>
import Icon from './Icon.svelte';
import { store, iconOf } from '$lib/state.svelte.js';
import { api } from '$lib/api.js';
import { esc, mark } from '$lib/text.js';
// "Search notes for …" from the right-click menu opens the palette with that text
let q = $state(store.paletteQuery || '');
store.paletteQuery = '';
let idx = $state(0);
let hits = $state([]);
let closing = $state(false);
let listEl = $state();
let timer;
let seq = 0;
// Full-text hits come from the SQLite index; titles are matched locally so results appear instantly.
$effect(() => {
const query = q.trim();
clearTimeout(timer);
if (!query) { hits = []; return; }
const mine = ++seq;
timer = setTimeout(async () => {
try {
const r = await api.search(query);
if (mine === seq) hits = r;
} catch { /* index unavailable */ }
}, 90);
});
let ql = $derived(q.trim().toLowerCase());
let pageItems = $derived.by(() => {
const out = [];
const seen = new Set();
if (!ql) {
// Recently opened first (minus the page you're on), topped up with other pages.
const list = store.recentPages.filter((p) => p.id !== store.currentId);
for (const p of store.pages) if (list.length < 8 && p.exists && p.id !== store.currentId && !list.includes(p)) list.push(p);
for (const p of list.slice(0, 8)) out.push({ page: p, snip: '' });
return out;
}
for (const p of store.pages) {
if (p.title.toLowerCase().includes(ql)) { out.push({ page: p, snip: '', rank: p.title.toLowerCase().startsWith(ql) ? 0 : 1 }); seen.add(p.id); }
}
out.sort((a, b) => a.rank - b.rank);
for (const h of hits) {
const p = store.pages.find((x) => x.id === h.id);
if (!p) continue;
if (seen.has(p.id)) { const o = out.find((x) => x.page.id === p.id); if (o) o.snip = h.snip; continue; }
out.push({ page: p, snip: h.snip });
seen.add(p.id);
}
return out.slice(0, 9);
});
let actions = $derived(
[
{ label: 'New page', icon: 'plus', run: () => store.newPage(null) },
{ label: "Open today's note", icon: 'sun', run: () => store.openDaily() },
{ label: 'New board', icon: 'board', run: () => store.newBoard(null) },
{ label: 'New database', icon: 'table', run: () => store.newDatabase(null) },
{ label: 'Import a CSV file as a new database', icon: 'download', run: () => store.pickCsv(null) },
...(store.isDatabase ? [
{ label: 'Import a CSV file into this database', icon: 'download', run: () => store.pickCsv(store.currentId) },
{ label: 'Export this database as CSV', icon: 'export', run: () => store.exportCsv() }
] : []),
{ label: 'New canvas', icon: 'canvas', run: () => store.newCanvas(null) },
...(store.doc && !store.isDatabase ? [{ label: store.isBoard ? 'Turn this board into a document' : 'Turn this page into a board', icon: store.isBoard ? 'file' : 'board', run: () => store.setBoard(!store.isBoard) }] : []),
{ label: store.currentId && store.isPinned(store.currentId) ? 'Remove this page from favorites' : 'Add this page to favorites', icon: 'star', run: () => store.togglePin() },
...(store.doc ? [{ label: 'Show version history', icon: 'clock', run: () => (store.historyOpen = store.currentId) }] : []),
{ label: 'Open a daily note by date', icon: 'calendar', run: () => (store.calendarOpen = true) },
{ label: 'Manage tags', icon: 'hash', run: () => (store.tagManagerOpen = true) },
{ label: 'Clip a web page as a note', icon: 'globe', run: () => (store.clipOpen = true) },
{ label: 'Import a folder (Obsidian or Notion export)', icon: 'download', run: () => store.importNotes('folder') },
{ label: 'Import a .zip export (Notion or Obsidian)', icon: 'download', run: () => store.importNotes('zip') },
{ label: 'Export all notes as a zip', icon: 'export', run: () => store.exportVaultZip() },
{ label: 'Open tasks', icon: 'check', run: () => (store.tasksOpen = true) },
{ label: 'Quick capture', icon: 'pen', run: () => api.openCapture?.() },
{ label: 'Open trash', icon: 'trash', run: () => (store.trashOpen = true) },
{ label: 'Settings', icon: 'gear', run: () => (store.settingsOpen = true) },
{ label: 'Open graph view', icon: 'graph', run: () => store.openGraph() },
...(store.doc && !store.isBoard && !store.isDatabase ? [{ label: store.outlineOpen ? 'Hide outline' : 'Show outline', icon: 'outline', run: () => store.toggleOutline() }] : []),
{ label: store.theme === 'dark' ? 'Switch to light theme' : 'Switch to dark theme', icon: store.theme === 'dark' ? 'sun' : 'moon', run: () => store.applyTheme(store.theme === 'dark' ? 'light' : 'dark') },
...(store.isCanvas && store.canvasView ? [{ label: 'Find in this canvas', icon: 'search', run: () => setTimeout(() => store.canvasView?.find(), 60) }] : []),
...(store.isCanvas && store.canvasView
? ['png', 'svg', 'pdf'].map((k) => ({ label: `Export this canvas as ${k.toUpperCase()}`, icon: 'export', run: () => store.canvasView?.export(k) }))
: [
{ label: 'Export this page as Markdown', icon: 'export', run: () => (store.exportFormat = 'md') },
{ label: 'Export this page as HTML', icon: 'export', run: () => (store.exportFormat = 'html') },
{ label: 'Export this page as PDF', icon: 'export', run: () => (store.exportFormat = 'pdf') }
]),
{ label: 'Show or hide the sidebar', icon: 'panel', run: () => store.toggleSidebar() },
{ label: 'Open a different notes folder', icon: 'folder', run: () => store.pick('choose') }
].filter((a) => !ql || a.label.toLowerCase().includes(ql))
);
// With something typed, offer to keep the search in the sidebar (always shown, whatever the text).
let saveable = $derived(!!ql && !store.isSearchSaved(ql));
let allActions = $derived(saveable ? [...actions, { label: 'Save this search to the sidebar', icon: 'star', run: () => store.saveSearch(q) }] : actions);
let total = $derived(pageItems.length + allActions.length);
$effect(() => { if (idx >= total) idx = 0; });
const snipHtml = (s) =>
esc(String(s || '').replace(/^---[\s\S]*?---/, '').replace(/[*_`#>]|\[[ xX]\]/g, ''))
.replace(/\u0001/g, '<mark>')
.replace(/\u0002/g, '</mark>');
const where = (p) => store.ancestors(p.id).map((a) => a.title).join(' / ') || store.vault?.name || '';
function close() {
closing = true;
setTimeout(() => { store.paletteOpen = false; closing = false; q = ''; idx = 0; }, 150);
}
function run(i) {
const fn = i < pageItems.length ? () => store.openPage(pageItems[i].page.id) : allActions[i - pageItems.length]?.run;
close();
if (fn) setTimeout(fn, 120);
}
function onkeydown(e) {
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
e.preventDefault();
if (!total) return;
idx = (idx + (e.key === 'ArrowDown' ? 1 : -1) + total) % total;
requestAnimationFrame(() => listEl?.querySelector('.pi.on')?.scrollIntoView({ block: 'nearest' }));
}
if (e.key === 'Enter') { e.preventDefault(); run(idx); }
if (e.key === 'Escape') { e.preventDefault(); e.stopPropagation(); close(); }
}
function focusOnMount(el) { el.focus(); }
</script>
<div class="overlay" class:closing role="presentation" onmousedown={(e) => e.target === e.currentTarget && close()}>
<div class="panel" role="dialog" aria-label="Search notes">
<div class="pal-top">
<Icon name="search" />
<input bind:value={q} placeholder="Search notes or type a command" autocomplete="off" spellcheck="false" use:focusOnMount {onkeydown} oninput={() => (idx = 0)} />
<kbd>Esc</kbd>
</div>
<div class="pal-list" bind:this={listEl}>
{#if pageItems.length}
<div class="pop-label">{ql ? 'Pages' : 'Recent pages'}</div>
{#each pageItems as item, i (item.page.id)}
<button class="pi" class:on={idx === i} onmousemove={() => (idx = i)} onclick={() => run(i)}>
<span class="row-ico"><Icon name={iconOf(item.page)} /></span>
<span class="pi-t">
<b>{@html mark(item.page.title, ql)}</b>
{#if item.snip}<small>{@html snipHtml(item.snip)}</small>{/if}
</span>
<span class="pi-path">{where(item.page)}</span>
</button>
{/each}
{/if}
{#if allActions.length}
<div class="pop-label">Actions</div>
{#each allActions as a, k (a.label)}
{@const i = pageItems.length + k}
<button class="pi" class:on={idx === i} onmousemove={() => (idx = i)} onclick={() => run(i)}>
<span class="row-ico"><Icon name={a.icon} /></span>
<span class="pi-t"><b>{@html mark(a.label, ql)}</b></span>
<span class="pi-enter"><Icon name="enter" /></span>
</button>
{/each}
{/if}
{#if !total}
<div class="pal-empty">No notes match “{q}”.</div>
{/if}
</div>
<div class="pal-foot"><span><kbd>↑</kbd><kbd>↓</kbd> move</span><span><kbd>Enter</kbd> open</span></div>
</div>
</div>
+192
View File
@@ -0,0 +1,192 @@
<script>
// One property value of a record, editable in place. Used by the database views and by a record's own page.
// value: what database.read() gives for the type; onchange(next): the edited value.
// onaddoption(name): a select option the user typed that the schema doesn't list yet.
import Icon from './Icon.svelte';
import { api, errorText } from '$lib/api.js';
import { toast } from '$lib/toast.svelte.js';
import { optionColor, formatNumber, barFraction, formulaDisplay } from '$lib/database.js';
let { prop, value, onchange, onaddoption, recordId, suggestions = [], choices = [], plain = false, error = '' } = $props();
let trigger = $state();
let box = $state();
let open = $state(false);
let q = $state('');
let at = $state({ left: 0, top: 0, bottom: null, width: 220, max: 280 });
let adding = $state(false);
let draft = $state('');
const isRelation = $derived(prop.type === 'relation');
const options = $derived(isRelation ? choices : prop.options || []);
const shade = (o) => (isRelation ? 'var(--ink-3)' : optionColor(o, prop));
let editingNumber = $state(false);
const showsFormat = $derived(prop.type === 'number' && prop.format && prop.format !== 'number');
const chosen = $derived(prop.type === 'select' ? (value ? [value] : []) : Array.isArray(value) ? value : []);
const shown = $derived.by(() => {
const s = q.trim().toLowerCase();
return s ? options.filter((o) => o.toLowerCase().includes(s)) : options;
});
const canCreate = $derived(!isRelation && q.trim() && !options.some((o) => o.toLowerCase() === q.trim().toLowerCase()));
function show() {
const r = trigger.getBoundingClientRect();
const width = Math.max(r.width, 220);
const below = innerHeight - r.bottom - 14, above = r.top - 14;
const up = below < 200 && above > below;
const left = Math.max(8, Math.min(r.left, innerWidth - width - 8));
at = up
? { left, top: null, bottom: innerHeight - r.top + 4, width, max: Math.min(300, above) }
: { left, top: r.bottom + 4, bottom: null, width, max: Math.min(300, below) };
q = '';
open = true;
requestAnimationFrame(() => box?.querySelector('input')?.focus());
}
function close(refocus = true) {
open = false;
if (refocus) trigger?.focus();
}
function pick(name) {
if (prop.type === 'select') {
close();
onchange(name === value ? '' : name);
} else {
onchange(chosen.includes(name) ? chosen.filter((x) => x !== name) : [...chosen, name]);
}
}
function create() {
const name = q.trim().replace(/,/g, ' ');
if (!name) return;
const known = options.find((o) => o.toLowerCase() === name.toLowerCase());
if (!known) onaddoption?.(name);
q = '';
if (prop.type === 'select') { close(); onchange(known || name); }
else if (!chosen.includes(known || name)) onchange([...chosen, known || name]);
}
function onfind(e) {
if (e.key === 'Enter') { e.preventDefault(); if (canCreate) create(); else if (shown.length === 1) pick(shown[0]); }
if (e.key === 'Escape') { e.preventDefault(); e.stopPropagation(); close(); }
}
function onpointerdown(e) {
if (open && box && !box.contains(e.target) && !trigger?.contains(e.target)) close(false);
}
/* people and files: a row of chips with an add field / button */
function addPerson() {
const name = draft.trim().replace(/,/g, ' ');
draft = '';
adding = false;
if (name && !chosen.includes(name)) onchange([...chosen, name]);
}
async function attach() {
try {
const names = await api.pickFile(recordId);
if (names && names.length) onchange([...new Set([...chosen, ...names])]);
} catch (err) {
toast(errorText(err), 'x');
}
}
async function openFile(name) {
try { await api.openFile(recordId, name); } catch (err) { toast(errorText(err), 'x'); }
}
const focusOnMount = (el) => el.focus();
// The option list is fixed-positioned from the cell's rectangle, so it lives on <body>: inside a page whose parts
// are animated or transformed, "fixed" would be measured from that part instead and the list would land elsewhere.
function portal(node) {
document.body.appendChild(node);
return { destroy() { node.remove(); } };
}
// an input that replaced the formatted number takes the keyboard
const numberFocus = (el, on) => { if (on) { el.focus(); el.select(); } };
</script>
<svelte:window {onpointerdown} />
{#if prop.type === 'formula'}
<span class="db-formula" class:err={!!error} class:empty={!error && (value === '' || value === null || value === undefined)} title={error || (prop.expression || 'Formula')}>
{#if error}<Icon name="bell" />Error{:else if value === 'true' || value === 'false'}<span class="db-ck" class:on={value === 'true'}><Icon name="tick" /></span>{:else}{formulaDisplay(prop, value)}{/if}
</span>
{:else if prop.type === 'checkbox'}
<label class="dbx" class:on={value}>
<input type="checkbox" checked={!!value} onchange={(e) => onchange(e.currentTarget.checked)} aria-label={prop.name} />
<span class="dbx-box"><Icon name="tick" /></span>
</label>
{:else if prop.type === 'select' || prop.type === 'multiselect' || isRelation}
<button class="db-sel" bind:this={trigger} aria-haspopup="listbox" aria-expanded={open} onclick={() => (open ? close(false) : show())}>
{#each chosen as o (o)}<span class="db-opt" style="--oc:{shade(o)}">{o}</span>{/each}
{#if !chosen.length}<span class="db-empty">{plain ? 'Empty' : ''}</span>{/if}
</button>
{#if open}
<div class="pop db-pop" use:portal bind:this={box} style="left:{at.left}px;{at.top !== null ? `top:${at.top}px` : `bottom:${at.bottom}px`};width:{at.width}px;max-height:{at.max}px">
<input class="db-find" placeholder={isRelation ? 'Find a record' : prop.type === 'select' ? 'Find or create an option' : 'Find or add options'} bind:value={q} onkeydown={onfind} />
<div class="db-list" role="listbox">
{#if prop.type === 'select' && value}
<button class="mi" role="option" aria-selected="false" onclick={() => pick(value)}><Icon name="x" /><span>Clear</span></button>
{/if}
{#each shown as o (o)}
<button class="mi" role="option" aria-selected={chosen.includes(o)} onclick={() => pick(o)}>
<span class="db-opt" style="--oc:{shade(o)}">{o}</span>
{#if chosen.includes(o)}<span class="mi-tick"><Icon name="tick" /></span>{/if}
</button>
{/each}
{#if canCreate}
<button class="mi" onclick={create}><Icon name="plus" /><span>Create “{q.trim()}”</span></button>
{:else if !shown.length}
<div class="db-none">{isRelation ? 'No other records.' : 'No options yet. Type a name and press Enter.'}</div>
{/if}
</div>
</div>
{/if}
{:else if prop.type === 'people'}
<div class="db-chips">
{#each chosen as n (n)}
<span class="db-person"><i>{n.slice(0, 1).toUpperCase()}</i>{n}<button class="chip-x" aria-label="Remove {n}" onclick={() => onchange(chosen.filter((x) => x !== n))}><Icon name="x" /></button></span>
{/each}
{#if adding}
<input
class="chip-in db-add"
list="db-people-{prop.key}"
placeholder="Name"
bind:value={draft}
use:focusOnMount
onkeydown={(e) => { if (e.key === 'Enter' || e.key === ',') { e.preventDefault(); addPerson(); } if (e.key === 'Escape') { draft = ''; adding = false; } }}
onblur={addPerson}
/>
<datalist id="db-people-{prop.key}">{#each suggestions.filter((s) => !chosen.includes(s)) as s (s)}<option value={s}></option>{/each}</datalist>
{:else}
<button class="chip-add db-plus" aria-label="Add a person" onclick={() => (adding = true)}><Icon name="plus" />{chosen.length ? '' : 'Add'}</button>
{/if}
</div>
{:else if prop.type === 'files'}
<div class="db-chips">
{#each chosen as f (f)}
<span class="db-file"><button class="db-file-name" title="Open {f}" onclick={() => openFile(f)}><Icon name="paperclip" />{f}</button><button class="chip-x" aria-label="Remove {f}" onclick={() => onchange(chosen.filter((x) => x !== f))}><Icon name="x" /></button></span>
{/each}
<button class="chip-add db-plus" aria-label="Attach files" onclick={attach}><Icon name="plus" />{chosen.length ? '' : 'Add'}</button>
</div>
{:else if showsFormat && !editingNumber}
<button class="db-numfmt" aria-label="{prop.name}: edit" onclick={() => (editingNumber = true)}>
{#if prop.format === 'bar'}
<span class="db-barline" role="progressbar" aria-valuenow={value ?? 0} aria-valuemax={prop.max || 100}><i style="width:{barFraction(value, prop) * 100}%"></i></span>
<span class="db-barnum">{value === null ? '' : formatNumber(value, { format: 'commas' })}</span>
{:else}
<span>{formatNumber(value, prop)}</span>
{/if}
</button>
{:else if prop.type === 'number'}
<input
class="db-in num"
type="number"
step="any"
value={value ?? ''}
aria-label={prop.name}
use:numberFocus={editingNumber}
onblur={() => (editingNumber = false)}
onchange={(e) => onchange(e.currentTarget.value === '' ? null : Number(e.currentTarget.value))}
/>
{:else if prop.type === 'date'}
<input class="db-in" type="date" value={value || ''} aria-label={prop.name} onchange={(e) => onchange(e.currentTarget.value)} />
{:else}
<input class="db-in" type={prop.type === 'url' ? 'url' : 'text'} value={value ?? ''} aria-label={prop.name} placeholder={plain ? 'Empty' : ''} onchange={(e) => onchange(e.currentTarget.value)} />
{/if}
+127
View File
@@ -0,0 +1,127 @@
<script>
// A styled replacement for <select>: a field that opens a list, with a search box when the list is long.
import Icon from './Icon.svelte';
// options: [{ value, label, detail? }]; onchange(value)
let { options, value = '', onchange, label = '', placeholder = 'Choose…', searchAt = 12 } = $props();
let trigger = $state();
let box = $state();
let open = $state(false);
let q = $state('');
let active = $state(0);
let at = $state({ left: 0, top: 0, bottom: null, width: 260, max: 300 });
const searchable = $derived(options.length > searchAt);
const current = $derived(options.find((o) => o.value === value));
const shown = $derived.by(() => {
const s = q.trim().toLowerCase();
if (!s) return options;
const rank = (o) => (o.label.toLowerCase().startsWith(s) ? 0 : o.label.toLowerCase().includes(s) ? 1 : (o.detail || '').toLowerCase().includes(s) ? 2 : 3);
return options.map((o) => [rank(o), o]).filter(([r]) => r < 3).sort((a, b) => a[0] - b[0]).map(([, o]) => o);
});
function show() {
const r = trigger.getBoundingClientRect();
const width = Math.max(r.width, 260);
const below = innerHeight - r.bottom - 14, above = r.top - 14;
const up = below < 260 && above > below;
const left = Math.max(8, Math.min(r.right - width, innerWidth - width - 8));
at = up
? { left, top: null, bottom: innerHeight - r.top + 6, width, max: Math.min(320, above) }
: { left, top: r.bottom + 6, bottom: null, width, max: Math.min(320, below) };
q = '';
active = Math.max(0, options.findIndex((o) => o.value === value));
open = true;
// the search box (or the list) takes the keyboard; the current row is scrolled into view
requestAnimationFrame(() => {
box?.querySelector('.sm-find input')?.focus();
box?.querySelector('.mi.act')?.scrollIntoView({ block: 'nearest' });
});
}
function close(refocus = true) {
open = false;
if (refocus) trigger?.focus();
}
function choose(o) {
if (!o) return;
close();
if (o.value !== value) onchange?.(o.value);
}
function onkeydown(e) {
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
e.preventDefault();
if (!shown.length) return;
active = (active + (e.key === 'ArrowDown' ? 1 : -1) + shown.length) % shown.length;
box?.querySelector('.mi.act')?.scrollIntoView({ block: 'nearest' });
} else if (e.key === 'Enter') {
e.preventDefault();
choose(shown[active]);
} else if (e.key === 'Escape') {
e.preventDefault();
e.stopPropagation(); // the dialog stays
close();
} else if (e.key === 'Tab') {
close(false);
}
}
function onTriggerKey(e) {
if (['Enter', ' ', 'ArrowDown', 'ArrowUp'].includes(e.key)) {
e.preventDefault();
open ? close() : show();
}
}
const mark = (s) => {
const t = q.trim().toLowerCase();
const i = t ? s.toLowerCase().indexOf(t) : -1;
return i < 0 ? [s, '', ''] : [s.slice(0, i), s.slice(i, i + t.length), s.slice(i + t.length)];
};
</script>
<svelte:window onresize={() => (open = false)} onpointerdown={(e) => { if (open && !box?.contains(e.target) && !trigger?.contains(e.target)) close(false); }} />
<button
bind:this={trigger}
class="fp sm"
class:open
role="combobox"
aria-label={label}
aria-haspopup="listbox"
aria-expanded={open}
onclick={() => (open ? close() : show())}
onkeydown={onTriggerKey}
>
<span class="sm-val" class:muted={!current}>{current ? current.label : placeholder}</span>
<span class="fp-chev"><Icon name="chev" /></span>
</button>
{#if open}
<div bind:this={box} class="pop fp-list sm-list" class:up={at.top === null} role="listbox" aria-label={label}
style="left:{at.left}px;{at.top !== null ? `top:${at.top}px;` : `bottom:${at.bottom}px;`}width:{at.width}px;max-height:{at.max}px">
{#if searchable}
<div class="sm-find">
<Icon name="search" />
<input bind:value={q} placeholder="Find…" aria-label="Find in the list" spellcheck="false" autocomplete="off" {onkeydown} oninput={() => (active = 0)} />
</div>
{/if}
<div class="sm-rows" tabindex="-1" role="presentation" {onkeydown}>
{#each shown as o, i (o.value)}
{@const [a, m, b] = mark(o.label)}
<button
class="mi"
class:act={i === active}
role="option"
aria-selected={o.value === value}
onmousemove={() => (active = i)}
onclick={() => choose(o)}
>
<span class="mi-label">{a}{#if m}<mark>{m}</mark>{/if}{b}</span>
{#if o.detail}<em>{o.detail}</em>{/if}
{#if o.value === value}<Icon name="tick" />{/if}
</button>
{:else}
<p class="fp-none">Nothing matches.</p>
{/each}
</div>
</div>
{/if}
+587
View File
@@ -0,0 +1,587 @@
<script>
import { onMount } from 'svelte';
import Icon from './Icon.svelte';
import FolderPicker from './FolderPicker.svelte';
import SelectMenu from './SelectMenu.svelte';
import { store } from '$lib/state.svelte.js';
import { api, isDesktop, errorText } from '$lib/api.js';
import { toast } from '$lib/toast.svelte.js';
import { KINDS } from '$lib/editor.js';
import { DEFAULTS, DATE_FORMATS } from '$lib/prefs.js';
import { formatDate } from '$lib/daily.js';
import { ACTIONS, comboOf, eventCombo, format, valid as validShortcut } from '$lib/shortcuts.js';
let closing = $state(false);
let spell = $state(null); // { enabled, languages, available, system }
let linkPreviews = $state(true);
let words = $state([]);
let newWord = $state('');
let backup = $state({ folder: '', last: 0 });
let backingUp = $state(false);
const p = $derived(store.prefs);
const set = (patch) => store.setPrefs(patch);
const setGraph = (patch) => store.setGraph(patch);
const hidden = $derived(new Set(p.hiddenKinds));
const kinds = KINDS.filter((k) => k.t !== 'p');
let tab = $state('appearance');
const TABS = [
['appearance', 'Appearance', 'palette'], ['editor', 'Editor', 'pen'], ['notes', 'Notes', 'folder'],
['canvas', 'Graph & canvas', 'graph'], ['general', 'General', 'gear'], ['shortcuts', 'Shortcuts', 'key']
];
const UI_FONTS = [['inter', 'Inter'], ['system', 'System'], ['mono', 'Mono']];
const DENSITIES = [['compact', 'Compact'], ['default', 'Default'], ['comfortable', 'Roomy']];
const AUTOSAVE = [[300, 'Fast'], [600, 'Default'], [1500, 'Relaxed'], [3000, 'Slow']];
const BACKUPS = [['off', 'Off'], ['daily', 'Daily'], ['weekly', 'Weekly']];
const CAPTURE_TO = [['daily', "Today's note"], ['inbox', 'Inbox']];
const WEEKS = [['monday', 'Monday'], ['sunday', 'Sunday']];
const NEW_ICON = [['random', 'Random'], ['none', 'None']];
const EXPORT_LINKS = [['wiki', '[[Wiki]]'], ['markdown', 'Markdown']];
const CARD_COLORS = [['', 'None', null], ['1', 'Red', '#e5534b'], ['2', 'Orange', '#e8883a'], ['3', 'Yellow', '#d9b130'], ['4', 'Green', '#44ad6e'], ['5', 'Cyan', '#3aa6c4'], ['6', 'Purple', '#9a6de0']];
const LOCALES = [['', 'System'], ['en-US', 'English (US)'], ['en-GB', 'English (UK)'], ['de-DE', 'Deutsch'], ['fr-FR', 'Français'], ['es-ES', 'Español'], ['it-IT', 'Italiano'], ['pt-BR', 'Português (Brasil)'], ['nl-NL', 'Nederlands'], ['sv-SE', 'Svenska'], ['pl-PL', 'Polski'], ['ru-RU', 'Русский'], ['ja-JP', '日本語'], ['zh-CN', '简体中文'], ['ko-KR', '한국어']];
const mac = typeof navigator !== 'undefined' && /mac/i.test(navigator.platform);
/* rebinding: click a shortcut, press the new keys; Escape cancels, Backspace puts the original back */
let capturing = $state(null); // action id
const comboNow = (id) => comboOf(id, p.shortcuts);
function onCaptureKey(e) {
if (!capturing) return;
e.preventDefault();
e.stopPropagation();
if (e.key === 'Escape') { capturing = null; return; }
const id = capturing;
if (e.key === 'Backspace' || e.key === 'Delete') {
const next = { ...p.shortcuts };
delete next[id];
set({ shortcuts: next });
capturing = null;
return;
}
const c = eventCombo(e);
if (!c) return; // only modifiers so far
if (!validShortcut(c)) { toast('Use Ctrl (or ⌘) with a key', 'x'); return; }
const clash = ACTIONS.find((a) => a.id !== id && comboOf(a.id, p.shortcuts) === c);
if (clash) { toast(`${format(c, mac)} is already “${clash.label}”`, 'x'); return; }
const def = ACTIONS.find((a) => a.id === id).combo;
const next = { ...p.shortcuts };
if (c === def) delete next[id]; else next[id] = c;
set({ shortcuts: next });
capturing = null;
}
const M = mac ? '⌘' : 'Ctrl';
const SHORTCUTS = [
['General', [
['Command palette', [M, 'K']], ['Settings', [M, ',']], ['New page', [M, 'N']], ["Today's note", [M, 'Shift', 'D']],
['Graph view', [M, 'G']], ['Toggle sidebar', [M, '\\']], ['Split view', [M, 'Shift', '\\']], ['Outline', [M, 'Shift', 'O']],
['Find in page', [M, 'F']], ['Save now', [M, 'S']], ['Back', ['Alt', '←']], ['Forward', ['Alt', '→']]
]],
['Writing', [
['Bold', [M, 'B']], ['Italic', [M, 'I']], ['Inline code', [M, 'E']], ['Strikethrough', [M, 'Shift', 'S']],
['Highlight', [M, 'Shift', 'H']], ['Link', [M, 'K']], ['Duplicate block', [M, 'D']], ['Block menu', ['/']], ['Link to a page', ['[[']]
]],
['Markdown', [
['Heading 1 – 3', ['#', '##', '###']], ['Bulleted list', ['- ']], ['Numbered list', ['1. ']], ['To-do', ['[] ']],
['Quote', ['> ']], ['Code block', ['```']], ['Divider', ['---']]
]]
];
const sample = formatDate(new Date(), 'YYYY-MM-DD');
// [name, label, swatch color]: the swatch shows the dark-theme tone, close enough in either.
const ACCENTS = [['blush', 'Blush', '#e4ada5'], ['amber', 'Amber', '#e0b672'], ['sage', 'Sage', '#a3bf9c'], ['sky', 'Sky', '#8fb6e0'], ['violet', 'Violet', '#b3a1dc']];
// [name, label, dark, light], each [ground, sidebar, raised, text]
const PALETTES = [
['default', 'Default', ['#0e0e0f', '#161617', '#222224', '#ececed'], ['#e7e7e9', '#fbfbfb', '#ffffff', '#1c1c1e']],
['midnight', 'Midnight', ['#0a0e17', '#0f1522', '#1b2336', '#e6eaf2'], ['#dfe5ee', '#f7f9fc', '#ffffff', '#17202e']],
['nord', 'Nord', ['#232831', '#2b303b', '#3a4150', '#e5e9f0'], ['#dde2ea', '#eceff4', '#ffffff', '#2e3440']],
['forest', 'Forest', ['#0c120f', '#121a15', '#1e2922', '#e4ece6'], ['#dfe6df', '#f6f9f5', '#ffffff', '#1a241d']],
['sepia', 'Sepia', ['#17130e', '#1e1912', '#2d261c', '#ece4d6'], ['#e6dccb', '#f8f1e3', '#fffaf0', '#3a2f20']],
['catppuccin', 'Catppuccin', ['#11111b', '#181825', '#313244', '#cdd6f4'], ['#dce0e8', '#eff1f5', '#ffffff', '#4c4f69']],
['catppuccin-dark', 'Catppuccin Dark', ['#08080d', '#0d0d14', '#232333', '#cdd6f4'], ['#dce0e8', '#eff1f5', '#ffffff', '#4c4f69']],
['amoled', 'AMOLED', ['#000000', '#000000', '#161617', '#ececed'], ['#e7e7e9', '#fbfbfb', '#ffffff', '#1c1c1e']]
];
const FONTS = [['sans', 'Sans'], ['serif', 'Serif'], ['mono', 'Mono']];
const SPACING = [['compact', 'Compact'], ['default', 'Default'], ['relaxed', 'Relaxed']];
const STARTS = [['last', 'Last page'], ['today', "Today's note"]];
const THEMES = [['dark', 'Dark', 'moon'], ['light', 'Light', 'sun'], ['system', 'System', 'monitor']];
const LAYOUTS = [['vertical', 'Vertical', 'layoutV'], ['horizontal', 'Horizontal', 'layoutH']];
const WIDTHS = [['narrow', 'Narrow'], ['default', 'Default'], ['wide', 'Wide'], ['full', 'Full']];
const SIZES = { min: 13, max: 22, base: 16 };
// "de-DE" -> "German (Germany)", in the user's own language.
let names;
try { names = new Intl.DisplayNames([navigator.language, 'en'], { type: 'language' }); } catch { names = null; }
const langName = (code) => {
try { return (names && names.of(code)) || code; } catch { return code; }
};
let languages = $derived(
(spell?.available || []).map((c) => ({ code: c, name: langName(c) })).sort((a, b) => a.name.localeCompare(b.name))
);
onMount(async () => {
try { linkPreviews = (await api.getSettings()).linkPreviews !== false; } catch { /* stays on */ }
try { words = await api.spellWords(); } catch { words = []; }
try { backup = await api.backupInfo(); } catch { /* browser build */ }
try { spell = await api.getSpellcheck(); } catch { spell = { enabled: true, languages: [], available: [], system: true }; }
});
function close() {
closing = true;
setTimeout(() => { store.settingsOpen = false; closing = false; }, 150);
}
async function saveSpell(enabled, langs) {
// Plain arrays only: IPC can't send Svelte's reactive proxies.
const clean = [...new Set(langs.filter(Boolean))];
const languages = clean.length ? clean : [...spell.languages];
spell = { ...spell, enabled, languages };
try { await api.setSpellcheck(enabled, languages); } catch (err) { toast(errorText(err), 'x'); }
}
const setMain = (code) => saveSpell(spell.enabled, [code, ...spell.languages.slice(1).filter((c) => c !== code)]);
const setSecond = (code) => saveSpell(spell.enabled, [spell.languages[0], code]);
const autoDaily = () => store.setDailyFolder('');
async function setLinkPreviews() {
linkPreviews = !linkPreviews;
try { await api.setSettings({ linkPreviews }); } catch (err) { toast(errorText(err), 'x'); }
}
function changeFolder() {
close();
setTimeout(() => store.pick('choose'), 160);
}
function toggleKind(t) {
const next = new Set(p.hiddenKinds);
next.has(t) ? next.delete(t) : next.add(t);
set({ hiddenKinds: [...next] });
}
async function addWord() {
const w = newWord.trim();
if (!w || /\s/.test(w)) return;
newWord = '';
try { await api.addSpellWord(w); words = await api.spellWords(); } catch (err) { toast(errorText(err), 'x'); }
}
async function dropWord(w) {
try { await api.removeSpellWord(w); words = words.filter((x) => x !== w); } catch (err) { toast(errorText(err), 'x'); }
}
async function backupNow() {
backingUp = true;
try {
const r = await api.backupNow();
backup = await api.backupInfo();
toast(`Backed up ${r.count} files`, 'check');
} catch (err) { toast(errorText(err), 'x'); }
backingUp = false;
}
async function pickBackupFolder() {
try {
const dir = await api.pickBackupFolder();
if (dir) { set({ backupFolder: dir }); backup = await api.backupInfo(); }
} catch (err) { toast(errorText(err), 'x'); }
}
const resetAll = () => {
if (!confirm('Put every setting back to how it came? Your notes are not touched.')) return;
store.resetSettings();
toast('Settings reset', 'check');
};
// A number box that applies on change (not on every keystroke) and snaps back to the allowed range.
const num = (key) => (e) => { set({ [key]: e.currentTarget.value }); e.currentTarget.value = store.prefs[key]; };
const lastBackup = $derived(backup.last ? new Date(backup.last).toLocaleString(p.locale || undefined) : 'Never');
</script>
<svelte:window onkeydowncapture={onCaptureKey} onkeydown={(e) => e.key === 'Escape' && !capturing && close()} />
{#snippet toggle(label, desc, on, flip)}
<div class="set-row">
<div class="set-t"><b>{label}</b>{#if desc}<small>{desc}</small>{/if}</div>
<button class="tgl" class:on role="switch" aria-checked={on} aria-label={label} onclick={flip}><span></span></button>
</div>
{/snippet}
{#snippet seg(label, desc, options, value, pick)}
<div class="set-row">
<div class="set-t"><b>{label}</b>{#if desc}<small>{desc}</small>{/if}</div>
<div class="seg3" role="radiogroup" aria-label={label}>
{#each options as [v, text] (v)}
<button class:on={value === v} role="radio" aria-checked={value === v} onclick={() => pick(v)}>{text}</button>
{/each}
</div>
</div>
{/snippet}
<div class="overlay" class:closing role="presentation" onmousedown={(e) => e.target === e.currentTarget && close()}>
<div class="panel settings" role="dialog" aria-label="Settings">
<div class="set-top">
<Icon name="gear" />
<h2>Settings</h2>
<button class="btn-icon" aria-label="Close" onclick={close}><Icon name="x" /></button>
</div>
<div class="set-main">
<nav class="set-nav" aria-label="Settings sections">
{#each TABS as [v, label, icon] (v)}
<button class:on={tab === v} aria-current={tab === v ? 'page' : undefined} onclick={() => (tab = v)}><Icon name={icon} />{label}</button>
{/each}
</nav>
<div class="set-body">
{#if tab === 'appearance'}
<h3>Theme</h3>
<div class="set-row">
<div class="set-t"><b>Mode</b><small>System follows your desktop's light or dark setting.</small></div>
<div class="seg3" role="radiogroup" aria-label="Theme">
{#each THEMES as [v, label, icon] (v)}
<button class:on={store.themePref === v} role="radio" aria-checked={store.themePref === v} onclick={(e) => store.setThemePref(v, e.currentTarget)}>
<Icon name={icon} />{label}
</button>
{/each}
</div>
</div>
<div class="set-block">
<div class="set-t"><b>Color theme</b><small>The tint of backgrounds and text. Works in light and dark mode{store.theme === 'light' ? '; AMOLED is dark-only' : ''}.</small></div>
<div class="palettes" role="radiogroup" aria-label="Color theme">
{#each PALETTES as [v, label, dark, light] (v)}
{@const c = store.theme === 'light' ? light : dark}
<button class="pal" class:on={store.palette === v} role="radio" aria-checked={store.palette === v} style="--g:{c[0]};--i:{c[1]};--r:{c[2]};--t:{c[3]}" onclick={() => store.setPalette(v)}>
<i><b></b><b></b><b></b></i>
<span>{label}</span>
</button>
{/each}
</div>
</div>
<div class="set-row">
<div class="set-t"><b>Accent color</b><small>Highlights, links, toggles and the caret.</small></div>
<div class="swatches" role="radiogroup" aria-label="Accent color">
{#each ACCENTS as [v, label, c] (v)}
<button class="swatch" class:on={store.accent === v} style="--c:{c}" role="radio" aria-checked={store.accent === v} aria-label={label} title={label} onclick={() => store.setAccent(v)}>
{#if store.accent === v}<Icon name="check" />{/if}
</button>
{/each}
</div>
</div>
{@render seg('Interface font', 'The font of menus, the sidebar and buttons.', UI_FONTS, p.uiFont, (v) => set({ uiFont: v }))}
<h3>Layout</h3>
<div class="set-row">
<div class="set-t"><b>Text size</b><small>For page text and headings.</small></div>
<div class="set-size">
<span class="a-small">A</span>
<input type="range" min={SIZES.min} max={SIZES.max} step="1" value={store.fontSize} aria-label="Text size" aria-valuetext="{store.fontSize} pixels" oninput={(e) => store.setFontSize(e.currentTarget.value)} />
<span class="a-big">A</span>
<button class="set-val" title="Back to {SIZES.base}" onclick={() => store.setFontSize(SIZES.base)} disabled={store.fontSize === SIZES.base}>{store.fontSize}px</button>
</div>
</div>
<p class="set-preview" style="font-size:var(--fs)">The quick brown fox jumps over the lazy dog.</p>
<div class="set-row">
<div class="set-t"><b>Page width</b><small>How wide text runs on ordinary pages. Boards always use the full width.</small></div>
<div class="seg3" role="radiogroup" aria-label="Page width">
{#each WIDTHS as [v, label] (v)}
<button class:on={store.pageWidth === v} role="radio" aria-checked={store.pageWidth === v} onclick={() => store.setPageWidth(v)}>{label}</button>
{/each}
</div>
</div>
{@render seg('Density', 'Height of rows in the sidebar.', DENSITIES, p.density, (v) => set({ density: v }))}
<div class="set-row">
<div class="set-t"><b>Sidebar width</b><small>Drag to resize.</small></div>
<div class="set-size">
<input type="range" min="220" max="420" step="4" value={p.sidebarWidth} aria-label="Sidebar width" oninput={(e) => set({ sidebarWidth: e.currentTarget.value })} />
<button class="set-val" title="Back to {DEFAULTS.sidebarWidth}" onclick={() => set({ sidebarWidth: DEFAULTS.sidebarWidth })} disabled={p.sidebarWidth === DEFAULTS.sidebarWidth}>{p.sidebarWidth}px</button>
</div>
</div>
<div class="set-row">
<div class="set-t"><b>Sidebar shortcuts</b><small>Search, New page, Today and Graph view, as a list or a row of icons.</small></div>
<div class="seg3" role="radiogroup" aria-label="Sidebar shortcuts">
{#each LAYOUTS as [v, label, icon] (v)}
<button class:on={store.quickLayout === v} role="radio" aria-checked={store.quickLayout === v} data-layout={v} onclick={() => store.setQuickLayout(v)}><Icon name={icon} />{label}</button>
{/each}
</div>
</div>
<h3>Pages</h3>
{@render toggle('Cover image', 'The banner at the top of a page.', p.showCover, () => set({ showCover: !p.showCover }))}
{@render toggle('Page icon', null, p.showIcon, () => set({ showIcon: !p.showIcon }))}
{@render toggle('Linked from', 'The list of pages that link here, under the page.', p.showBacklinks, () => set({ showBacklinks: !p.showBacklinks }))}
<h3>Motion</h3>
{@render toggle('Reduce motion', 'Turns off animations and transitions.', store.reduceMotion, () => store.setReduceMotion(!store.reduceMotion))}
{:else if tab === 'editor'}
<h3>Writing</h3>
{@render seg('Page font', 'For page text and titles.', FONTS, store.pageFont, (v) => store.setPageFont(v))}
<div class="set-row">
<div class="set-t"><b>Custom page font</b><small>The name of a font installed on this computer, e.g. Georgia or Atkinson Hyperlegible. Empty uses the choice above.</small></div>
<input class="set-in" type="text" value={p.customPageFont} spellcheck="false" aria-label="Custom page font" onchange={(e) => set({ customPageFont: e.currentTarget.value.trim() })} />
</div>
<div class="set-row">
<div class="set-t"><b>Custom code font</b><small>For code blocks and inline code.</small></div>
<input class="set-in" type="text" value={p.customCodeFont} spellcheck="false" aria-label="Custom code font" onchange={(e) => set({ customCodeFont: e.currentTarget.value.trim() })} />
</div>
{@render seg('Line spacing', 'Space between lines of text.', SPACING, store.lineSpacing, (v) => store.setLineSpacing(v))}
<p class="set-preview" style="font-size:var(--fs);font-family:var(--f-page);line-height:var(--lh)">The quick brown fox jumps over the lazy dog. Pack my box with five dozen liquor jugs.</p>
<div class="set-row">
<div class="set-t"><b>Code size</b><small>Relative to the page text.</small></div>
<div class="set-size">
<input type="range" min="80" max="140" step="5" value={p.codeSize} aria-label="Code size" oninput={(e) => set({ codeSize: e.currentTarget.value })} />
<button class="set-val" title="Back to 100%" onclick={() => set({ codeSize: 100 })} disabled={p.codeSize === 100}>{p.codeSize}%</button>
</div>
</div>
{@render toggle('Wrap long code lines', 'Instead of scrolling sideways.', p.codeWrap, () => set({ codeWrap: !p.codeWrap }))}
<h3>Typing</h3>
{@render toggle('Close brackets and quotes', 'Typing ( [ { or ` adds the closing one.', p.autoPair, () => set({ autoPair: !p.autoPair }))}
{@render toggle('Smart typography', 'Curly quotes, -- as an em dash, ... as an ellipsis.', p.smartTypography, () => set({ smartTypography: !p.smartTypography }))}
{@render toggle('Markdown shortcuts', 'Typing "# " or "- " at the start of a line makes a heading or a list.', p.mdShortcuts, () => set({ mdShortcuts: !p.mdShortcuts }))}
{@render toggle('Paste formatted text as Markdown', 'Text copied from a web page keeps its headings, lists, links and bold. Off pastes plain text.', p.pasteRich, () => set({ pasteRich: !p.pasteRich }))}
{@render toggle('Typewriter mode', 'Keeps the line you are writing in the middle of the screen.', p.typewriter, () => set({ typewriter: !p.typewriter }))}
{@render seg('Autosave', 'How long after you stop typing the page is saved.', AUTOSAVE, p.autosaveDelay, (v) => set({ autosaveDelay: v }))}
<h3>Slash menu</h3>
<p class="set-sub">Click a block to hide it from the “/” menu. It can still be made with its Markdown shortcut.</p>
<div class="chips">
{#each kinds as k (k.t)}
<button class="chip-t" class:on={!hidden.has(k.t)} aria-pressed={!hidden.has(k.t)} onclick={() => toggleKind(k.t)}>{k.label}</button>
{/each}
</div>
<h3>Spellcheck</h3>
{@render spellcheck()}
{:else if tab === 'notes'}
<h3>Notes folder</h3>
<div class="set-row">
<div class="set-t">
<b>{store.vault?.name || 'No folder'}</b>
<small class="mono" title={store.vault?.path}>{store.vault?.path || '—'}</small>
</div>
<div class="set-btns">
{#if isDesktop}<button class="btn" onclick={() => api.reveal('')}><Icon name="folder" />Show</button>{/if}
<button class="btn" onclick={changeFolder}>Change…</button>
</div>
</div>
<h3>Daily notes</h3>
<div class="set-row">
<div class="set-t">
<b>Folder</b>
<small>Today's note is made here. A path puts it inside other folders, e.g. Private/Daily.</small>
</div>
<FolderPicker value={store.dailyFolder || ''} label="Daily notes folder" onchange={(v) => store.setDailyFolder(v)} />
</div>
{#if store.dailyFolder}
<p class="set-note">Using <b>{store.dailyFolder}</b>. <button class="set-link" onclick={autoDaily}>Find it automatically</button></p>
{:else}
<p class="set-note">Found automatically: <b>{store.dailyFolderNow}</b>. Type a path above to choose another.</p>
{/if}
<div class="set-row">
<div class="set-t"><b>Note name</b><small>How today's note is named. Notes already made keep their names.</small></div>
<SelectMenu
label="Daily note name"
value={p.dateFormat}
options={DATE_FORMATS.map((f) => ({ value: f, label: formatDate(new Date(), f, p.locale), detail: f }))}
onchange={(v) => set({ dateFormat: v })}
/>
</div>
<div class="set-block">
<div class="set-t"><b>Template</b><small>What a new daily note starts with, in Markdown. {'{{date}}'} and {'{{weekday}}'} are filled in.</small></div>
<textarea class="set-area" rows="4" spellcheck="false" placeholder={'## {{weekday}}\n\n- [ ] '} value={p.dailyTemplate} onchange={(e) => set({ dailyTemplate: e.currentTarget.value })}></textarea>
</div>
<h3>New pages</h3>
{@render seg('Icon', 'What a new page starts with.', NEW_ICON, p.newPageIcon, (v) => set({ newPageIcon: v }))}
{@render seg('Cover', null, NEW_ICON, p.newPageCover, (v) => set({ newPageCover: v }))}
<h3>Dates</h3>
{@render seg('Week starts on', 'For calendar views of a database.', WEEKS, p.weekStart, (v) => set({ weekStart: v }))}
<div class="set-row">
<div class="set-t"><b>Language of dates</b><small>Month and day names, e.g. {formatDate(new Date(), 'dddd, MMMM D', p.locale)}.</small></div>
<SelectMenu label="Language of dates" value={p.locale} options={LOCALES.map(([value, label]) => ({ value, label }))} onchange={(v) => set({ locale: v })} />
</div>
<h3>Trash and history</h3>
{@render toggle('Ask before deleting', 'Confirm before a page goes to the trash.', p.confirmTrash, () => set({ confirmTrash: !p.confirmTrash }))}
<div class="set-row">
<div class="set-t"><b>Empty the trash after</b><small>Days a deleted page stays restorable. 0 keeps it until you empty it. Applies at launch.</small></div>
<input class="set-in num" type="number" min="0" max="365" value={p.trashDays} aria-label="Days in trash" onchange={num('trashDays')} />
</div>
<div class="set-row">
<div class="set-t"><b>Versions kept per page</b><small>Older ones are removed.</small></div>
<input class="set-in num" type="number" min="5" max="1000" value={p.historyKeep} aria-label="Versions kept" onchange={num('historyKeep')} />
</div>
<div class="set-row">
<div class="set-t"><b>Minutes between versions</b><small>While you edit, at most one version is kept per this many minutes.</small></div>
<input class="set-in num" type="number" min="1" max="120" value={p.historyGapMin} aria-label="Minutes between versions" onchange={num('historyGapMin')} />
</div>
<h3>Export</h3>
{@render seg('Links in Markdown', 'How links to other pages are written when you export or copy Markdown.', EXPORT_LINKS, p.exportLinks, (v) => set({ exportLinks: v }))}
{:else if tab === 'canvas'}
<h3>Graph view</h3>
<p class="set-sub">What the graph shows when you open it. The graph's own panel changes the same options.</p>
{@render seg('Show', null, [['all', 'All notes'], ['local', 'This page']], store.graph.local ? 'local' : 'all', (v) => setGraph({ local: v === 'local' }))}
{@render toggle('Notes without links', null, store.graph.orphans, () => setGraph({ orphans: !store.graph.orphans }))}
{@render toggle('Links to missing pages', null, store.graph.ghosts, () => setGraph({ ghosts: !store.graph.ghosts }))}
{@render toggle('Tags as nodes', 'Each tag becomes a node its pages hang from. Clicking one filters the sidebar.', !!store.graph.tags, () => setGraph({ tags: !store.graph.tags }))}
{@render toggle('Page hierarchy', null, store.graph.folders, () => setGraph({ folders: !store.graph.folders }))}
<h3>Canvas</h3>
{@render toggle('Preview web links', "Link cards show the page's title and description. This visits the address when a canvas is opened.", linkPreviews, setLinkPreviews)}
{@render toggle('Snap to grid', 'Cards and edges line up on a grid. Hold Alt while dragging to skip it for one move.', p.snapToGrid, () => set({ snapToGrid: !p.snapToGrid }))}
<div class="set-row">
<div class="set-t"><b>New card color</b></div>
<div class="swatches" role="radiogroup" aria-label="New card color">
{#each CARD_COLORS as [v, label, c] (v)}
<button class="swatch" class:on={p.cardColor === v} style="--c:{c || 'transparent'}" role="radio" aria-checked={p.cardColor === v} aria-label={label} title={label} onclick={() => set({ cardColor: v })}>
{#if p.cardColor === v}<Icon name="check" />{/if}
</button>
{/each}
</div>
</div>
{:else if tab === 'general'}
<h3>Startup</h3>
<div class="set-row">
<div class="set-t"><b>Open on launch</b><small>Which page is shown when the app starts.</small></div>
<div class="seg3" role="radiogroup" aria-label="Open on launch">
{#each STARTS as [v, label] (v)}
<button class:on={store.startPage === v} role="radio" aria-checked={store.startPage === v} onclick={() => store.setStartPage(v)}>{label}</button>
{/each}
</div>
</div>
{#if isDesktop}
{@render toggle('Start minimized', null, p.startMinimized, () => set({ startMinimized: !p.startMinimized }))}
<h3>Quick capture</h3>
<div class="set-row">
<div class="set-t"><b>Shortcut</b><small>Works from any app, e.g. CommandOrControl+Alt+N. Empty turns it off. Some Wayland desktops don't allow global shortcuts.</small></div>
<input class="set-in" type="text" value={p.captureShortcut} spellcheck="false" aria-label="Quick capture shortcut" onchange={(e) => set({ captureShortcut: e.currentTarget.value.trim() })} />
</div>
{@render seg('Add to', 'Where the capture window puts what you type. Today\'s note is made if it is missing; Inbox is a page at the top level.', CAPTURE_TO, p.captureTo, (v) => set({ captureTo: v }))}
<h3>Window</h3>
{@render toggle('Always on top', 'Keep the window above other windows.', p.alwaysOnTop, () => set({ alwaysOnTop: !p.alwaysOnTop }))}
<h3>Import and export</h3>
<div class="set-row">
<div class="set-t"><b>Import notes</b><small>An Obsidian vault or a Notion "Markdown &amp; CSV" export, as a folder or a .zip. It goes into a new folder; nothing existing changes.</small></div>
<div class="set-btns">
<button class="btn" onclick={() => store.importNotes('folder')}><Icon name="folder" />Folder…</button>
<button class="btn" onclick={() => store.importNotes('zip')}><Icon name="download" />.zip…</button>
</div>
</div>
<div class="set-row">
<div class="set-t"><b>Export everything</b><small>All pages and pictures as one .zip (without version history and the trash).</small></div>
<div class="set-btns"><button class="btn" onclick={() => store.exportVaultZip()}><Icon name="export" />Export .zip…</button></div>
</div>
<h3>Backups</h3>
{@render seg('Back up automatically', 'A zip of your notes folder (without version history and the trash).', BACKUPS, p.backupEvery, (v) => set({ backupEvery: v }))}
<div class="set-row">
<div class="set-t"><b>Backup folder</b><small class="mono" title={backup.folder}>{backup.folder || '—'}</small></div>
<div class="set-btns">
<button class="btn" onclick={() => api.revealBackups()}><Icon name="folder" />Show</button>
<button class="btn" onclick={pickBackupFolder}>Change…</button>
</div>
</div>
<div class="set-row">
<div class="set-t"><b>Backups kept</b><small>The oldest are deleted.</small></div>
<input class="set-in num" type="number" min="1" max="50" value={p.backupKeep} aria-label="Backups kept" onchange={num('backupKeep')} />
</div>
<div class="set-row">
<div class="set-t"><b>Back up now</b><small>Last backup: {lastBackup}</small></div>
<button class="btn" disabled={backingUp} onclick={backupNow}>{backingUp ? 'Backing up…' : 'Back up now'}</button>
</div>
{/if}
<h3>Reset</h3>
<div class="set-row">
<div class="set-t"><b>Reset all settings</b><small>Appearance, editor and the rest go back to how they came. Your notes and folder are not touched.</small></div>
<button class="btn danger-btn" onclick={resetAll}>Reset…</button>
</div>
{:else}
<h3>Your shortcuts</h3>
<p class="set-sub">Click one, then press the new keys. Escape cancels; Backspace puts the original back.</p>
<div class="keys rebind">
{#each ACTIONS as a (a.id)}
<span>{a.label}</span>
<button class="kd kd-btn" class:capturing={capturing === a.id} aria-label="Change shortcut for {a.label}" onclick={() => (capturing = capturing === a.id ? null : a.id)}>
{#if capturing === a.id}<em>Press keys…</em>{:else}<kbd>{format(comboNow(a.id), mac)}</kbd>{/if}
</button>
{/each}
</div>
{#if Object.keys(p.shortcuts || {}).length}
<button class="btn" style="margin-top:10px" onclick={() => set({ shortcuts: {} })}>Reset all to the originals</button>
{/if}
<h3>Keyboard shortcuts</h3>
{#each SHORTCUTS as [group, rows] (group)}
<p class="set-sub">{group}</p>
<div class="keys">
{#each rows as [label, keys] (label)}
<span>{label}</span>
<span class="kd">{#each keys as k, i (i)}<kbd>{k}</kbd>{/each}</span>
{/each}
</div>
{/each}
{/if}
</div>
</div>
</div>
</div>
{#snippet spellcheck()}
<div class="set-row">
<div class="set-t"><b>Check spelling</b><small>Underlines misspelled words; right-click one for suggestions.</small></div>
<button
class="tgl"
class:on={spell?.enabled}
role="switch"
aria-checked={!!spell?.enabled}
aria-label="Check spelling"
disabled={!spell}
onclick={() => saveSpell(!spell.enabled, spell.languages)}
><span></span></button>
</div>
{#if spell?.enabled}
{#if spell.system}
<p class="set-note">Spelling follows your system's languages here.</p>
{:else}
<div class="set-row">
<div class="set-t"><b>Language</b></div>
<SelectMenu
label="Spellcheck language"
value={spell.languages[0] || ''}
options={languages.map((l) => ({ value: l.code, label: l.name, detail: l.code }))}
onchange={setMain}
/>
</div>
<div class="set-row">
<div class="set-t"><b>Also check</b><small>A second language, for notes that mix two.</small></div>
<SelectMenu
label="Second spellcheck language"
value={spell.languages[1] || ''}
options={[{ value: '', label: 'None' }, ...languages.filter((l) => l.code !== spell.languages[0]).map((l) => ({ value: l.code, label: l.name, detail: l.code }))]}
onchange={setSecond}
/>
</div>
<p class="set-note">Dictionaries are downloaded once per language, then work offline.</p>
{/if}
{/if}
{#if isDesktop}
<div class="set-block">
<div class="set-t"><b>Custom dictionary</b><small>Words you added with “Add to dictionary”. They are never underlined.</small></div>
<form class="set-btns" style="margin-top:10px" onsubmit={(e) => { e.preventDefault(); addWord(); }}>
<input class="set-in" placeholder="Add a word" aria-label="Add a word" bind:value={newWord} />
<button class="btn" type="submit" disabled={!newWord.trim()}>Add</button>
</form>
{#if words.length}
<div class="words">
{#each words as w (w)}
<span class="word">{w}<button aria-label="Remove {w}" onclick={() => dropWord(w)}><Icon name="x" /></button></span>
{/each}
</div>
{/if}
</div>
{/if}
{/snippet}
+302
View File
@@ -0,0 +1,302 @@
<script>
import Icon from './Icon.svelte';
import TreeNode from './TreeNode.svelte';
import { store, iconOf } from '$lib/state.svelte.js';
import { api, isDesktop } from '$lib/api.js';
import { tagHex } from '$lib/tagColors.js';
import { comboOf, format } from '$lib/shortcuts.js';
let roots = $derived(store.children(null));
let tagged = $derived(
store.filterTag
? store.pages.filter((p) => p.tags.includes(store.filterTag)).sort((a, b) => a.title.localeCompare(b.title))
: []
);
// Many tags: a search box over them, and the list scrolls inside a short box instead of pushing the page tree away.
const TAG_FIND_AT = 8;
let tagQ = $state('');
let shownTags = $derived.by(() => {
const q = tagQ.trim().toLowerCase().replace(/^#/, '');
return q ? store.tagCounts.filter(([t]) => t.toLowerCase().includes(q)) : store.tagCounts;
});
let mod = $derived(isDesktop && window.vault.platform === 'darwin' ? '⌘' : 'Ctrl');
const mac = isDesktop && window.vault.platform === 'darwin';
const key = (id) => format(comboOf(id, store.prefs.shortcuts), mac);
// Tab lands on the list as one stop; the arrow keys then move between rows (TreeNode), starting from the open page.
function focusTree(e) {
if (e.target !== e.currentTarget) return;
const rows = [...e.currentTarget.querySelectorAll('.row[data-id]')].filter((r) => r.offsetHeight > 0);
(rows.find((r) => r.classList.contains('active')) || rows[0])?.focus();
}
/* drag a page onto another to nest it, or onto empty space / the Pages header for the top level */
let treeEl = $state();
// A page opened from search, a link or Back: bring its row into view (after its folders have opened).
$effect(() => {
const id = store.currentId;
if (!id || !treeEl) return;
const t = setTimeout(() => {
const row = [...treeEl.querySelectorAll('.row[data-id]')].find((r) => r.dataset.id === id);
row?.scrollIntoView({ block: 'nearest' });
}, 120);
return () => clearTimeout(t);
});
let drag = null; // { id, x, y, ghost, started, target, hoverTimer, hoverId }
let dropOn = $state(undefined); // page id, null = top level, undefined = nowhere
let dragging = $state(null);
const canDrop = (id, parent) => {
const page = store.pages.find((p) => p.id === id);
return !!page && page.parent !== parent && parent !== id && !(parent && parent.startsWith(id + '/'));
};
function onTreeDown(e) {
if (e.button !== 0 || e.target.closest('button, input')) return;
const row = e.target.closest('.row[data-id]');
if (!row || store.renaming) return;
drag = { id: row.dataset.id, x: e.clientX, y: e.clientY, ghost: null, started: false };
document.addEventListener('pointermove', onMove);
document.addEventListener('pointerup', onUp);
document.addEventListener('pointercancel', stop);
document.addEventListener('keydown', onKey, true);
}
function begin() {
drag.started = true;
dragging = drag.id;
const page = store.pages.find((p) => p.id === drag.id);
const g = document.createElement('div');
g.className = 'page-ghost';
g.textContent = page ? page.title : drag.id;
document.body.append(g);
drag.ghost = g;
document.body.classList.add('page-dragging');
getSelection()?.removeAllRanges();
}
function onMove(e) {
if (!drag.started) {
if (Math.hypot(e.clientX - drag.x, e.clientY - drag.y) < 5) return;
begin();
}
drag.ghost.style.transform = `translate(${e.clientX + 12}px, ${e.clientY + 10}px)`;
const el = document.elementFromPoint(e.clientX, e.clientY);
const row = el && el.closest('.sidebar .row[data-id]');
const toRoot = !row && el && (el.closest('.tree') || el.closest('.sec-pages'));
const want = row ? row.dataset.id : toRoot ? null : undefined;
dropOn = want !== undefined && canDrop(drag.id, want) ? want : undefined;
// Over an open canvas, letting go puts the page on it as a note card.
store.canvasView?.dropHint(!!(el && el.closest('.cv')));
// Over the page itself, its left or right half takes the page into split view.
const main = el && !el.closest('.cv') && el.closest('.main');
if (main && store.view === 'page' && drag.id !== store.currentId) {
const r = main.getBoundingClientRect();
store.splitHint = e.clientX < r.left + r.width / 2 ? 'left' : 'right';
} else store.splitHint = null;
// Hovering a closed page with sub-pages opens it after a moment.
if (row && row.dataset.id !== drag.hoverId) {
clearTimeout(drag.hoverTimer);
drag.hoverId = row.dataset.id;
const id = row.dataset.id;
drag.hoverTimer = setTimeout(() => { if (!store.open[id] && store.children(id).length) store.toggleFold(id); }, 650);
} else if (!row) {
clearTimeout(drag.hoverTimer);
drag.hoverId = null;
}
// Scroll the list near its edges.
if (treeEl) {
const r = treeEl.getBoundingClientRect();
if (e.clientY < r.top + 24) treeEl.scrollTop -= 8;
else if (e.clientY > r.bottom - 24) treeEl.scrollTop += 8;
}
}
function onUp(e) {
const d = drag, to = dropOn, side = store.splitHint;
stop();
if (!d.started) return;
// The click that follows a drag should not open a page.
window.addEventListener('click', swallow, true);
setTimeout(() => window.removeEventListener('click', swallow, true), 0);
const over = document.elementFromPoint(e.clientX, e.clientY);
if (over && over.closest('.cv') && store.canvasView) {
store.canvasView.addPageAt(d.id, e.clientX, e.clientY);
return;
}
if (side) { store.dropToSide(d.id, side); return; }
if (to !== undefined) store.movePage(d.id, to);
}
const swallow = (e) => e.stopPropagation();
const onKey = (e) => { if (e.key === 'Escape') { e.preventDefault(); e.stopPropagation(); stop(); } };
function stop() {
if (!drag) return;
clearTimeout(drag.hoverTimer);
drag.ghost?.remove();
store.canvasView?.dropHint(false);
store.splitHint = null;
document.body.classList.remove('page-dragging');
document.removeEventListener('pointermove', onMove);
document.removeEventListener('pointerup', onUp);
document.removeEventListener('pointercancel', stop);
document.removeEventListener('keydown', onKey, true);
drag = null;
dropOn = undefined;
dragging = null;
}
</script>
<aside class="sidebar island" aria-label="Notes">
<div class="sb-inner">
<div class="ws">
<div class="ws-mark">{(store.vault?.name?.[0] || 'N').toUpperCase()}</div>
<div class="ws-txt">
<strong>{store.vault?.name}</strong>
</div>
<button class="btn-icon" aria-label="Hide sidebar" title="Hide sidebar ({key('sidebar')})" onclick={() => store.toggleSidebar()}>
<Icon name="panel" />
</button>
</div>
<div class="qas" class:row={store.quickLayout === 'horizontal'}>
<button class="qa" aria-label="Search" title="Search ({key('palette')})" onclick={() => (store.paletteOpen = true)}><Icon name="search" /><span class="qa-t">Search</span><kbd>{key('palette')}</kbd></button>
<button class="qa" aria-label="New page" title="New page ({key('newPage')})" onclick={() => (store.templatesOpen = { parent: null })}><Icon name="plus" /><span class="qa-t">New page</span><kbd>{key('newPage')}</kbd></button>
<button class="qa" aria-label="Today" title="Today ({key('daily')})" onclick={() => store.openDaily()}><Icon name="sun" /><span class="qa-t">Today</span><kbd>{key('daily')}</kbd></button>
<button class="qa" class:on={store.view === 'graph'} aria-label="Graph view" title="Graph view ({key('graph')})" onclick={() => (store.view === 'graph' ? store.closeGraph() : store.openGraph())}><Icon name="graph" /><span class="qa-t">Graph view</span><kbd>{key('graph')}</kbd></button>
</div>
{#if store.pinned.length}
<div class="sec"><span>Favorites</span></div>
<div class="favs" role="tree" aria-label="Favorites" tabindex="-1" onpointerdown={onTreeDown}>
{#each store.pinned as p (p.id)}
<div
class="row fav"
class:active={store.currentId === p.id}
class:drop-in={dropOn === p.id}
class:lifted={dragging === p.id}
data-id={p.id}
role="treeitem"
aria-selected={store.currentId === p.id}
tabindex="0"
onclick={() => store.openPage(p.id)}
onkeydown={(e) => e.key === 'Enter' && store.openPage(p.id)}
oncontextmenu={(e) => { e.preventDefault(); e.stopPropagation(); store.menu = { x: e.clientX, y: e.clientY, id: p.id }; }}
>
<span class="row-ico"><Icon name={iconOf(p)} /></span>
<span class="name">{p.title}</span>
<button
class="row-add"
tabindex="-1"
aria-label="Remove {p.title} from favorites"
title="Remove from favorites"
onclick={(e) => { e.stopPropagation(); store.togglePin(p.id); }}
><Icon name="x" /></button>
</div>
{/each}
</div>
{/if}
{#if store.searches.length}
<div class="sec"><span>Saved searches</span></div>
<div class="favs" role="list" aria-label="Saved searches">
{#each store.searches as s (s.q)}
<div class="row fav" role="listitem" tabindex="0" title="Search for {s.q}" onclick={() => store.runSearch(s.q)} onkeydown={(e) => e.key === 'Enter' && store.runSearch(s.q)}>
<span class="row-ico"><Icon name="search" /></span>
<span class="name">{s.q}</span>
<button class="row-add" tabindex="-1" aria-label="Remove saved search {s.q}" title="Remove" onclick={(e) => { e.stopPropagation(); store.removeSearch(s.q); }}><Icon name="x" /></button>
</div>
{/each}
</div>
{/if}
<div class="sec sec-pages" class:drop-root={dropOn === null}>
<span>Pages</span>
<button class="btn-icon" aria-label="New page" onclick={() => (store.templatesOpen = { parent: null })}><Icon name="plus" /></button>
</div>
<div
class="tree"
class:drop-root={dropOn === null}
bind:this={treeEl}
role="tree"
aria-label="Pages"
tabindex="0"
onfocus={focusTree}
onpointerdown={onTreeDown}
oncontextmenu={(e) => { e.preventDefault(); store.menu = { x: e.clientX, y: e.clientY, id: null }; }}>
{#if store.filterTag}
<div class="filter-head">
<span>Tagged <b>#{store.filterTag}</b></span>
<button onclick={() => (store.filterTag = null)}>Clear</button>
</div>
{#each tagged as p, i (p.id)}
<div
class="row flat"
class:active={store.currentId === p.id}
class:drop-in={dropOn === p.id}
class:lifted={dragging === p.id}
data-id={p.id}
style="--i:{i}"
role="treeitem"
aria-selected={store.currentId === p.id}
tabindex="0"
onclick={() => store.openPage(p.id)}
onkeydown={(e) => e.key === 'Enter' && store.openPage(p.id)}
oncontextmenu={(e) => { e.preventDefault(); e.stopPropagation(); store.menu = { x: e.clientX, y: e.clientY, id: p.id }; }}
>
<span class="row-ico"><Icon name={iconOf(p)} /></span>
<span class="name">{p.title}</span>
</div>
{/each}
{:else}
{#each roots as p (p.id)}
<TreeNode page={p} {dropOn} {dragging} />
{:else}
<p class="empty-tree">No pages yet.</p>
{/each}
{/if}
</div>
<div class="sec sec-tags">
<button class="sec-toggle" aria-expanded={store.tagsOpen} title={store.tagsOpen ? 'Hide tags' : 'Show tags'} onclick={() => store.setTagsOpen(!store.tagsOpen)}>
<span class="chev" class:open={store.tagsOpen}><Icon name="chev" /></span>Tags{#if store.tagCounts.length}<em>{store.tagCounts.length}</em>{/if}
</button>
<!-- with the list folded away, the tag being filtered by stays in view, with a way to clear it -->
{#if store.tagCounts.length}<button class="btn-icon" aria-label="Manage tags" title="Manage tags" onclick={() => (store.tagManagerOpen = true)}><Icon name="gear" /></button>{/if}
{#if store.filterTag && !store.tagsOpen}
<button class="tag on sec-active" class:tinted={!!tagHex(store.tagColors[store.filterTag])} style={tagHex(store.tagColors[store.filterTag]) ? `--tc:${tagHex(store.tagColors[store.filterTag])}` : undefined} title="Stop filtering by this tag" onclick={() => (store.filterTag = null)}><span>#</span>{store.filterTag}<Icon name="x" /></button>
{/if}
</div>
<!-- always there, so it can slide open and shut; `inert` while shut keeps it out of the tab order -->
<div class="tags-wrap" class:open={store.tagsOpen} inert={!store.tagsOpen}>
<div class="tags-in">
{#if store.tagCounts.length > TAG_FIND_AT}
<div class="tag-find">
<Icon name="search" />
<input
placeholder="Find a tag…"
aria-label="Find a tag"
bind:value={tagQ}
onkeydown={(e) => { if (e.key === 'Escape') { tagQ = ''; e.currentTarget.blur(); } }}
/>
{#if tagQ}<button class="btn-icon" aria-label="Clear" onclick={() => (tagQ = '')}><Icon name="x" /></button>{/if}
</div>
{/if}
<div class="tags">
{#each shownTags as [tag, n] (tag)}
<button class="tag" class:tinted={!!tagHex(store.tagColors[tag])} class:on={store.filterTag === tag} style={tagHex(store.tagColors[tag]) ? `--tc:${tagHex(store.tagColors[tag])}` : undefined} onclick={() => (store.filterTag = store.filterTag === tag ? null : tag)} oncontextmenu={(e) => { e.preventDefault(); e.stopPropagation(); store.menu = { x: e.clientX, y: e.clientY, tag }; }}>
<span>#</span>{tag}<em>{n}</em>
</button>
{:else}
<span class="muted">{store.tagCounts.length ? 'No tag matches' : 'No tags yet'}</span>
{/each}
</div>
</div>
</div>
<div class="sb-foot">
<span>{store.pages.filter((p) => p.exists).length} notes</span>
<button class="btn-icon foot-btn" aria-label="Tasks" title="Tasks ({key('tasks')})" onclick={() => (store.tasksOpen = true)}><Icon name="check" /></button>
<button class="btn-icon foot-btn foot-trash" aria-label="Trash" title="Trash" onclick={() => (store.trashOpen = true)}><Icon name="trash" /></button>
<button class="btn-icon foot-btn" aria-label="Settings" title="Settings ({key('settings')})" onclick={() => (store.settingsOpen = true)}><Icon name="gear" /></button>
</div>
</div>
</aside>
+176
View File
@@ -0,0 +1,176 @@
<script>
import { onMount } from 'svelte';
import Icon from './Icon.svelte';
import { createEditor } from '$lib/editor.js';
import { editorHooks } from '$lib/editorHooks.js';
import { store, iconOf } from '$lib/state.svelte.js';
import { api, errorText } from '$lib/api.js';
import { toast } from '$lib/toast.svelte.js';
import { parseMarkdown, toMarkdown } from '$lib/markdown.js';
import { isBoard } from '$lib/kanban.js';
import { isDatabase } from '$lib/database.js';
import Board from './Board.svelte';
import Database from './Database.svelte';
import CanvasView from './CanvasView.svelte';
import { isCanvasId } from '$lib/canvas.js';
// The side pane: one page of any kind (document, board, database, canvas) with its own save, next to the page
// the store has open. A document gets its own editor; the others get their views with this pane's save passed in.
let { id } = $props();
let host = $state();
let doc = $state.raw(null); // { meta, blocks, body }
let kind = $state('page'); // 'page' | 'board' | 'database' | 'canvas'
let version = $state(0);
let blocks = [];
let raw = null; // the body of a board, database or canvas; null for a document, which saves from blocks
let base = null; // body as this pane last read or wrote it
let dirty = false;
let timer = null;
let chain = Promise.resolve();
let editor = null;
const page = $derived(store.pages.find((p) => p.id === id) || null);
async function load() {
let r;
try { r = await api.read(id); } catch (err) { toast(errorText(err), 'x'); store.split = null; return; }
if (!r.exists && !page) { store.split = null; return; }
apply(r);
}
function apply(r) {
kind = isCanvasId(id) ? 'canvas' : isBoard(r.meta) ? 'board' : isDatabase(r.meta) ? 'database' : 'page';
base = r.body;
raw = kind === 'page' ? null : r.body;
blocks = kind === 'canvas' ? [] : parseMarkdown(r.body);
dirty = false;
doc = { meta: r.meta || {}, blocks, body: r.body };
version++;
}
// a board, database or canvas changed: its new file text
function editRaw(text) {
raw = text;
dirty = true;
clearTimeout(timer);
timer = setTimeout(save, 600);
}
// front matter of a board (its column colours)
function editMeta(patch) {
const meta = { ...doc.meta, ...patch };
for (const k of Object.keys(meta)) if (meta[k] === undefined) delete meta[k];
doc = { ...doc, meta };
editRaw(raw);
}
function edit(b) {
blocks = b;
dirty = true;
clearTimeout(timer);
timer = setTimeout(save, 600);
}
const save = () => {
clearTimeout(timer);
chain = chain.then(write);
return chain;
};
async function write() {
if (!dirty || !doc) return;
dirty = false;
const body = raw ?? toMarkdown(blocks);
try {
const disk = await api.read(id).catch(() => null);
if (disk && disk.exists && disk.body !== base && disk.body !== body) {
await api.keepVersion(id).catch(() => {});
toast('This note was also changed elsewhere. Your version was saved; the other one is in version history.', 'clock', {
label: 'Show versions',
run: () => { store.historyOpen = id; }
});
}
await api.write(id, { ...doc.meta }, body);
base = body;
} catch (err) {
dirty = true;
toast('Could not save: ' + errorText(err), 'x');
}
}
// mount a fresh editor whenever a version of the page is loaded
$effect(() => {
version;
if (!host || !doc || kind !== 'page') return;
const instance = createEditor(host, { blocks: doc.blocks, animate: false, onChange: edit, ...editorHooks(id) });
editor = instance;
return () => {
instance.destroy();
if (editor === instance) editor = null;
};
});
// links dim or brighten as pages come and go
$effect(() => {
store.pages;
editor?.refreshLinks();
});
// the page was deleted or moved away from under the pane
$effect(() => {
if (store.pages.length && !store.pages.some((p) => p.id === id)) store.split = null;
});
onMount(() => {
load();
store.splitSave = save;
const off = api.onChanged((ids) => {
if (!ids.includes(id) || dirty) return; // a pending edit wins and overwrites on save
api.read(id).then((r) => {
if (!r.exists || r.body === base || dirty) return;
apply(r);
}).catch(() => {});
});
const onBlur = () => { save(); };
window.addEventListener('blur', onBlur);
return () => {
off?.();
window.removeEventListener('blur', onBlur);
save();
if (store.splitSave === save) store.splitSave = null;
};
});
</script>
<section class="split" aria-label="Split view">
<header class="split-bar">
<button class="crumb" title="Open in the main pane" onclick={() => store.swapSplit()}>
<Icon name={iconOf(page || { id, title: id })} /><span>{page ? page.title : id}</span>
</button>
<button class="btn-icon" aria-label="Swap panes" title="Swap panes" onclick={() => store.swapSplit()}><Icon name="columns" /></button>
<button class="btn-icon" aria-label="Close split view" title="Close split view" onclick={() => store.closeSplit()}><Icon name="x" /></button>
</header>
{#if doc && kind === 'canvas'}
{#key version}<CanvasView {id} body={doc.body} onsave={editRaw} title={page ? page.title : null} />{/key}
{:else}
<div class="scroller">
{#if doc && kind !== 'page'}
<div class="page wide">
<h1 class="title split-title">{page ? page.title : id}</h1>
</div>
{#key version}
{#if kind === 'board'}
<Board body={doc.body} onsave={editRaw} meta={doc.meta} onmeta={editMeta} pageId={id} />
{:else}
<Database {id} body={doc.body} onsave={editRaw} />
{/if}
{/key}
{:else if doc}
<article class="page">
<h1 class="title split-title">{page ? page.title : id}</h1>
<div bind:this={host}></div>
</article>
{/if}
</div>
{/if}
</section>
+68
View File
@@ -0,0 +1,68 @@
<script>
import Icon from './Icon.svelte';
import { store } from '$lib/state.svelte.js';
import { tagHex } from '$lib/tagColors.js';
let closing = $state(false);
let q = $state('');
let editing = $state(null); // tag being renamed
let draft = $state('');
let armed = $state(null); // tag about to be removed
let list = $derived(store.tagCounts.filter(([t]) => t.toLowerCase().includes(q.trim().toLowerCase().replace(/^#/, ''))));
const same = (a, b) => a.toLowerCase() === b.toLowerCase();
const target = $derived(editing ? store.tagCounts.find(([t]) => same(t, draft.trim().replace(/^#/, '').replace(/\s+/g, '-')) && t !== editing) : null);
function begin(t) { editing = t; draft = t; armed = null; }
async function commit() {
const from = editing, to = draft;
editing = null;
if (from && to.trim()) await store.renameTag(from, to);
}
async function remove(t) {
if (armed !== t) { armed = t; return; }
armed = null;
await store.deleteTag(t);
}
function show(t) {
store.filterTag = t;
close();
}
function close() {
closing = true;
setTimeout(() => { store.tagManagerOpen = false; closing = false; }, 150);
}
const focusSelect = (el) => { el.focus(); el.select(); };
</script>
<svelte:window onkeydown={(e) => e.key === 'Escape' && (editing ? (editing = null) : close())} />
<div class="overlay" class:closing role="presentation" onmousedown={(e) => e.target === e.currentTarget && close()}>
<div class="panel tasks-panel" role="dialog" aria-label="Tags">
<div class="trash-top">
<Icon name="hash" />
<h2>Tags<span class="muted"> · {store.tagCounts.length}</span></h2>
<button class="btn-icon" aria-label="Close" onclick={close}><Icon name="x" /></button>
</div>
<div class="tk-bar"><input class="tk-q" placeholder="Find a tag" aria-label="Find a tag" bind:value={q} /></div>
<div class="trash-list tk-list">
{#each list as [tag, n] (tag)}
<div class="tr">
<span class="tag-dot" style="background:{tagHex(store.tagColors[tag]) || 'var(--ink-3)'}"></span>
{#if editing === tag}
<input class="ctx-input tg-edit" bind:value={draft} aria-label="New name for {tag}" use:focusSelect
onkeydown={(e) => { if (e.key === 'Enter') { e.preventDefault(); commit(); } }} />
<button class="btn" onclick={commit}>{target ? `Merge into #${target[0]}` : 'Rename'}</button>
{:else}
<button class="tr-t tg-name" onclick={() => show(tag)} title="Show these pages"><b>#{tag}</b><small>{n} {n === 1 ? 'page' : 'pages'}</small></button>
<button class="btn" onclick={() => begin(tag)}><Icon name="pen" />Rename</button>
<button class="btn danger" class:armed={armed === tag} onclick={() => remove(tag)} title="Takes the tag off every page">{armed === tag ? 'Sure?' : 'Remove'}</button>
{/if}
</div>
{:else}
<div class="trash-empty"><Icon name="hash" />{store.tagCounts.length ? 'No tag matches.' : 'No tags yet. Add one from a page’s Tags row.'}</div>
{/each}
</div>
<div class="trash-foot"><span>Renaming a tag to one that exists merges them. Colors move with a rename.</span></div>
</div>
</div>
+139
View File
@@ -0,0 +1,139 @@
<script>
import { onMount } from 'svelte';
import Icon from './Icon.svelte';
import { store, iconOf } from '$lib/state.svelte.js';
import { api, errorText } from '$lib/api.js';
import { toast } from '$lib/toast.svelte.js';
let items = $state(null);
let closing = $state(false);
let status = $state('open'); // 'open' | 'done' | 'all'
let when = $state('any'); // 'any' | 'overdue' | 'today' | 'week' | 'none'
let tag = $state('');
let q = $state('');
const iso = (d) => `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
const now = new Date();
const today = iso(now);
const weekEnd = iso(new Date(now.getFullYear(), now.getMonth(), now.getDate() + 7));
async function load() {
try {
await store.flush(); // what is on screen is on disk before it is read here
items = (await api.tasks()) || [];
} catch (err) {
items = [];
toast(errorText(err), 'x');
}
}
onMount(load);
const plain = (t) => t
.replace(/\[\[([^\]|]*)\|([^\]]*)\]\]/g, '$2')
.replace(/\[\[([^\]]*)\]\]/g, '$1')
.replace(/\[([^\]]*)\]\([^)]*\)/g, '$1')
.replace(/(?:📅|\bdue:)\s*\d{4}-\d{2}-\d{2}/g, '')
.replace(/\*\*|__|==|`|\*/g, '')
.replace(/\s+/g, ' ')
.trim();
let tags = $derived([...new Set((items || []).flatMap((t) => t.tags))].sort((a, b) => a.localeCompare(b)));
let shown = $derived.by(() => {
const needle = q.trim().toLowerCase();
return (items || []).filter((t) => {
if (status === 'open' && t.done) return false;
if (status === 'done' && !t.done) return false;
if (tag && !t.tags.includes(tag)) return false;
if (when === 'none' && t.due) return false;
if (when === 'overdue' && !(t.due && t.due < today && !t.done)) return false;
if (when === 'today' && t.due !== today) return false;
if (when === 'week' && !(t.due && t.due >= today && t.due <= weekEnd)) return false;
if (needle && !(t.text.toLowerCase().includes(needle) || t.title.toLowerCase().includes(needle))) return false;
return true;
});
});
let groups = $derived.by(() => {
const map = new Map();
for (const t of shown) {
if (!map.has(t.id)) map.set(t.id, { id: t.id, title: t.title, icon: t.icon, tasks: [] });
map.get(t.id).tasks.push(t);
}
return [...map.values()].sort((a, b) => a.title.localeCompare(b.title));
});
let openCount = $derived((items || []).filter((t) => !t.done).length);
async function toggle(t) {
const done = !t.done;
t.done = done; // optimistic; undone below if the file refuses
try {
await api.setTask(t.id, t.line, t.text, done);
await store.refresh();
} catch (err) {
toast(errorText(err), 'x');
await load();
}
}
function openPage(id) {
close();
setTimeout(() => store.openPage(id), 120);
}
function close() {
closing = true;
setTimeout(() => { store.tasksOpen = false; closing = false; }, 150);
}
const dueText = (d) => (d === today ? 'Today' : new Date(d + 'T00:00').toLocaleDateString(undefined, { month: 'short', day: 'numeric' }));
</script>
<svelte:window onkeydown={(e) => e.key === 'Escape' && close()} />
<div class="overlay" class:closing role="presentation" onmousedown={(e) => e.target === e.currentTarget && close()}>
<div class="panel tasks-panel" role="dialog" aria-label="Tasks">
<div class="trash-top">
<Icon name="check" />
<h2>Tasks{#if items}<span class="muted"> · {openCount} open</span>{/if}</h2>
<button class="btn-icon" aria-label="Close" onclick={close}><Icon name="x" /></button>
</div>
<div class="tk-bar">
<div class="seg3" role="radiogroup" aria-label="Status">
{#each [['open', 'Open'], ['done', 'Done'], ['all', 'All']] as [v, label] (v)}
<button class:on={status === v} role="radio" aria-checked={status === v} onclick={() => (status = v)}>{label}</button>
{/each}
</div>
<select class="tk-sel" aria-label="Due date" bind:value={when}>
<option value="any">Any date</option>
<option value="overdue">Overdue</option>
<option value="today">Today</option>
<option value="week">Next 7 days</option>
<option value="none">No date</option>
</select>
<select class="tk-sel" aria-label="Tag" bind:value={tag}>
<option value="">Any tag</option>
{#each tags as t (t)}<option value={t}>#{t}</option>{/each}
</select>
<input class="tk-q" placeholder="Filter by text or page" aria-label="Filter tasks" bind:value={q} />
</div>
<div class="trash-list tk-list">
{#if items === null}
<div class="trash-empty">Loading…</div>
{:else if !groups.length}
<div class="trash-empty"><Icon name="check" />{items.length ? 'No tasks match.' : 'No tasks yet. Write “- [ ] something” on any page.'}</div>
{:else}
{#each groups as g (g.id)}
<div class="tk-group">
<button class="tk-page" onclick={() => openPage(g.id)}><span class="row-ico"><Icon name={iconOf(g)} /></span>{g.title}</button>
{#each g.tasks as t (t.line)}
<label class="tk" class:done={t.done} style="padding-left:{10 + Math.min(t.depth, 4) * 18}px">
<input type="checkbox" checked={t.done} onchange={() => toggle(t)} />
<span class="tk-t">{plain(t.text)}</span>
{#if t.due}<span class="tk-due" class:over={!t.done && t.due < today}>{dueText(t.due)}</span>{/if}
{#each t.tags as x (x)}<span class="tk-tag">#{x}</span>{/each}
</label>
{/each}
</div>
{/each}
{/if}
</div>
<div class="trash-foot"><span>Add a due date with 📅 2026-03-01 or due:2026-03-01 in the task. Boards are left out.</span></div>
</div>
</div>
+127
View File
@@ -0,0 +1,127 @@
<script>
// "New page": a blank page or one of the templates (Markdown files in <notes folder>/.notes/templates).
// Cards show each template's icon, name and the gist of its text. Arrow keys move, Enter creates.
import { onMount } from 'svelte';
import Icon from './Icon.svelte';
import { store, iconOf } from '$lib/state.svelte.js';
import { api, isDesktop, errorText } from '$lib/api.js';
import { toast } from '$lib/toast.svelte.js';
let templates = $state([]); // [{ name, icon, preview }]
let closing = $state(false);
let gridEl = $state();
const parent = store.templatesOpen?.parent || null;
// The first few lines of a template as plain words: no #, -, [ ], > or **.
const gist = (body) =>
String(body || '')
.split('\n')
.map((l) => l.replace(/^\s*(?:#{1,6}\s+|>\s?(?:\[!\w+\]\s*)?|[-*+]\s+(?:\[[ xX]\]\s*)?|\d+[.)]\s+)/, '').replace(/!?\[\[|\]\]|[*_`~]|==/g, '').trim())
.filter(Boolean)
.slice(0, 4)
.join(' · ');
async function load() {
try {
const names = (await api.listTemplates()) || [];
const full = await Promise.all(names.map(async (name) => {
const t = await api.getTemplate(name).catch(() => null);
return { name, icon: t?.meta?.icon ? iconOf({ icon: t.meta.icon, title: name }) : 'file', preview: gist(t?.body) };
}));
templates = full;
} catch (err) {
toast(errorText(err), 'x');
}
}
onMount(() => {
load();
gridEl?.querySelector('.tpl-card')?.focus(); // Enter right away = a blank page
});
function close() {
closing = true;
setTimeout(() => { store.templatesOpen = null; closing = false; }, 150);
}
function create(name) {
close();
store.newPage(parent, name);
}
async function openFolder() {
try {
const problem = await api.openTemplatesFolder();
if (problem) toast(problem, 'x');
} catch (err) {
toast(errorText(err), 'x');
}
}
function onKey(e) {
if (e.key === 'Escape') { e.preventDefault(); close(); return; }
if (e.key === 'Enter' && !e.isComposing) {
const el = document.activeElement;
const card = el?.closest?.('.tpl-card');
if (card) { e.preventDefault(); card.click(); }
else if (!el?.closest?.('.tpl-panel')) { e.preventDefault(); create(null); } // focus got lost: Enter still means "blank page"
return; // on the footer or close button, the browser's own Enter handling applies
}
const step = { ArrowLeft: -1, ArrowRight: 1, ArrowUp: 'up', ArrowDown: 'down' }[e.key];
if (!step || !gridEl) return;
const cards = [...gridEl.querySelectorAll('.tpl-card')];
const i = cards.indexOf(document.activeElement);
e.preventDefault();
if (i < 0) return cards[0]?.focus();
const cols = cards.filter((c) => c.offsetTop === cards[0].offsetTop).length || 1; // cards in the first row
const n = i + (step === 'up' ? -cols : step === 'down' ? cols : step);
if (n >= 0 && n < cards.length) cards[n].focus();
}
</script>
<svelte:window onkeydown={onKey} onfocus={load} />
<div class="overlay" class:closing role="presentation" onmousedown={(e) => e.target === e.currentTarget && close()}>
<div class="panel tpl-panel" role="dialog" aria-label="Templates">
<div class="trash-top">
<Icon name="plus" />
<h2>New page</h2>
<button class="btn-icon" aria-label="Close" onclick={close}><Icon name="x" /></button>
</div>
<p class="tpl-lead">Start from a blank page{templates.length ? ' or a template' : ''}.</p>
<div class="tpl-grid" bind:this={gridEl} role="group" aria-label="Templates">
<button class="tpl-card blank" data-blank onclick={() => create(null)}>
<span class="tpl-ico"><Icon name="file" /></span>
<span class="tpl-name">Blank page</span>
<span class="tpl-prev">Start with an empty page.</span>
</button>
<button class="tpl-card blank" data-board onclick={() => { close(); store.newBoard(parent); }}>
<span class="tpl-ico"><Icon name="board" /></span>
<span class="tpl-name">Board</span>
<span class="tpl-prev">Kanban columns of cards to move from To do to Done.</span>
</button>
<button class="tpl-card blank" data-database onclick={() => { close(); store.newDatabase(parent); }}>
<span class="tpl-ico"><Icon name="table" /></span>
<span class="tpl-name">Database</span>
<span class="tpl-prev">Records with typed properties, shown as a table, board, calendar, gallery, timeline or list.</span>
</button>
<button class="tpl-card blank" data-canvas onclick={() => { close(); store.newCanvas(parent); }}>
<span class="tpl-ico"><Icon name="canvas" /></span>
<span class="tpl-name">Canvas</span>
<span class="tpl-prev">An open space for cards, notes and images, joined by arrows.</span>
</button>
{#each templates as t (t.name)}
<button class="tpl-card" data-name={t.name} onclick={() => create(t.name)}>
<span class="tpl-ico"><Icon name={t.icon} /></span>
<span class="tpl-name">{t.name}</span>
<span class="tpl-prev">{t.preview || 'An empty template.'}</span>
</button>
{/each}
</div>
<div class="tpl-foot">
<span>
{#if templates.length}Templates are Markdown files in <code>.notes/templates</code>.{:else}No templates yet. Save a Markdown file in <code>.notes/templates</code> and it shows up here.{/if}
</span>
{#if isDesktop}<button class="btn" onclick={openFolder}><Icon name="folder" />Open folder</button>{/if}
</div>
</div>
</div>
+158
View File
@@ -0,0 +1,158 @@
<script>
// The right-click menu for text (replacing the system one): spelling, links, formatting, cut/copy/paste.
// Electron's main process reports what was clicked (only it knows the spelling suggestions); actions that
// need it (clipboard, spelling) go back to it. The menu never takes focus, so the selection stays put.
import { onMount, tick } from 'svelte';
import Icon from './Icon.svelte';
import { store } from '$lib/state.svelte.js';
import { api, platform } from '$lib/api.js';
import { toast } from '$lib/toast.svelte.js';
const info = store.textMenu;
const MOD = platform === 'darwin' ? '⌘' : 'Ctrl+';
const target = info.target && info.target.nodeType === 1 ? info.target : info.target?.parentElement;
const webLink = target?.closest('a.xl');
const pageLink = target?.closest('.wl');
const inEditor = !!target?.closest('.main .blocks') && !target.closest('.emb');
const formats = inEditor && info.isEditable && info.selectionText ? store.editor?.formatState() : null;
const snippet = info.selectionText.trim().replace(/\s+/g, ' ');
const FORMATS = [
['bold', '<b>B</b>', 'Bold', MOD + 'B'],
['italic', '<i>I</i>', 'Italic', MOD + 'I'],
['strike', '<s>S</s>', 'Strikethrough', MOD + 'Shift+S'],
['mark', '<span class="fmt-mark">H</span>', 'Highlight', MOD + 'Shift+H'],
['code', null, 'Code', MOD + 'E'],
['weblink', null, 'Link', MOD + 'K']
];
const edit = (action) => () => api.editAction(action);
const copyText = (text, what) => () => navigator.clipboard.writeText(text).then(() => toast(`${what} copied`, 'copy'), () => {});
// Menu rows, in groups; separators go between groups that have rows.
const groups = [];
if (info.misspelledWord) {
groups.push([
...(info.suggestions.length
? info.suggestions.map((s) => ({ label: s, strong: true, run: () => api.replaceMisspelling(s) }))
: [{ label: 'No spelling suggestions', disabled: true }]),
{ label: `Add “${info.misspelledWord}” to dictionary`, icon: 'plus', run: () => api.addToDictionary(info.misspelledWord) }
]);
}
if (webLink) {
const href = webLink.getAttribute('href');
groups.push([
{ label: 'Open link', icon: 'globe', run: () => window.open(href, '_blank', 'noopener') },
{ label: 'Copy link address', icon: 'copy', run: copyText(href, 'Link') },
...(inEditor && info.isEditable
? [
{ label: 'Edit link…', icon: 'pen', run: () => store.editor?.linkAction(webLink, 'edit') },
{ label: 'Remove link', icon: 'unlink', run: () => store.editor?.linkAction(webLink, 'unlink') }
]
: [])
]);
} else if (pageLink) {
groups.push([{ label: 'Open page', icon: 'file', run: () => store.openLink(pageLink.dataset.ref) }]);
}
if (info.mediaType === 'image') groups.push([{ label: 'Copy image', icon: 'image', run: () => api.copyImageAt(info.x, info.y) }]);
const editRows = [];
if (info.isEditable) editRows.push({ label: 'Cut', icon: 'cut', kbd: MOD + 'X', disabled: !info.can.cut, run: edit('cut') });
if (info.isEditable || info.selectionText) editRows.push({ label: 'Copy', icon: 'copy', kbd: MOD + 'C', disabled: !info.can.copy, run: edit('copy') });
if (info.isEditable) {
editRows.push({ label: 'Paste', icon: 'paste', kbd: MOD + 'V', disabled: !info.can.paste, run: edit('paste') });
editRows.push({ label: 'Select all', icon: 'selectAll', kbd: MOD + 'A', disabled: !info.can.selectAll, run: edit('selectAll') });
}
if (editRows.length) groups.push(editRows);
if (snippet && snippet.length <= 60 && store.vault) {
groups.push([{ label: `Search notes for “${snippet.length > 24 ? snippet.slice(0, 23) + '…' : snippet}”`, icon: 'search', run: () => { store.paletteQuery = snippet; store.paletteOpen = true; } }]);
}
const rows = groups.flat();
const enabled = rows.filter((r) => !r.disabled);
let active = $state(-1); // index into `enabled` (keyboard / hover)
let el = $state();
let pos = $state({ x: info.x, y: info.y });
function close() { store.textMenu = null; }
function run(row) {
if (!row || row.disabled) return;
close();
row.run();
}
function runFormat(f) {
close();
store.editor?.format(f);
}
// Nothing to offer here (e.g. plain page background): no menu at all.
if (!rows.length && !formats) queueMicrotask(close);
onMount(async () => {
await tick();
if (!el) return;
const r = el.getBoundingClientRect();
pos = {
x: Math.max(8, Math.min(info.x, innerWidth - r.width - 8)),
y: info.y + r.height > innerHeight - 8 ? Math.max(8, info.y - r.height) : info.y
};
});
// Keys are read at the window, so focus (and the selection) stays where it was.
function onKey(e) {
if (e.key === 'Escape') { e.preventDefault(); e.stopPropagation(); close(); return; }
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
e.preventDefault();
e.stopPropagation();
const n = enabled.length;
if (!n) return;
active = active < 0 ? (e.key === 'ArrowDown' ? 0 : n - 1) : (active + (e.key === 'ArrowDown' ? 1 : -1) + n) % n;
return;
}
if (e.key === 'Enter' && active >= 0) { e.preventDefault(); e.stopPropagation(); run(enabled[active]); return; }
if (!['Shift', 'Control', 'Alt', 'Meta'].includes(e.key)) close(); // typing goes on as usual
}
function onDown(e) { if (el && !el.contains(e.target)) close(); }
</script>
<svelte:window onkeydowncapture={onKey} onpointerdowncapture={onDown} onblur={close} onresize={close} onscrollcapture={close} />
{#if rows.length || formats}
<div
class="pop menu tmenu"
bind:this={el}
role="menu"
tabindex="-1"
style="left:{pos.x}px;top:{pos.y}px"
onmousedown={(e) => e.preventDefault()}
oncontextmenu={(e) => e.preventDefault()}
>
{#if formats}
<div class="tm-fmt" role="group" aria-label="Format">
{#each FORMATS as [f, glyph, label, keys] (f)}
<button class:on={formats[f]} aria-label={label} title="{label} ({keys})" onclick={() => runFormat(f)}>
{#if glyph}{@html glyph}{:else}<Icon name={f === 'code' ? 'code' : 'link'} />{/if}
</button>
{/each}
</div>
{#if rows.length}<div class="sepline"></div>{/if}
{/if}
{#each groups as group, g (g)}
{#if g > 0}<div class="sepline"></div>{/if}
{#each group as row (row.label)}
<button
class="mi"
class:act={active >= 0 && enabled[active] === row}
class:strong={row.strong}
role="menuitem"
disabled={row.disabled}
onmousemove={() => !row.disabled && (active = enabled.indexOf(row))}
onclick={() => run(row)}
>
{#if row.icon}<Icon name={row.icon} />{:else}<span class="mi-pad"></span>{/if}
<span class="mi-label">{row.label}</span>
{#if row.kbd}<kbd>{row.kbd}</kbd>{/if}
</button>
{/each}
{/each}
</div>
{/if}
+11
View File
@@ -0,0 +1,11 @@
<script>
import Icon from './Icon.svelte';
import { toastState, runToastAction } from '$lib/toast.svelte.js';
</script>
<div class="toast" class:show={toastState.show} class:act={!!toastState.action} role="status" aria-live="polite">
<Icon name={toastState.icon} />{toastState.msg}
{#if toastState.action}
<button class="toast-btn" tabindex={toastState.show ? 0 : -1} onclick={runToastAction}>{toastState.action.label}</button>
{/if}
</div>
+168
View File
@@ -0,0 +1,168 @@
<script>
import Icon from './Icon.svelte';
import WindowControls from './WindowControls.svelte';
import { store, iconOf } from '$lib/state.svelte.js';
import { api, isDesktop, platform } from '$lib/api.js';
import { toast } from '$lib/toast.svelte.js';
let menuOpen = $state(false);
let armed = $state(false);
let menuEl = $state();
let btnEl = $state();
let trail = $derived(store.currentId ? [...store.ancestors(store.currentId), store.current].filter(Boolean) : []);
// the database this page is a record of
let owner = $derived(store.current && store.current.parent ? store.pages.find((p) => p.id === store.current.parent && p.db) || null : null);
function toggleMenu() {
menuOpen = !menuOpen;
armed = false;
}
function run(kind) {
if (kind === 'delete') {
if (!armed) { armed = true; return; }
menuOpen = false;
store.removePage();
return;
}
menuOpen = false;
if (kind === 'md' || kind === 'html' || kind === 'pdf') store.exportFormat = kind;
if (kind === 'png' || kind === 'svg' || kind === 'cpdf') store.canvasView?.export(kind === 'cpdf' ? 'pdf' : kind);
if (kind === 'board') store.setBoard(!store.isBoard);
if (kind === 'csv') store.exportCsv();
if (kind === 'importCsv') store.pickCsv(store.currentId);
if (kind === 'moveOut' && owner) store.movePage(store.currentId, owner.parent);
if (kind === 'reveal') api.reveal(store.currentId);
if (kind === 'history') { store.historyOpen = store.currentId; }
if (kind === 'path') {
api.filePath(store.currentId).then((p) =>
navigator.clipboard.writeText(p).then(() => toast('File path copied', 'copy'), () => toast(p, 'file'))
);
}
}
function onWindowClick(e) {
if (menuOpen && menuEl && !menuEl.contains(e.target) && !btnEl.contains(e.target)) menuOpen = false;
}
</script>
<svelte:window onclick={onWindowClick} onkeydown={(e) => e.key === 'Escape' && (menuOpen = false)} />
<header class="topbar">
<button class="btn-icon menu-btn" aria-label="Show sidebar" onclick={() => store.toggleSidebar()}><Icon name="panel" /></button>
<div class="nav-btns" role="group" aria-label="Navigation history">
<button
class="btn-icon nav-btn"
aria-label="Back"
title="Back ({platform === 'darwin' ? '⌘[' : 'Alt+Left'})"
disabled={!store.canGoBack}
onclick={() => store.back()}
><Icon name="chevLeft" /></button>
<button
class="btn-icon nav-btn"
aria-label="Forward"
title="Forward ({platform === 'darwin' ? '⌘]' : 'Alt+Right'})"
disabled={!store.canGoForward}
onclick={() => store.forward()}
><Icon name="chevRight" /></button>
</div>
<nav class="crumbs" aria-label="Breadcrumb">
{#each trail as p, i (p.id)}
{#if i > 0}<span class="sep">/</span>{/if}
<button class="crumb" onclick={() => p.id !== store.currentId && store.openPage(p.id)}
oncontextmenu={(e) => { e.preventDefault(); e.stopPropagation(); store.menu = { x: e.clientX, y: e.clientY, id: p.id }; }}>
<Icon name={iconOf(p)} /><span>{p.id === store.currentId && store.titleDraft !== null ? store.titleDraft || 'Untitled' : p.title}</span>
</button>
{/each}
</nav>
{#if store.currentDay}
<div class="day-nav" role="group" aria-label="Daily notes">
<button class="btn-icon" aria-label="Previous daily note" title="Previous daily note" onclick={() => store.stepDaily(-1)}><Icon name="chevLeft" /></button>
<button class="btn-icon" aria-label="Pick a day" title="Pick a day" onclick={() => (store.calendarOpen = true)}><Icon name="calendar" /></button>
<button class="btn-icon" aria-label="Next daily note" title="Next daily note (or today)" onclick={() => store.stepDaily(1)}><Icon name="chevRight" /></button>
</div>
{/if}
{#if store.saving || store.justSaved}
<div class="status" class:saving={store.saving} class:visible={store.saving || store.justSaved}>
<i class="dot"></i><span>{store.saving ? 'Saving…' : 'Saved to disk'}</span>
</div>
{/if}
{#if !store.isCanvas && !store.isDatabase}
<span class="wc" title="About {Math.max(1, Math.round(store.words / 230))} min to read at 230 words a minute">{store.words} {store.words === 1 ? 'word' : 'words'}{#if store.words >= 100} · {Math.max(1, Math.round(store.words / 230))} min{/if}</span>
<button
class="btn-icon"
class:on={store.outlineOpen && !store.isBoard}
aria-label="Outline"
aria-pressed={store.outlineOpen && !store.isBoard}
title={store.isBoard ? 'Boards have no outline' : `Outline (${platform === 'darwin' ? '⌘⇧O' : 'Ctrl+Shift+O'})`}
disabled={!store.doc || store.isBoard || store.isDatabase}
onclick={() => store.toggleOutline()}
><Icon name="outline" /></button>
{/if}
<button
class="btn-icon"
class:on={!!store.split}
aria-label="Split view"
aria-pressed={!!store.split}
title="Split view ({platform === 'darwin' ? '⌘⇧\\' : 'Ctrl+Shift+\\'})"
disabled={!store.doc}
onclick={() => store.toggleSplit()}
><Icon name="columns" /></button>
<button
class="btn-icon"
class:on={store.focusMode}
aria-label="Focus mode"
aria-pressed={store.focusMode}
title="Focus mode"
onclick={() => store.toggleFocusMode()}
><Icon name="maximize" /></button>
<button
class="btn-icon star-btn"
class:on={store.currentId && store.isPinned(store.currentId)}
aria-label={store.currentId && store.isPinned(store.currentId) ? 'Remove from favorites' : 'Add to favorites'}
title={store.currentId && store.isPinned(store.currentId) ? 'Remove from favorites' : 'Add to favorites'}
disabled={!store.doc}
onclick={() => store.togglePin()}
><Icon name="star" /></button>
<button class="btn-icon" bind:this={btnEl} aria-label="Page options" aria-expanded={menuOpen} onclick={toggleMenu} disabled={!store.doc}>
<Icon name="dots" class="fat" />
</button>
<WindowControls />
{#if menuOpen}
<div class="pop menu" bind:this={menuEl} role="menu">
{#if !store.isCanvas}
{#if !store.isDatabase}
<button class="mi" role="menuitem" onclick={() => run('board')}>
<Icon name={store.isBoard ? 'file' : 'board'} />{store.isBoard ? 'Turn into document' : 'Turn into board'}
</button>
<div class="sepline"></div>
{/if}
{#if store.isDatabase}
<button class="mi" role="menuitem" onclick={() => run('csv')}><Icon name="export" />Export as CSV</button>
<button class="mi" role="menuitem" onclick={() => run('importCsv')}><Icon name="download" />Import CSV…</button>
{:else}
<button class="mi" role="menuitem" onclick={() => run('md')}><Icon name="export" />Export as Markdown</button>
<button class="mi" role="menuitem" onclick={() => run('html')}><Icon name="export" />Export as HTML</button>
<button class="mi" role="menuitem" onclick={() => run('pdf')}><Icon name="export" />Export as PDF</button>
{/if}
{#if owner}
<button class="mi" role="menuitem" onclick={() => run('moveOut')}><Icon name="folder" />Move out of {owner.title}</button>
{/if}
{:else}
<button class="mi" role="menuitem" onclick={() => run('png')}><Icon name="export" />Export as PNG</button>
<button class="mi" role="menuitem" onclick={() => run('svg')}><Icon name="export" />Export as SVG</button>
<button class="mi" role="menuitem" onclick={() => run('cpdf')}><Icon name="export" />Export as PDF</button>
<div class="sepline"></div>
{/if}
{#if isDesktop}
<button class="mi" role="menuitem" onclick={() => run('reveal')}><Icon name="folder" />Show in folder</button>
{/if}
<button class="mi" role="menuitem" onclick={() => run('path')}><Icon name="copy" />Copy file path</button>
<button class="mi" role="menuitem" onclick={() => run('history')}><Icon name="clock" />Version history</button>
<div class="sepline"></div>
<button class="mi danger" class:armed role="menuitem" onclick={() => run('delete')}>
<Icon name="trash" /><span>{armed ? 'Click again to move to trash' : 'Delete page'}</span>
</button>
</div>
{/if}
</header>
+89
View File
@@ -0,0 +1,89 @@
<script>
import { onMount } from 'svelte';
import Icon from './Icon.svelte';
import { store, iconOf } from '$lib/state.svelte.js';
import { api, errorText } from '$lib/api.js';
import { toast } from '$lib/toast.svelte.js';
let items = $state(null);
let closing = $state(false);
let armed = $state(null); // entry about to be deleted, or '*' for everything
async function load() {
try { items = (await api.trash()) || []; } catch (err) { items = []; toast(errorText(err), 'x'); }
}
onMount(load);
function close() {
closing = true;
setTimeout(() => { store.trashOpen = false; closing = false; }, 150);
}
async function restore(t) {
close();
await store.restorePage(t.entry);
}
async function purge(entry) {
if (armed !== entry) { armed = entry; return; }
armed = null;
try {
await api.purge(entry === '*' ? null : entry);
await load();
} catch (err) {
toast('Could not delete: ' + errorText(err), 'x');
}
}
const ago = (t) => {
const s = (Date.now() - t) / 1000;
if (s < 60) return 'just now';
if (s < 3600) return `${Math.floor(s / 60)} min ago`;
if (s < 86400) return `${Math.floor(s / 3600)} h ago`;
const d = Math.floor(s / 86400);
return d === 1 ? 'yesterday' : `${d} days ago`;
};
const where = (id) => (id.includes('/') ? id.slice(0, id.lastIndexOf('/')) : 'Top level');
</script>
<svelte:window onkeydown={(e) => e.key === 'Escape' && close()} />
<div class="overlay" class:closing role="presentation" onmousedown={(e) => e.target === e.currentTarget && close()}>
<div class="panel" role="dialog" aria-label="Trash">
<div class="trash-top">
<Icon name="trash" />
<h2>Trash</h2>
<button class="btn-icon" aria-label="Close" onclick={close}><Icon name="x" /></button>
</div>
<div class="trash-list">
{#if items === null}
<div class="trash-empty">Loading…</div>
{:else if !items.length}
<div class="trash-empty"><Icon name="trash" />Nothing in the trash.</div>
{:else}
{#each items as t (t.entry)}
<div class="tr">
<span class="row-ico"><Icon name={iconOf(t)} /></span>
<span class="tr-t">
<b>{t.title}</b>
<small>{where(t.id)} · {ago(t.deletedAt)}{t.pages > 1 ? ` · ${t.pages} pages` : ''}</small>
</span>
<button class="btn" onclick={() => restore(t)}><Icon name="undo" />Restore</button>
<button
class="btn danger"
class:armed={armed === t.entry}
title="Moves it to the system trash"
onclick={() => purge(t.entry)}
>{armed === t.entry ? 'Sure?' : 'Delete'}</button>
</div>
{/each}
{/if}
</div>
<div class="trash-foot">
<span>Pages stay here for 30 days, then go to the system trash.</span>
{#if items?.length}
<button class="btn danger" class:armed={armed === '*'} onclick={() => purge('*')}>
{armed === '*' ? 'Click again to empty' : 'Empty trash'}
</button>
{/if}
</div>
</div>
</div>
+133
View File
@@ -0,0 +1,133 @@
<script>
import { isCanvasId } from '$lib/canvas.js';
import TreeNode from './TreeNode.svelte';
import Icon from './Icon.svelte';
import { store, iconOf } from '$lib/state.svelte.js';
let { page, depth = 0, dropOn = undefined, dragging = null } = $props();
// a database's records are shown in the database, not as a folder of pages
let kids = $derived(page.db ? [] : store.children(page.id));
let open = $derived(!!store.open[page.id]);
let active = $derived(store.currentId === page.id);
let name = $derived(active && store.titleDraft !== null ? store.titleDraft || 'Untitled' : page.title);
let renaming = $derived(store.renaming === page.id);
// A folder's rows are made when it is first opened (a vault of thousands of pages would otherwise draw them all),
// and a very long list is shown in steps; the page you are on, or the folder holding it, is always among them.
const STEP = 100;
let seen = $state(false);
$effect(() => { if (open) seen = true; });
let limit = $state(STEP);
const onPath = (id) => store.currentId === id || (!!store.currentId && store.currentId.startsWith(id + '/'));
let shown = $derived(kids.length <= limit ? kids : kids.filter((k, i) => i < limit || onPath(k.id)));
function oncontextmenu(e) {
e.stopPropagation();
if (renaming) return; // native cut/copy/paste for the name input
e.preventDefault();
store.menu = { x: e.clientX, y: e.clientY, id: page.id };
}
function focusSelect(input) {
input.focus();
input.select();
}
function finishRename(input, save) {
if (store.renaming !== page.id) return;
store.renaming = null;
if (save) store.renamePage(page.id, input.value);
}
function onkeydown(e) {
if (renaming) return;
if (e.key === 'F2') { e.preventDefault(); store.renaming = page.id; }
if (e.key === 'Enter') { e.preventDefault(); store.openPage(page.id); }
if (e.key === 'ArrowRight' && kids.length && !open) store.toggleFold(page.id);
else if (e.key === 'ArrowLeft' && kids.length && open) store.toggleFold(page.id);
else if (e.key === 'ArrowLeft' && !open && page.parent) {
// a closed (or leaf) row: Left goes to its parent, as in a file manager
e.preventDefault();
e.currentTarget.closest('.tree, .favs')?.querySelector(`.row[data-id="${CSS.escape(page.parent)}"]`)?.focus();
}
if (['ArrowDown', 'ArrowUp', 'Home', 'End'].includes(e.key)) {
e.preventDefault();
const rows = [...e.currentTarget.closest('.tree, .favs').querySelectorAll('.row[data-id]')].filter((r) => r.offsetHeight > 0);
const i = rows.indexOf(e.currentTarget);
const to = e.key === 'Home' ? 0 : e.key === 'End' ? rows.length - 1 : Math.min(rows.length - 1, Math.max(0, i + (e.key === 'ArrowDown' ? 1 : -1)));
rows[to]?.focus();
}
}
</script>
<div class="node">
<div
class="row"
class:active
class:fresh={store.fresh === page.id}
class:ghost={!page.exists}
class:drop-in={dropOn === page.id}
class:lifted={dragging === page.id}
data-id={page.id}
style="--d:{depth}"
role="treeitem"
aria-selected={active}
aria-expanded={kids.length ? open : undefined}
tabindex={active ? 0 : -1}
onclick={(e) => { if (renaming) return; if (e.altKey && page.exists) store.openSplit(page.id); else store.openPage(page.id); }}
{onkeydown}
{oncontextmenu}
>
<button
class="chev"
class:open
class:leaf={!kids.length}
tabindex="-1"
aria-label={open ? 'Collapse' : 'Expand'}
onclick={(e) => { e.stopPropagation(); store.toggleFold(page.id); }}
><Icon name="chev" /></button>
<span class="row-ico"><Icon name={iconOf(page)} /></span>
{#if renaming}
<input
class="name row-rename"
value={page.title}
aria-label="Page name"
use:focusSelect
onclick={(e) => e.stopPropagation()}
onkeydown={(e) => {
if (e.key === 'Enter') { e.preventDefault(); finishRename(e.currentTarget, true); }
if (e.key === 'Escape') { e.preventDefault(); finishRename(e.currentTarget, false); }
}}
onblur={(e) => finishRename(e.currentTarget, true)}
/>
{:else}
<span class="name">{name}</span>
{/if}
{#if !isCanvasId(page.id)}<button
class="row-add"
tabindex="-1"
aria-label="Add inside"
title="Add inside"
onclick={(e) => {
e.stopPropagation();
const r = e.currentTarget.getBoundingClientRect();
store.menu = { x: r.left, y: r.bottom + 4, id: page.id, add: true };
}}
><Icon name="plus" /></button>{/if}
</div>
{#if kids.length && (open || seen)}
<div class="kids" class:open inert={!open}>
<div class="kids-in">
{#each shown as kid (kid.id)}
<TreeNode page={kid} depth={depth + 1} {dropOn} {dragging} />
{/each}
{#if shown.length < kids.length}
<button class="row more" style="--d:{depth + 1}" onclick={() => (limit += STEP)}>
<span class="name">Show {Math.min(STEP, kids.length - shown.length)} more of {kids.length - shown.length}</span>
</button>
{/if}
</div>
</div>
{/if}
</div>
+18
View File
@@ -0,0 +1,18 @@
<script>
import Icon from './Icon.svelte';
import WindowControls from './WindowControls.svelte';
import { store } from '$lib/state.svelte.js';
</script>
<div class="welcome">
<WindowControls />
<div class="welcome-card island">
<img class="welcome-logo" src="/icon.svg" alt="Ink" width="72" height="72" />
<h1>Where should your notes live?</h1>
<p>Every page is a plain Markdown file in a folder you pick. Nothing leaves this computer, and an existing Obsidian vault works too.</p>
<div class="welcome-actions">
<button class="btn primary" onclick={() => store.pick('default')}><Icon name="folder" />Use ~/Notes</button>
<button class="btn" onclick={() => store.pick('choose')}>Choose a folder…</button>
</div>
</div>
</div>
+25
View File
@@ -0,0 +1,25 @@
<script>
import { onMount } from 'svelte';
import Icon from './Icon.svelte';
import { api, isDesktop, platform } from '$lib/api.js';
// macOS keeps its native traffic lights, and the browser build has no window to control.
const show = isDesktop && platform !== 'darwin';
let maximized = $state(false);
onMount(() => {
if (!show) return;
api.isMaximized().then((v) => (maximized = v));
return api.onMaximized((v) => (maximized = v));
});
</script>
{#if show}
<div class="winctl">
<button class="btn-icon" aria-label="Minimize" onclick={() => api.minimize()}><Icon name="minimize" /></button>
<button class="btn-icon" aria-label={maximized ? 'Restore' : 'Maximize'} onclick={() => api.toggleMaximize()}>
<Icon name={maximized ? 'restore' : 'maximize'} />
</button>
<button class="btn-icon close" aria-label="Close" onclick={() => api.close()}><Icon name="x" /></button>
</div>
{/if}
+185
View File
@@ -0,0 +1,185 @@
// CSV in and out of a database. Pure functions (no DOM), so they can be tested on their own.
// parseCsv(text) -> { rows: string[][], delimiter } RFC 4180 quoting; , ; or tab
// toCsv(rows) -> text
// databaseCsv(db, records) -> text: a Name column, then one per property
// planImport({...}) -> what to create: new properties, options to add, and the records
import { read, write, keyFor, TYPES, isListType } from './database.js';
export function parseCsv(text) {
let s = String(text || '').replace(/^/, '');
// the delimiter: whichever of , ; tab occurs most outside quotes in the first line
const first = (() => {
let q = false, out = '';
for (const ch of s) {
if (ch === '"') q = !q;
else if (!q && (ch === '\n' || ch === '\r')) break;
if (!q) out += ch;
}
return out;
})();
const count = (d) => first.split(d).length - 1;
const delimiter = [',', ';', '\t'].sort((a, b) => count(b) - count(a))[0];
const rows = [];
let row = [], cell = '', q = false, i = 0;
const endCell = () => { row.push(cell); cell = ''; };
const endRow = () => { endCell(); rows.push(row); row = []; };
for (; i < s.length; i++) {
const ch = s[i];
if (q) {
if (ch === '"') { if (s[i + 1] === '"') { cell += '"'; i++; } else q = false; }
else cell += ch;
} else if (ch === '"' && cell === '') q = true;
else if (ch === delimiter) endCell();
else if (ch === '\n') endRow();
else if (ch === '\r') { if (s[i + 1] === '\n') i++; endRow(); }
else cell += ch;
}
if (cell !== '' || row.length) endRow();
// drop rows that are entirely empty (a trailing blank line)
return { rows: rows.filter((r) => r.some((c) => c.trim() !== '')), delimiter };
}
export function toCsv(rows, delimiter = ',') {
const cell = (v) => {
const s = String(v ?? '');
return s === '' ? '' : new RegExp(`["\\n\\r${delimiter === '\t' ? '\\t' : delimiter}]|^\\s|\\s$`).test(s) ? `"${s.replace(/"/g, '""')}"` : s;
};
return rows.map((r) => r.map(cell).join(delimiter)).join('\r\n') + '\r\n';
}
// A property value as plain text in a CSV cell (lists are joined with ", ").
const cellOf = (rec, p) => {
const v = read(rec.props ? rec.props[p.key] : undefined, p.type);
return Array.isArray(v) ? v.join(', ') : v === null ? '' : typeof v === 'boolean' ? String(v) : String(v);
};
export function databaseCsv(db, records) {
const rows = [['Name', ...db.properties.map((p) => p.name)]];
for (const r of records) rows.push([r.title, ...db.properties.map((p) => cellOf(r, p))]);
return toCsv(rows);
}
/* import */
const TRUE = /^(true|yes|y|x|1|on|checked|✓|✔)$/i;
const FALSE = /^(false|no|n|0|off|unchecked|)$/i;
// "$1,234.50", "12%", "1 234,5" -> a number, or null
export function parseNumber(s) {
let t = String(s ?? '').trim().replace(/[$€£¥%\s]/g, '');
if (!t || !/\d/.test(t)) return null;
if (/^-?\d{1,3}(\.\d{3})+(,\d+)?$/.test(t)) t = t.replace(/\./g, '').replace(',', '.'); // 1.234,5
else if (/^-?\d{1,3}(,\d{3})+(\.\d+)?$/.test(t)) t = t.replace(/,/g, ''); // 1,234.5 and 5,200
else if (/^-?\d+,\d{1,2}$/.test(t)) t = t.replace(',', '.'); // 12,5
else t = t.replace(/,/g, '');
return /^-?\d*\.?\d+(e[-+]?\d+)?$/i.test(t) ? Number(t) : null;
}
const pad = (n) => String(n).padStart(2, '0');
const iso = (y, m, d) => {
const t = new Date(Date.UTC(y, m - 1, d));
return t.getUTCFullYear() === y && t.getUTCMonth() === m - 1 && t.getUTCDate() === d ? `${y}-${pad(m)}-${pad(d)}` : '';
};
// 2026-03-01, 2026-03-01T10:00, 3/1/2026, 1.3.2026, "Mar 1, 2026" -> YYYY-MM-DD, or ''
export function parseDate(s) {
const t = String(s ?? '').trim();
let m = t.match(/^(\d{4})-(\d{1,2})-(\d{1,2})(?:[T ].*)?$/);
if (m) return iso(+m[1], +m[2], +m[3]);
m = t.match(/^(\d{1,2})\.(\d{1,2})\.(\d{4})$/);
if (m) return iso(+m[3], +m[2], +m[1]);
m = t.match(/^(\d{1,2})\/(\d{1,2})\/(\d{4})$/);
if (m) return +m[1] > 12 ? iso(+m[3], +m[2], +m[1]) : iso(+m[3], +m[1], +m[2]);
if (/[a-z]{3}/i.test(t) && /\d{4}/.test(t)) {
const d = new Date(t + ' 12:00');
if (!isNaN(d)) return iso(d.getFullYear(), d.getMonth() + 1, d.getDate());
}
return '';
}
const splitList = (s) => String(s ?? '').split(/[,;]/).map((x) => x.trim()).filter(Boolean);
// The type that suits a column's values.
export function inferType(values) {
const vals = values.map((v) => String(v ?? '').trim()).filter(Boolean);
if (vals.length < 1) return { type: 'text' };
if (vals.every((v) => /^https?:\/\/\S+$/i.test(v))) return { type: 'url' };
if (vals.every((v) => /^(true|false|yes|no|x|y|n|0|1)$/i.test(v)) && vals.some((v) => !/^[01]$/.test(v))) return { type: 'checkbox' };
if (vals.every((v) => parseDate(v))) return { type: 'date' };
if (vals.every((v) => parseNumber(v) !== null)) return { type: 'number' };
const distinct = [...new Set(vals)];
if (vals.length >= 3 && distinct.length <= 12 && distinct.length <= vals.length * 0.6 && distinct.every((v) => v.length <= 30 && !/[,;]/.test(v))) return { type: 'select' };
const parts = [...new Set(vals.flatMap(splitList))];
if (vals.length >= 3 && vals.filter((v) => /[,;]/.test(v)).length >= vals.length * 0.2 && parts.length <= 15 && parts.every((v) => v.length <= 30 && parseNumber(v) === null)) return { type: 'multiselect' };
return { type: 'text' };
}
// Which database property each CSV column goes to.
// returns [{ index, header, target: 'title' | 'skip' | 'existing' | 'new', key?, name, type }]
export function autoMap(headers, rows, existing = []) {
existing = existing.filter((p) => p.type !== 'formula'); // computed: nothing to import into
const used = new Set();
const cols = headers.map((h, index) => ({ index, header: String(h || '').trim() }));
let titleAt = cols.findIndex((c) => /^(name|title|page|record)$/i.test(c.header));
if (titleAt < 0) titleAt = cols.findIndex((c) => c.header !== '' || rows.some((r) => String(r[c.index] ?? '').trim()));
return cols.map((c) => {
const values = rows.map((r) => r[c.index]);
const empty = !values.some((v) => String(v ?? '').trim());
if (c.index === titleAt) return { ...c, target: 'title', name: c.header || 'Name', type: 'text' };
if (empty && !c.header) return { ...c, target: 'skip', name: '', type: 'text' };
const hit = existing.find((p) => p.name.toLowerCase() === c.header.toLowerCase() && !used.has(p.key));
if (hit) { used.add(hit.key); return { ...c, target: 'existing', key: hit.key, name: hit.name, type: hit.type }; }
return { ...c, target: empty ? 'skip' : 'new', name: c.header || `Column ${c.index + 1}`, type: inferType(values).type };
});
}
// What an import creates, from the CSV rows (header row excluded) and the mapping (see autoMap):
// properties: the new properties, with options gathered from the values
// options: { key: [new options] } for existing select properties that gain some
// items: [{ title, meta }] one per row, meta holding stored values (strings or lists)
export function planImport({ rows, columns, existing = [] }) {
const taken = existing.map((p) => p.key);
const writable = existing.filter((p) => p.type !== 'formula');
const properties = [];
const byIndex = new Map();
for (const c of columns) {
if (c.target === 'new') {
const type = TYPES.some((t) => t.type === c.type) ? c.type : 'text';
const p = { key: keyFor(c.name, [...taken, ...properties.map((x) => x.key)]), name: c.name, type };
if (type === 'select' || type === 'multiselect') p.options = [];
properties.push(p);
byIndex.set(c.index, p);
} else if (c.target === 'existing') {
const p = writable.find((x) => x.key === c.key);
if (p) byIndex.set(c.index, p);
}
}
const options = {};
const addOption = (p, o) => {
if (properties.includes(p)) { if (!p.options.includes(o)) p.options.push(o); return; } // a new property collects its own
if ((p.options || []).includes(o)) return;
const list = (options[p.key] ||= []);
if (!list.includes(o)) list.push(o);
};
const titleCol = columns.find((c) => c.target === 'title');
const items = rows.map((r) => {
const meta = {};
for (const [index, p] of byIndex) {
const raw = String(r[index] ?? '').trim();
if (!raw) continue;
let v;
switch (p.type) {
case 'number': { const n = parseNumber(raw); v = n === null ? '' : write(n, 'number'); break; }
case 'checkbox': v = TRUE.test(raw) ? 'true' : FALSE.test(raw) ? 'false' : ''; break;
case 'date': v = parseDate(raw); break;
case 'select': v = raw.replace(/,/g, ' '); break;
default: v = isListType(p.type) ? splitList(raw.replace(/\s*\n\s*/g, ', ')).map((x) => x.replace(/,/g, ' ')) : raw.replace(/\s*\n\s*/g, ' ');
}
if (v === '' || (Array.isArray(v) && !v.length)) continue;
if (p.type === 'select') addOption(p, v);
if (p.type === 'multiselect') for (const o of v) addOption(p, o);
meta[p.key] = v;
}
const title = String(titleCol ? r[titleCol.index] ?? '' : '').replace(/\s+/g, ' ').trim() || 'Untitled';
return { title, meta };
});
return { properties, options, items };
}
+47
View File
@@ -0,0 +1,47 @@
// Where daily notes go: <folder>/YYYY-MM-DD. The folder is a page (and its sub-pages), by path, like "Private/Daily".
export const DEFAULT_FOLDER = 'Daily';
// A folder path typed by the user, tidied up ("" when there is nothing usable).
export function cleanFolder(text) {
return String(text || '')
.split(/[\\/]+/)
.map((p) => p.replace(/[<>:"|?*\u0000-\u001f]/g, '-').replace(/\s+/g, ' ').trim().replace(/^\.+$/, ''))
.filter(Boolean)
.join('/');
}
// With no folder chosen: the top-level "Daily" if it is there; otherwise the one page called "Daily" somewhere else
// (so a "Daily" kept inside another folder is found, not duplicated at the top); otherwise "Daily".
export function autoFolder(pages) {
const there = pages.filter((p) => p.exists && !/\.canvas$/i.test(p.id));
if (there.some((p) => p.id === DEFAULT_FOLDER)) return DEFAULT_FOLDER;
const named = there.filter((p) => p.title.toLowerCase() === 'daily');
return named.length === 1 ? named[0].id : DEFAULT_FOLDER;
}
// A date as text: YYYY MM DD (numbers), D (day, no zero), dddd (weekday name) and MMMM (month name), in `locale`.
// Characters a file name can't have are never produced, so the result is safe as a page name.
export function formatDate(date, format = 'YYYY-MM-DD', locale) {
const z = (n) => String(n).padStart(2, '0');
const names = (opts) => date.toLocaleDateString(locale || undefined, opts);
const parts = {
YYYY: String(date.getFullYear()),
MMMM: names({ month: 'long' }),
MM: z(date.getMonth() + 1),
DD: z(date.getDate()),
dddd: names({ weekday: 'long' }),
D: String(date.getDate())
};
return format.replace(/YYYY|MMMM|MM|DD|dddd|D/g, (t) => parts[t]).replace(/[\\/<>:"|?*\u0000-\u001f]/g, '-');
}
export function dailyId(folder, date = new Date(), format = 'YYYY-MM-DD', locale) {
return `${folder || DEFAULT_FOLDER}/${formatDate(date, format, locale)}`;
}
// The text a new daily note starts with: the template with {{date}} (as in the note's name) and {{weekday}} filled in.
export function dailyBody(template, date = new Date(), format = 'YYYY-MM-DD', locale) {
return String(template || '')
.replace(/\{\{\s*date\s*\}\}/gi, formatDate(date, format, locale))
.replace(/\{\{\s*weekday\s*\}\}/gi, formatDate(date, 'dddd', locale));
}
+576
View File
@@ -0,0 +1,576 @@
// Databases stored as plain Markdown.
//
// ---
// ink-database: basic <- front matter marks the page as a database
// ---
//
// %% ink-database
// { "properties": [...], "views": [...] } <- the schema: typed properties and the saved views
// %%
//
// Every record is a sub-page of the database. Its properties are ordinary front matter keys on that page
// (`status: In progress`, `due: 2026-03-01`, `done: true`, `people: [Ana, Ben]`), so records stay normal notes
// and Obsidian reads them as properties. The schema only says which keys are columns and how to read them.
import { tagHex } from './tagColors.js';
import { evaluateFormula, FormulaError, show as showValue } from './formula.js';
export const DB_KEY = 'ink-database';
export const isDatabase = (meta) => !!(meta && meta[DB_KEY]);
export const TYPES = [
{ type: 'text', label: 'Text', icon: 'file' },
{ type: 'number', label: 'Number', icon: 'hash' },
{ type: 'select', label: 'Select', icon: 'tag' },
{ type: 'multiselect', label: 'Multi-select', icon: 'list' },
{ type: 'date', label: 'Date', icon: 'calendar' },
{ type: 'checkbox', label: 'Checkbox', icon: 'check2' },
{ type: 'people', label: 'People', icon: 'users' },
{ type: 'files', label: 'Files', icon: 'paperclip' },
{ type: 'url', label: 'URL', icon: 'link' },
{ type: 'relation', label: 'Relation', icon: 'graph' }, // titles of other records in this database (timeline dependencies)
{ type: 'formula', label: 'Formula', icon: 'sparkle' } // computed from the record's other values; never stored (formula.js)
];
export const VIEW_TYPES = [
{ type: 'table', label: 'Table', icon: 'table' },
{ type: 'board', label: 'Board', icon: 'board' },
{ type: 'calendar', label: 'Calendar', icon: 'calendar' },
{ type: 'gallery', label: 'Gallery', icon: 'grid' },
{ type: 'timeline', label: 'Timeline', icon: 'chart' },
{ type: 'list', label: 'List', icon: 'list' }
];
export const typeInfo = (t) => TYPES.find((x) => x.type === t) || TYPES[0];
const LISTY = new Set(['multiselect', 'people', 'files', 'relation']);
export const isListType = (t) => LISTY.has(t);
// Keys that already mean something on a page.
const RESERVED = new Set(['icon', 'cover', 'tags', 'created', DB_KEY, 'kanban-plugin', 'title']);
export const isReservedKey = (k) => RESERVED.has(k);
let seq = 0;
export const uid = (p = 'v') => p + Date.now().toString(36) + (++seq).toString(36) + Math.random().toString(36).slice(2, 5);
// "Due date" -> "due-date", made unique among `taken`.
export function keyFor(name, taken = []) {
let base = String(name || '').trim().toLowerCase().replace(/[^a-z0-9_]+/g, '-').replace(/^-+|-+$/g, '') || 'property';
if (RESERVED.has(base)) base += '-2';
let key = base, n = 2;
while (taken.includes(key) || RESERVED.has(key)) key = `${base}-${n++}`;
return key;
}
export const prop = (name, type = 'text', taken = []) => ({
key: keyFor(name, taken), name, type, ...(type === 'select' || type === 'multiselect' ? { options: [] } : {})
});
export const view = (type = 'table', name) => ({
id: uid('v'), name: name || (VIEW_TYPES.find((v) => v.type === type) || VIEW_TYPES[0]).label, type,
sort: null, filters: [], hidden: [], widths: {}, calc: {}
});
export function defaultDatabase() {
const status = { ...prop('Status', 'select'), options: ['Not started', 'In progress', 'Done'] };
const date = prop('Date', 'date');
return {
properties: [status, date],
views: [
{ ...view('table', 'Table') },
{ ...view('calendar', 'Calendar'), dateProp: date.key }
]
};
}
/* the file body */
const BLOCK = /^[ \t]*%%[ \t]*ink-database[ \t]*\r?\n([\s\S]*?)\r?\n[ \t]*%%[ \t]*$/m;
function clean(s) {
const out = { properties: [], views: [] };
const keys = [];
for (const p of Array.isArray(s && s.properties) ? s.properties : []) {
if (!p || typeof p.key !== 'string' || !p.key || keys.includes(p.key)) continue;
keys.push(p.key);
const type = TYPES.some((t) => t.type === p.type) ? p.type : 'text';
const q = { key: p.key, name: String(p.name || p.key), type };
if (type === 'select' || type === 'multiselect') {
q.options = [...new Set((Array.isArray(p.options) ? p.options : []).map(String).filter(Boolean))];
const colors = Object.entries(p.colors && typeof p.colors === 'object' ? p.colors : {}).filter(([k, c]) => k && tagHex(c));
if (colors.length) q.colors = Object.fromEntries(colors);
}
if (type === 'number') {
if (NUMBER_FORMATS.some((f) => f.value === p.format && f.value !== 'number')) q.format = p.format;
if (q.format === 'bar' && Number(p.max) > 0) q.max = Number(p.max);
}
if (type === 'formula') {
q.expression = typeof p.expression === 'string' ? p.expression : '';
if (NUMBER_FORMATS.some((f) => f.value === p.format && f.value !== 'number' && f.value !== 'bar')) q.format = p.format;
}
out.properties.push(q);
}
for (const v of Array.isArray(s && s.views) ? s.views : []) {
if (!v || typeof v !== 'object') continue;
const type = VIEW_TYPES.some((t) => t.type === v.type) ? v.type : 'table';
out.views.push({
...v, id: typeof v.id === 'string' && v.id ? v.id : uid('v'), type, name: String(v.name || type),
filters: Array.isArray(v.filters) ? v.filters.filter((f) => f && f.key) : [],
hidden: Array.isArray(v.hidden) ? v.hidden.map(String) : [],
sort: v.sort && v.sort.key ? { key: String(v.sort.key), dir: v.sort.dir === 'desc' ? 'desc' : 'asc' } : null,
calc: Object.fromEntries(Object.entries(v.calc && typeof v.calc === 'object' ? v.calc : {}).filter(([k, c]) => k && typeof c === 'string' && AGG_KINDS.has(c))),
widths: Object.fromEntries(Object.entries(v.widths && typeof v.widths === 'object' ? v.widths : {}).filter(([, w]) => Number.isFinite(w)).map(([k, w]) => [k, Math.max(MIN_WIDTH, Math.min(MAX_WIDTH, Math.round(w)))]))
});
}
if (!out.views.length) out.views.push(view('table'));
return out;
}
// -> { properties, views, rest } (rest: any text outside the schema block, kept as is)
export function parseDatabase(body) {
const text = String(body || '').replace(/\r\n?/g, '\n');
const m = text.match(BLOCK);
if (!m) return { ...(text.trim() ? clean(null) : defaultDatabase()), rest: text.trim() ? text : '' };
let data = null;
try { data = JSON.parse(m[1]); } catch { /* damaged: fall back to a plain table */ }
const rest = (text.slice(0, m.index) + text.slice(m.index + m[0].length)).replace(/^\n+|\n+$/g, '').replace(/\n{3,}/g, '\n\n');
return { ...clean(data), rest };
}
export function databaseToMd(db) {
const data = { properties: db.properties, views: db.views };
const block = `%% ink-database\n${JSON.stringify(data, null, 2)}\n%%\n`;
return db.rest ? `${block}\n${db.rest}\n` : block;
}
export const MIN_WIDTH = 80, MAX_WIDTH = 720;
export const NUMBER_FORMATS = [
{ value: 'number', label: 'Number' },
{ value: 'commas', label: 'Number with commas' },
{ value: 'percent', label: 'Percent' },
{ value: 'dollar', label: 'Dollar' },
{ value: 'euro', label: 'Euro' },
{ value: 'bar', label: 'Progress bar' }
];
// A number as the property's format shows it (the file always holds the plain number).
export function formatNumber(n, p) {
if (n === null || n === undefined || !Number.isFinite(n)) return '';
const f = (opts) => n.toLocaleString('en-US', { maximumFractionDigits: 10, ...opts });
switch (p && p.format) {
case 'commas': return f();
case 'percent': return `${f()}%`;
case 'dollar': return f({ style: 'currency', currency: 'USD', maximumFractionDigits: 2 });
case 'euro': return f({ style: 'currency', currency: 'EUR', maximumFractionDigits: 2 });
default: return String(n);
}
}
// How full a progress bar is, 0..1 (the property's max, default 100).
export const barFraction = (n, p) => (n === null || !Number.isFinite(n) ? 0 : Math.max(0, Math.min(1, n / (p && p.max > 0 ? p.max : 100))));
/* values
A record's `props` holds what the file says: a string, or a list of strings. `read` turns that into the value
the property's type means, `write` turns an edited value back into what goes in the file. */
const toList = (v) => (Array.isArray(v) ? v.map(String).filter(Boolean) : v === undefined || v === null || v === '' ? [] : String(v).split(',').map((s) => s.trim()).filter(Boolean));
export function read(raw, type) {
switch (type) {
case 'number': {
if (raw === undefined || raw === null || raw === '' || Array.isArray(raw)) return null;
const n = Number(String(raw).replace(',', '.'));
return Number.isFinite(n) ? n : null;
}
case 'checkbox': return /^(true|yes|y|x|1|on)$/i.test(String(raw ?? '').trim());
case 'multiselect': case 'people': case 'files': case 'relation': return toList(raw);
default: return Array.isArray(raw) ? raw.join(', ') : raw === undefined || raw === null ? '' : String(raw);
}
}
export function write(value, type) {
switch (type) {
case 'number': return value === null || value === '' || value === undefined || !Number.isFinite(Number(value)) ? '' : String(Number(value));
case 'checkbox': return value ? 'true' : 'false';
case 'multiselect': case 'people': case 'files': case 'relation': return toList(value);
default: return String(value ?? '').trim();
}
}
export const isEmpty = (value, type) => (type === 'checkbox' ? false : Array.isArray(value) ? !value.length : value === null || value === '' || value === undefined);
// A date as written: YYYY-MM-DD (a time after it is ignored).
export const dayOf = (s) => { const m = String(s || '').match(/^(\d{4})-(\d{2})-(\d{2})/); return m ? `${m[1]}-${m[2]}-${m[3]}` : ''; };
export const dayNum = (iso) => { const m = dayOf(iso); return m ? Math.round(Date.UTC(+m.slice(0, 4), +m.slice(5, 7) - 1, +m.slice(8, 10)) / 864e5) : null; };
export const isoOfDay = (n) => new Date(n * 864e5).toISOString().slice(0, 10);
export const todayIso = () => { const d = new Date(); return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`; };
// What a record has in a column: the page's title is the `title` column.
export function valueOf(rec, p) {
if (p === 'title') return rec.title;
return read(rec.props ? rec.props[p.key] : undefined, p.type);
}
/* filters */
export const OPS = {
text: ['contains', 'is', 'is not', 'is empty', 'is not empty'],
number: ['=', '≠', '>', '<', '≥', '≤', 'is empty', 'is not empty'],
select: ['is', 'is not', 'is empty', 'is not empty'],
multiselect: ['contains', 'does not contain', 'is empty', 'is not empty'],
date: ['is', 'before', 'after', 'is empty', 'is not empty'],
checkbox: ['is checked', 'is unchecked'],
formula: ['is', 'is not', 'contains', '>', '<', '≥', '≤', 'is empty', 'is not empty']
};
export const opsFor = (type) => OPS[type] || (isListType(type) ? OPS.multiselect : OPS.text);
export const needsValue = (op) => !['is empty', 'is not empty', 'is checked', 'is unchecked'].includes(op);
export function matches(rec, f, props) {
const p = f.key === 'title' ? { key: 'title', type: 'text' } : props.find((x) => x.key === f.key);
if (!p) return true;
const v = f.key === 'title' ? rec.title : valueOf(rec, p);
const want = String(f.value ?? '');
const low = (s) => String(s).toLowerCase();
switch (f.op) {
case 'is empty': return isEmpty(v, p.type);
case 'is not empty': return !isEmpty(v, p.type);
case 'is checked': return v === true;
case 'is unchecked': return v === false;
}
if (!want && needsValue(f.op)) return true; // a half-made filter lets everything through
if (p.type === 'formula' && ['>', '<', '≥', '≤'].includes(f.op)) {
const a = Number(v), b = Number(want);
if (v === '' || !Number.isFinite(a) || !Number.isFinite(b)) return false;
return { '>': a > b, '<': a < b, '≥': a >= b, '≤': a <= b }[f.op];
}
if (p.type === 'number') {
const n = Number(want);
if (v === null || !Number.isFinite(n)) return false;
return { '=': v === n, '≠': v !== n, '>': v > n, '<': v < n, '≥': v >= n, '≤': v <= n }[f.op] ?? true;
}
if (p.type === 'date') {
const a = dayNum(v), b = dayNum(want === 'today' ? todayIso() : want);
if (a === null || b === null) return false;
return { is: a === b, before: a < b, after: a > b }[f.op] ?? true;
}
if (Array.isArray(v)) {
const has = v.some((x) => low(x) === low(want)) || (f.op === 'contains' && v.some((x) => low(x).includes(low(want))));
return f.op === 'does not contain' ? !has : has;
}
const s = String(v);
switch (f.op) {
case 'contains': return low(s).includes(low(want));
case 'is': return low(s) === low(want);
case 'is not': return low(s) !== low(want);
}
return true;
}
const cmp = (a, b) => (typeof a === 'number' && typeof b === 'number' ? a - b : String(a).localeCompare(String(b), undefined, { numeric: true, sensitivity: 'base' }));
// Records that pass the view's filters, in its sort order (empty values last either way; title as the tie-breaker).
export function select(records, v, props) {
let out = records.filter((r) => (v.filters || []).every((f) => matches(r, f, props)));
const s = v.sort;
const sp = s && (s.key === 'title' ? 'title' : props.find((p) => p.key === s.key));
const by = (r) => {
const x = sp === 'title' ? r.title : sp ? valueOf(r, sp) : '';
return Array.isArray(x) ? x.join(', ') : x;
};
const type = sp && sp !== 'title' ? sp.type : 'text';
out = [...out].sort((a, b) => {
if (sp) {
const x = by(a), y = by(b);
const ex = isEmpty(x, type) || (type === 'checkbox' && false), ey = isEmpty(y, type);
if (ex !== ey) return ex ? 1 : -1;
if (!ex) {
// options sort in the order they're listed, not alphabetically
let d = 0;
if (type === 'select' && Array.isArray(sp.options)) d = sp.options.indexOf(x) - sp.options.indexOf(y);
else d = type === 'checkbox' ? Number(x) - Number(y) : type === 'formula' && x !== '' && y !== '' && Number.isFinite(Number(x)) && Number.isFinite(Number(y)) ? Number(x) - Number(y) : cmp(x, y);
if (d) return s.dir === 'desc' ? -d : d;
}
}
return a.title.localeCompare(b.title, undefined, { numeric: true, sensitivity: 'base' });
});
return out;
}
/* board */
// Columns for a board grouped by a select (options in order, then what's left over) or checkbox property.
export function columnsFor(records, p) {
if (!p) return [{ id: '', title: 'All', value: '', records }];
if (p.type === 'checkbox') {
return [
{ id: 'false', title: 'Unchecked', value: false, records: records.filter((r) => !valueOf(r, p)) },
{ id: 'true', title: 'Checked', value: true, records: records.filter((r) => valueOf(r, p)) }
];
}
const known = p.options || [];
const values = (r) => { const v = valueOf(r, p); return Array.isArray(v) ? v : v ? [v] : []; };
const extra = [];
for (const r of records) for (const x of values(r)) if (!known.includes(x) && !extra.includes(x)) extra.push(x);
const names = [...known, ...extra];
const cols = names.map((n) => ({ id: 'o:' + n, title: n, value: n, records: records.filter((r) => values(r).includes(n)) }));
return [{ id: '', title: `No ${p.name.toLowerCase()}`, value: '', records: records.filter((r) => !values(r).length) }, ...cols];
}
/* calendar */
export const MONTHS = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December'];
export const WEEKDAYS = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'];
// Day names for a week that starts on Monday (the default) or Sunday.
export const weekdayNames = (sunday = false) => (sunday ? ['Sun', ...WEEKDAYS.slice(0, 6)] : WEEKDAYS);
// The seven days (Monday first, or Sunday first) of the week that holds `iso`.
export function weekOf(iso, sunday = false) {
const n = dayNum(iso);
if (n === null) return [];
const start = n - ((new Date(n * 864e5).getUTCDay() + (sunday ? 0 : 6)) % 7);
return Array.from({ length: 7 }, (_, d) => { const i = isoOfDay(start + d); return { iso: i, day: +i.slice(8), inMonth: true }; });
}
// The weeks (Monday first, or Sunday first) that show month `month` (0-11) of `year`: always whole weeks, 5 or 6 of them.
export function monthGrid(year, month, sunday = false) {
const first = Math.round(Date.UTC(year, month, 1) / 864e5);
const last = Math.round(Date.UTC(year, month + 1, 0) / 864e5);
const lead = (new Date(first * 864e5).getUTCDay() + (sunday ? 0 : 6)) % 7; // the first day of the week = 0
const start = first - lead;
const weeks = Math.ceil((lead + (last - first + 1)) / 7);
const out = [];
for (let w = 0; w < weeks; w++) {
out.push(Array.from({ length: 7 }, (_, d) => {
const iso = isoOfDay(start + w * 7 + d);
return { iso, day: +iso.slice(8), inMonth: +iso.slice(5, 7) - 1 === month };
}));
}
return out;
}
// Records by the days they fall on. With an end date property a record covers every day from start to end.
export function byDay(records, startProp, endProp) {
const map = new Map();
if (!startProp) return map;
const put = (iso, r) => { if (!map.has(iso)) map.set(iso, []); map.get(iso).push(r); };
for (const r of records) {
const a = dayNum(valueOf(r, startProp));
if (a === null) continue;
let b = endProp ? dayNum(valueOf(r, endProp)) : null;
if (b === null || b < a || b - a > 366) b = a;
for (let n = a; n <= b; n++) put(isoOfDay(n), r);
}
return map;
}
/* timeline */
// Bars for records with a start date (end = the end property, else a single day). Returns the day span to draw.
export function timeline(records, startProp, endProp) {
const bars = [];
if (!startProp) return { bars, from: 0, to: 0, undated: records };
const undated = [];
for (const r of records) {
const a = dayNum(valueOf(r, startProp));
if (a === null) { undated.push(r); continue; }
let b = endProp ? dayNum(valueOf(r, endProp)) : null;
if (b === null || b < a) b = a;
bars.push({ rec: r, start: a, end: b });
}
bars.sort((x, y) => x.start - y.start || x.end - y.end || x.rec.title.localeCompare(y.rec.title));
if (!bars.length) { const t = dayNum(todayIso()); return { bars, from: t - 3, to: t + 40, undated }; }
const lo = Math.min(...bars.map((b) => b.start)), hi = Math.max(...bars.map((b) => b.end));
return { bars, from: lo - 2, to: Math.max(hi + 4, lo + 40), undated };
}
/* formulas
A formula property has no stored value: it is computed from the record's other values each time (see
formula.js). withFormulas() returns the records with those results filled into their `props` (as plain text, so
sorting, filtering and totals work like for any other property) and `fx`, the error message of any that failed. */
export function computeFormulas(properties, rec, today = todayIso()) {
const formulas = properties.filter((p) => p.type === 'formula');
const out = {};
if (!formulas.length) return out;
const byName = new Map();
for (const p of properties) { byName.set(p.name.toLowerCase(), p); if (!byName.has(p.key.toLowerCase())) byName.set(p.key.toLowerCase(), p); }
const memo = new Map();
const visiting = new Set();
const valueOfProp = (p) => {
if (p.type !== 'formula') return read(rec.props ? rec.props[p.key] : undefined, p.type);
if (memo.has(p.key)) {
const m = memo.get(p.key);
if (m.error) throw new FormulaError(m.error);
return m.value;
}
if (visiting.has(p.key)) throw new FormulaError(`“${p.name}” refers to itself`);
visiting.add(p.key);
try {
const expr = (p.expression || '').trim();
const value = expr ? evaluateFormula(expr, ctx) : null;
memo.set(p.key, { value });
return value;
} catch (e) {
const error = e instanceof FormulaError ? e.message : 'The formula could not be calculated';
memo.set(p.key, { error });
throw new FormulaError(error);
} finally {
visiting.delete(p.key);
}
};
const ctx = {
today,
prop(name) {
const low = String(name).trim().toLowerCase();
const p = byName.get(low);
if (p) return valueOfProp(p);
if (low === 'name' || low === 'title') return rec.title;
throw new FormulaError(`Unknown property “${name}”`);
}
};
for (const p of formulas) {
try {
const v = valueOfProp(p);
out[p.key] = { value: v, text: showValue(v) };
} catch (e) {
out[p.key] = { value: null, text: '', error: e.message };
}
}
return out;
}
export const hasFormulas = (properties) => properties.some((p) => p.type === 'formula');
export function withFormulas(records, properties, today) {
if (!hasFormulas(properties)) return records;
return records.map((r) => {
const c = computeFormulas(properties, r, today);
const props = { ...(r.props || {}) };
const fx = {};
for (const [k, v] of Object.entries(c)) {
props[k] = v.text;
if (v.error) fx[k] = v.error;
}
return { ...r, props, fx };
});
}
// A formula's result as shown (numbers in the property's format).
export function formulaDisplay(p, text) {
if (text === '' || text === null || text === undefined) return '';
const n = Number(text);
if (p && p.format && Number.isFinite(n) && String(text).trim() !== '' && !/^\d{4}-\d{2}-\d{2}/.test(text)) return formatNumber(n, p);
return String(text);
}
/* totals: what the table's "Calculate" row can show under a column */
export const AGGREGATES = [
{ value: 'count', label: 'Count all', for: 'any' },
{ value: 'filled', label: 'Count values', for: 'any' },
{ value: 'empty', label: 'Count empty', for: 'any' },
{ value: 'unique', label: 'Count unique values', for: 'any' },
{ value: 'percentFilled', label: 'Percent filled', for: 'any' },
{ value: 'sum', label: 'Sum', for: 'number' },
{ value: 'avg', label: 'Average', for: 'number' },
{ value: 'median', label: 'Median', for: 'number' },
{ value: 'min', label: 'Minimum', for: 'number' },
{ value: 'max', label: 'Maximum', for: 'number' },
{ value: 'range', label: 'Range', for: 'number' },
{ value: 'earliest', label: 'Earliest', for: 'date' },
{ value: 'latest', label: 'Latest', for: 'date' },
{ value: 'span', label: 'Date range (days)', for: 'date' },
{ value: 'checked', label: 'Checked', for: 'checkbox' },
{ value: 'unchecked', label: 'Unchecked', for: 'checkbox' },
{ value: 'percentChecked', label: 'Percent checked', for: 'checkbox' }
];
const AGG_KINDS = new Set(AGGREGATES.map((a) => a.value));
// the aggregates that make sense for a property type ('title' is text)
export function aggregatesFor(type) {
const kind = type === 'number' || type === 'formula' ? 'number' : type === 'date' ? 'date' : type === 'checkbox' ? 'checkbox' : null;
return AGGREGATES.filter((a) => a.for === 'any' || a.for === kind || (type === 'formula' && a.for === 'date'));
}
// -> { text, label } for `kind` over the typed values of one column. `p` is the property (for its number format).
export function aggregate(kind, values, type, p) {
const label = (AGGREGATES.find((a) => a.value === kind) || {}).label || '';
const total = values.length;
const filled = values.filter((v) => (type === 'checkbox' ? v === true : !isEmpty(v, type)));
const nums = () => values
.map((v) => (typeof v === 'number' ? v : typeof v === 'string' && v.trim() !== '' && Number.isFinite(Number(v)) && !/^\d{4}-\d{2}-\d{2}/.test(v) ? Number(v) : null))
.filter((n) => n !== null);
const dates = () => values.map((v) => dayNum(typeof v === 'string' ? v : '')).filter((n) => n !== null);
const fmt = (n) => (p && (p.type === 'number' || p.type === 'formula') && p.format && p.format !== 'bar' ? formatNumber(n, p) : String(Number(n.toFixed(2))));
const pct = (a, b) => (b ? `${Math.round((a / b) * 100)}%` : '0%');
let text = '';
switch (kind) {
case 'count': text = String(total); break;
case 'filled': text = String(type === 'checkbox' ? total : filled.length); break;
case 'empty': text = String(type === 'checkbox' ? 0 : total - filled.length); break;
case 'unique': text = String(new Set(filled.flatMap((v) => (Array.isArray(v) ? v : [v])).map((v) => String(v).toLowerCase())).size); break;
case 'percentFilled': text = pct(type === 'checkbox' ? total : filled.length, total); break;
case 'sum': { const n = nums(); text = n.length ? fmt(n.reduce((a, b) => a + b, 0)) : '0'; break; }
case 'avg': { const n = nums(); text = n.length ? fmt(n.reduce((a, b) => a + b, 0) / n.length) : ''; break; }
case 'median': {
const n = nums().sort((a, b) => a - b);
text = n.length ? fmt(n.length % 2 ? n[(n.length - 1) / 2] : (n[n.length / 2 - 1] + n[n.length / 2]) / 2) : '';
break;
}
case 'min': { const n = nums(); text = n.length ? fmt(Math.min(...n)) : ''; break; }
case 'max': { const n = nums(); text = n.length ? fmt(Math.max(...n)) : ''; break; }
case 'range': { const n = nums(); text = n.length ? fmt(Math.max(...n) - Math.min(...n)) : ''; break; }
case 'earliest': { const d = dates(); text = d.length ? isoOfDay(Math.min(...d)) : ''; break; }
case 'latest': { const d = dates(); text = d.length ? isoOfDay(Math.max(...d)) : ''; break; }
case 'span': { const d = dates(); text = d.length ? String(Math.max(...d) - Math.min(...d)) : ''; break; }
case 'checked': text = String(values.filter((v) => v === true).length); break;
case 'unchecked': text = String(values.filter((v) => v !== true).length); break;
case 'percentChecked': text = pct(values.filter((v) => v === true).length, total); break;
default: text = '';
}
return { text, label };
}
/* embeds
![[Tasks#Board|sort=due:desc; filter=status:is:Done; hide=owner]] shows a database inside a page. `#Board` picks
one of its saved views; the |alias holds this embed's own sort, filters and hidden properties, which sit on top of
the view's and never change the database itself. */
const enc = (s) => encodeURIComponent(String(s)).replace(/%20/g, ' ');
const dec = (s) => { try { return decodeURIComponent(s); } catch { return s; } };
export function parseEmbedAlias(alias) {
const out = { view: '', sort: null, filters: [], hidden: [] };
for (const part of String(alias || '').split(';')) {
const i = part.indexOf('=');
if (i < 0) continue;
const k = part.slice(0, i).trim().toLowerCase(), v = part.slice(i + 1).trim();
if (k === 'view') out.view = dec(v);
else if (k === 'sort') {
const [key, dir] = v.split(':');
if (key) out.sort = { key: dec(key), dir: dir === 'desc' ? 'desc' : 'asc' };
} else if (k === 'filter') {
const [key, op, ...rest] = v.split(':');
if (key && op) out.filters.push({ key: dec(key), op: dec(op), value: dec(rest.join(':')) });
} else if (k === 'hide') out.hidden.push(...v.split(',').map(dec).filter(Boolean));
}
return out;
}
export function embedAlias(o) {
const parts = [];
if (o.view) parts.push('view=' + enc(o.view));
if (o.sort && o.sort.key) parts.push(`sort=${enc(o.sort.key)}:${o.sort.dir === 'desc' ? 'desc' : 'asc'}`);
for (const f of o.filters || []) parts.push(`filter=${enc(f.key)}:${enc(f.op)}:${enc(f.value ?? '')}`);
if ((o.hidden || []).length) parts.push('hide=' + o.hidden.map(enc).join(','));
return parts.join('; ');
}
/* files */
export const IMAGE_EXT = /\.(png|jpe?g|gif|webp|svg|avif|bmp)$/i;
export const firstImage = (rec, props) => {
for (const p of props) {
if (p.type !== 'files') continue;
const f = valueOf(rec, p).find((x) => IMAGE_EXT.test(x));
if (f) return f;
}
return null;
};
// A colour for an option: stable for its name, one of the tag colours.
const HUES = ['#e5534b', '#e8883a', '#d9b130', '#44ad6e', '#3aa6c4', '#5b8def', '#9a6de0', '#e06fa6'];
export function optionColor(name, p) {
const chosen = p && p.colors && tagHex(p.colors[name]);
if (chosen) return chosen;
let h = 5;
for (const ch of String(name)) h = (h * 33 + ch.charCodeAt(0)) | 0;
return HUES[Math.abs(h) % HUES.length];
}
// The properties a record shows beside its title (those the view doesn't hide), in schema order.
export const visibleProps = (db, v) => db.properties.filter((p) => !(v.hidden || []).includes(p.key));
+11
View File
@@ -0,0 +1,11 @@
// Mounts a live database view (Database.svelte in its embedded mode) into an ![[embed]] block of the editor.
import { mount, unmount } from 'svelte';
import Database from './components/Database.svelte';
// -> a function that removes it again
export function mountDatabaseEmbed(el, { id, body, alias, setAlias }) {
const instance = mount(Database, { target: el, props: { id, body, embed: { alias, setAlias } } });
return () => {
try { unmount(instance); } catch { /* already gone */ }
};
}
+1468 -98
View File
File diff suppressed because it is too large. Load diff
+63
View File
@@ -0,0 +1,63 @@
// What createEditor (editor.js) needs from the app, for the page the store has open: link lookup, copy, images.
// Shared by the page editor and the card editor on a canvas.
import { store } from './state.svelte.js';
import { api, errorText } from './api.js';
import { toast } from './toast.svelte.js';
import { parseMarkdown } from './markdown.js';
import { headingsOf } from './embed.js';
import { isDatabase, parseDatabase } from './database.js';
import { mountDatabaseEmbed } from './databaseEmbed.js';
// pageId: the page the editor belongs to when that is not the one the store has open (the side pane)
export function editorHooks(pageId = null) {
const here = () => pageId || store.currentId;
return {
recentKinds: () => store.slashRecent,
rememberKind: (t) => store.rememberSlash(t),
prefs: () => store.prefs,
onCopy: (text) =>
navigator.clipboard.writeText(text).then(
() => toast('Code copied', 'copy'),
() => toast('Copying is blocked here. Select the text instead.', 'x')
),
links: {
pages: () => store.pages,
resolve: (ref) => store.resolveLink(ref),
refFor: (id) => store.refFor(id),
open: (ref) => store.openLink(ref),
create: (title) => store.createLinked(title),
current: here,
// headings of a page, for [[Page#… suggestions
async headings(id) {
if (id === store.currentId && !pageId) return store.headings;
try {
const r = await api.read(id);
// a database has no headings: its views take their place (![[Tasks#Board]] shows that view)
if (isDatabase(r.meta)) return parseDatabase(r.body).views.map((v) => ({ id: v.id, level: 2, text: v.name }));
return headingsOf(parseMarkdown(r.body));
} catch { return []; }
},
mountDatabase: (el, props) => mountDatabaseEmbed(el, props),
embed: (ref) => store.loadEmbed(ref)
},
images: {
url: (src) => api.mediaUrl(here(), src),
async save(file, name) {
try {
return await api.saveImage(here(), name, new Uint8Array(await file.arrayBuffer()));
} catch (err) {
toast('Could not add the image: ' + errorText(err), 'x');
return null;
}
},
async pick() {
try {
return await api.pickImage(here());
} catch (err) {
toast('Could not add the image: ' + errorText(err), 'x');
return null;
}
}
}
};
}
+95
View File
@@ -0,0 +1,95 @@
// Embeds: ![[Page]] shows another note's content inline, ![[Page#Heading]] just that section.
// Pure helpers (no DOM), so they can be tested on their own:
// headingsOf(blocks) the headings of a page, for pickers and the outline
// sectionOf(blocks, text) a heading plus everything under it, up to the next heading of the same or a higher level
// staticHtml(blocks, opts) read-only HTML for a list of blocks, styled by the same CSS as the editor's blocks
import { renderTex } from './katex.js';
import { ic } from './icons.js';
import { esc, htmlText } from './text.js';
import { highlight } from './highlight.js';
import { linkChip } from './markdown.js';
import { matchesHeading } from './links.js';
const isHeading = (b) => b.type === 'h1' || b.type === 'h2' || b.type === 'h3';
export const headingText = (b) => htmlText(b.html).replace(/\s+/g, ' ').trim();
export const headingsOf = (blocks) =>
blocks.filter(isHeading).map((b) => ({ id: b.id, level: +b.type[1], text: headingText(b) })).filter((h) => h.text);
export function sectionOf(blocks, heading) {
const i = blocks.findIndex((b) => isHeading(b) && matchesHeading(heading, headingText(b)));
if (i < 0) return null;
const level = +blocks[i].type[1];
let j = i + 1;
while (j < blocks.length && !(isHeading(blocks[j]) && +blocks[j].type[1] <= level)) j++;
return blocks.slice(i, j);
}
const CALLOUT_ICONS = { note: 'leaf', tip: 'bulb', warning: 'bell', caution: 'bolt' };
// Blocks use the editor's class names but are never editable, and carry no `.block` class, so the editor's own
// queries (drag, undo, find) don't mistake them for the page's blocks.
// embed(ref) -> { title, html } shows an ![[embed]] block's note in place (null: just a chip for it)
export function staticHtml(blocks, { imageUrl = (src) => src, embed = null } = {}) {
let html = '';
const render = (b) => {
const e = embed && b.type === 'embed' ? embed(b.ref) : null;
return e
? `<div class="eb b-embed"><div class="emb open"><div class="emb-head"><button class="emb-title" data-ref="${esc(b.ref)}" title="Open this note">${ic('file')}<span>${esc(e.title)}</span></button></div><div class="emb-body">${e.html}</div></div></div>`
: one(b, imageUrl);
};
for (let i = 0; i < blocks.length; i++) {
const b = blocks[i];
if (!b.col) { html += render(b); continue; }
// blocks of one column group sit side by side
let end = i;
while (end + 1 < blocks.length && blocks[end + 1].col && blocks[end + 1].col.g === b.col.g) end++;
const byCol = new Map();
for (const x of blocks.slice(i, end + 1)) {
if (!byCol.has(x.col.i)) byCol.set(x.col.i, []);
byCol.get(x.col.i).push(x);
}
const keys = [...byCol.keys()].sort((a, b2) => a - b2);
const cols = keys.map((k) => `<div class="eb-col">${byCol.get(k).map(render).join('')}</div>`);
const ws = keys.map((k) => byCol.get(k)[0].col.w);
const tpl = ws.every((w) => w > 0) ? `;grid-template-columns:${ws.map((w) => `minmax(0,${w}fr)`).join(' ')}` : '';
html += `<div class="eb-cols" style="--n:${cols.length}${tpl}">${cols.join('')}</div>`;
i = end;
}
return html;
}
function one(b, imageUrl) {
const c = `<div class="c">${b.html || ''}</div>`;
const row = (inner, cls = '') => `<div class="eb b-${b.type}${cls}"${b.indent ? ` style="margin-left:${b.indent * 24}px"` : ''}>${inner}</div>`;
switch (b.type) {
case 'p': return htmlText(b.html).trim() ? row(c) : '';
case 'bullet': return row(`<span class="bul"></span>${c}`);
case 'number': return row(`<span class="num"></span>${c}`);
case 'todo': return row(`<span class="check${b.checked ? ' on' : ''}">${ic('tick')}</span>${c}`, b.checked ? ' done' : '');
case 'callout': {
const k = b.kind || 'note';
return row(`<div class="callout" data-kind="${esc(k)}"><span class="callout-ico">${ic(CALLOUT_ICONS[k] || 'leaf')}</span>${c}</div>`);
}
case 'code': return row(`<div class="code"><div class="code-bar"><span class="lang">${esc(b.lang || 'txt')}</span></div><pre class="c code-c">${highlight(b.text || '')}</pre></div>`);
case 'mermaid': return row(`<div class="code"><div class="code-bar"><span class="lang">mermaid</span></div><pre class="c code-c">${highlight(b.text || '')}</pre></div>`);
case 'math': {
let out;
try { out = renderTex(b.text || '', true); } catch { out = esc(b.text || ''); }
return row(`<div class="math"><div class="math-preview">${out}</div></div>`);
}
case 'divider': return row('<div class="hr"></div>');
case 'image':
return row(`<figure class="img"><img src="${esc(imageUrl(b.src))}" alt="${esc(b.alt || '')}" draggable="false"><span class="img-miss">${ic('image')}Image not found: ${esc(b.src)}</span></figure>`);
case 'table': {
const align = (i) => (b.align?.[i] ? ` style="text-align:${esc(b.align[i])}"` : '');
const rows = (b.rows || []).map((r, ri) => `<tr>${r.map((h, ci) => `<${ri ? 'td' : 'th'}${align(ci)}><div class="ecell">${h || ''}</div></${ri ? 'td' : 'th'}>`).join('')}</tr>`).join('');
return row(`<div class="tbl"><div class="tbl-scroll"><table><tbody>${rows}</tbody></table></div></div>`);
}
case 'toggle':
return row(`<details class="etg"${b.open ? ' open' : ''}><summary class="c">${b.html || ''}</summary><div class="c etg-body">${b.body || ''}</div></details>`);
case 'footnote': return `<div class="eb b-footnote" data-fid="${esc(b.fid)}"><span class="fn-num">${esc(b.fid)}</span>${c}</div>`;
case 'embed': return row(`<div class="emb-nested">${ic('file')}${linkChip(b.ref)}</div>`);
default: return row(c); // h1 h2 h3 quote
}
}
+24
View File
@@ -0,0 +1,24 @@
// Footnote numbers on screen. References ([^id] and ^[inline] notes, as <sup class="fn">) are numbered by first
// appearance; footnote texts (elements with data-fid) get the number of their id, or show the id if nothing
// references them. `inScope(el)` leaves out parts of `root` that number on their own (embeds).
export function paintFootnotes(root, inScope = () => true) {
const texts = new Set([...root.querySelectorAll('[data-fid]')].filter(inScope).map((el) => el.dataset.fid));
const order = new Map();
let n = 0;
for (const s of root.querySelectorAll('sup.fn')) {
if (!inScope(s)) continue;
if ('inline' in s.dataset) { s.textContent = String(++n); continue; }
const id = s.dataset.fn;
if (!order.has(id)) order.set(id, ++n);
s.textContent = String(order.get(id));
s.classList.toggle('missing', !texts.has(id));
}
for (const el of root.querySelectorAll('[data-fid]')) {
if (!inScope(el)) continue;
const k = order.get(el.dataset.fid);
const num = el.querySelector('.fn-num');
if (num) num.textContent = k ? String(k) : el.dataset.fid;
el.classList.toggle('orphan', !k);
}
return order;
}
+341
View File
@@ -0,0 +1,341 @@
// A small formula language for database properties. Pure functions (no DOM), so they can be tested on their own.
//
// prop("Price") * prop("Quantity") a property by name ({Price} is the same)
// if(prop("Opened"), dateBetween(prop("Expiry"), today(), "days"), "")
// concat(prop("Brand"), " – ", prop("Name"))
//
// Values are numbers, text, true / false, dates (text like 2026-03-01) and lists. An empty value counts as 0 in
// arithmetic, "" in text and false in conditions. Nothing here can touch anything but the values it is given.
export class FormulaError extends Error {}
/* tokens */
const isDigit = (c) => c >= '0' && c <= '9';
const isIdentStart = (c) => /[A-Za-z_]/.test(c);
const isIdent = (c) => /[A-Za-z0-9_]/.test(c);
function tokenize(src) {
const out = [];
let i = 0;
while (i < src.length) {
const c = src[i];
if (/\s/.test(c)) { i++; continue; }
const at = i;
if (isDigit(c) || (c === '.' && isDigit(src[i + 1] || ''))) {
let j = i;
while (j < src.length && (isDigit(src[j]) || src[j] === '.')) j++;
const text = src.slice(i, j);
if ((text.match(/\./g) || []).length > 1) throw new FormulaError(`“${text}” is not a number (at ${at + 1})`);
out.push({ t: 'num', v: Number(text), at });
i = j;
} else if (c === '"' || c === "'") {
let j = i + 1, s = '';
while (j < src.length && src[j] !== c) {
if (src[j] === '\\' && j + 1 < src.length) {
const n = src[j + 1];
s += n === 'n' ? '\n' : n === 't' ? '\t' : n;
j += 2;
} else s += src[j++];
}
if (j >= src.length) throw new FormulaError(`The text starting at ${at + 1} is not closed`);
out.push({ t: 'str', v: s, at });
i = j + 1;
} else if (c === '{') {
const j = src.indexOf('}', i);
if (j < 0) throw new FormulaError(`The “{” at ${at + 1} is not closed`);
out.push({ t: 'prop', v: src.slice(i + 1, j).trim(), at });
i = j + 1;
} else if (isIdentStart(c)) {
let j = i;
while (j < src.length && isIdent(src[j])) j++;
out.push({ t: 'id', v: src.slice(i, j), at });
i = j;
} else {
const two = src.slice(i, i + 2);
if (['==', '!=', '<=', '>=', '&&', '||', '<>'].includes(two)) { out.push({ t: 'op', v: two === '<>' ? '!=' : two, at }); i += 2; }
else if ('+-*/%^<>!(),='.includes(c)) { out.push({ t: 'op', v: c === '=' ? '==' : c, at }); i++; }
else throw new FormulaError(`Unexpected “${c}” at ${at + 1}`);
}
}
return out;
}
/* parser: precedence climbing */
export function parseFormula(src) {
const toks = tokenize(String(src));
let p = 0;
const peek = () => toks[p];
const next = () => toks[p++];
const isOp = (v) => peek() && peek().t === 'op' && peek().v === v;
const isWord = (v) => peek() && peek().t === 'id' && peek().v.toLowerCase() === v;
const expect = (v) => {
if (!isOp(v)) throw new FormulaError(peek() ? `Expected “${v}” at ${peek().at + 1}` : `Expected “${v}” at the end`);
p++;
};
function or() {
let a = and();
while (isOp('||') || isWord('or')) { p++; a = { t: 'or', a, b: and() }; }
return a;
}
function and() {
let a = eq();
while (isOp('&&') || isWord('and')) { p++; a = { t: 'and', a, b: eq() }; }
return a;
}
function eq() {
let a = cmp();
while (isOp('==') || isOp('!=')) { const op = next().v; a = { t: 'bin', op, a, b: cmp() }; }
return a;
}
function cmp() {
let a = add();
while (isOp('<') || isOp('>') || isOp('<=') || isOp('>=')) { const op = next().v; a = { t: 'bin', op, a, b: add() }; }
return a;
}
function add() {
let a = mul();
while (isOp('+') || isOp('-')) { const op = next().v; a = { t: 'bin', op, a, b: mul() }; }
return a;
}
function mul() {
let a = unary();
while (isOp('*') || isOp('/') || isOp('%')) { const op = next().v; a = { t: 'bin', op, a, b: unary() }; }
return a;
}
function unary() {
if (isOp('-')) { p++; return { t: 'un', op: '-', a: unary() }; }
if (isOp('!') || isWord('not')) { p++; return { t: 'un', op: '!', a: unary() }; }
return power();
}
function power() {
const a = primary();
if (isOp('^')) { p++; return { t: 'bin', op: '^', a, b: unary() }; } // right-associative
return a;
}
function primary() {
const t = next();
if (!t) throw new FormulaError('The formula ends too soon');
if (t.t === 'num') return { t: 'lit', v: t.v };
if (t.t === 'str') return { t: 'lit', v: t.v };
if (t.t === 'prop') return { t: 'prop', name: t.v };
if (t.t === 'op' && t.v === '(') {
const e = or();
expect(')');
return e;
}
if (t.t === 'id') {
const low = t.v.toLowerCase();
if (isOp('(')) {
p++;
const args = [];
if (!isOp(')')) {
for (;;) {
args.push(or());
if (isOp(',')) { p++; continue; }
break;
}
}
expect(')');
return { t: 'call', name: low, args, at: t.at };
}
if (low === 'true') return { t: 'lit', v: true };
if (low === 'false') return { t: 'lit', v: false };
if (low === 'empty' || low === 'null') return { t: 'lit', v: null };
throw new FormulaError(`“${t.v}” is not a function or a value. Property names go in prop("…") or {…}`);
}
throw new FormulaError(`Unexpected “${t.v}” at ${t.at + 1}`);
}
if (!toks.length) throw new FormulaError('The formula is empty');
const ast = or();
if (p < toks.length) throw new FormulaError(`Unexpected “${toks[p].v}” at ${toks[p].at + 1}`);
return ast;
}
/* values */
export const isDateText = (v) => typeof v === 'string' && /^\d{4}-\d{2}-\d{2}(?:$|[T ])/.test(v);
const dayNum = (s) => Math.round(Date.UTC(+s.slice(0, 4), +s.slice(5, 7) - 1, +s.slice(8, 10)) / 864e5);
const isoDay = (n) => new Date(n * 864e5).toISOString().slice(0, 10);
const blank = (v) => v === null || v === undefined || v === '' || (Array.isArray(v) && !v.length);
export const truthy = (v) => (Array.isArray(v) ? v.length > 0 : typeof v === 'string' ? v.trim() !== '' && v.toLowerCase() !== 'false' : !!v);
function num(v, what = 'a number') {
if (blank(v)) return 0;
if (typeof v === 'number') return v;
if (typeof v === 'boolean') return v ? 1 : 0;
if (typeof v === 'string' && v.trim() !== '' && Number.isFinite(Number(v))) return Number(v);
throw new FormulaError(`“${Array.isArray(v) ? v.join(', ') : v}” can't be used as ${what}`);
}
const numeric = (v) => typeof v === 'number' || (typeof v === 'string' && v.trim() !== '' && Number.isFinite(Number(v)) && !isDateText(v));
export function show(v) {
if (blank(v)) return '';
if (typeof v === 'number') return Number.isFinite(v) ? String(Number(v.toFixed(10))) : '';
if (typeof v === 'boolean') return v ? 'true' : 'false';
if (Array.isArray(v)) return v.map(show).join(', ');
return String(v);
}
const UNITS = { day: 'days', days: 'days', week: 'weeks', weeks: 'weeks', month: 'months', months: 'months', year: 'years', years: 'years' };
const unitOf = (u) => UNITS[String(u ?? 'days').toLowerCase()] || (() => { throw new FormulaError(`Unknown unit “${u}” (use days, weeks, months or years)`); })();
function dateOf(v, what = 'a date') {
if (isDateText(v)) return v.slice(0, 10);
throw new FormulaError(`“${show(v)}” can't be used as ${what}`);
}
function addMonths(iso, n) {
const y = +iso.slice(0, 4), m = +iso.slice(5, 7) - 1, d = +iso.slice(8, 10);
const t = new Date(Date.UTC(y, m + n, 1));
const last = new Date(Date.UTC(t.getUTCFullYear(), t.getUTCMonth() + 1, 0)).getUTCDate();
return new Date(Date.UTC(t.getUTCFullYear(), t.getUTCMonth(), Math.min(d, last))).toISOString().slice(0, 10);
}
function dateAdd(iso, n, unit) {
const u = unitOf(unit);
if (u === 'days') return isoDay(dayNum(iso) + n);
if (u === 'weeks') return isoDay(dayNum(iso) + n * 7);
return addMonths(iso, u === 'years' ? n * 12 : n);
}
function dateBetween(a, b, unit) {
const u = unitOf(unit);
const x = dayNum(a), y = dayNum(b);
if (u === 'days') return x - y;
if (u === 'weeks') return Math.trunc((x - y) / 7);
// whole months between two dates (negative if a is before b)
let months = (+a.slice(0, 4) - +b.slice(0, 4)) * 12 + (+a.slice(5, 7) - +b.slice(5, 7));
const da = +a.slice(8, 10), db = +b.slice(8, 10);
if (months > 0 && da < db) months--;
else if (months < 0 && da > db) months++;
return u === 'years' ? Math.trunc(months / 12) : months;
}
const MONTH_NAMES = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December'];
function formatDate(iso, fmt = 'MMM D, YYYY') {
const y = iso.slice(0, 4), m = +iso.slice(5, 7), d = +iso.slice(8, 10);
return String(fmt)
.replace(/YYYY/g, y).replace(/YY/g, y.slice(2))
.replace(/MMMM/g, MONTH_NAMES[m - 1]).replace(/MMM/g, MONTH_NAMES[m - 1].slice(0, 3))
.replace(/MM/g, String(m).padStart(2, '0')).replace(/DD/g, String(d).padStart(2, '0')).replace(/\bD\b/g, String(d));
}
/* evaluation */
const flat = (args) => args.flatMap((a) => (Array.isArray(a) ? a : [a]));
const nums = (args) => flat(args).filter((v) => !blank(v)).map((v) => num(v));
const FUNCS = {
// logic
if: null, and: null, or: null, // lazily, below
not: (c) => !truthy(c),
empty: (v) => blank(v),
// numbers
abs: (x) => Math.abs(num(x)),
round: (x, d = 0) => { const k = 10 ** num(d); return Math.round((num(x) + Number.EPSILON) * k) / k; },
floor: (x) => Math.floor(num(x)),
ceil: (x) => Math.ceil(num(x)),
sqrt: (x) => { const n = num(x); if (n < 0) throw new FormulaError('The square root of a negative number'); return Math.sqrt(n); },
pow: (x, y) => num(x) ** num(y),
min: (...a) => { const n = nums(a); return n.length ? Math.min(...n) : null; },
max: (...a) => { const n = nums(a); return n.length ? Math.max(...n) : null; },
sum: (...a) => nums(a).reduce((s, x) => s + x, 0),
average: (...a) => { const n = nums(a); return n.length ? n.reduce((s, x) => s + x, 0) / n.length : null; },
tonumber: (v) => { if (blank(v)) return 0; const n = Number(String(v).replace(/[^0-9.+\-eE]/g, '')); if (!Number.isFinite(n)) throw new FormulaError(`“${show(v)}” is not a number`); return n; },
// text
concat: (...a) => flat(a).map(show).join(''),
join: (list, sep = ', ') => (Array.isArray(list) ? list : [list]).map(show).join(show(sep)),
length: (v) => (Array.isArray(v) ? v.length : show(v).length),
lower: (v) => show(v).toLowerCase(),
upper: (v) => show(v).toUpperCase(),
trim: (v) => show(v).trim(),
contains: (a, b) => (Array.isArray(a) ? a.map((x) => show(x).toLowerCase()).includes(show(b).toLowerCase()) : show(a).toLowerCase().includes(show(b).toLowerCase())),
replace: (s, from, to) => show(s).split(show(from)).join(show(to)),
format: (v) => show(v),
// dates
today: () => null, // filled in by the evaluator (it knows the day)
now: () => null,
datebetween: (a, b, unit) => dateBetween(dateOf(a), dateOf(b), unit),
dateadd: (d, n, unit) => dateAdd(dateOf(d), num(n), unit),
year: (d) => +dateOf(d).slice(0, 4),
month: (d) => +dateOf(d).slice(5, 7),
day: (d) => +dateOf(d).slice(8, 10),
formatdate: (d, f) => formatDate(dateOf(d), f)
};
export const FUNCTION_NAMES = Object.keys(FUNCS).filter((n) => n !== 'now' && n !== 'tonumber').concat('toNumber');
function compare(op, a, b) {
const bothNum = numeric(a) && numeric(b);
if (bothNum) {
const x = Number(a), y = Number(b);
return { '<': x < y, '>': x > y, '<=': x <= y, '>=': x >= y, '==': x === y, '!=': x !== y }[op];
}
if (blank(a) || blank(b)) {
const eq = blank(a) && blank(b);
if (op === '==') return eq;
if (op === '!=') return !eq;
return false;
}
const x = show(a), y = show(b);
const c = x < y ? -1 : x > y ? 1 : 0;
return { '<': c < 0, '>': c > 0, '<=': c <= 0, '>=': c >= 0, '==': c === 0, '!=': c !== 0 }[op];
}
function evalNode(n, ctx) {
switch (n.t) {
case 'lit': return n.v;
case 'prop': return ctx.prop(n.name);
case 'or': return truthy(evalNode(n.a, ctx)) || truthy(evalNode(n.b, ctx));
case 'and': return truthy(evalNode(n.a, ctx)) && truthy(evalNode(n.b, ctx));
case 'un': {
const v = evalNode(n.a, ctx);
return n.op === '-' ? -num(v) : !truthy(v);
}
case 'bin': {
const a = evalNode(n.a, ctx), b = evalNode(n.b, ctx);
switch (n.op) {
case '+':
if (isDateText(a) && !blank(b) && numeric(b)) return dateAdd(a.slice(0, 10), Number(b), 'days');
if ((typeof a === 'string' && !numeric(a)) || (typeof b === 'string' && !numeric(b)) || Array.isArray(a) || Array.isArray(b)) return show(a) + show(b);
return num(a) + num(b);
case '-':
if (isDateText(a) && isDateText(b)) return dateBetween(a.slice(0, 10), b.slice(0, 10), 'days');
if (isDateText(a) && !blank(b) && numeric(b)) return dateAdd(a.slice(0, 10), -Number(b), 'days');
return num(a) - num(b);
case '*': return num(a) * num(b);
case '/': { const d = num(b); if (d === 0) throw new FormulaError('Division by zero'); return num(a) / d; }
case '%': { const d = num(b); if (d === 0) throw new FormulaError('Division by zero'); return num(a) % d; }
case '^': return num(a) ** num(b);
default: return compare(n.op, a, b);
}
}
case 'call': {
const name = n.name;
if (name === 'if') {
if (n.args.length < 2 || n.args.length > 3) throw new FormulaError('if() takes a condition, a value for true and optionally one for false');
return truthy(evalNode(n.args[0], ctx)) ? evalNode(n.args[1], ctx) : n.args[2] ? evalNode(n.args[2], ctx) : null;
}
if (name === 'and') return n.args.every((a) => truthy(evalNode(a, ctx)));
if (name === 'or') return n.args.some((a) => truthy(evalNode(a, ctx)));
if (name === 'prop') {
if (n.args.length !== 1) throw new FormulaError('prop() takes one property name');
return ctx.prop(show(evalNode(n.args[0], ctx)));
}
if (name === 'today' || name === 'now') return ctx.today;
const f = FUNCS[name];
if (typeof f !== 'function') throw new FormulaError(`There is no function called “${name}”`);
return f(...n.args.map((a) => evalNode(a, ctx)));
}
default: throw new FormulaError('Unsupported formula');
}
}
const cache = new Map();
// Evaluate `src`. ctx: { prop(name) -> value (throws FormulaError for an unknown name), today: 'YYYY-MM-DD' }.
// Returns the value; throws FormulaError.
export function evaluateFormula(src, ctx) {
let ast = cache.get(src);
if (!ast) {
ast = parseFormula(src);
if (cache.size > 500) cache.clear();
cache.set(src, ast);
}
const v = evalNode(ast, ctx);
if (typeof v === 'number' && !Number.isFinite(v)) throw new FormulaError('The result is not a number');
return v;
}
+12 -1
View File
@@ -7,7 +7,7 @@
// Like d3-force, every push is scaled by `alpha`, which cools from 1 towards 0 so the layout settles;
// dragging a node or changing the graph warms it up again.
const THETA2 = 0.81; // Barnes–Hut accuracy (theta = 0.9): bigger = faster, rougher
let THETA2 = 0.81; // Barnes–Hut accuracy (theta = 0.9): bigger = faster, rougher; a big graph sets it per tick
export function createSimulation({ repel = 420, linkDistance = 96, gravity = 0.04, velocityDecay = 0.42 } = {}) {
let nodes = [];
@@ -54,7 +54,18 @@ export function createSimulation({ repel = 420, linkDistance = 96, gravity = 0.0
reheat(to = 0.6) { sim.alpha = Math.max(sim.alpha, to); return sim; },
get running() { return sim.alpha > sim.alphaMin || sim.alphaTarget > 0; },
// Ticks until `times` are done or `ms` have passed (at least one): a big graph must not freeze the page.
tickFor(times, ms) {
const end = performance.now() + ms;
for (let t = 0; t < times; t++) {
sim.tick(1);
if (performance.now() >= end) break;
}
return sim;
},
tick(times = 1) {
THETA2 = nodes.length > 3000 ? 1.8 : 0.81; // 10k notes: coarser far-field, about twice as fast
for (let t = 0; t < times; t++) {
sim.alpha += (sim.alphaTarget - sim.alpha) * sim.alphaDecay;
const alpha = sim.alpha;
+11
View File
@@ -0,0 +1,11 @@
import { tagHex } from './tagColors.js';
// The colour of a dot in the graph: the colour of the first of its tags that has one (set in the sidebar's tag
// menu), otherwise `plain`. (There is no automatic colour per tag: it would look just like a chosen one.)
export function nodeColor(tags, tagColors, plain) {
for (const t of tags || []) {
const hex = tagHex(tagColors && tagColors[t]);
if (hex) return hex;
}
return plain;
}
+20
View File
@@ -0,0 +1,20 @@
import { esc } from './text.js';
// Small single-pass highlighter: comments, strings, keywords, numbers, function calls.
const RE =
/(\/\/[^\n]*|#[^\n]*)|("(?:\\.|[^"\\\n])*"|'(?:\\.|[^'\\\n])*'|`(?:\\.|[^`\\])*`)|\b(const|let|var|function|return|if|else|for|while|import|from|export|default|async|await|new|class|def|in|of|true|false|null|undefined|None|True|False|npm|npx|cd)\b|\b(\d+(?:\.\d+)?)\b|([A-Za-z_$][\w$]*)(?=\()/g;
export function highlight(src) {
let out = '', last = 0, m;
RE.lastIndex = 0;
while ((m = RE.exec(src))) {
out += esc(src.slice(last, m.index));
const cls = m[1] ? 'tc' : m[2] ? 'ts' : m[3] ? 'tk' : m[4] ? 'tn' : 'tf';
out += `<span class="${cls}">${esc(m[0])}</span>`;
last = RE.lastIndex;
}
out += esc(src.slice(last));
// A trailing newline needs a <br> to get its own visible line in a contenteditable <pre>.
if (src.endsWith('\n')) out += '<br>';
return out;
}
+136
View File
@@ -0,0 +1,136 @@
// HTML -> Markdown for whole documents and clipboard content (web pages, Notion/Word/Docs copies).
// Runs in the page (DOMParser). `base` makes relative links and pictures absolute.
// htmlToMarkdown(html, { base }) -> Markdown text
// articleOf(html) -> { title, html } the main content of a web page
const BLOCKS = new Set(['P', 'DIV', 'SECTION', 'ARTICLE', 'MAIN', 'HEADER', 'FOOTER', 'ASIDE', 'FIGURE', 'FIGCAPTION', 'DETAILS', 'SUMMARY', 'ADDRESS', 'BODY', 'HTML']);
const DROP = new Set(['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEMPLATE', 'IFRAME', 'OBJECT', 'EMBED', 'SVG', 'CANVAS', 'FORM', 'BUTTON', 'SELECT', 'TEXTAREA', 'HEAD', 'LINK', 'META']);
const esc = (s) => s.replace(/([\\`*_[\]])/g, '\\$1');
function absolute(href, base) {
if (!href) return '';
try { return new URL(href, base || undefined).href; } catch { return href; }
}
const safeHref = (h) => (/^(https?:|mailto:)/i.test(h) ? h : '');
export function htmlToMarkdown(html, { base = '' } = {}) {
const doc = new DOMParser().parseFromString(String(html || ''), 'text/html');
const out = walkBlocks(doc.body, { base, depth: 0 });
return out.replace(/[ \t]+\n/g, '\n').replace(/\n{3,}/g, '\n\n').trim() + '\n';
}
// inline content of an element
function inline(node, cx) {
let s = '';
for (const n of node.childNodes) {
if (n.nodeType === 3) { s += esc(n.data.replace(/\s+/g, ' ')); continue; }
if (n.nodeType !== 1 || DROP.has(n.tagName.toUpperCase())) continue;
const tag = n.tagName.toUpperCase();
const inner = () => inline(n, cx);
const wrap = (m) => { const t = inner(); const lead = t.match(/^\s*/)[0], trail = t.match(/\s*$/)[0]; const core = t.trim(); return core ? lead + m + core + m + trail : t; };
switch (tag) {
case 'STRONG': case 'B': s += wrap('**'); break;
case 'EM': case 'I': s += wrap('*'); break;
case 'DEL': case 'S': case 'STRIKE': s += wrap('~~'); break;
case 'MARK': s += wrap('=='); break;
case 'CODE': s += '`' + n.textContent.replace(/`/g, "'") + '`'; break;
case 'BR': s += '\n'; break;
case 'IMG': {
const src = absolute(n.getAttribute('src') || '', cx.base);
if (/^https?:/i.test(src)) s += `![${esc(n.getAttribute('alt') || '')}](${src})`;
break;
}
case 'A': {
const href = safeHref(absolute(n.getAttribute('href') || '', cx.base));
const t = inner().trim();
s += href && t ? `[${t}](${href.replace(/\)/g, '%29')})` : t;
break;
}
case 'INPUT': break;
default: s += inner();
}
}
return s;
}
const hasBlock = (el) => [...el.children].some((c) => BLOCKS.has(c.tagName) || /^(H[1-6]|UL|OL|LI|TABLE|BLOCKQUOTE|PRE|HR)$/.test(c.tagName));
// block content of an element, as Markdown with blank lines between blocks
function walkBlocks(el, cx) {
const parts = [];
let run = '';
const flush = () => { const t = run.replace(/[ \t]*\n[ \t]*/g, '\n').trim(); if (t) parts.push(t.replace(/\n/g, ' \n')); run = ''; };
for (const n of el.childNodes) {
if (n.nodeType === 3) { run += esc(n.data.replace(/\s+/g, ' ')); continue; }
if (n.nodeType !== 1) continue;
const tag = n.tagName.toUpperCase();
if (DROP.has(tag)) continue;
const m = /^H([1-6])$/.exec(tag);
if (m) { flush(); const t = inline(n, cx).replace(/\s+/g, ' ').trim(); if (t) parts.push('#'.repeat(+m[1]) + ' ' + t); continue; }
if (tag === 'P' || ((tag === 'DIV' || BLOCKS.has(tag)) && !hasBlock(n))) { flush(); const t = inline(n, cx).replace(/[ \t]*\n[ \t]*/g, '\n').trim(); if (t) parts.push(t.replace(/\n/g, ' \n')); continue; }
if (BLOCKS.has(tag)) { flush(); const t = walkBlocks(n, cx); if (t) parts.push(t); continue; }
if (tag === 'UL' || tag === 'OL') { flush(); parts.push(list(n, cx, 0)); continue; }
if (tag === 'BLOCKQUOTE') { flush(); const t = walkBlocks(n, cx); if (t) parts.push(t.split('\n').map((l) => (l ? '> ' + l : '>')).join('\n')); continue; }
if (tag === 'PRE') {
flush();
const code = n.querySelector('code');
const lang = ((code || n).className.match(/(?:language|lang)-([\w+#-]+)/) || [])[1] || '';
parts.push('```' + lang + '\n' + n.textContent.replace(/\n+$/, '') + '\n```');
continue;
}
if (tag === 'HR') { flush(); parts.push('---'); continue; }
if (tag === 'TABLE') { flush(); const t = table(n, cx); if (t) parts.push(t); continue; }
if (tag === 'LI') { flush(); parts.push(list({ children: [n], tagName: 'UL', getAttribute: () => null }, cx, 0)); continue; }
run += inline({ childNodes: [n] }, cx);
}
flush();
return parts.join('\n\n');
}
function list(el, cx, depth) {
const ordered = el.tagName === 'OL';
let i = Number(el.getAttribute && el.getAttribute('start')) || 1;
const lines = [];
for (const li of el.children) {
if (li.tagName !== 'LI') continue;
const sub = [];
let own = '';
for (const n of li.childNodes) {
if (n.nodeType === 1 && (n.tagName === 'UL' || n.tagName === 'OL')) sub.push(list(n, cx, depth + 1));
else if (n.nodeType === 1 && (n.tagName === 'P' || n.tagName === 'DIV')) own += ' ' + inline(n, cx);
else if (n.nodeType === 1 && DROP.has(n.tagName)) continue;
else own += inline({ childNodes: [n] }, cx);
}
const box = li.querySelector(':scope > input[type=checkbox], :scope > p > input[type=checkbox]');
const text = own.replace(/\s+/g, ' ').trim();
const mark = ordered ? `${i++}. ` : '- ';
lines.push(' '.repeat(depth) + mark + (box ? (box.hasAttribute('checked') ? '[x] ' : '[ ] ') : '') + text);
for (const s of sub) lines.push(s);
}
return lines.join('\n');
}
function table(el, cx) {
const rows = [...el.querySelectorAll('tr')].map((tr) => [...tr.children].filter((c) => /^T[DH]$/.test(c.tagName)).map((c) => inline(c, cx).replace(/\s+/g, ' ').replace(/\|/g, '\\|').trim()));
if (!rows.length || !rows[0].length) return '';
const w = Math.max(...rows.map((r) => r.length));
const pad = (r) => '| ' + Array.from({ length: w }, (_, i) => r[i] || '').join(' | ') + ' |';
return [pad(rows[0]), '|' + ' --- |'.repeat(w), ...rows.slice(1).map(pad)].join('\n');
}
// A web page's main content: <article>, <main> or the biggest block of text, with page chrome removed.
export function articleOf(html, { base = '' } = {}) {
const doc = new DOMParser().parseFromString(String(html || ''), 'text/html');
for (const bad of doc.querySelectorAll('script,style,noscript,nav,footer,aside,form,header nav,[role=navigation],[role=banner],[aria-hidden=true],.advert,.ads,.cookie,.share,.social')) bad.remove();
const title = (doc.querySelector('meta[property="og:title"]')?.getAttribute('content') || doc.querySelector('h1')?.textContent || doc.title || '').replace(/\s+/g, ' ').trim();
let root = doc.querySelector('article') || doc.querySelector('main,[role=main]');
if (!root) {
let best = null, score = 0;
for (const el of doc.querySelectorAll('div,section')) {
const own = [...el.children].filter((c) => c.tagName === 'P').reduce((n, p) => n + p.textContent.length, 0);
if (own > score) { score = own; best = el; }
}
root = best || doc.body;
}
return { title, markdown: htmlToMarkdown(root.outerHTML, { base }) };
}
+118 -3
View File
@@ -2,6 +2,8 @@
export const PATHS = {
plus: 'M12 5v14|M5 12h14',
chev: 'M9 6l6 6-6 6',
chevLeft: 'M15 18l-6-6 6-6',
chevRight: 'M9 6l6 6-6 6',
search: 'M4 11a7 7 0 1 0 14 0a7 7 0 1 0-14 0|M16.5 16.5L21 21',
panel: 'M4 5h16v14H4z|M9.5 5v14',
dots: 'M6 12h.01|M12 12h.01|M18 12h.01',
@@ -10,6 +12,10 @@ export const PATHS = {
minimize: 'M6 12h12',
maximize: 'M6 6h12v12H6z',
restore: 'M8 9h10v10H8z|M6 14V5h11',
card: 'M4 5h16v14H4z|M8 10h8|M8 14h5',
group: 'M3 7V4h3|M18 4h3v3|M21 17v3h-3|M6 20H3v-3|M10 4h4|M10 20h4|M8 9h8v6H8z',
fit: 'M4 9V4h5|M15 4h5v5|M20 15v5h-5|M9 20H4v-5',
canvas: 'M4 4h6v5H4z|M14 15h6v5h-6z|M7 9v4.5a1.5 1.5 0 0 0 1.5 1.5H14|M14 4h6v5h-6z',
board: 'M4 4h4.5v16H4z|M9.75 4h4.5v11h-4.5z|M15.5 4H20v7h-4.5z',
star: 'M12 3.8l2.5 5.2 5.7.8-4.1 4 1 5.6L12 16.7l-5.1 2.7 1-5.6-4.1-4 5.7-.8z',
undo: 'M9 14L4 9l5-5|M4 9h10.5a5.5 5.5 0 0 1 0 11H11',
@@ -44,6 +50,17 @@ export const PATHS = {
link: 'M10 14a4 4 0 0 0 5.66 0l3-3a4 4 0 0 0-5.66-5.66l-1 1|M14 10a4 4 0 0 0-5.66 0l-3 3a4 4 0 0 0 5.66 5.66l1-1',
unlink: 'M10 14a4 4 0 0 0 5.66 0l3-3a4 4 0 0 0-5.66-5.66l-1 1|M14 10a4 4 0 0 0-5.66 0l-3 3a4 4 0 0 0 5.66 5.66l1-1|M4 4l16 16',
alignLeft: 'M4 6h16|M4 10h10|M4 14h16|M4 18h10',
layoutV: 'M4 6h2|M9 6h11|M4 12h2|M9 12h11|M4 18h2|M9 18h11',
layoutH: 'M4 8h4v8H4z|M10 8h4v8h-4z|M16 8h4v8h-4z',
// lining up and spreading out boxes (canvas selection bar)
boxLeft: 'M4 3v18|M8 7h12v4H8z|M8 14h7v4H8z',
boxCenterX: 'M12 3v18|M6 7h12v4H6z|M8.5 14h7v4h-7z',
boxRight: 'M20 3v18|M4 7h12v4H4z|M9 14h7v4H9z',
boxTop: 'M3 4h18|M7 8v12h4V8z|M14 8v7h4V8z',
boxCenterY: 'M3 12h18|M7 6v12h4V6z|M14 8.5v7h4v-7z',
boxBottom: 'M3 20h18|M7 4v12h4V4z|M14 9v7h4V9z',
spreadX: 'M4 3v18|M20 3v18|M10 7h4v10h-4z',
spreadY: 'M3 4h18|M3 20h18|M7 10h10v4H7z',
alignCenter: 'M4 6h16|M7 10h10|M4 14h16|M7 18h10',
alignRight: 'M4 6h16|M10 10h10|M4 14h16|M10 18h10',
minus: 'M5 12h14',
@@ -89,7 +106,70 @@ export const PATHS = {
tag: 'M3 12V4h8l10 10-8 8z|M7.5 8h.01',
inbox: 'M3 13l3-8h12l3 8v6H3z|M3 13h5l1 2h6l1-2h5',
layers: 'M12 3l9 5-9 5-9-5z|M3 13l9 5 9-5',
check2: 'M3 12a9 9 0 1 0 18 0a9 9 0 1 0-18 0|M8 12.5l2.5 2.5L16 9.5'
check2: 'M3 12a9 9 0 1 0 18 0a9 9 0 1 0-18 0|M8 12.5l2.5 2.5L16 9.5',
// more page icons
shield: 'M12 3l7 3v5c0 5-3.2 8.5-7 10-3.8-1.5-7-5-7-10V6z',
eye: 'M2.5 12S6 5.5 12 5.5 21.5 12 21.5 12 18 18.5 12 18.5 2.5 12 2.5 12z|M9.5 12a2.5 2.5 0 1 0 5 0a2.5 2.5 0 1 0-5 0',
wifi: 'M2.5 9a14 14 0 0 1 19 0|M5.5 12.5a9.5 9.5 0 0 1 13 0|M8.5 16a5 5 0 0 1 7 0|M12 19.5h.01',
phone: 'M7 3h10v18H7z|M11 18h2',
headphones: 'M4 14v-2a8 8 0 0 1 16 0v2|M4 14h3v6H4z|M17 14h3v6h-3z',
mic: 'M9 6a3 3 0 0 1 6 0v5a3 3 0 0 1-6 0z|M5 11a7 7 0 0 0 14 0|M12 18v3|M9 21h6',
video: 'M3 6h12v12H3z|M15 10l6-3v10l-6-3',
film: 'M4 4h16v16H4z|M8 4v16|M16 4v16|M4 8h4|M4 12h4|M4 16h4|M16 8h4|M16 12h4|M16 16h4',
printer: 'M6 9V3h12v6|M6 18H4V9h16v9h-2|M6 14h12v7H6z',
download: 'M12 4v11|M7 11l5 5 5-5|M5 20h14',
upload: 'M12 16V5|M7 9l5-5 5 5|M5 20h14',
cloud: 'M7 18a4 4 0 0 1-.5-8A6 6 0 0 1 18 9.5 4 4 0 0 1 17 18z',
umbrella: 'M3 12a9 9 0 0 1 18 0z|M12 12v6a2 2 0 0 0 4 0|M12 3v1',
snowflake: 'M12 3v18|M4.2 7.5l15.6 9|M4.2 16.5l15.6-9',
droplet: 'M12 3s6 6.2 6 11a6 6 0 0 1-12 0c0-4.8 6-11 6-11z',
fire: 'M12 3c1 3 5 5 5 10a5 5 0 0 1-10 0c0-2 1-3 2-4 .3 1.2 1 2 2 2 0-3-1-5 1-8z',
anchor: 'M9.5 5.5a2.5 2.5 0 1 0 5 0a2.5 2.5 0 1 0-5 0|M12 8v13|M5 12H3a9 9 0 0 0 18 0h-2|M8 12h8',
flask: 'M9 3h6|M10 3v6l-5 9a2 2 0 0 0 1.8 3h10.4a2 2 0 0 0 1.8-3l-5-9V3|M8 15h8',
magnet: 'M5 4h4v9a3 3 0 0 0 6 0V4h4v9a7 7 0 0 1-14 0z|M5 8h4|M15 8h4',
scissors: 'M3 6a3 3 0 1 0 6 0a3 3 0 1 0-6 0|M3 18a3 3 0 1 0 6 0a3 3 0 1 0-6 0|M8.5 7.5L21 19|M8.5 16.5L21 5',
paperclip: 'M20 11l-8.5 8.5a5 5 0 0 1-7-7l9-9a3.5 3.5 0 0 1 5 5l-9 9a2 2 0 0 1-3-3l8-8',
pin: 'M9 4h6l-1 6 3 3H7l3-3z|M12 13v8',
grid: 'M4 4h7v7H4z|M13 4h7v7h-7z|M4 13h7v7H4z|M13 13h7v7h-7z',
columns: 'M4 4h16v16H4z|M10 4v16|M15 4v16',
crown: 'M3 8l4.5 4L12 5l4.5 7L21 8l-2 11H5z',
trophy: 'M8 4h8v6a4 4 0 0 1-8 0z|M8 6H4v2a3 3 0 0 0 4 3|M16 6h4v2a3 3 0 0 1-4 3|M12 14v4|M8 21h8|M10 18h4',
medal: 'M7.5 9a4.5 4.5 0 1 0 9 0a4.5 4.5 0 1 0-9 0|M8.5 13L7 21l5-3 5 3-1.5-8',
diamond: 'M6 4h12l4 6-10 11L2 10z|M2 10h20|M9 4l-2 6 5 11 5-11-2-6',
skull: 'M12 3a8 8 0 0 0-5 14v3h10v-3a8 8 0 0 0-5-14z|M9 12h.01|M15 12h.01|M10 20v-2|M14 20v-2',
ghost: 'M5 21V11a7 7 0 0 1 14 0v10l-3-2-2 2-2-2-2 2-2-2z|M9.5 11h.01|M14.5 11h.01',
bug: 'M9 8a3 3 0 0 1 6 0v1H9z|M7 9h10v6a5 5 0 0 1-10 0z|M12 9v11|M3 12h4|M17 12h4|M4 6l3 2|M20 6l-3 2|M4 19l3-2|M20 19l-3-2',
infinity: 'M7 8a4 4 0 1 0 0 8c4 0 6-8 10-8a4 4 0 1 1 0 8c-4 0-6-8-10-8z',
building: 'M5 21V4h9v17|M14 9h5v12|M8 8h2|M8 12h2|M8 16h2|M16 13h.01|M16 17h.01',
bank: 'M3 9l9-5 9 5z|M5 10v8|M9.5 10v8|M14.5 10v8|M19 10v8|M3 21h18',
cross: 'M9 3h6v6h6v6h-6v6H9v-6H3V9h6z',
pill: 'M10.5 20.5a5 5 0 0 1-7-7l10-10a5 5 0 0 1 7 7z|M8.5 8.5l7 7',
dumbbell: 'M6.5 6.5v11|M17.5 6.5v11|M3 9v6|M21 9v6|M6.5 12h11',
bike: 'M2 16.5a3.5 3.5 0 1 0 7 0a3.5 3.5 0 1 0-7 0|M15 16.5a3.5 3.5 0 1 0 7 0a3.5 3.5 0 1 0-7 0|M5.5 16.5l4-8h6l3 8|M9.5 8.5L12 16.5H5.5|M15.5 8.5l-1-3h2',
car: 'M5 16v-5l2-5h10l2 5v5|M3 16h18|M7 19v-3|M17 19v-3|M7.5 13h.01|M16.5 13h.01',
plane: 'M2 16l8-3V5.5a2 2 0 0 1 4 0V13l8 3v2l-8-2v3l2 1.5V22l-4-1-4 1v-1.5L10 19v-3l-8 2z',
wine: 'M8 3h8l-1 7a3 3 0 0 1-6 0z|M12 13v8|M8 21h8',
cake: 'M4 12h16v8H4z|M4 15c2 1.5 4 1.5 6 0s4-1.5 6 0 2.5 1 4 0|M12 12V8|M12 5.5c.8 0 1.2-1 0-2.5-1.2 1.5-.8 2.5 0 2.5z',
apple: 'M12 7c-3-2-7-1-7 4 0 5 3 10 5 10 1 0 1.5-.5 2-.5s1 .5 2 .5c2 0 5-5 5-10 0-5-4-6-7-4z|M12 7c0-2 1-3.5 3-4',
feather: 'M20 4c-8 0-14 4-14 12l-2 4|M20 4c0 8-4 12-12 12|M9 15l6-6',
mountain: 'M3 19l6-11 4 7 3-4 5 8z',
waves: 'M3 9c2-2 4-2 6 0s4 2 6 0 4-2 6 0|M3 14c2-2 4-2 6 0s4 2 6 0 4-2 6 0|M3 19c2-2 4-2 6 0s4 2 6 0 4-2 6 0',
text: 'M5 6V4h14v2|M12 4v16|M9 20h6',
quote: 'M5 8h5v5c0 3-1.5 4.5-4 5|M14 8h5v5c0 3-1.5 4.5-4 5',
smile: 'M3 12a9 9 0 1 0 18 0a9 9 0 1 0-18 0|M8 14.5a5 5 0 0 0 8 0|M9 9.5h.01|M15 9.5h.01',
thumbsUp: 'M7 11v9H4v-9z|M7 11l4-8c1.7 0 2.5 1 2.5 2.5V9H19a2 2 0 0 1 2 2.3l-1 6a2.5 2.5 0 0 1-2.5 2.2H7',
dice: 'M4 4h16v16H4z|M8.5 8.5h.01|M15.5 8.5h.01|M12 12h.01|M8.5 15.5h.01|M15.5 15.5h.01',
wrench: 'M14.5 6.5a4 4 0 0 0-5 5L3 18l3 3 6.5-6.5a4 4 0 0 0 5-5l-2.5 2.5-2.5-.5-.5-2.5z',
ruler: 'M3 17L17 3l4 4L7 21z|M7 13l2 2|M10 10l2 2|M13 7l2 2',
brush: 'M18 3l3 3-9 9-3-3z|M9 12c-3 0-4 2-4 4s-1 3-2 4c3 .5 7 0 7-4',
archive: 'M3 4h18v4H3z|M5 8v12h14V8|M10 12h4',
package: 'M12 3l9 4.5v9L12 21l-9-4.5v-9z|M3 7.5L12 12l9-4.5|M12 12v9',
hourglass: 'M6 3h12|M6 21h12|M7 3c0 5 5 6 5 9s-5 4-5 9|M17 3c0 5-5 6-5 9s5 4 5 9',
tv: 'M3 7h18v12H3z|M8 3l4 4 4-4',
bookOpen: 'M12 6c-2-1.5-5-2-8-2v14c3 0 6 .5 8 2 2-1.5 5-2 8-2V4c-3 0-6 .5-8 2z|M12 6v14',
newspaper: 'M4 4h13v16H6a2 2 0 0 1-2-2z|M17 8h3v10a2 2 0 0 1-2 2|M8 8h5|M8 12h5|M8 16h3',
coins: 'M4 8a6 3 0 1 0 12 0a6 3 0 1 0-12 0|M4 8v4c0 1.7 2.7 3 6 3s6-1.3 6-3V8|M16 11c2.5.3 4 1.3 4 2.5v4c0 1.7-2.7 3-6 3-2.5 0-4.6-.8-5.5-1.8',
at: 'M16 12a4 4 0 1 1-8 0 4 4 0 0 1 8 0z|M16 8v5a2 2 0 0 0 4 0v-1a8 8 0 1 0-3 6.2'
};
// The stroke icons offered in the icon picker, with words to find them by.
@@ -107,7 +187,26 @@ export const PICKER_ICONS = [
['user', 'person me profile'], ['users', 'people team group'], ['mail', 'email letter'], ['chat', 'message talk'],
['globe', 'world web internet'], ['map', 'travel place'], ['send', 'share plane'], ['tree', 'nature forest'],
['sun', 'day light summer'], ['pulse', 'health fitness activity'], ['food', 'recipe cooking eat'], ['cart', 'shopping buy'],
['gift', 'present birthday'], ['lock', 'private secure'], ['key', 'password access'], ['trash', 'delete bin']
['gift', 'present birthday'], ['lock', 'private secure'], ['key', 'password access'], ['trash', 'delete bin'],
['canvas', 'whiteboard map diagram'], ['graph', 'network connections links'], ['table', 'spreadsheet grid data'], ['list', 'bullets outline'],
['link', 'url connect'], ['search', 'find look'], ['gear', 'settings tools config'], ['save', 'disk store'],
['export', 'share out'], ['copy', 'duplicate clone'], ['monitor', 'screen display desktop'],
['shield', 'security protect safe'], ['eye', 'view watch look'], ['wifi', 'network wireless internet'], ['phone', 'mobile call'],
['headphones', 'audio listen music'], ['mic', 'record podcast voice'], ['video', 'film movie record'], ['film', 'movie cinema'],
['printer', 'print paper'], ['download', 'get save'], ['upload', 'send put'], ['cloud', 'weather sky online'],
['umbrella', 'rain weather'], ['snowflake', 'winter cold snow'], ['droplet', 'water rain'], ['fire', 'hot flame trending'],
['anchor', 'ship sea fixed'], ['flask', 'science chemistry lab test'], ['magnet', 'attract'], ['scissors', 'cut craft'],
['paperclip', 'attach file'], ['pin', 'location keep'], ['grid', 'tiles layout overview'], ['columns', 'layout split'],
['crown', 'king queen best'], ['trophy', 'win award prize'], ['medal', 'award achievement'], ['diamond', 'gem premium'],
['skull', 'danger dead hacker'], ['ghost', 'spooky halloween'], ['bug', 'insect error issue'], ['infinity', 'loop forever'],
['building', 'office company city'], ['bank', 'finance institution money'], ['cross', 'medical health plus'], ['pill', 'medicine health'],
['dumbbell', 'gym workout fitness'], ['bike', 'cycling ride'], ['car', 'drive vehicle'], ['plane', 'flight travel trip'],
['wine', 'drink glass'], ['cake', 'birthday party dessert'], ['apple', 'fruit food health'], ['feather', 'light write'],
['mountain', 'hike outdoors peak'], ['waves', 'sea ocean water'], ['text', 'type font writing'], ['quote', 'citation saying'],
['smile', 'happy mood face'], ['thumbsUp', 'like good approve'], ['dice', 'game random chance'], ['wrench', 'fix repair tool'],
['ruler', 'measure design'], ['brush', 'paint art draw'], ['archive', 'old storage box'], ['package', 'box delivery release'],
['hourglass', 'wait time later'], ['tv', 'television watch'], ['bookOpen', 'read library story'], ['newspaper', 'news article press'],
['coins', 'money cash savings'], ['at', 'mention email']
];
// Emoji offered in the picker, as "emoji words" pairs.
@@ -127,7 +226,20 @@ export const PICKER_EMOJI = [
'❤️ heart love', '💪 strong fitness', '🏃 run exercise', '🧘 yoga calm', '😴 sleep', '🩺 doctor health', '💊 pill medicine', '🏋️ gym',
'🔒 lock private', '🔑 key password', '🛠️ tools build', '🔧 wrench fix', '⚙️ gear settings', '🧰 toolbox', '🐛 bug', '🧹 cleanup',
'📦 package box', '🎁 gift', '🛒 shopping cart', '🏷️ tag label', '🔔 bell reminder', '❓ question', '❗ important', '⚠️ warning',
'🐶 dog', '🐱 cat', '🦊 fox', '🐼 panda', '🦉 owl', '🐢 turtle', '🦋 butterfly', '🐙 octopus'
'🐶 dog', '🐱 cat', '🦊 fox', '🐼 panda', '🦉 owl', '🐢 turtle', '🦋 butterfly', '🐙 octopus',
'🧭 compass direction', '🔭 telescope space', '🪐 planet space', '🌟 glowing star', '💫 dizzy star', '🌠 shooting star', '🌌 galaxy night', '🛰️ satellite',
'🛸 ufo', '🧬 dna biology', '⚗️ alembic chemistry', '🧲 magnet', '🔋 battery', '💾 floppy save', '💿 disc', '📀 dvd',
'🖨️ printer', '📱 phone mobile', '☎️ telephone', '📡 antenna signal', '🔌 plug power', '🪙 coin', '💎 gem diamond',
'👑 crown', '🏆 trophy win', '🥇 gold medal first', '🎖️ medal', '🎓 graduation cap', '🏛️ classical building', '⚖️ scales law', '🧮 abacus math',
'✏️ pencil', '🖊️ pen', '🖋️ fountain pen', '🖌️ paintbrush', '📐 triangle ruler', '📏 ruler', '✂️ scissors', '📋 clipboard',
'📰 newspaper news', '🗞️ rolled newspaper', '📜 scroll', '📃 page', '📄 document', '📑 bookmark tabs', '📓 notebook', '📔 notebook decorated',
'🍇 grapes', '🍊 orange fruit', '🍋 lemon', '🍓 strawberry', '🥑 avocado', '🥕 carrot', '🌶️ chili pepper', '🍞 bread',
'🧀 cheese', '🍜 noodles ramen', '🍣 sushi', '🍪 cookie', '🍫 chocolate', '🍷 wine', '🍺 beer', '🥂 cheers',
'⚽ football soccer', '🏀 basketball', '🎾 tennis', '🏓 ping pong', '⛳ golf', '🎳 bowling', '🥊 boxing', '🛹 skateboard',
'🚂 train', '🚌 bus', '🚢 ship', '⛵ sailboat', '🚁 helicopter', '🏍️ motorcycle', '🛴 scooter', '⛽ fuel',
'🦄 unicorn', '🐧 penguin', '🦁 lion', '🐯 tiger', '🐻 bear', '🐨 koala', '🐝 bee', '🐞 ladybug',
'😀 smile happy', '😎 cool sunglasses', '🤔 thinking', '😂 laugh', '🥳 party', '😇 angel', '🤯 mind blown', '🫡 salute',
'👍 thumbs up like', '👏 clap', '🙏 thanks pray', '✌️ peace victory', '💯 hundred perfect', '🎉 party celebrate', '🎊 confetti', '🔮 crystal ball'
].map((s) => { const i = s.indexOf(' '); return [s.slice(0, i), s.slice(i + 1)]; });
export const isEmoji = (s) => typeof s === 'string' && /\p{Extended_Pictographic}/u.test(s) && s.length <= 16;
@@ -140,6 +252,9 @@ export const ic = (name, cls = '') =>
export const PAGE_ICONS = ['sparkle', 'pen', 'leaf', 'book', 'code', 'moon', 'compass', 'cup'];
// A cover can be a gradient (a key of COVERS) or a picture saved next to the page ("header.png" in its front matter).
export const IMAGE_COVER = /\.(png|jpe?g|gif|webp|avif|svg|bmp)$/i;
export const isImageCover = (v) => typeof v === 'string' && !!v && !(v in COVERS) && IMAGE_COVER.test(v) && !/^[a-z][a-z0-9+.-]*:/i.test(v);
export const COVERS = {
blush: ['#d99a91', '#caa07c'],
sage: ['#97b096', '#d1b48e'],
+91
View File
@@ -0,0 +1,91 @@
// Kanban boards stored as plain Markdown, in the same shape as the Obsidian Kanban plugin:
//
// ---
// kanban-plugin: basic <- front matter marks the page as a board
// ---
//
// ## To do <- a column
//
// - [ ] Write the intro <- a card (text keeps inline Markdown: **bold**, [[links]] …)
// with a second line <- indented lines belong to the card above
// - [x] Pick a title <- a checked card
//
// %% kanban:settings ... %% <- anything from a *** or %% line on is kept as is
//
// Lines in a column that aren't cards (e.g. the plugin's **Complete** marker) are kept too.
import { parseMarkdown, htmlToMd } from './markdown.js';
export const BOARD_KEY = 'kanban-plugin';
export const isBoard = (meta) => !!(meta && meta[BOARD_KEY]);
let seq = 0;
const uid = (p) => p + (++seq).toString(36) + Math.random().toString(36).slice(2, 6);
export const card = (text = '', done = false) => ({ id: uid('k'), text, done });
export const column = (title = 'Untitled', cards = []) => ({ id: uid('c'), title, extra: [], cards });
export function parseBoard(src) {
const lines = String(src || '').replace(/\r\n?/g, '\n').split('\n');
const board = { head: '', columns: [], tail: '' };
const head = [];
let col = null;
let last = null; // card that indented lines continue
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
const t = line.trim();
if (t === '***' || t.startsWith('%% kanban')) {
board.tail = lines.slice(i).join('\n').replace(/\s+$/, '');
break;
}
let m;
if ((m = line.match(/^##\s+(.*?)\s*$/))) {
col = column(m[1]);
board.columns.push(col);
last = null;
continue;
}
if (!col) { head.push(line); continue; }
if ((m = line.match(/^[-*+]\s+(?:\[([ xX])\]\s?)?(.*)$/))) {
last = card(m[2], !!m[1] && m[1] !== ' ');
col.cards.push(last);
continue;
}
if (last && /^(\s{2,}|\t)\S/.test(line)) { last.text += '\n' + line.replace(/^(\s{2}|\t)/, ''); continue; }
if (line.trim()) { col.extra.push(line); last = null; }
}
board.head = head.join('\n').trim();
return board;
}
export function boardToMd(board) {
const parts = [];
if (board.head) parts.push(board.head);
for (const c of board.columns) {
const body = [...c.extra, ...c.cards.map((k) => `- [${k.done ? 'x' : ' '}] ` + k.text.replace(/\n/g, '\n '))];
parts.push(`## ${c.title}` + (body.length ? '\n\n' + body.join('\n') : ''));
}
if (board.tail) parts.push(board.tail);
return parts.length ? parts.join('\n\n\n') + '\n' : '';
}
export const defaultBoard = () => ({ head: '', tail: '', columns: [column('To do'), column('Doing'), column('Done')] });
// Turn an ordinary page into a board: each ## heading becomes a column, list items and paragraphs become cards.
// Anything before the first heading lands in a "To do" column.
export function boardFromMarkdown(body) {
const blocks = parseMarkdown(body);
const columns = [];
let col = null;
for (const b of blocks) {
if (b.type === 'h1' || b.type === 'h2' || b.type === 'h3') {
col = column(htmlToMd(b.html).trim() || 'Untitled');
columns.push(col);
continue;
}
const text = b.type === 'code' ? '`' + (b.text || '').split('\n')[0] + '`' : b.type === 'toggle' ? htmlToMd(b.html) : htmlToMd(b.html);
if (b.type === 'divider' || b.type === 'image' || !text.trim()) continue;
if (!col) { col = column('To do'); columns.unshift(col); }
col.cards.push(card(text.trim().replace(/\n+/g, '\n'), !!b.checked));
}
if (!columns.length) return defaultBoard();
return { head: '', tail: '', columns };
}
+23
View File
@@ -0,0 +1,23 @@
// KaTeX is loaded the first time a formula is drawn, not at startup.
// renderTex() answers at once: the finished HTML when KaTeX is already loaded, else a placeholder that is
// replaced as soon as it arrives (so callers that build HTML strings need no async plumbing).
import { esc } from './text.js';
let lib = null;
let loading = null;
export const loadKatex = () => (loading ||= import('katex').then((m) => (lib = m.default)));
function typeset() {
if (typeof document === 'undefined' || !lib) return;
for (const el of document.querySelectorAll('.math-pending')) {
const display = el.dataset.display === '1';
try { el.outerHTML = lib.renderToString(el.dataset.tex || '', { throwOnError: false, displayMode: display }); } catch { el.classList.remove('math-pending'); }
}
}
export function renderTex(tex, displayMode = false) {
if (lib) return lib.renderToString(tex, { throwOnError: false, displayMode });
if (typeof document !== 'undefined') loadKatex().then(() => setTimeout(typeset, 0), () => {});
return `<span class="math-pending" data-display="${displayMode ? 1 : 0}" data-tex="${esc(tex)}">${esc(tex)}</span>`;
}
+76
View File
@@ -0,0 +1,76 @@
// [[Wiki links]] between pages. Kept in step with electron/links.cjs, which does the same on disk.
//
// [[Roadmap]] -> the page titled "Roadmap" (shortest path wins when titles repeat)
// [[Projects/Roadmap]] -> that exact page
// [[Roadmap|the plan]] -> shown as "the plan"
// [[Roadmap#Goals]] -> that heading of the page; shown as "Roadmap › Goals"
// [[#Goals]] -> a heading of the page you're on
// ![[Roadmap]] -> the page's content shown inline (an "embed"); ![[Roadmap#Goals]] just that section
export const refPage = (ref) => String(ref || '').split('#')[0].replace(/\.md$/i, '').trim();
// The heading a link points at ('' if none). Only the last #part counts, and #^blockid isn't a heading.
export const headingOf = (ref) => {
const s = String(ref || '');
const i = s.lastIndexOf('#');
if (i < 0) return '';
const h = s.slice(i + 1).trim();
return h.startsWith('^') ? '' : h;
};
// Headings are matched ignoring case and extra spaces.
export const normHeading = (s) => String(s || '').replace(/\s+/g, ' ').trim().toLowerCase();
// Heading text as it is written inside a link: # | ^ [ ] would break the link syntax, so they become spaces.
export const linkHeading = (s) => String(s || '').replace(/[#|^\[\]]/g, ' ').replace(/\s+/g, ' ').trim();
// Does a link's heading part point at this heading? (Either as the heading is written or in its link-safe form.)
export const matchesHeading = (linkText, headingText) => {
const a = normHeading(linkText);
return !!a && (a === normHeading(headingText) || a === normHeading(linkHeading(headingText)));
};
// Files that ![[...]] shows as a picture, and other files (not notes) that it can't show.
export const IMAGE_FILE = /\.(png|jpe?g|gif|webp|avif|svg|bmp)$/i;
export const OTHER_FILE = /\.(pdf|mp3|mp4|wav|m4a|ogg|webm|mov|mkv|zip|docx?|xlsx?|pptx?|csv)$/i;
export const linkLabel = (ref) => {
const p = refPage(ref);
const name = p ? p.slice(p.lastIndexOf('/') + 1) : '';
const h = headingOf(ref);
return name && h ? `${name} › ${h}` : name || h || String(ref || '');
};
// Lookup tables for one list of pages (the app replaces the list when pages change, so a list's table never
// goes stale): lower-cased full id -> id, and lower-cased file name -> its shortest id.
const indexes = new WeakMap();
function indexOf(pages) {
let ix = indexes.get(pages);
if (ix && ix.n === pages.length) return ix;
const exact = new Map();
const byName = new Map();
for (const p of pages) {
const id = p.id;
const low = id.toLowerCase();
if (!exact.has(low)) exact.set(low, id);
const name = low.slice(low.lastIndexOf('/') + 1);
const cur = byName.get(name);
if (cur === undefined || id.length < cur.length) byName.set(name, id);
}
ix = { n: pages.length, exact, byName };
indexes.set(pages, ix);
return ix;
}
export function resolveLink(ref, pages) {
const want = refPage(ref).toLowerCase();
if (!want) return null;
const { exact, byName } = indexOf(pages);
// exact id first; else by name: a note's title, or a canvas's file name ("Map.canvas"), so [[Map]] never means a canvas
return exact.get(want) ?? byName.get(want) ?? null;
}
// The shortest text that links to `id`: its title when that's unique, else the full path.
export function refFor(id, pages) {
const title = id.slice(id.lastIndexOf('/') + 1);
const same = pages.filter((p) => p.id.slice(p.id.lastIndexOf('/') + 1).toLowerCase() === title.toLowerCase());
return same.length <= 1 ? title : id;
}
+69 -2
View File
@@ -241,6 +241,34 @@ export function parseMarkdown(src) {
out.push(block('quote', mdToHtml(buf.join('\n'))));
continue;
}
// :::: columns / ::: column ... ::: / :::: : side-by-side blocks (fenced divs; plain text anywhere else)
if (/^:{4,}\s*columns\s*$/.test(line)) {
let j = i + 1;
while (j < lines.length && !/^:{4,}\s*$/.test(lines[j])) j++;
if (j < lines.length) {
const cols = [];
let cur = null;
for (const l of lines.slice(i + 1, j)) {
const open = l.match(/^:{3}\s*(?:column|\{\s*\.column(?:\s+width\s*=\s*"?([\d.]+)%"?)?\s*\})\s*$/);
if (open) { cur = { lines: [], w: open[1] ? +open[1] : null }; cols.push(cur); }
else if (/^:{3}\s*$/.test(l)) cur = null;
else if (cur) cur.lines.push(l);
}
if (cols.length) {
flush();
const g = blockId();
// widths count only if every column has one
const widths = cols.every((c) => c.w > 0) ? cols.map((c) => c.w) : null;
cols.forEach((col, k) => {
let inner = parseMarkdown(col.lines.join('\n'));
if (!inner.length) inner = [block('p')];
for (const b of inner) { if (b.col) delete b.col; b.col = widths ? { g, i: k, w: widths[k] } : { g, i: k }; out.push(b); }
});
i = j + 1;
continue;
}
}
}
if (/^<details(\s[^>]*)?>\s*$/i.test(line.trim())) {
flush();
i++;
@@ -341,12 +369,38 @@ export function blockToMd(b, n = 1) {
}
}
// A run of blocks that share a column group, as fenced columns.
function columnsToMd(run) {
const byCol = new Map();
for (const b of run) {
if (!byCol.has(b.col.i)) byCol.set(b.col.i, []);
byCol.get(b.col.i).push(b);
}
const keys = [...byCol.keys()].sort((a, b) => a - b);
const widths = keys.every((k) => byCol.get(k)[0].col.w > 0) ? keys.map((k) => byCol.get(k)[0].col.w) : null;
const parts = keys.map((k, n) => `${widths ? `::: {.column width="${+widths[n].toFixed(1)}%"}` : '::: column'}\n${toMarkdown(byCol.get(k).map((b) => ({ ...b, col: undefined }))).trimEnd()}\n:::`);
return `:::: columns\n${parts.join('\n')}\n::::`;
}
export function toMarkdown(blocks) {
let out = '';
let prevList = false;
let prevType = null;
let n = 0; // position in a run of numbered items
for (const b of blocks) {
for (let idx = 0; idx < blocks.length; idx++) {
const b = blocks[idx];
if (b.col) {
let end = idx;
while (end + 1 < blocks.length && blocks[end + 1].col && blocks[end + 1].col.g === b.col.g) end++;
const md = columnsToMd(blocks.slice(idx, end + 1));
out += out ? '\n\n' : '';
out += md;
idx = end;
prevList = false;
prevType = 'columns';
n = 0;
continue;
}
if (b.type === 'p' && !htmlToMd(b.html).trim()) continue;
const isList = LISTS.has(b.type);
n = b.type === 'number' ? n + 1 : 0;
@@ -391,7 +445,18 @@ export function toHtmlDocument(title, raw) {
const blocks = raw.map((b) => ({ ...b, html: fns.html(b.html), body: b.body && fns.html(b.body), rows: b.rows && b.rows.map((r) => r.map(fns.html)) }));
const body = [];
let list = null; // 'ul' or 'ol' while inside a list
let curKey = null, curG = null; // the column (group:index) and group the output is in
for (const b of blocks) {
const key = b.col ? `${b.col.g}:${b.col.i}` : null;
const g = b.col ? b.col.g : null;
if (key !== curKey) {
if (list) { body.push(`</${list}>`); list = null; }
if (curG !== null && g !== curG) body.push('</div></div>');
else if (curKey !== null) body.push('</div>');
if (g !== null && g !== curG) body.push('<div class="cols"><div class="col">');
else if (g !== null) body.push('<div class="col">');
curKey = key; curG = g;
}
const want = b.type === 'number' ? 'ol' : LISTS.has(b.type) ? 'ul' : null;
if (list && list !== want) { body.push(`</${list}>`); list = null; }
if (want && !list) { body.push(`<${want}>`); list = want; }
@@ -418,7 +483,8 @@ export function toHtmlDocument(title, raw) {
default: if (b.html) body.push(`<p>${b.html}</p>`);
}
}
if (list) body.push(`</${list}>`);
if (list) { body.push(`</${list}>`); list = null; }
if (curG !== null) body.push('</div></div>');
return `<!doctype html>
<html lang="en">
<head>
@@ -438,6 +504,7 @@ export function toHtmlDocument(title, raw) {
hr { border: 0; border-top: 1px solid #e3e3e6; margin: 24px 0; }
img { max-width: 100%; border-radius: 12px; }
table { border-collapse: collapse; margin: 12px 0; } th, td { border: 1px solid #e3e3e6; padding: 6px 10px; } th { background: #f3f3f4; text-align: left; }
.cols { display: flex; gap: 28px; } .col { flex: 1; min-width: 0; }
a { color: #a8564c; }
.wl { color: #a8564c; text-decoration: underline; text-decoration-color: #e3c2bb; }
</style>
+307
View File
@@ -0,0 +1,307 @@
// Browser-only stand-in for the Electron bridge. Same method names and shapes as preload.cjs.
// Keeps notes in memory (and localStorage when available) so `npm run dev:web` is usable.
import { resolveLink } from './links.js';
const KEY = 'notes-web-mock-v1';
const FILE = /\.(png|jpe?g|gif|webp|avif|svg|bmp|pdf|mp3|mp4|wav|m4a|ogg|webm|mov|mkv|zip|docx?|xlsx?|pptx?|csv)$/i;
const today = () => new Date().toISOString().slice(0, 10);
const SEED = {
'Getting started': {
meta: { icon: 'sparkle', cover: 'blush', tags: ['guide'], created: today() },
body: `> [!note]
> This is the browser preview. In the desktop app every page is a .md file in your notes folder.
## Write the way you think
- Type \`/\` on any line to add a heading, to-do, quote, toggle or code block.
- Start a line with \`#\`, \`-\`, \`[]\` or \`>\` and it turns into that block as you type.
- Press <kbd>Ctrl</kbd> <kbd>K</kbd> to search every note.
- [x] Pick a folder for your notes
- [ ] Write one page, however short
`
},
Projects: { meta: { icon: 'compass', cover: 'sage', created: today() }, body: 'Things in motion.\n' },
'Projects/Note app roadmap': {
meta: { icon: 'code', cover: 'dusk', tags: ['dev', 'todo'], created: today() },
body: `An offline notes app built on Electron and SvelteKit.
### v0.1
- [x] Block editor with a slash menu
- [ ] Full-text search with SQLite FTS5
\`\`\`js
const id = await window.vault.create(null, 'Untitled')
\`\`\`
`
}
};
export function createMock() {
let files = null;
let settings = { theme: 'dark' };
try {
const saved = JSON.parse(localStorage.getItem(KEY) || 'null');
if (saved) { files = saved.files; settings = saved.settings || settings; }
} catch { /* storage unavailable */ }
let opened = !!files;
files = files || structuredClone(SEED);
const save = () => { try { localStorage.setItem(KEY, JSON.stringify({ files, settings })); } catch { /* ignore */ } };
const parentOf = (id) => (id.includes('/') ? id.slice(0, id.lastIndexOf('/')) : null);
const titleOf = (id) => id.slice(id.lastIndexOf('/') + 1);
const pages = () => {
const ids = new Set(Object.keys(files));
for (const id of [...ids]) { let p = parentOf(id); while (p) { ids.add(p); p = parentOf(p); } }
return [...ids].map((id) => {
const f = files[id];
return {
id, parent: parentOf(id), title: titleOf(id), exists: !!f, mtime: 0,
icon: f?.meta.icon || null, cover: f?.meta.cover || null, tags: f?.meta.tags || [], created: f?.meta.created || null,
...(f?.meta['ink-database'] ? { db: true } : {}),
props: Object.fromEntries(Object.entries(f?.meta || {}).filter(([k]) => !['icon', 'cover', 'tags', 'created'].includes(k)))
};
});
};
const snap = () => ({ path: '~/Notes', name: 'Notes', pages: pages() });
const free = (parent, base) => {
for (let n = 1; ; n++) {
const id = (parent ? parent + '/' : '') + (n === 1 ? base : `${base} ${n}`);
if (!files[id] && !pages().some((p) => p.id === id)) return id;
}
};
const listeners = new Set();
const images = new Map();
const trash = []; // [{ entry, id, title, icon, pages, deletedAt, gone: { id: file } }], kept for this session
return {
platform: 'web',
async listTemplates() {
return ['Daily Note', 'Meeting'];
},
async duplicate(id) {
const f = files[id];
if (!f) throw new Error('That page has no file to copy.');
let next = id + ' copy';
for (let n = 2; files[next]; n++) next = `${id} copy ${n}`;
files[next] = structuredClone(f);
save();
return next;
},
async saveImageFromUrl() { throw new Error('Adding a picture from a link needs the desktop app.'); },
async pickFile() { throw new Error('Attaching files needs the desktop app.'); },
async openFile() { throw new Error('Opening files needs the desktop app.'); },
async createMany(parent, items) {
const ids = [];
for (const it of items || []) ids.push(await this.create(parent, it.title, it.meta, ''));
return ids;
},
async createCanvas() { throw new Error('Canvases need the desktop app.'); },
async openTemplatesFolder() { return 'The templates folder needs the desktop app.'; },
async getTemplate(name) {
if (name === 'Daily Note') return { meta: { tags: ['daily'] }, body: '# Daily Note\n\n' };
if (name === 'Meeting') return { meta: { tags: ['meeting'] }, body: '# Meeting\n\n' };
return null;
},
async get() { return opened ? snap() : null; },
async list() { return snap(); },
async pick() { opened = true; save(); return snap(); },
async read(id) {
const f = files[id];
return { id, title: titleOf(id), meta: f ? structuredClone(f.meta) : {}, body: f ? f.body : '', exists: !!f, mtime: 0 };
},
async write(id, meta, body) { files[id] = { meta: structuredClone(meta || {}), body: body || '' }; save(); return Date.now(); },
async create(parent, title, meta, body) {
const id = free(parent, (title || 'Untitled').replace(/[\\/:*?"<>|]/g, '-').trim() || 'Untitled');
files[id] = { meta: { created: today(), ...(meta || {}) }, body: body || '' };
save();
return id;
},
async rename(id, title) {
const clean = (title || 'Untitled').replace(/[\\/:*?"<>|]/g, '-').trim() || 'Untitled';
if (clean === titleOf(id)) return id;
const next = free(parentOf(id), clean);
for (const k of Object.keys(files)) {
if (k === id || k.startsWith(id + '/')) { files[next + k.slice(id.length)] = files[k]; delete files[k]; }
}
save();
return next;
},
async move(id, parent) {
if ((parent || null) === parentOf(id)) return id;
if (parent && (parent === id || parent.startsWith(id + '/'))) throw new Error('A page cannot go inside itself.');
const next = free(parent || null, titleOf(id));
for (const k of Object.keys(files)) {
if (k === id || k.startsWith(id + '/')) { files[next + k.slice(id.length)] = files[k]; delete files[k]; }
}
save();
return next;
},
// No version history in the browser preview.
async keepVersion() {},
async listHistory() { return []; },
async readHistory() { throw new Error('Version history needs the desktop app.'); },
async restoreHistory() { throw new Error('Version history needs the desktop app.'); },
async graph() {
const all = pages();
const nodes = all.filter((p) => p.exists).map((p) => ({ id: p.id, title: p.title, icon: p.icon, parent: p.parent, tags: p.tags }));
const edges = [], ghosts = new Map(), seen = new Set();
for (const [id, f] of Object.entries(files)) {
for (const m of f.body.matchAll(/(!?)\[\[([^\[\]\n|#]*)/g)) {
// ![[Page]] embeds count as links; ![[photo.png]] is a file, and [[#Heading]] points at its own page
if ((m[1] && FILE.test(m[2].trim())) || !m[2].trim()) continue;
let to = resolveLink(m[2], all);
if (!to) { to = '?' + m[2].trim().toLowerCase(); ghosts.set(to, { id: to, title: m[2].trim() }); }
if (to !== id && !seen.has(id + to)) { seen.add(id + to); edges.push([id, to]); }
}
}
return { nodes, edges, ghosts: [...ghosts.values()] };
},
// A heading of page `id` was renamed: update [[Page#from]] links and ![[Page#from]] embeds elsewhere (browser preview).
async renameHeading(id, from, to) {
const all = pages();
const norm = (s) => String(s || '').replace(/\s+/g, ' ').trim().toLowerCase();
const safe = String(to).replace(/[#|^\[\]]/g, ' ').replace(/\s+/g, ' ').trim();
const touched = [];
let count = 0;
if (!safe) return { pages: touched, count };
for (const [k, f] of Object.entries(files)) {
if (k === id) continue;
let n = 0;
const body = f.body.replace(/(!?)\[\[([^\[\]\n|]+)(\|[^\[\]\n]*)?\]\]/g, (m, bang, ref, alias) => {
const at = ref.lastIndexOf('#');
if (at < 0 || !ref.slice(0, at).replace(/#.*/, '').trim() || resolveLink(ref, all) !== id || norm(ref.slice(at + 1)) !== norm(from)) return m;
n++;
return `${bang}[[${ref.slice(0, at + 1)}${safe}${alias || ''}]]`;
});
if (n) { f.body = body; touched.push(k); count += n; }
}
if (touched.length) save();
return { pages: touched, count };
},
async tasks() {
const out = [];
for (const [id, f] of Object.entries(files)) {
if (f.meta['kanban-plugin'] || id.endsWith('.canvas')) continue;
f.body.split('\n').forEach((l, line) => {
const m = l.match(/^\s*[-*+]\s+\[([ xX])\]\s+(.+)$/);
if (!m) return;
const due = m[2].match(/(?:📅|\bdue:)\s*(\d{4}-\d{2}-\d{2})/);
const tags = new Set(f.meta.tags || []);
for (const t of m[2].matchAll(/(?:^|\s)#([\w/-]+)/g)) tags.add(t[1]);
out.push({ id, title: titleOf(id), icon: f.meta.icon || null, line, done: m[1] !== ' ', text: m[2].trim(), due: due ? due[1] : '', tags: [...tags], depth: 0 });
});
}
return out;
},
async setTask(id, line, text, done) {
const rows = files[id].body.split('\n');
const m = rows[line] && rows[line].match(/^(\s*[-*+]\s+)\[[ xX]\](\s+)(.*)$/);
if (!m || m[3].trim() !== text.trim()) throw new Error('That task changed. Reopen the list.');
rows[line] = m[1] + (done ? '[x]' : '[ ]') + m[2] + m[3];
files[id].body = rows.join('\n');
save();
},
async unlinked(id) {
const title = titleOf(id);
if (title.length < 3) return [];
const all = pages();
const re = new RegExp('(?<![\\p{L}\\p{N}_])' + title.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '(?![\\p{L}\\p{N}_])', 'iu');
return Object.entries(files)
.filter(([k, f]) => k !== id && re.test(f.body.replace(/\[\[[^\]]*\]\]/g, '')) && ![...f.body.matchAll(/\[\[([^\[\]\n|#]+)/g)].some((m) => resolveLink(m[1], all) === id))
.map(([k, f]) => ({ id: k, title: titleOf(k), icon: f.meta.icon || null, snip: (f.body.split('\n').find((l) => re.test(l)) || '').trim() }));
},
async linkMention(id, target) {
const title = titleOf(target);
const re = new RegExp('(?<![\\p{L}\\p{N}_])' + title.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '(?![\\p{L}\\p{N}_])', 'iu');
files[id].body = files[id].body.replace(re, (m) => (m === title ? `[[${title}]]` : `[[${title}|${m}]]`));
save();
},
async openCapture() {},
async takeCaptures() { return []; },
onCapture() { return () => {}; },
async backlinks(id) {
const all = pages();
return Object.entries(files)
.filter(([k, f]) => k !== id && [...f.body.matchAll(/(!?)\[\[([^\[\]\n|]+)/g)].some((m) => !(m[1] && FILE.test(m[2].trim())) && resolveLink(m[2], all) === id))
.map(([k, f]) => ({ id: k, title: titleOf(k), icon: f.meta.icon || null, snip: (f.body.split('\n').find((l) => l.includes('[[')) || '').replace(/[[\]]/g, '') }));
},
// Images live in memory only here; the desktop app writes them next to the note.
async saveImage(_id, name, bytes) {
const key = `${Date.now().toString(36)}-${name}`;
images.set(key, URL.createObjectURL(new Blob([bytes])));
return key;
},
async pickImage(id) {
const input = Object.assign(document.createElement('input'), { type: 'file', accept: 'image/*' });
const file = await new Promise((done) => { input.onchange = () => done(input.files[0] || null); input.click(); });
return file ? this.saveImage(id, file.name, new Uint8Array(await file.arrayBuffer())) : null;
},
mediaUrl: (_id, src) => images.get(src) || src,
async remove(id) {
const gone = {};
for (const k of Object.keys(files)) if (k === id || k.startsWith(id + '/')) { gone[k] = files[k]; delete files[k]; }
const entry = Date.now().toString(36) + Math.random().toString(36).slice(2, 6);
trash.unshift({ entry, id, title: titleOf(id), icon: gone[id]?.meta.icon || null, pages: Object.keys(gone).length, deletedAt: Date.now(), gone });
save();
return entry;
},
async trash() { return trash.map(({ gone, ...t }) => t); },
async restore(entry) {
const t = trash.find((x) => x.entry === entry);
if (!t) throw new Error('Unknown trash item.');
const next = free(parentOf(t.id), titleOf(t.id));
for (const [k, f] of Object.entries(t.gone)) files[next + k.slice(t.id.length)] = f;
trash.splice(trash.indexOf(t), 1);
save();
return next;
},
async purge(entry) {
const keep = entry ? trash.filter((x) => x.entry !== entry) : [];
trash.splice(0, trash.length, ...keep);
},
async search(q) {
const terms = String(q).toLowerCase().match(/[\p{L}\p{N}_]+/gu) || [];
if (!terms.length) return [];
return Object.entries(files)
.filter(([id, f]) => terms.every((t) => (id + ' ' + f.body).toLowerCase().includes(t)))
.slice(0, 20)
.map(([id, f]) => {
const lo = f.body.toLowerCase();
const at = lo.indexOf(terms[0]);
const start = Math.max(0, at - 30);
const snip = at < 0 ? f.body.slice(0, 90)
: (start ? '…' : '') + f.body.slice(start, at) + '\u0001' + f.body.slice(at, at + terms[0].length) + '\u0002' + f.body.slice(at + terms[0].length, at + 70);
return { id, title: titleOf(id), snip };
});
},
async reveal() {},
async filePath(id) { return `~/Notes/${id}.md`; },
async saveExport(name, content) {
const a = document.createElement('a');
a.href = URL.createObjectURL(new Blob([content], { type: 'text/plain' }));
a.download = name;
a.click();
setTimeout(() => URL.revokeObjectURL(a.href), 1000);
return name;
},
async linkPreview() { return null; },
async getSettings() { return { ...settings }; },
async setSettings(patch) { settings = { ...settings, ...patch }; save(); return settings; },
async getSpellcheck() { return { enabled: settings.spellcheck !== false, languages: [], available: [], system: true }; },
async setSpellcheck(enabled) { settings = { ...settings, spellcheck: !!enabled }; save(); return true; },
async spellWords() { return Array.isArray(settings.words) ? settings.words : []; },
async addSpellWord(word) { settings = { ...settings, words: [...new Set([...(settings.words || []), word])] }; save(); },
async removeSpellWord(word) { settings = { ...settings, words: (settings.words || []).filter((w) => w !== word) }; save(); },
async backupNow() { throw new Error('Backups need the desktop app.'); },
async backupInfo() { return { folder: '', last: 0 }; },
async pickBackupFolder() { return null; },
async revealBackups() {},
onNav(cb) { return () => {}; },
onChanged(cb) { listeners.add(cb); return () => listeners.delete(cb); },
onFlush() { return () => {}; }
};
}
+115
View File
@@ -0,0 +1,115 @@
// Settings beyond the few that have their own field on the store (theme, text size, ...). One object, saved as `prefs`.
// Every key has a default; cleanPrefs() fills gaps and drops anything of the wrong kind from a file written by hand.
export const DEFAULTS = {
// appearance
uiFont: 'inter', // 'inter' | 'system' | 'mono'
density: 'default', // sidebar rows and spacing: 'compact' | 'default' | 'comfortable'
sidebarWidth: 268, // px
showCover: true,
showIcon: true,
showBacklinks: true,
codeSize: 100, // % of the page's code size
codeWrap: false, // wrap long lines in code blocks instead of scrolling
customPageFont: '', // an installed font's name for page text; '' uses the Page font choice
customCodeFont: '', // same, for code
// editor
autoPair: true, // ( [ { ` close themselves
smartTypography: false, // -- to an em dash, ... to an ellipsis, curly quotes
pasteRich: true, // pasted web / rich text keeps headings, lists, links and bold as Markdown
mdShortcuts: true, // "# " and "- " turn a line into a heading or a list
typewriter: false, // keep the line being typed in the middle of the screen
hiddenKinds: [], // block kinds left out of the slash menu
autosaveDelay: 600, // ms after the last keystroke
// notes
dateFormat: 'YYYY-MM-DD', // daily note names
dailyTemplate: '', // text a new daily note starts with; {{date}} and {{weekday}} are filled in
newPageIcon: 'random', // 'random' | 'none'
newPageCover: 'random', // 'random' | 'none'
weekStart: 'monday', // calendar views: 'monday' | 'sunday'
locale: '', // date language; '' follows the system
exportLinks: 'wiki', // [[Page]] links in Markdown exports: 'wiki' | 'markdown'
confirmTrash: false, // ask before moving a page to the trash
trashDays: 30, // days in the trash before it is emptied; 0 never
historyKeep: 100, // versions kept per page
historyGapMin: 5, // at most one version per this many minutes of editing
// canvas
snapToGrid: true,
cardColor: '', // color of new canvas cards: '' (none) or a preset 1-6
// window
alwaysOnTop: false,
startMinimized: false,
// keyboard
shortcuts: {}, // { action id: 'Mod+Shift+K' } for the shortcuts that were changed (src/lib/shortcuts.js)
// quick capture
captureShortcut: 'CommandOrControl+Alt+N', // global shortcut for the capture window; '' turns it off
captureTo: 'daily', // 'daily' (today's note) | 'inbox'
// backups
backupEvery: 'off', // 'off' | 'daily' | 'weekly'
backupKeep: 5, // zips kept
backupFolder: '' // '' = <app data>/backups
};
export const DATE_FORMATS = ['YYYY-MM-DD', 'DD-MM-YYYY', 'MM-DD-YYYY', 'YYYY.MM.DD', 'YYYY-MM-DD dddd', 'MMMM D, YYYY'];
const CHOICES = {
uiFont: ['inter', 'system', 'mono'],
density: ['compact', 'default', 'comfortable'],
newPageIcon: ['random', 'none'],
newPageCover: ['random', 'none'],
weekStart: ['monday', 'sunday'],
exportLinks: ['wiki', 'markdown'],
backupEvery: ['off', 'daily', 'weekly'],
captureTo: ['daily', 'inbox'],
dateFormat: DATE_FORMATS,
cardColor: ['', '1', '2', '3', '4', '5', '6']
};
const RANGES = {
sidebarWidth: [220, 420], codeSize: [80, 140], autosaveDelay: [200, 5000],
trashDays: [0, 365], historyKeep: [5, 1000], historyGapMin: [1, 120], backupKeep: [1, 50]
};
export function cleanPrefs(raw) {
const src = raw && typeof raw === 'object' ? raw : {};
const out = {};
for (const [k, def] of Object.entries(DEFAULTS)) {
const v = src[k];
if (k === 'shortcuts') {
out[k] = {};
if (v && typeof v === 'object' && !Array.isArray(v)) for (const [id, c] of Object.entries(v)) if (typeof c === 'string' && /^Mod(\+(Alt|Shift))*\+.+$/.test(c)) out[k][id] = c;
continue;
}
if (CHOICES[k]) out[k] = CHOICES[k].includes(v) ? v : def;
else if (RANGES[k]) {
const n = Math.round(Number(v));
out[k] = Number.isFinite(n) && v !== '' && v !== null ? Math.min(RANGES[k][1], Math.max(RANGES[k][0], n)) : def;
} else if (typeof def === 'boolean') out[k] = typeof v === 'boolean' ? v : def;
else if (Array.isArray(def)) out[k] = Array.isArray(v) ? v.filter((x) => typeof x === 'string').slice(0, 64) : [];
else out[k] = typeof v === 'string' ? v.slice(0, 4000) : def;
}
return out;
}
// [[Page]] -> [Page](Page.md), [[Page|label]] -> [label](Page.md), ![[photo.png]] -> ![](photo.png); a #heading goes
// into the address. Links inside code are left alone.
export function wikiToMarkdownLinks(md) {
const enc = (t) => encodeURI(t).replace(/\(/g, '%28').replace(/\)/g, '%29');
const link = (embed, target, alias) => {
const [name, heading] = target.split('#');
const file = /\.[a-z0-9]{2,5}$/i.test(name) ? name : name + '.md';
const label = alias || target;
if (embed && /\.(png|jpe?g|gif|webp|svg|avif)$/i.test(name)) return `![${alias || ''}](${enc(name)})`;
return `${embed ? '!' : ''}[${label}](${enc(file)}${heading ? '#' + enc(heading.toLowerCase().replace(/\s+/g, '-')) : ''})`;
};
let fenced = false;
return md
.split('\n')
.map((line) => {
if (/^\s*(```|~~~)/.test(line)) { fenced = !fenced; return line; }
if (fenced) return line;
return line
.split(/(`[^`]*`)/)
.map((part, i) => (i % 2 ? part : part.replace(/(!?)\[\[([^\]|]+)(?:\|([^\]]*))?\]\]/g, (_, bang, target, alias) => link(!!bang, target.trim(), alias && alias.trim()))))
.join('');
})
.join('\n');
}
Loaded 100 of 162 files, more files were not shown because too many files have changed in this diff. Show more