diff --git a/.gitignore b/.gitignore index 9860187..2d11632 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,4 @@ release/ .vite/ coverage/ *.tsbuildinfo +builtin-extensions/*/dist/ diff --git a/README.md b/README.md index 9f9fcc1..9ba0d30 100644 --- a/README.md +++ b/README.md @@ -15,21 +15,30 @@ tracked against; any block can become one. you type, with the usual shortcuts (`**bold**`, `- ` for a list, ```` ```ts ```` for a code block, ⌘B / ⌘I / ⌘K…). The composer is a bare input: the only formatting UI is a small bubble menu that appears when you select text. - Enter posts, Shift+Enter starts a new line. -- **Every block is a node.** Reply to a block to start a thread, hover - between two blocks and press **+** to insert one there, double-click a - block (or press ↑ in the empty composer) to edit it, drag its grip to - reorder it within the day, or **Hide** it to collapse it into a one-line - stub. Timestamps and actions take no space: on hover they float over + Enter posts, Shift+Enter starts a new line. Start typing with nothing + focused and the text goes into the note box for the canvas on screen. +- **Every block is a page.** Double-click a block (or its **Open** + action) to open it: the block sits at the top as the page's surface, and + what you write there goes inside it, as blocks of their own that can be + opened in turn. **Alt+Enter** posts a block and opens it straight away; + **Alt+↑** (or **↑ Up**, or the breadcrumb) goes back up a level. In a + stream, a block with blocks inside shows a chip ("3 blocks inside") + instead of the blocks themselves. Hover between two blocks and press + **+** to insert one there, press ↑ in the empty composer to edit the last + block (on a page, double-click the surface to edit it), drag a block's + grip to reorder it among the blocks beside it, or **Hide** it to collapse + it into a one-line stub. Timestamps and actions take no space: on hover they float over whatever sits above the block. Blocks you wrote have no marker; blocks the app created carry a leading brace, muted for captured commits and in the accent colour for task blocks (click it to open the task). Blocks on past days work the same way. -- **Canvases nest however you slice your work.** Besides the journal, make a +- **Canvases nest however you slice your work.** Make a canvas per client, with project canvases inside it, with task canvases - inside those; or flatter, or deeper. The sidebar is the tree. "Website" - under two clients is two different canvases. Blocks can be moved between - canvases, and search covers all of them. + inside those; or flatter, or deeper. The sidebar is the tree; right-click a + canvas there for its properties, to start it, to add a canvas or task + inside it, or to archive it. "Website" under two clients is two different + canvases. Blocks can be moved between canvases, and search covers all of + them. Devlog opens on the canvas you last had open. - **Every canvas has a surface.** Above the stream sits free-form markdown that is not a dated note: links to the issue tracker, environments, contacts, credentials, how-tos, or the evolving write-up of whatever you are @@ -37,22 +46,24 @@ tracked against; any block can become one. double-click) turns it into the editor, which saves as you type. - **Notes are notes until you say otherwise.** Posting on a client or project canvas is just a note. A block becomes a **task** with - ⌘⇧Enter, with `#task` anywhere on its first line, or with the **Task** - action on hover: Devlog creates a task canvas beneath the current one, - titled from the block, links the block to it, and starts the clock. The - task canvas has its own surface and stream for everything that follows. -- **One active task, tracked for you.** Posting on a task canvas, or - **Start** in the status bar, makes it the active task; it stays active + ⌘⇧Enter, with `#task` anywhere on its first line, or with **Make task** + on hover: a task canvas appears beneath the current one, titled from the + block, the block links to it, and the clock starts. The task canvas has + its own surface and stream for everything that follows. +- **One active task, tracked for you.** Time tracking is the built-in + **Time tracking** extension (devlog-time). Posting on a task canvas, + **Start** in its header, or **Start** in the status bar makes it the + active task; it stays active until you post on another task, press Stop, lock the machine, go idle or sleep. Devlog keeps running in the tray to watch. A block with an explicit duration like `[2h]` or `[45m]` overrides tracking for that window when you know better. -- **Todos, always in view.** A panel pinned to the right edge lists the open - todos for the canvas on screen and everything inside it (or every todo, - with one click). Type one, or paste a list from anywhere and get one todo - per line. Each todo takes comments, can be reordered by dragging, and turns - into a task with one click. Ticking one off writes a "✓ done" block into - the canvas stream, so the log records when things got finished. +- **Todos are blocks, and always in view.** A todo is a block with a + checkbox, written anywhere: on a canvas, inside a block, inside another + todo. A panel pinned to the right edge gathers the open ones for the page + on screen and everything inside it (or every todo, with one click), + grouped under the canvases and blocks they live in. Click one to open it + as a page and write your notes about it there. - **Archive what you're done with.** Archive a canvas, with everything beneath it, to get it out of the sidebar. Archived things stay readable and searchable, and one click brings them back. @@ -118,18 +129,11 @@ existing devlog** (a clone from another machine). README.md devlog.json ← { "format": 3 }: the storage format .gitattributes ← block files and activity logs merge by keeping both sides -entries/ ← the journal - 2026/ - 09/ - 2026-09-19.md - assets/ - 2026-09-19-143201-a1b2.png - todos.md ← the journal's todo list +entries/ ← the old journal (retired; only in devlogs that used it) canvases/ k3/ ← shard: the first two characters of the id k3m9x2q7vd/ ← a canvas (a client, "Acme Corp") canvas.md ← title, parent, task flag, repos, archived flag + the surface - todos.md ← its todo list assets/ ← images pasted into the surface entries/2026/09/2026-09-19.md ← its stream, same day-file format 7w/ @@ -162,47 +166,23 @@ Marketing site rebuild. Weekly sync on Tuesdays. - Tracker: https://issues.example.com/acme ``` -`alias` lines record ids a canvas had before (see *Upgrading* below), so old -activity logs and links still find it. - -### Upgrading older devlogs - -Devlog 0.5 uses storage format 3 (above). Opening an older devlog (0.3's -format 1, or 0.4's format 2) upgrades it once, automatically: - -1. **Pull first** (when a remote is configured and reachable), so the upgrade - covers everything already pushed from your other machines. -2. **Migrate.** From format 1, canvases move from `canvases//` to - `canvases///`; every reference (parents, task links, todo links, - image paths in blocks and surfaces) is rewritten; the old folder name - becomes an `alias`. The new tree is built in `.devlog-migrate/` and - swapped in at the end, so an interrupted upgrade is rolled back (or - finished) on the next open rather than left half done. From either - format, block files are rewritten as append-only logs (below); format 2 - files are rewritten one at a time in place, so an interrupted upgrade - just carries on next time. -3. **Commit** it as its own commit, `devlog: migrate to storage format 3`, - and push. - -The migration is deterministic: an upgraded canvas's id is derived from its -old folder name, so two machines that upgrade the same history produce -byte-identical files. If another machine already pushed the upgrade, this one -just pulls it; if this machine also has unsynced changes from before the -upgrade, it migrates them the same way and merges, instead of replaying them -onto the moved files. Devlog 0.2 devlogs (`pages/` and `categories/`) go -through the same upgrade. - -**Update the app on every machine before opening the devlog there:** older -versions cannot read the new format (0.3 would show an empty sidebar, 0.4 -would misread the logs; nothing is lost either way, but they would not see -new work). +`alias` lines record ids a canvas had before (older devlogs used title +slugs), so old activity logs and links still find it. + +### Older devlogs + +This version reads and writes storage format 3 only. A devlog from before +Devlog 0.5 (format 1 or 2, or a 0.2 `pages/` + `categories/` layout) is +refused with a message: open it once with Devlog 0.5, which upgrades it, +then again with this version. A devlog written by a newer Devlog is refused +too, rather than misread. ## Canvases, surfaces and tasks Click a canvas in the sidebar to open it. The header shows its breadcrumbs, the canvases inside it as chips, and actions: **Edit** (rename, move under -another canvas, mark as a task, repositories), **Archive**, and for tasks -**Start** / **Stop**. +another canvas, its type, repositories), **Archive**, and whatever +extensions add for canvases of their type (**Start** / **Stop** on a task). The **surface** sits above the stream. It opens rendered: links open in the browser, images open full size. **Add surface** / **Edit surface** (or a @@ -211,33 +191,57 @@ image support as blocks, but no posting: Enter is just a new line and every change is saved a moment later (the header says "Saved"); **Done** switches back. Surfaces are searched along with blocks. -A **task** is a canvas with the task flag. Three ways to make one from a -block you are writing or have written: +A canvas can have a **type** that an extension gives meaning to (its icon +shows in the sidebar; choose it under **Type** in its **Properties**, or +**New task inside…** on a canvas's right-click menu). The one that ships is +the **task**, from the Time tracking extension: a canvas you record time +against. Three ways to make one from a block you are writing or have +written: - press ⌘⇧Enter instead of Enter when posting, - put `#task` anywhere on the block's first line (it is stripped on save), -- hover an existing block and choose **Task**. +- hover an existing block and choose **Make task**. -Each creates a task canvas beneath the block's canvas (top-level for journal -blocks), titled from the block's first sentence, marks the block as the link +Each creates a task canvas beneath the block's canvas, titled from the block's first sentence, marks the block as the link to it (a chip opens the task), and makes it the active task. Blocks inside a -task canvas can be anything: more notes, pasted evidence, further tasks. A -canvas can also be flagged as a task, or unflagged, in **Edit**. - -The task controls live in one place, the status bar. **Start ▾** opens a -menu of every task (the canvas on screen, or the tasks inside it, first; -type to filter; Enter picks the first match) plus **New task…**, which -creates a task under the canvas on screen and starts it. **Stop** replaces -it while a task is active. - -**Hide** on a block collapses it (with its thread) into a "1 hidden block" +task canvas can be anything: more notes, pasted evidence, further tasks. +Without the extension a task canvas is a plain canvas; nothing in it is lost. + +On a task canvas, **▶ Start** in the header starts it (**Switch to this +task** when another is running, **■ Stop** once it is the active one). +The status bar's **Start** is a split button: while a task canvas is on +screen its main part reads **▶ Start** *that task* and starts it in one +click; the ▾ beside it opens the list of every task (the canvas on screen, +or the tasks inside it, first; type to filter; Enter picks), and a name that +matches nothing offers **+ New task**, made under the canvas on screen and +started. **Stop** sits next to it while a task is active. + +**Block pages.** A standup, a call, a bug: post a block for it (Alt+Enter +opens it), write your notes inside, and later edit the block itself into the +summary worth keeping. The stream above then reads as a list of those +summaries, each one openable. Blocks inside a block live in its day file, +whenever they were written; the review, summary, timeline and timesheet +count each one on the day it was written. A page on a task canvas belongs to +the task: writing there makes it the active task. + +To put a block inside another, drag it by its grip onto the middle of that +block (the top and bottom edges still reorder); a block on another day of +the same canvas works too. **Move → Out of this block** takes one back out, +beside the block it was in; **Move** to another canvas puts it at that +canvas's top level. Making a block with notes inside it a task (**Make +task**, ⌘⇧Enter) moves those notes into the new task's stream; the block stays +behind as the link to it. The quick switcher (⌘K / Go to…) lists the pages +you opened lately first. + +**Hide** on a block collapses it (with what is inside it) into a "1 hidden block" stub so a busy stream reads cleanly; click the stub to look inside and **Unhide** to bring it back. Nothing is deleted; hidden blocks stay in the file (`hidden=1` in the marker), in search and in the review counts. **Drag to reorder.** Hover a block and drag the grip at its left edge to -another spot in the same day; the day's order is the file's order, so this -is the same operation as insert-between. Timestamps do not change: the +another spot among the blocks beside it (on a canvas, within the same day; +on a page, among the blocks inside the same block); the order is the file's +order, so this is the same operation as insert-between. Timestamps do not change: the time on a block is when it was written, its position is where you keep it. Blocks cannot be dragged across days, because a day is a file; move them with **Move** instead. @@ -247,27 +251,42 @@ repository as sensitive, because it is. ## Todos +A todo is a block with a checkbox. Post `[ ] Call Dana` (or several +`[ ] …` lines, or `- [ ]` ones pasted from anywhere) and each line becomes a +todo, wherever you are writing: on a canvas, on a block's page, on another +todo's page. Ask for a review, paste the list of findings inside that block, +and work through them one by one, writing notes inside each as you go; when +something new comes up, add it right there. + The **To do** panel on the right stays put whatever the stream is doing, and collapses to a thin strip showing the open count. -- **Adding.** Type in the box at the top and press Enter. Paste a list (from - an email, Slack, a Markdown file) and each line becomes a todo; bullets, - numbers and `- [ ]` checkboxes are stripped. New todos go to the canvas on - screen, or to the journal from the other views. -- **Scope.** *Here* shows the canvas on screen and everything inside it, - grouped by canvas when there is more than one; *All* shows every open todo. - On the journal and on the review, summary and timeline views it is always - everything. -- **Working a todo.** Click it for its comment thread, **Edit**, **Delete** - and **Make task** (a task canvas beneath this one, clock started). Drag to - reorder. -- **Done.** Ticking the box moves it to *Done* at the bottom of the panel and - writes a read-only "✓ …" block into today's stream on that canvas, marked - with the automatic brace. Unticking the same day removes that block again. - -Todos are blocks, stored per canvas in `canvases///todos.md` (the -journal's in `entries/todos.md`) in the same format as day files, comments -included. They are searched along with everything else. +- **Adding.** Type in the box at the top and press Enter, or paste a list + (from an email, Slack, a Markdown file) and each line becomes a todo; + bullets, numbers and checkboxes are stripped. New todos go into the page + on screen: the block you have open, or the canvas. +- **Scope.** *Here* shows the page on screen and everything inside it; *All* + shows every open todo. On the review, summary and timeline views it is + always everything. +- **Grouping.** Todos sit under the canvases and blocks they live in, in the + order they appear there. A heading with nothing of its own and a single + heading beneath it merges into it ("Acme Corp / Website"). Click a heading + to open it, or its arrow to fold it (remembered). Drag a todo to reorder it + among the ones beside it. +- **Working a todo.** Click it to open it as a page: its checkbox is at the + top, and what you write goes inside it. The count beside a todo is how + much is inside. **Task** on its block turns it into a task canvas and + starts the clock. +- **Done.** Tick the box, in the panel or in the stream. The todo stays where + it is, struck through (a run of them folds into one line), and moves to + *Done* in the panel for two weeks. The timeline shows when it was ticked. + +Todos are ordinary blocks (`kind=todo`, with a `done` time once ticked) in +the day files, so they are searched, moved and reordered like everything +else. Devlogs from before 0.15 kept each canvas's todos in its own +`todos.md`; opening one moves them into the streams (each on the day it was +written, its comments becoming blocks inside it) and marks the devlog as +storage format 4. ## Archiving @@ -278,19 +297,30 @@ Archived canvases cannot become the active task or receive commits. The sidebar's collapsible **Archived** section lists everything archived so it is never lost. +The journal (a canvas-less notebook in earlier versions) is retired. If your +devlog has notes in it, it is listed under **Archived** as *Journal*, read +only; **Move** the blocks worth keeping onto a canvas. + ## Time tracking -There is one active task at a time, and a task is a canvas with the task -flag. The workflow: write a line or two to wrap up what you were doing, then +Time tracking is the built-in **Time tracking** extension (devlog-time): +without it Devlog has no tasks, no clock, no Summary and no Timesheet. A +devlog that tracked time before 0.17 gets it added and allowed on each +machine that tracked time, with the active task and idle setting carried +over, so nothing changes. Its settings (Settings → Time tracking) are the +idle minutes and whether its log is synced with the devlog. + +There is one active task at a time, and a task is a canvas of the task +type. The workflow: write a line or two to wrap up what you were doing, then either post on the task you are picking up (that makes it active), pick it from **Start ▾** in the status bar, or write the next thing as a new block and post it with ⌘⇧Enter so it becomes a task of its own. The status bar shows the active task with a running clock and a **Stop** button (also ⌘⇧. and in the tray -menu). Posting on the journal or on a canvas that is not a task never -touches the clock: those are just notes. +menu). Posting on a canvas that is not a task never touches the clock: +those are just notes. Time stops accruing while the screen is locked, the machine sleeps, or there -has been no input for a while (default 10 minutes, adjustable), and resumes on +has been no input for a while (default 10 minutes, in the extension's settings), and resumes on the same task afterwards. Each of those pauses the clock independently, so a laptop that wakes in the background while still locked stays paused until you unlock it. On Windows and macOS the lock state is also polled every 15 @@ -318,10 +348,13 @@ folded into the window you were actually working in. So a 20-minute Outlook session that you alt-tabbed out of and back into five times shows as 20 minutes of Outlook. -Everything the tracker sees goes to an append-only activity log, one JSON -file per day and one folder per machine -(`activity//YYYY/MM/YYYY-MM-DD.jsonl`, where `` is the host -name plus a short id kept in the app's data folder). By default it lives in +What the clock does (start, task switches, stop, heartbeats) goes to the +extension's own append-only log, and what the machine does (lock, idle, +sleep) and git events go to the app's; both are one JSON file per day and one +folder per machine (`extensions/builtin.devlog-time//…` and +`activity//YYYY/MM/YYYY-MM-DD.jsonl`, where `` is the host +name plus a short id kept in the app's data folder). Older time, from before +the extension, stays in `activity/` and still counts. By default it lives in the devlog repository, so the review and timeline add up time from every machine you work on (window titles included, so consider what they contain); Settings can keep it on this machine only. Each machine only appends to its @@ -331,15 +364,23 @@ machines both tracked time at once, the task picked or machine woken most recently wins, so no minute is counted twice. Removing time in the review applies whichever machine tracked it. Recorded events: task switches, lock/unlock, idle/active, sleep/wake, app start/stop, a -heartbeat, foreground-window changes (process name and window title, which -for browsers is the active tab), and git events from watched repositories. Focus tracking uses a small PowerShell helper -on Windows, `osascript` on macOS (window titles need the Accessibility -permission) and `xdotool` on Linux if present. +heartbeat every five minutes (only while the clock can run: nothing is +written, and so nothing committed, while the machine is locked, idle or +asleep), and git events from watched repositories. + +**Window tracking** (which app and window title is in front, for screen +time in the timeline, review and summary) is the built-in **Window +tracking** extension (devlog-focus): add it in Settings → Extensions and allow it. +It runs unrestricted, so you are asked whether you trust it: it starts a +small helper to read the window in front (PowerShell on Windows, `osascript` +on macOS, where window titles need the Accessibility permission, `xdotool` +on Linux if present). It records only while the machine is unlocked and +awake, into `extensions/builtin.devlog-focus//` in the devlog. ## Working copies: commits as blocks, branches as events Every canvas header has a **Link a repository…** button (also under -**Edit → Git repositories**). Pick the folder of a working copy you code in, +**Properties → Repositories**). Pick the folder of a working copy you code in, not the devlog repository. Devlog checks it is a git repository first (a subfolder resolves to its repository root) and refuses anything else. Each linked repository shows as a chip; ✕ on the chip unlinks it, keeping the @@ -378,6 +419,9 @@ timestamps instead (each note counts until the next one, capped at an hour). ## Summary +The Summary and the Timesheet are pages of the Time tracking extension, +listed in the sidebar with the app's own views (and in the quick switcher). + **Summary** (⌘⇧H) is the timesheet view: one row per top-level canvas (client) with hours for the chosen range (this week, last week, this month, last month, or any two dates), a share bar, the block count and the exact @@ -387,6 +431,69 @@ see its projects and tasks rounded the same way; click a name to open the canvas. Rounding happens per row, so the rounded rows may not add up to the rounded total. +## Timesheet + +**Timesheet** (sidebar) turns a week of tracked time into what you report: +sessions on the same task (gaps up to 30 minutes count as work), each +rounded once to quarter hours (anything above zero is at least 15 minutes), +starting on quarter hours. Days with no tracking use the review's estimate. + +It is a grid: a column per day, a row per task, tasks grouped under their +client with a total row for each client, and at the bottom the day's total +reported (and, muted, what was actually worked). Click a cell to open its +entries below the grid and change start times, durations (15-minute steps), +the task, the day or the note, remove one, or add one; click an empty cell to +add time there, and **+ Add a task…** for a task with no time yet. When +rounding inflates a client's day, its row shows the suggested trim (−0:45) +on that day; click it to apply. **Mark final** approves the week. + +**Hour targets.** Give any canvas a weekly or a monthly target (right-click +→ Properties → Time tracking: *Hours per week*, *Hours per month*). The +Timesheet lists the targets that apply to the week on screen, each against +the timesheet hours under its canvas (it and everything inside it) for its +own period: this week, and each month the week touches (other weeks count +as saved, or as drafted from tracked time). Targets overlap and each applies +on its own: 168 h for a client in September and 20 h a week for a task under +that client are two separate measures, and a target is not taken on by the +canvases inside. + +**Sending to Jira.** Add the built-in **Jira worklogs** extension in +Settings → Extensions and allow it (it is sandboxed: it only talks to Jira). +It gets a page of its own in Settings, marked until it is set up: the Jira +address and your account email (saved in the devlog), and an API token +(kept on this computer only; for Data Center leave the email empty and use a +personal access token). **Save and test** checks the connection. Put +the Jira issue key on the task canvases (right-click → Properties → Jira worklogs), or on a +client or project canvas for everything beneath it. On a final week, +**Send to Jira…** shows what would be created, changed or removed, then +sends it: one worklog per entry, with its start time, duration and note. +Sending again only sends what changed since the last time, from any +machine. **Check the Jira connection** (quick switcher) tests the settings. + +**Sending to CMS.** The built-in **CMS timesheets** extension fills in the +Technology Partners consultant timesheet the way the browser does (CMS has +no API): it logs in with your username and password, reads the week, and +sets each day's hours (decimal, `7.5`). Add it in Settings → Extensions, +allow it, and on its page enter your CMS username (saved in the devlog) and +password (kept on this computer only, never in the repository). **List my +CMS assignments** (quick switcher) shows your assignments as +`number: Client / Project`; put the number (or the project name) on each +client canvas (right-click → Properties → CMS timesheets). On a final week, **Send to CMS…** +shows the hours per assignment per day against what CMS has now, and only +changes the days that differ; a day this sent before that no longer has +time goes back to 0, and days you typed into CMS yourself are left alone. +The note is sent as the day's description when there is one; otherwise the +description CMS has is kept. CMS only opens a day on that day, so time on +days still ahead (typically the week's Sunday) waits: send again then. + +Timesheets are saved in a **Timesheets** canvas the Time tracking extension +keeps (made on first use): one block per week on its Monday, a readable +table with the exact data underneath, read-only in the stream; every change +is an edit record, so the history of the adjustments is kept, and what was +sent where is written inside the week. Sending goes through the app to the +Jira or CMS extension, so each keeps its own credentials. See +`docs/TIMESHEETS.md`. + ## Timeline **Timeline** (⌘⇧T) shows one day sliced into fixed intervals (5, 15, 30 or 60 @@ -409,7 +516,7 @@ Started on the git sync. Pull before push, rebase on conflicts. ![shot](assets/2026-09-19-143201-a1b2.png) -A reply in the thread under the first note. +A block written inside the first one (its page). Fix the login redirect @@ -433,7 +540,7 @@ blocks you see are what you get by replaying the records in order. - **Order** comes from the `pos` keys, which sort as text: reordering a block appends one `set` with a new key between its neighbours' keys, and - threading, unthreading and inserting work the same way. Nothing else moves. + nesting a block inside another and inserting work the same way. Nothing else moves. - **Safety.** A bug can add a wrong record, but it cannot overwrite what is there: everything ever written stays in the file (and in git). A crash mid-write leaves at most a torn last record, which is skipped. @@ -461,6 +568,40 @@ write, and it re-reads files that changed on disk when a devlog is opened, after a pull, and when the window regains focus (so edits made outside the app show up too). Delete it any time; it is rebuilt from the files. +## Extensions + +Integrations (time export, calendars, …) come as extensions that a devlog +opts into. Open **Extensions** from the quick switcher or Settings: + +- **Add** one from a GitHub repository's releases (`owner/repo` and a version + range such as `^1.0.0`), from a single `https://…/x.devlog-ext.zip`, or one + built into Devlog (`builtin`). It is recorded in `devlog.json`, and the + exact file it resolved to is pinned by hash in `devlog.lock.json`, so every + machine runs the same code. +- **Allow** it before it runs, on each machine: the dialog shows what it + says it talks to and lets you choose what it may **read** and **add + blocks to**: the whole devlog, or chosen canvases (with everything inside + them). A new version asks again. +- It runs in its own process with **no access to your files or other + programs**, and cannot see other extensions. An extension that needs more + (such as window tracking) says so, and runs unrestricted only if you tick + "I trust it" when allowing it. Its own data lives in + `extensions//` in the devlog (synced) and in user data on this + machine. Network access is not restricted, so only grant read access you + are comfortable sending to what it talks to. +- **Settings** it declares go into `devlog.json`; **secrets** (tokens, + passwords) are encrypted with the OS keychain on each machine and never + written to the devlog. Per-canvas values (a Jira issue, a client id) appear + as fields in the canvas dialog, are stored in `canvas.md`, and are + inherited by canvases inside. +- Its **commands** appear in the quick switcher; blocks it writes are marked + as its own and are read-only. +- **Check for updates** re-resolves the version ranges and pins what + changed. **Remove** takes it out of the devlog (its data stays). + +Writing one: `packages/extension-api/README.md`; the design: +`docs/EXTENSIONS.md`. + ## Sync behaviour | Trigger | What happens | @@ -468,7 +609,7 @@ app show up too). Delete it any time; it is rebuilt from the files. | Post / edit / delete / paste | File written immediately; a commit is scheduled (default 30s) | | Every N minutes (default 5) | Commit if dirty, fetch, pull `--rebase` if behind, push | | **Sync now** (⌘⇧S) | Same, immediately | -| App start | Pull (if a remote is configured); upgrade an older layout first | +| App start | Pull (if a remote is configured) | | App quit | Commit and push pending changes (up to 20s) | The status bar shows the current state (uncommitted changes, committing, @@ -478,8 +619,15 @@ conflicts is backed out (never left half-rebased) and reported. Errors such as a shown and retried on the next tick; nothing is ever lost because the files are already on disk. -All settings (sync interval, debounce, push/pull toggles, commit author, -remote URL) live under **Settings** (⌘,). +All settings live under **Settings** (⌘,, or the foot of the sidebar), a +page per topic: Repository (folder, remote, commit author), Sync, +Appearance, Activity and Updates, then Extensions: **Manage** to +add them, and a page for each one this devlog uses (what it may do, its +settings, its secrets on this computer, its own test). Save lights up only +once something changed, and each page with unsaved changes is marked in the +navigation; closing with unsaved changes asks first. Canvas properties work +the same way, with a page for the canvas, its repositories, and each +extension that adds fields to canvases. ## Updates and releases @@ -514,12 +662,11 @@ macOS builds still run but will not self-update. npm run typecheck # main + renderer npm test # unit tests: file format, store, git sync (uses a local bare remote) npm run smoke # builds, then drives the real app with Playwright (needs a display; use xvfb-run on Linux) -node scripts/smoke-migrate.mjs # after a build: opens a Devlog 0.3 repository and checks the upgrade npm run screens # builds, seeds a demo devlog and screenshots every view in light and dark mode ``` -The window remembers its size and position, closes to the tray while tracking -is on, and shows the current canvas in its title. Failed background actions +The window remembers its size and position, closes to the tray while an +extension asks it to (time tracking does), and shows the current canvas in its title. Failed background actions (a move, an archive, a sync) surface as a toast in the corner rather than disappearing into the console. @@ -531,13 +678,19 @@ Code map: | `packages/core/src/format/` | Block and canvas file formats, ids, hierarchy helpers | | `packages/core/src/node/store.ts` | `DevlogStore`: canvases, blocks, todos, assets | | `packages/core/src/node/repoIndex.ts` | `RepoIndex`: SQLite cache for listings and full-text search | -| `packages/core/src/node/migrate.ts` | Storage format upgrades, including across machines | +| `packages/core/src/node/manifest.ts` | `devlog.json` (format check, extensions, settings) and `devlog.lock.json` | +| `packages/core/src/extensions.ts` | Extension manifests, sources and ids, version ranges, grants | +| `packages/core/src/node/extensionFiles.ts` | The file broker behind an extension's private folders | +| `packages/extension-api/` | Types, wire protocol and test harness for extension authors | +| `src/main/extensions/` | Installer, sandboxed extension process, manager (consent, API) | | `packages/core/src/node/sync.ts` | `SyncManager`: commit / pull / push scheduler on `simple-git` | | `packages/core/src/node/activityLog.ts` | Per-machine append-only activity log | | `src/shared/theme.ts` | Colour presets and derived theme variables | | `src/shared/activity.ts` | Pure event → segment logic, app classification, roll-ups | | `src/shared/review.ts` | Weekly roll-up: week math, tracked/explicit/estimated time | -| `src/main/activity/` | Tracker (lock/idle/focus), commit watcher | +| `src/main/activity/` | Commit watcher | +| `builtin-extensions/` | Extensions that ship with Devlog (devlog-time, devlog-focus, Jira, CMS) | +| `packages/ui/` | `@devlog/ui`: React components and the view bridge for extension views | | `src/main/updates.ts` | Silent auto-update via electron-updater and GitHub Releases | | `src/shared/updates.ts` | Pure "is now a good moment to restart" policy | | `src/main/protocol.ts` | `devlog://asset/…` scheme serving images from the repo | diff --git a/builtin-extensions/README.md b/builtin-extensions/README.md new file mode 100644 index 0000000..ad51037 --- /dev/null +++ b/builtin-extensions/README.md @@ -0,0 +1,13 @@ +# Built-in extensions + +Extensions that ship inside the Devlog app, one folder each (with a +`devlog-extension.json`). A devlog enables one with `"": "builtin"` in +its `devlog.json`; it still runs sandboxed and still asks before it starts. +Packaged into the app's resources as `extensions/`. + +An extension written in TypeScript keeps its sources in `src/`: `src/main.ts` +is bundled to `dist/main.js` (its manifest's `main`), and each +`src/views/.tsx` to `dist/views/.html` with its script and +styles (React and `@devlog/ui` included), by `scripts/build-builtins.mjs`, +which `npm run build` runs first. `dist/` is not committed; `src/` is not +packaged. diff --git a/builtin-extensions/devlog-cms/devlog-extension.json b/builtin-extensions/devlog-cms/devlog-extension.json new file mode 100644 index 0000000..a62985d --- /dev/null +++ b/builtin-extensions/devlog-cms/devlog-extension.json @@ -0,0 +1,36 @@ +{ + "name": "devlog-cms", + "displayName": "CMS timesheets", + "version": "1.0.1", + "description": "Sends a finished timesheet to the Technology Partners consultant timesheet system (CMS): hours per assignment per day, on the assignment set on the task (or a canvas above it). Sending again only changes the days that differ from what CMS shows.", + "api": "^1.3.0", + "main": "main.js", + "contributes": { + "canvasFields": [ + { + "key": "assignment", + "label": "CMS assignment", + "placeholder": "12345, or the project name", + "description": "The CMS assignment time on this canvas (and canvases inside it) goes to: its number, or its project name as CMS shows it. \"List my CMS assignments\" in the quick switcher shows them." + } + ], + "settings": [ + { "key": "username", "label": "CMS username", "required": true, "placeholder": "jdoe" }, + { + "key": "baseurl", + "label": "CMS address", + "type": "url", + "placeholder": "https://consultant.technologypartners.net/consultant", + "description": "Leave empty for the usual address." + } + ], + "secrets": [{ "key": "password", "label": "CMS password", "required": true, "description": "Used to log in each time Devlog sends hours; CMS has no tokens." }], + "commands": [ + { "id": "check", "label": "Check the CMS login" }, + { "id": "assignments", "label": "List my CMS assignments" } + ], + "check": "check", + "destinations": [{ "id": "hours", "label": "CMS" }] + }, + "permissions": { "network": ["consultant.technologypartners.net"] } +} diff --git a/builtin-extensions/devlog-cms/main.js b/builtin-extensions/devlog-cms/main.js new file mode 100644 index 0000000..12e547d --- /dev/null +++ b/builtin-extensions/devlog-cms/main.js @@ -0,0 +1,353 @@ +/** + * devlog-cms: a finished timesheet → hours in the Technology Partners + * consultant timesheet system ("CMS"). + * + * CMS has no API, so this does what the browser does: + * + * - log in with the form (j_security_check), keeping the session cookie; + * - read the week page (TPIServlet?screen=TimesheetScreen&action=retrieve + * &ref=MM/DD/YY, ref = the Sunday): a row per assignment (client, project, + * the assignment id in its "Week" link) and a cell per day linking to that + * day's timesheet_id and showing its hours ("07.50"). A day only gets its + * link on the day itself, and not at all outside the assignment's dates; + * - to change a day, open its form (…×heet_id=N) for the current + * description and revision, then submit Update with hrs_worked (decimal + * hours) and work_desc. + * + * CMS weeks run Sunday to Saturday, so a Devlog week (Monday to Sunday) needs + * two CMS weeks. Hours per assignment per day are the sum of the timesheet's + * entries whose canvas (or a canvas above it) names the assignment. The + * preview compares with what CMS shows, so only days that differ are sent. + * Days this extension filled before (sent/.json) and that no longer + * have time are set back to 0; days you typed into CMS yourself are left + * alone. + */ +const DEFAULT_BASE = 'https://consultant.technologypartners.net/consultant' +const pad = (n) => String(n).padStart(2, '0') + +// --------------------------------------------------------------------------- +// Dates +// --------------------------------------------------------------------------- + +const parseDate = (s) => { + const [y, m, d] = s.split('-').map(Number) + return new Date(y, m - 1, d) +} +const ymd = (d) => `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}` +const addDays = (s, n) => { + const d = parseDate(s) + d.setDate(d.getDate() + n) + return ymd(d) +} +/** The Sunday a date's CMS week starts on. */ +const cmsSunday = (s) => addDays(s, -parseDate(s).getDay()) +/** CMS's ref=MM/DD/YY. */ +const ref = (sunday) => { + const d = parseDate(sunday) + return `${pad(d.getMonth() + 1)}/${pad(d.getDate())}/${String(d.getFullYear()).slice(2)}` +} + +/** Decimal hours as CMS takes them: 90 → "1.5", 15 → "0.25". */ +const hoursText = (minutes) => String(Math.round((minutes / 60) * 100) / 100) +const hm = (minutes) => `${Math.floor(minutes / 60)}:${pad(minutes % 60)}` + +// --------------------------------------------------------------------------- +// Parsing CMS pages +// --------------------------------------------------------------------------- + +const decode = (s) => + s + .replace(/<[^>]*>/g, ' ') + .replace(/ /gi, ' ') + .replace(/&/gi, '&') + .replace(/</gi, '<') + .replace(/>/gi, '>') + .replace(/"/gi, '"') + .replace(/'|'/gi, "'") + .replace(/\s+/g, ' ') + .trim() + +const isLoginPage = (html) => /j_security_check/i.test(html) + +/** + * The week page → { weekEnding: 'YYYY-MM-DD' | null, rows: [{ assignmentId, + * client, project, cells: [{ date, timesheetId, minutes }] }] }, dates from + * the Sunday it was asked for. A row's cells are its 3rd to 9th columns; a + * day CMS won't take hours for (before the assignment starts, after it ends, + * or not yet here) has no link, so its timesheetId is null. + */ +function parseWeek(html, sunday) { + const ending = /Week Ending:\s*(\d{2})\/(\d{2})\/(\d{2})/i.exec(html) + const weekEnding = ending ? `20${ending[3]}-${ending[1]}-${ending[2]}` : null + const rows = [] + const parts = html.split(//i)[0] + const tds = [...row.matchAll(/]*>([\s\S]*?)<\/TD>/gi)].map((m) => m[1]) + if (tds.length < 9) continue + const cells = tds.slice(2, 9).map((td, i) => { + const link = /timesheet_id=(\d+)/i.exec(td) + const hours = /\d+(?:\.\d+)?/.exec(decode(td)) + return { date: addDays(sunday, i), timesheetId: link ? link[1] : null, minutes: hours ? Math.round(parseFloat(hours[0]) * 60) : 0 } + }) + const assignment = /assignment_id=(\d+)/i.exec(row) + rows.push({ assignmentId: assignment ? assignment[1] : '', client: decode(tds[0]), project: decode(tds[1]), cells }) + } + return { weekEnding, rows } +} + +/** A day's form → its current description and revision. */ +function parseForm(html) { + const desc = /]*name=["']?work_desc["']?[^>]*>([\s\S]*?)<\/textarea>/i.exec(html) + const rev = /name=["']?revision_ts["']?\s+value=["']([^"']*)["']/i.exec(html) || /value=["']([^"']*)["']\s+name=["']?revision_ts/i.exec(html) + const hours = /name=["']?hrs_worked["']?[^>]*value=["']([\d.]*)["']/i.exec(html) + return { + description: desc ? decode(desc[1].replace(//gi, '\n')).trim() : '', + revision: rev ? rev[1] : '', + minutes: hours ? Math.round(parseFloat(hours[1] || '0') * 60) : null + } +} + +// --------------------------------------------------------------------------- +// The session: cookies, redirects, login +// --------------------------------------------------------------------------- + +class Session { + constructor(base, username, password) { + this.base = base + this.origin = new URL(base).origin + this.username = username + this.password = password + this.cookies = new Map() + } + + keep(res) { + const set = typeof res.headers.getSetCookie === 'function' ? res.headers.getSetCookie() : [res.headers.get('set-cookie')].filter(Boolean) + for (const c of set) { + const [pair] = c.split(';') + const eq = pair.indexOf('=') + if (eq > 0) this.cookies.set(pair.slice(0, eq).trim(), pair.slice(eq + 1).trim()) + } + } + + /** Fetch, following redirects by hand so every hop's cookies are kept. Returns { url, html }. */ + async request(url, init = {}) { + let target = new URL(url, this.base + '/').toString() + let options = init + for (let hop = 0; hop < 8; hop++) { + if (new URL(target).origin !== this.origin) throw new Error(`CMS redirected somewhere else: ${target}`) + const headers = { 'User-Agent': 'Devlog', ...(options.headers || {}) } + if (this.cookies.size) headers.Cookie = [...this.cookies].map(([k, v]) => `${k}=${v}`).join('; ') + const res = await fetch(target, { ...options, headers, redirect: 'manual' }) + this.keep(res) + if (res.status >= 300 && res.status < 400 && res.headers.get('location')) { + target = new URL(res.headers.get('location'), target).toString() + options = {} // a redirect after a POST is a GET + continue + } + if (!res.ok) throw new Error(`CMS: HTTP ${res.status} for ${new URL(target).pathname}`) + return { url: target, html: await res.text() } + } + throw new Error('CMS: too many redirects') + } + + servlet(params) { + return `${this.base}/servlet/TPIServlet?${new URLSearchParams(params).toString().replace(/%2F/g, '/')}` + } + + /** Get a page, logging in first if CMS asks for it. */ + async page(params) { + const url = this.servlet(params) + let res = await this.request(url) + if (!isLoginPage(res.html)) return res.html + // One login per session, even if two pages asked at once. + if (!this.login) { + const body = new URLSearchParams({ from: '', j_username: this.username, j_password: this.password }).toString() + this.login = this.request(`${this.base}/j_security_check`, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body }) + } + await this.login + res = await this.request(url) + if (isLoginPage(res.html)) throw new Error('CMS did not accept the username and password') + return res.html + } + + async week(sunday) { + const html = await this.page({ screen: 'TimesheetScreen', action: 'retrieve', ref: ref(sunday) }) + const week = parseWeek(html, sunday) + if (week.weekEnding && week.weekEnding !== addDays(sunday, 6)) throw new Error(`CMS showed the week ending ${week.weekEnding}, not ${addDays(sunday, 6)}`) + return week + } + + async update(timesheetId, minutes, description) { + const form = parseForm(await this.page({ screen: 'TimesheetScreen', action: 'retrieve', timesheet_id: timesheetId })) + const html = await this.page({ + screen: 'TimesheetScreen', + revision_ts: form.revision, + hrs_worked: hoursText(minutes), + work_desc: description === null ? form.description : description, + timesheet_id: timesheetId, + action: 'Update' + }) + // CMS answers with the week again: check the day now shows what was sent. + const shown = [...html.matchAll(/timesheet_id=(\d+)"?[^>]*>\s*([\d.]+)\s*<\/A>/gi)].find((m) => m[1] === String(timesheetId)) + if (shown && Math.round(parseFloat(shown[2]) * 60) !== minutes) throw new Error(`CMS shows ${shown[2]} hours after the update, not ${hoursText(minutes)}`) + } +} + +async function session(ctx) { + const username = String(ctx.settings.get('username') || '').trim() + if (!username) throw new Error('Set the CMS username in the extension settings') + const password = await ctx.secrets.get('password') + if (!password) throw new Error('Set the CMS password in the extension settings') + const base = String(ctx.settings.get('baseurl') || DEFAULT_BASE).trim().replace(/\/+$/, '') + return new Session(base, username, password) +} + +// --------------------------------------------------------------------------- +// Planning +// --------------------------------------------------------------------------- + +const norm = (s) => String(s || '').toLowerCase().replace(/\s+/g, ' ').trim() + +/** The CMS row a canvas's field names: an assignment number, a project name, or "Client / Project". */ +function findRow(rows, value) { + const v = norm(value) + if (/^\d+$/.test(v)) return rows.find((r) => r.assignmentId === v) || null + return rows.find((r) => norm(r.project) === v) || rows.find((r) => norm(`${r.client} / ${r.project}`) === v) || null +} + +async function readLedger(ctx, week) { + try { + return JSON.parse((await ctx.files.repo.readText(`sent/${week}.json`)) || '{}') || {} + } catch { + return {} + } +} + +/** Everything sending would do: { lines, cms (by assignment|date → cell) }. */ +async function plan(ctx, sheet, s) { + // CMS opens a day only on that day, so a week that hasn't started yet + // (usually the second one: the Devlog week's Sunday) isn't asked for. + const today = ymd(new Date()) + const sundays = [...new Set([cmsSunday(sheet.week), cmsSunday(addDays(sheet.week, 6))])].filter((sun) => sun <= today) + const weeks = [] + for (const sun of sundays) weeks.push(await s.week(sun)) + // A week still ahead has no page to read yet, but its assignments still + // need names: take them from this week. + const listed = [...weeks] + if (sundays.length < 2 && !sundays.includes(cmsSunday(today))) listed.push(await s.week(cmsSunday(today))) + const rows = new Map() // assignmentId → { client, project } + const cells = new Map() // `${assignmentId}|${date}` → cell + for (const w of listed) for (const r of w.rows) if (!rows.has(r.assignmentId)) rows.set(r.assignmentId, { ...r, cells: undefined }) + for (const w of weeks) for (const r of w.rows) for (const c of r.cells) cells.set(`${r.assignmentId}|${c.date}`, c) + const allRows = [...rows.values()] + const ledger = await readLedger(ctx, sheet.week) + + const lines = [] + const want = new Map() // key → { minutes, entryIds, notes } + for (const e of sheet.entries) { + const field = e.fields.assignment + const row = field ? findRow(allRows, field) : null + if (!row) { + lines.push({ + id: `skip:${e.id}`, + entryIds: [e.id], + date: e.date, + start: e.start, + minutes: e.minutes, + target: '', + description: e.task, + action: 'skip', + reason: field ? `"${field}" is not one of your CMS assignments` : 'No CMS assignment on this task or the canvases above it' + }) + continue + } + const key = `${row.assignmentId}|${e.date}` + const w = want.get(key) || { minutes: 0, entryIds: [], notes: [] } + w.minutes += e.minutes + w.entryIds.push(e.id) + if (e.note) w.notes.push(e.note) + want.set(key, w) + } + // Days sent before that no longer have time go back to 0. + for (const key of Object.keys(ledger)) if (!want.has(key)) want.set(key, { minutes: 0, entryIds: [], notes: [], clearing: true }) + + for (const [key, w] of [...want].sort(([a], [b]) => a.localeCompare(b))) { + const [assignmentId, date] = key.split('|') + const cell = cells.get(key) + const row = rows.get(assignmentId) + const label = row ? `${row.client} / ${row.project}` : `assignment ${assignmentId}` + const base = { id: key, entryIds: w.entryIds, date, minutes: w.minutes, target: label } + if (cell && cell.minutes === w.minutes) lines.push({ ...base, action: 'unchanged', description: `${hoursText(w.minutes)} h` }) + else if (date > today) lines.push({ ...base, action: 'skip', reason: `CMS takes hours for ${date} on the day; send again then` }) + else if (!cell || !cell.timesheetId) lines.push({ ...base, action: 'skip', reason: 'CMS has no hours for this assignment on this day (outside its dates?)' }) + else + lines.push({ + ...base, + action: w.minutes === 0 ? 'delete' : cell.minutes === 0 ? 'create' : 'update', + description: `${hoursText(cell.minutes)} → ${hoursText(w.minutes)} h${w.notes.length ? ` · ${w.notes.join('; ')}` : ''}`, + timesheetId: cell.timesheetId, + notes: w.notes + }) + } + return lines +} + +const publicLine = ({ timesheetId: _t, notes: _n, ...line }) => line + +exports.activate = (ctx) => { + // Also the settings page's Test button. + ctx.commands.register('check', async () => { + const s = await session(ctx) + const week = await s.week(cmsSunday(ymd(new Date()))) + return `Logged in to CMS as ${s.username}: ${week.rows.length} assignment${week.rows.length === 1 ? '' : 's'} this week` + }) + + ctx.commands.register('assignments', async () => { + const s = await session(ctx) + const week = await s.week(cmsSunday(ymd(new Date()))) + return week.rows.length ? week.rows.map((r) => `${r.assignmentId}: ${r.client} / ${r.project}`).join(' · ') : 'No assignments this week' + }) + + ctx.destinations.register('hours', { + async preview(sheet) { + return (await plan(ctx, sheet, await session(ctx))).map(publicLine) + }, + + async send(sheet) { + const s = await session(ctx) + const lines = await plan(ctx, sheet, s) + const ledger = await readLedger(ctx, sheet.week) + const done = [] + const failed = [] + const count = { create: 0, update: 0, delete: 0 } + let minutes = 0 + for (const line of lines) { + if (line.action === 'unchanged') minutes += line.minutes + if (!(line.action in count)) continue + try { + await s.update(line.timesheetId, line.minutes, line.notes.length ? line.notes.join('; ') : null) + if (line.minutes) ledger[line.id] = { minutes: line.minutes, timesheetId: line.timesheetId } + else delete ledger[line.id] + await ctx.files.repo.write(`sent/${sheet.week}.json`, JSON.stringify(ledger, null, 2) + '\n') + count[line.action]++ + minutes += line.minutes + done.push(line.id) + } catch (err) { + failed.push({ lineId: line.id, error: err instanceof Error ? err.message : String(err) }) + } + } + // Days already right count as sent (for the ledger), so clearing them later works. + for (const line of lines) if (line.action === 'unchanged' && line.minutes && !ledger[line.id]) ledger[line.id] = { minutes: line.minutes } + await ctx.files.repo.write(`sent/${sheet.week}.json`, JSON.stringify(ledger, null, 2) + '\n') + const parts = [] + if (count.create) parts.push(`${count.create} day${count.create === 1 ? '' : 's'} filled in`) + if (count.update) parts.push(`${count.update} changed`) + if (count.delete) parts.push(`${count.delete} cleared`) + if (!parts.length) parts.push('nothing to change') + return { done, failed, summary: `${parts.join(', ')} (${hm(minutes)} in CMS this week)` } + } + }) +} + +exports._internal = { parseWeek, parseForm, findRow, cmsSunday, ref, hoursText } diff --git a/builtin-extensions/devlog-focus/devlog-extension.json b/builtin-extensions/devlog-focus/devlog-extension.json new file mode 100644 index 0000000..5a05eb3 --- /dev/null +++ b/builtin-extensions/devlog-focus/devlog-extension.json @@ -0,0 +1,10 @@ +{ + "name": "devlog-focus", + "displayName": "Window tracking", + "version": "1.1.0", + "description": "Records which window is in front (app and title) while this computer is unlocked, for screen time in the timeline, review and summary. Kept per machine in the devlog. Runs unrestricted: it starts a small helper (PowerShell, osascript or xdotool) to read the window in front.", + "api": "^1.2.0", + "main": "main.js", + "permissions": { "unrestricted": true }, + "appendOnly": ["**/*.jsonl"] +} diff --git a/builtin-extensions/devlog-focus/main.js b/builtin-extensions/devlog-focus/main.js new file mode 100644 index 0000000..1b2f5a1 --- /dev/null +++ b/builtin-extensions/devlog-focus/main.js @@ -0,0 +1,265 @@ +/** + * devlog-focus: window tracking as an extension. + * + * It runs unrestricted (you are asked whether you trust it), because + * reading the window in front needs a small platform helper: PowerShell with + * GetForegroundWindow on Windows, an osascript loop on macOS (window titles + * need the Accessibility permission), xdotool on Linux if installed. + * + * Each change is appended to a JSON-lines file per machine and day in its + * own folder in the devlog, and handed back to the app's timeline, review + * and summary: + * + * extensions/builtin.devlog-focus//YYYY/MM/YYYY-MM-DD.jsonl + * {"t":"2026-09-26T09:14:03.120Z","app":"Code","title":"store.ts — devlog"} + * + * Nothing is recorded while the machine is locked or asleep. + */ +const { EventEmitter } = require('node:events') + +const FILE_RE = /^([^/]+)\/(\d{4})\/(\d{2})\/(\d{4}-\d{2}-\d{2})\.jsonl$/ + +const pad = (n) => String(n).padStart(2, '0') +const localDate = (d) => `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}` + +function fileFor(machine, iso) { + const date = localDate(new Date(iso)) + return `${machine}/${date.slice(0, 4)}/${date.slice(5, 7)}/${date}.jsonl` +} + +// --------------------------------------------------------------------------- +// The platform helper +// --------------------------------------------------------------------------- + +const WINDOWS_SCRIPT = ` +$ErrorActionPreference = 'SilentlyContinue' +Add-Type @" +using System; +using System.Runtime.InteropServices; +using System.Text; +public class DevlogFG { + [DllImport("user32.dll")] public static extern IntPtr GetForegroundWindow(); + [DllImport("user32.dll", CharSet = CharSet.Unicode)] public static extern int GetWindowText(IntPtr h, StringBuilder s, int n); + [DllImport("user32.dll")] public static extern uint GetWindowThreadProcessId(IntPtr h, out uint pid); +} +"@ +$last = '' +while ($true) { + $h = [DevlogFG]::GetForegroundWindow() + $sb = New-Object System.Text.StringBuilder 1024 + [void][DevlogFG]::GetWindowText($h, $sb, 1024) + $procId = 0 + [void][DevlogFG]::GetWindowThreadProcessId($h, [ref]$procId) + $name = '' + if ($procId -ne 0) { $p = Get-Process -Id $procId; if ($p) { $name = $p.ProcessName } } + $title = $sb.ToString() + $key = $name + '|' + $title + if ($key -ne $last) { + $last = $key + $o = @{ app = $name; title = $title } | ConvertTo-Json -Compress + [Console]::Out.WriteLine($o) + [Console]::Out.Flush() + } + Start-Sleep -Milliseconds 1000 +} +` + +const MAC_SCRIPT = ` +set lastKey to "" +repeat + set n to "" + set t to "" + try + tell application "System Events" + set p to first application process whose frontmost is true + set n to name of p + try + set t to name of front window of p + end try + end tell + end try + set k to n & "|" & t + if k is not lastKey then + set lastKey to k + log k + end if + delay 1 +end repeat +` + +/** Emits `change` with { app, title } whenever the window in front changes. */ +class ForegroundWatcher extends EventEmitter { + constructor() { + super() + this.child = null + this.timer = null + this.last = null + this.stopped = true + } + + start() { + this.stopped = false + if (process.platform === 'win32') this.startWindows() + else if (process.platform === 'darwin') this.startMac() + else this.startLinux() + } + + stop() { + this.stopped = true + if (this.timer) clearInterval(this.timer) + this.timer = null + if (this.child) { + this.child.kill() + this.child = null + } + } + + emitChange(info) { + const key = `${info.app}|${info.title}` + if (key === this.last) return + this.last = key + this.emit('change', info) + } + + startWindows() { + const encoded = Buffer.from(WINDOWS_SCRIPT, 'utf16le').toString('base64') + this.spawnHelper('powershell.exe', ['-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-EncodedCommand', encoded], 'stdout', (line) => { + try { + const o = JSON.parse(line) + this.emitChange({ app: String(o.app || ''), title: String(o.title || '') }) + } catch { + // not a line from the helper + } + }) + } + + startMac() { + this.spawnHelper('osascript', ['-e', MAC_SCRIPT], 'stderr', (line) => { + const idx = line.indexOf('|') + if (idx < 0) return + this.emitChange({ app: line.slice(0, idx), title: line.slice(idx + 1) }) + }) + } + + startLinux() { + const { execFile } = require('node:child_process') + const { readFileSync } = require('node:fs') + execFile('xdotool', ['--version'], (err) => { + if (err || this.stopped) return + this.timer = setInterval(() => { + execFile('xdotool', ['getactivewindow', 'getwindowname', 'getwindowpid'], { timeout: 2000 }, (e, stdout) => { + if (e) return + const [title = '', pid = ''] = stdout.split('\n') + let app = '' + try { + app = readFileSync(`/proc/${pid.trim()}/comm`, 'utf8').trim() + } catch { + // the process is gone + } + this.emitChange({ app, title }) + }) + }, 2000) + }) + } + + spawnHelper(cmd, args, stream, onLine) { + const { spawn } = require('node:child_process') + let child + try { + child = spawn(cmd, args, { stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true }) + } catch { + return + } + this.child = child + let buf = '' + child[stream].setEncoding('utf8') + child[stream].on('data', (chunk) => { + buf += chunk + let nl + while ((nl = buf.indexOf('\n')) >= 0) { + const line = buf.slice(0, nl).replace(/\r$/, '') + buf = buf.slice(nl + 1) + if (line.trim()) onLine(line) + } + }) + child.on('error', () => undefined) + child.on('exit', () => { + this.child = null + // Restart after a pause unless told to stop. + if (!this.stopped) setTimeout(() => !this.stopped && this.start(), 15_000) + }) + } +} + +// --------------------------------------------------------------------------- +// The extension +// --------------------------------------------------------------------------- + +/** + * Wire window changes to files and back. `watcher` is anything with + * start/stop that emits `change` ({ app, title }); tests pass a fake. + */ +function run(ctx, watcher) { + let last = null + let paused = false + let writes = Promise.resolve() + + const record = (t, app, title) => { + const line = JSON.stringify({ t, app, title }) + '\n' + // Keep lines in order even if two changes arrive at once. + writes = writes.then(() => ctx.files.repo.append(fileFor(ctx.machine, t), line)).catch((err) => console.error('devlog-focus: write failed', err)) + } + + watcher.on('change', (w) => { + // The lock screen and waking up shuffle windows around; that is not work. + if (paused) return + last = { app: String(w.app || '').slice(0, 200), title: String(w.title || '').slice(0, 1000) } + record(new Date().toISOString(), last.app, last.title) + }) + + ctx.activity.on((n) => { + if (n.type === 'pause' && (n.reason === 'locked' || n.reason === 'suspended')) paused = true + else if (n.type === 'resume') { + paused = false + // Back from a lock, sleep or idle spell: the window in front may not + // have changed, so say again what it is, or its time would not resume. + if (last) record(n.t, last.app, last.title) + } + }) + + ctx.provide.focus(async (fromDate, toDate) => { + await writes + const events = [] + for (const f of await ctx.files.repo.list()) { + const m = FILE_RE.exec(f.path) + if (!m || m[4] < fromDate || m[4] > toDate) continue + const text = (await ctx.files.repo.readText(f.path)) || '' + for (const line of text.split('\n')) { + if (!line.trim()) continue + try { + const o = JSON.parse(line) + if (typeof o.t === 'string') events.push({ t: o.t, app: String(o.app || ''), title: String(o.title || ''), machine: m[1] }) + } catch { + // A torn last line (a crash mid-write): skip it. + } + } + } + return events + }) + + watcher.start() + return watcher +} + +let running = null + +exports.activate = (ctx) => { + running = run(ctx, new ForegroundWatcher()) +} + +exports.deactivate = () => { + running?.stop() + running = null +} + +exports.run = run diff --git a/builtin-extensions/devlog-jira/devlog-extension.json b/builtin-extensions/devlog-jira/devlog-extension.json new file mode 100644 index 0000000..9dd5989 --- /dev/null +++ b/builtin-extensions/devlog-jira/devlog-extension.json @@ -0,0 +1,61 @@ +{ + "name": "devlog-jira", + "displayName": "Jira worklogs", + "version": "1.1.0", + "description": "Sends a finished timesheet to Jira as worklogs, one per entry, on the issue set on the task (or a canvas above it). Sending again only creates, updates or deletes what changed.", + "api": "^1.3.0", + "main": "main.js", + "contributes": { + "canvasFields": [ + { + "key": "issue", + "label": "Jira issue", + "placeholder": "ACME-123", + "description": "Time on this canvas, and on canvases inside it that don't set their own, is logged on this issue." + } + ], + "settings": [ + { + "key": "baseurl", + "label": "Jira address", + "type": "url", + "required": true, + "placeholder": "https://yourcompany.atlassian.net", + "description": "The address you open Jira at." + }, + { + "key": "email", + "label": "Account email", + "type": "email", + "placeholder": "you@example.com", + "description": "Jira Cloud: the email you sign in with. Jira Data Center: leave empty and use a personal access token below." + } + ], + "secrets": [ + { + "key": "token", + "label": "API token", + "required": true, + "description": "Jira Cloud: create one at id.atlassian.com \u2192 Security \u2192 API tokens. Data Center: a personal access token from your Jira profile." + } + ], + "commands": [ + { + "id": "check", + "label": "Check the Jira connection" + } + ], + "destinations": [ + { + "id": "worklogs", + "label": "Jira" + } + ], + "check": "check" + }, + "permissions": { + "network": [ + "*.atlassian.net" + ] + } +} diff --git a/builtin-extensions/devlog-jira/main.js b/builtin-extensions/devlog-jira/main.js new file mode 100644 index 0000000..2418936 --- /dev/null +++ b/builtin-extensions/devlog-jira/main.js @@ -0,0 +1,176 @@ +/** + * devlog-jira: a finished timesheet → Jira worklogs. + * + * Each timesheet entry whose task (or a canvas above it) has a Jira issue + * becomes one worklog on that issue: its start time, its duration, and its + * note as the comment. What was sent is kept per week in this extension's + * synced folder (sent/.json: entry id → issue, worklog id, and what + * was sent), so sending again, from any machine, only creates, updates or + * deletes what changed. + * + * Talks to the REST API v2 (Jira Cloud and Data Center): Basic auth with an + * account email and API token (Cloud), or a personal access token (Data + * Center, email left empty). + */ +const ISSUE_RE = /^[A-Z][A-Z0-9_]*-\d+$/ + +const pad = (n, w = 2) => String(n).padStart(w, '0') + +/** Jira's timestamp: local time with its UTC offset, e.g. 2026-09-22T09:00:00.000+0200. */ +function jiraTime(iso) { + const d = new Date(iso) + const off = -d.getTimezoneOffset() + const sign = off >= 0 ? '+' : '-' + const abs = Math.abs(off) + return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}.000${sign}${pad(Math.floor(abs / 60))}${pad(abs % 60)}` +} + +const hm = (minutes) => `${Math.floor(minutes / 60)}:${pad(minutes % 60)}` + +async function config(ctx) { + const base = String(ctx.settings.get('baseurl') || '').trim().replace(/\/+$/, '') + if (!base) throw new Error('Set the Jira URL in the extension settings') + if (!/^https:\/\/[^/]+/.test(base) && !/^http:\/\/(localhost|127\.0\.0\.1)(:\d+)?$/.test(base)) throw new Error('The Jira URL must start with https://') + const token = await ctx.secrets.get('token') + if (!token) throw new Error('Set the Jira token in the extension settings') + const email = String(ctx.settings.get('email') || '').trim() + const auth = email ? `Basic ${Buffer.from(`${email}:${token}`).toString('base64')}` : `Bearer ${token}` + return { base, auth } +} + +async function jira(cfg, method, path, body) { + const res = await fetch(cfg.base + path, { + method, + headers: { Authorization: cfg.auth, Accept: 'application/json', ...(body ? { 'Content-Type': 'application/json' } : {}) }, + body: body ? JSON.stringify(body) : undefined + }) + if (!res.ok) { + let detail = '' + try { + const j = await res.json() + detail = [...(j.errorMessages || []), ...Object.values(j.errors || {})].join('; ') + } catch { + // not JSON + } + const err = new Error(`Jira ${method} ${path}: HTTP ${res.status}${detail ? ` (${detail})` : ''}`) + err.status = res.status + throw err + } + return res.status === 204 ? null : res.json().catch(() => null) +} + +async function readLedger(ctx, week) { + const text = await ctx.files.repo.readText(`sent/${week}.json`) + if (!text) return {} + try { + return JSON.parse(text) || {} + } catch { + return {} + } +} + +const writeLedger = (ctx, week, ledger) => ctx.files.repo.write(`sent/${week}.json`, JSON.stringify(ledger, null, 2) + '\n') + +/** What each entry should be in Jira, or why it is not sent there. */ +function wanted(entry) { + const raw = String(entry.fields.issue || '').trim().toUpperCase() + if (!raw) return { skip: 'No Jira issue on this task or the canvases above it' } + if (!ISSUE_RE.test(raw)) return { skip: `"${raw}" is not a Jira issue key` } + return { issue: raw, started: jiraTime(entry.start), seconds: entry.minutes * 60, comment: entry.note || '' } +} + +/** Plan: one line per thing to do (or not do), against what was sent before. */ +function plan(sheet, ledger) { + const lines = [] + const ids = new Set() + for (const e of sheet.entries) { + ids.add(e.id) + const w = wanted(e) + const prev = ledger[e.id] + const base = { entryIds: [e.id], date: e.date, start: e.start, minutes: e.minutes, description: e.note ? `${e.task} · ${e.note}` : e.task } + if (w.skip) { + if (prev) lines.push({ ...base, id: `${e.id}:old`, target: prev.issue, action: 'delete', reason: 'no longer mapped to an issue', prev }) + lines.push({ ...base, id: e.id, target: '', action: 'skip', reason: w.skip }) + continue + } + if (prev && prev.issue !== w.issue) { + lines.push({ ...base, id: `${e.id}:old`, target: prev.issue, action: 'delete', reason: `moved to ${w.issue}`, prev }) + lines.push({ ...base, id: e.id, target: w.issue, action: 'create', want: w }) + } else if (!prev) lines.push({ ...base, id: e.id, target: w.issue, action: 'create', want: w }) + else if (prev.started === w.started && prev.seconds === w.seconds && prev.comment === w.comment) lines.push({ ...base, id: e.id, target: w.issue, action: 'unchanged' }) + else lines.push({ ...base, id: e.id, target: w.issue, action: 'update', want: w, prev }) + } + for (const [id, prev] of Object.entries(ledger)) { + if (!ids.has(id)) lines.push({ id: `gone:${id}`, entryIds: [id], date: prev.started.slice(0, 10), minutes: prev.seconds / 60, target: prev.issue, description: 'removed from the timesheet', action: 'delete', prev }) + } + return lines +} + +const publicLine = ({ want: _w, prev: _p, ...line }) => line + +exports.activate = (ctx) => { + // Also the settings page's Test button: the returned text is shown there. + ctx.commands.register('check', async () => { + const me = await jira(await config(ctx), 'GET', '/rest/api/2/myself') + return `Connected to Jira as ${(me && (me.displayName || me.name || me.emailAddress)) || 'you'}` + }) + + ctx.destinations.register('worklogs', { + async preview(sheet) { + return plan(sheet, await readLedger(ctx, sheet.week)).map(publicLine) + }, + + async send(sheet) { + const cfg = await config(ctx) + const ledger = await readLedger(ctx, sheet.week) + const lines = plan(sheet, ledger) + const done = [] + const failed = [] + const count = { create: 0, update: 0, delete: 0, unchanged: 0 } + let minutes = 0 + const issues = new Set() + for (const line of lines) { + try { + if (line.action === 'create') { + const w = line.want + const res = await jira(cfg, 'POST', `/rest/api/2/issue/${encodeURIComponent(w.issue)}/worklog`, { started: w.started, timeSpentSeconds: w.seconds, comment: w.comment }) + ledger[line.entryIds[0]] = { ...w, worklogId: String(res && res.id) } + } else if (line.action === 'update') { + const w = line.want + await jira(cfg, 'PUT', `/rest/api/2/issue/${encodeURIComponent(w.issue)}/worklog/${encodeURIComponent(line.prev.worklogId)}`, { started: w.started, timeSpentSeconds: w.seconds, comment: w.comment }) + ledger[line.entryIds[0]] = { ...w, worklogId: line.prev.worklogId } + } else if (line.action === 'delete') { + try { + await jira(cfg, 'DELETE', `/rest/api/2/issue/${encodeURIComponent(line.prev.issue)}/worklog/${encodeURIComponent(line.prev.worklogId)}`) + } catch (err) { + if (err.status !== 404) throw err // already gone in Jira: fine + } + if (ledger[line.entryIds[0]] && ledger[line.entryIds[0]].worklogId === line.prev.worklogId) delete ledger[line.entryIds[0]] + } else continue + // Record progress as it happens, so a failure halfway is not sent twice. + await writeLedger(ctx, sheet.week, ledger) + count[line.action]++ + done.push(line.id) + } catch (err) { + failed.push({ lineId: line.id, error: err instanceof Error ? err.message : String(err) }) + } + } + for (const l of lines) { + if (l.action === 'unchanged') count.unchanged++ + if ((l.action === 'create' || l.action === 'update' || l.action === 'unchanged') && !failed.some((f) => f.lineId === l.id)) { + minutes += l.minutes + issues.add(l.target) + } + } + const parts = [] + if (count.create) parts.push(`${count.create} worklog${count.create === 1 ? '' : 's'} created`) + if (count.update) parts.push(`${count.update} updated`) + if (count.delete) parts.push(`${count.delete} deleted`) + if (count.unchanged) parts.push(`${count.unchanged} unchanged`) + if (!parts.length) parts.push('nothing to send') + return { done, failed, summary: `${parts.join(', ')} (${hm(minutes)} on ${issues.size} issue${issues.size === 1 ? '' : 's'})` } + } + }) +} + +exports._internal = { jiraTime, plan } diff --git a/builtin-extensions/devlog-time/devlog-extension.json b/builtin-extensions/devlog-time/devlog-extension.json new file mode 100644 index 0000000..47346c6 --- /dev/null +++ b/builtin-extensions/devlog-time/devlog-extension.json @@ -0,0 +1,138 @@ +{ + "name": "devlog-time", + "displayName": "Time tracking", + "version": "1.1.0", + "description": "Tasks you record time against: one active task at a time, paused while the computer is locked, idle or asleep. Brings the Start/Stop item, the task type, posting as a task, and the time behind the timeline, review, summary and timesheet.", + "api": "^1.7.0", + "main": "dist/main.js", + "contributes": { + "nodeTypes": [ + { + "id": "task", + "label": "Task", + "icon": "◉", + "placeholder": "posting here makes it the active task" + } + ], + "settings": [ + { + "key": "idle_minutes", + "label": "Pause after this many minutes without input", + "type": "number", + "placeholder": "10", + "description": "0 never pauses for idleness (locking the screen or sleep still does)." + }, + { + "key": "in_repo", + "label": "Keep time in the devlog", + "type": "checkbox", + "description": "On: synced with the devlog, so every machine's time adds up. Off: kept on this machine only." + } + ], + "commands": [ + { + "id": "stop", + "label": "Stop the clock", + "keybinding": "Mod+Shift+.", + "menus": [ + "tray" + ] + }, + { + "id": "switch", + "label": "Start a task…", + "menus": [ + "tray" + ] + }, + { + "id": "start", + "label": "Start this task", + "nodeType": "task", + "menus": [ + "canvas" + ] + }, + { + "id": "posttask", + "label": "Posts as a task", + "keybinding": "Mod+Shift+Enter", + "post": true, + "tag": "task" + }, + { + "id": "maketask", + "label": "Make task", + "menus": [ + "block" + ] + }, + { + "id": "open-summary", + "label": "Open the Summary", + "keybinding": "Mod+Shift+H" + } + ], + "views": [ + { + "id": "timesheet", + "title": "Timesheet", + "entry": "dist/views/timesheet.html", + "placement": "page", + "icon": "▤" + }, + { + "id": "summary", + "title": "Summary", + "entry": "dist/views/summary.html", + "placement": "page", + "icon": "Σ" + }, + { + "id": "status", + "title": "Time tracking", + "entry": "dist/views/status.html", + "placement": "statusbar" + }, + { + "id": "picker", + "title": "Start a task", + "entry": "dist/views/picker.html", + "placement": "popover" + }, + { + "id": "header", + "title": "Start or stop this task", + "entry": "dist/views/header.html", + "placement": "canvasHeader", + "nodeType": "task" + } + ], + "canvasFields": [ + { + "key": "target_week", + "label": "Hours per week", + "type": "number", + "placeholder": "20", + "description": "A weekly target for the hours under this canvas, shown on the Timesheet. Canvases inside it keep their own targets.", + "inherited": false + }, + { + "key": "target_month", + "label": "Hours per month", + "type": "number", + "placeholder": "168", + "description": "A monthly target for the hours under this canvas, shown on the Timesheet.", + "inherited": false + } + ] + }, + "permissions": { + "read": true, + "write": true, + "send": true + }, + "appendOnly": [ + "**/*.jsonl" + ] +} diff --git a/builtin-extensions/devlog-time/src/main.ts b/builtin-extensions/devlog-time/src/main.ts new file mode 100644 index 0000000..ddf7cd0 --- /dev/null +++ b/builtin-extensions/devlog-time/src/main.ts @@ -0,0 +1,156 @@ +/** + * devlog-time: time tracking, as an extension. + * + * It brings the task node type (a canvas you record time against), the + * clock (one active task; paused while the machine is locked, idle or + * asleep), the Start/Stop item in the status bar and on task canvases, the + * commands (Stop, Start a task…, post as a task, turn a block into a task), + * the tray label, and the time events the app's timeline, review, summary + * and timesheet are drawn from. See docs/TIME-EXTENSION.md. + */ +import type { CommandContext, DevlogContext } from '@devlog/extension-api' +import { parseDurationMarker } from '@devlog/core' +import { Clock, TASK_TYPE_ID, type ClockStatus } from './tracker' +import { Timesheets, asMeta } from './timesheets' + +let clock: Clock | null = null + +export async function activate(ctx: DevlogContext): Promise { + const c = new Clock(ctx) + clock = c + + const idle = (): number => { + const n = Number(ctx.settings.get('idle_minutes') ?? '10') + return Number.isFinite(n) && n >= 0 ? n : 10 + } + ctx.activity.idleAfter(idle()) + ctx.settings.onChange(() => ctx.activity.idleAfter(idle())) + ctx.activity.on((n) => c.notice(n)) + ctx.app.keepRunning(true) + ctx.provide.activity((from, to) => c.events(from, to)) + + // What the views, the tray and the sidebar show. + const publish = (st: ClockStatus): void => { + ctx.app.setTrayLabel(st.active ? `${st.label}${st.paused ? ' (paused)' : ''}` : 'no active task') + ctx.ui.highlight(st.active) + for (const v of ['status', 'header', 'picker']) ctx.views.post(v, st) + } + c.onChange(publish) + + // Posting on a task makes it the active task (a block with a duration records the past instead). + ctx.devlog.onBlockAdded((ev) => { + if (ev.block.kind && ev.block.kind !== 'note') return + if (parseDurationMarker(ev.block.markdown) !== null) return + void c + .refreshCanvases() + .then(() => (c.isTask(ev.canvasId) && c.status().active !== ev.canvasId ? c.setTask(ev.canvasId, ev.block.id) : undefined)) + .catch(() => undefined) + }) + + /** Turn a block into a task (just inside its canvas) and start it. */ + const makeTask = async (context: CommandContext): Promise => { + if (!context.canvasId || !context.date || !context.blockId) throw new Error('Run this on a block') + const { canvas } = await ctx.devlog.promote(context.canvasId, context.date, context.blockId, { type: TASK_TYPE_ID }) + await c.setTask(canvas.id, context.blockId) + return null + } + + ctx.commands.register('stop', () => c.setTask(null)) + ctx.commands.register('start', async (context) => { + if (!context.canvasId) throw new Error('Open a task first') + await c.setTask(context.canvasId) + }) + ctx.commands.register('switch', async (context) => { + await c.refreshCanvases() + const items = c.tasks(context.canvasId).map((t) => ({ id: t.id, label: t.title, hint: t.path })) + if (c.status().active) items.unshift({ id: '', label: 'Stop the clock', hint: c.status().label ?? '' }) + const id = await ctx.ui.pick(items, { placeholder: 'Start a task…' }) + if (id === null) return + await c.setTask(id || null) + }) + ctx.commands.register('open-summary', () => ctx.ui.openPage('summary')) + ctx.commands.register('posttask', makeTask) + ctx.commands.register('maketask', makeTask) + + // Views: the status bar item, its picker, and the button on task canvases. + const handler = async (method: string, args: unknown[]): Promise => { + switch (method) { + case 'status': + return c.status() + case 'tasks': + await c.refreshCanvases() + return c.tasks(typeof args[0] === 'string' ? args[0] : undefined) + case 'task': { + // A task, if the canvas is one: for the Start button on it. + await c.refreshCanvases() + const id = typeof args[0] === 'string' ? args[0] : '' + return c.isTask(id) ? (c.tasks(id).find((t) => t.id === id) ?? null) : null + } + case 'start': + await c.setTask(String(args[0])) + return c.status() + case 'stop': + await c.setTask(null) + return c.status() + case 'newTask': { + const title = String(args[0] ?? '').trim() + if (!title) throw new Error('Give the task a name') + const parentId = typeof args[1] === 'string' && args[1] ? args[1] : null + const task = await ctx.devlog.createCanvas({ title, parentId, type: TASK_TYPE_ID }) + await c.setTask(task.id) + return c.status() + } + default: + throw new Error(`No method ${method}`) + } + } + for (const v of ['status', 'header', 'picker']) ctx.views.handle(v, handler) + + // The Timesheet and Summary pages. + const sheets = new Timesheets(ctx) + const str = (v: unknown): string => { + if (typeof v !== 'string') throw new Error('Expected text') + return v + } + const destination = (v: unknown): { extension: string; id: string } => { + const d = (v ?? {}) as { extension?: unknown; id?: unknown } + return { extension: str(d.extension), id: str(d.id) } + } + const pages = async (method: string, args: unknown[]): Promise => { + switch (method) { + case 'canvases': { + const kept = await sheets.canvas() + return asMeta((await ctx.devlog.canvases()).filter((x) => x.id !== kept)) + } + case 'range': + return (await ctx.devlog.range(str(args[0]), str(args[1]))).map((d) => ({ canvasId: d.canvasId, date: d.date, blocks: d.blocks })) + case 'activity': + return ctx.devlog.activity(str(args[0]), str(args[1])) + case 'timesheet': + return sheets.read(str(args[0])) + case 'saveTimesheet': + return sheets.save(args[0]) + case 'destinations': + return ctx.destinations.list() + case 'previewSend': + return sheets.preview(destination(args[0]), str(args[1])) + case 'send': + return sheets.send(destination(args[0]), str(args[1])) + case 'pref': + return (await ctx.files.local.readText(`prefs/${str(args[0]).replace(/[^a-z0-9._-]/gi, '')}`)) ?? null + case 'setPref': + await ctx.files.local.write(`prefs/${str(args[0]).replace(/[^a-z0-9._-]/gi, '')}`, str(args[1])) + return null + default: + throw new Error(`No method ${method}`) + } + } + for (const v of ['timesheet', 'summary']) ctx.views.handle(v, pages) + + await c.start() +} + +export async function deactivate(): Promise { + await clock?.stop() + clock = null +} diff --git a/builtin-extensions/devlog-time/src/targets.ts b/builtin-extensions/devlog-time/src/targets.ts new file mode 100644 index 0000000..a0928bb --- /dev/null +++ b/builtin-extensions/devlog-time/src/targets.ts @@ -0,0 +1,70 @@ +/** + * Hour targets: weekly or monthly, on any canvas (canvas fields + * `target_week` and `target_month`). Each is measured on its own, against + * the timesheet hours under its canvas (the canvas and everything inside + * it) in its own period; a target is not taken on by the canvases inside. + * So 168 h for a client in September and 20 h a week for a task under that + * client are two separate measures. See docs/TIME-EXTENSION.md. + */ +import { isWithin, localDate, type CanvasMeta, type TimesheetEntry } from '@devlog/core' + +export interface TargetRow { + canvasId: string + period: 'week' | 'month' + /** The week's Monday, or the month ("2026-09"). */ + key: string + /** Target, in minutes. */ + target: number + /** Timesheet minutes under the canvas in the period. */ + actual: number +} + +/** A target in hours from a field, or null (unset, not a number, not positive). */ +export function targetHours(raw: string | undefined): number | null { + const n = Number(String(raw ?? '').trim().replace(',', '.')) + return raw && Number.isFinite(n) && n > 0 ? n : null +} + +/** The months ("YYYY-MM") a week (by its Monday) touches. */ +export function monthsOfWeek(week: string): string[] { + const [y, m, d] = week.split('-').map(Number) + const out = new Set() + for (let i = 0; i < 7; i++) out.add(localDate(new Date(y, m - 1, d + i)).slice(0, 7)) + return [...out] +} + +/** Every week (by its Monday) that has a day in a month. */ +export function weeksOfMonth(month: string): string[] { + const [y, m] = month.split('-').map(Number) + const last = new Date(y, m, 0) + const out: string[] = [] + for (let d = new Date(y, m - 1, 1 - ((new Date(y, m - 1, 1).getDay() + 6) % 7)); d <= last; d = new Date(d.getFullYear(), d.getMonth(), d.getDate() + 7)) out.push(localDate(d)) + return out +} + +function minutesUnder(canvases: CanvasMeta[], canvasId: string, entries: TimesheetEntry[]): number { + return entries.reduce((n, e) => (e.canvasId === canvasId || isWithin(canvases, e.canvasId, canvasId) ? n + e.minutes : n), 0) +} + +/** + * The targets that apply to a week on screen: each canvas's weekly target + * for that week, and its monthly target for every month the week touches. + * `weekEntries` is the week's timesheet; `monthEntries` has every + * timesheet entry of those months (other weeks' saved or drafted sheets, + * and this one's), by month. + */ +export function targetsFor(canvases: CanvasMeta[], week: string, weekEntries: TimesheetEntry[], monthEntries: Map): TargetRow[] { + const rows: TargetRow[] = [] + for (const c of canvases) { + if (c.archived) continue + const perWeek = targetHours(c.fields?.target_week) + if (perWeek !== null) rows.push({ canvasId: c.id, period: 'week', key: week, target: Math.round(perWeek * 60), actual: minutesUnder(canvases, c.id, weekEntries) }) + const perMonth = targetHours(c.fields?.target_month) + if (perMonth === null) continue + for (const month of monthsOfWeek(week)) { + const inMonth = (monthEntries.get(month) ?? []).filter((e) => e.date.startsWith(month)) + rows.push({ canvasId: c.id, period: 'month', key: month, target: Math.round(perMonth * 60), actual: minutesUnder(canvases, c.id, inMonth) }) + } + } + return rows +} diff --git a/builtin-extensions/devlog-time/src/timesheets.ts b/builtin-extensions/devlog-time/src/timesheets.ts new file mode 100644 index 0000000..5c47956 --- /dev/null +++ b/builtin-extensions/devlog-time/src/timesheets.ts @@ -0,0 +1,74 @@ +/** + * Timesheets: one block per week (on its Monday) in the Timesheets canvas, + * which this extension keeps: a readable table and the data in a fenced + * block (docs/TIMESHEETS.md). The first save adds the week's block; later + * saves edit it, so the history of the shuffling is kept. What was sent + * where is written inside it. + */ +import { canvasLabel, parseTimesheet, sanitizeTimesheet, serializeTimesheet, topLevelCanvasId, type CanvasMeta, type Timesheet } from '@devlog/core' +import type { DestinationInfo, DevlogContext, ExtensionBlock, ExtensionCanvas, SendResult } from '@devlog/extension-api' + +export const TIMESHEETS_KEY = 'timesheets' + +/** Extension canvases as the core helpers want them. */ +export function asMeta(list: ExtensionCanvas[]): CanvasMeta[] { + return list.map((c) => ({ id: c.id, title: c.title, parentId: c.parentId, task: c.task, ...(c.type ? { type: c.type } : {}), archived: c.archived, createdAt: '', updatedAt: '', repos: [], hasSurface: false, fields: { ...c.fields } })) +} + +export class Timesheets { + private canvasId: string | null = null + + constructor( + private readonly ctx: DevlogContext, + private readonly now: () => Date = () => new Date() + ) {} + + /** The Timesheets canvas (made on first use). */ + async canvas(): Promise { + if (!this.canvasId) this.canvasId = (await this.ctx.devlog.managedCanvas(TIMESHEETS_KEY, { title: 'Timesheets' })).id + return this.canvasId + } + + private async block(week: string): Promise<{ canvasId: string; block: ExtensionBlock | undefined }> { + const canvasId = await this.canvas() + const blocks = await this.ctx.devlog.blocks(canvasId, week) + return { canvasId, block: blocks.find((b) => b.kind === 'timesheet' && b.meta?.week === week && !b.parentId) } + } + + async read(week: string): Promise { + const { block } = await this.block(week) + return block ? parseTimesheet(block.markdown) : null + } + + async save(input: unknown): Promise { + const sheet = sanitizeTimesheet({ ...(input as object), updatedAt: this.now().toISOString() }) + const all = asMeta(await this.ctx.devlog.canvases()) + const markdown = serializeTimesheet( + sheet, + (id) => canvasLabel(all, id), + (id) => topLevelCanvasId(all, id) + ) + const { canvasId, block } = await this.block(sheet.week) + if (block) await this.ctx.devlog.editBlock(canvasId, sheet.week, block.id, markdown) + else await this.ctx.devlog.addBlock(canvasId, markdown, { kind: 'timesheet', date: sheet.week, meta: { week: sheet.week } }) + return sheet + } + + /** Send the saved week through a destination, and write what happened inside its block. */ + async send(to: Pick, week: string): Promise { + const { canvasId, block } = await this.block(week) + const sheet = block ? parseTimesheet(block.markdown) : null + if (!block || !sheet) throw new Error('Save the timesheet first') + const result = await this.ctx.destinations.send(to, sheet) + const label = (await this.ctx.destinations.list()).find((d) => d.extension === to.extension && d.id === to.id)?.label ?? to.id + const failed = result.failed.length ? ` ${result.failed.length} failed: ${result.failed.map((f) => f.error).join('; ')}` : '' + await this.ctx.devlog.addBlock(canvasId, `Sent to ${label}: ${result.summary}.${failed}`, { parentId: block.id, date: week, meta: { destination: to.id, to: to.extension } }) + return result + } + + async preview(to: Pick, week: string): ReturnType { + const sheet = await this.read(week) + if (!sheet) throw new Error('Save the timesheet first') + return this.ctx.destinations.preview(to, sheet) + } +} diff --git a/builtin-extensions/devlog-time/src/tracker.ts b/builtin-extensions/devlog-time/src/tracker.ts new file mode 100644 index 0000000..b9367c9 --- /dev/null +++ b/builtin-extensions/devlog-time/src/tracker.ts @@ -0,0 +1,207 @@ +/** + * The clock: one active task at a time, paused while the machine is locked, + * idle or asleep. Every change is appended to a JSON-lines file per machine + * and day, in the extension's own folder of the devlog (or on this machine + * only, if you choose), and handed to the app's views as time events: + * + * extensions/builtin.devlog-time//YYYY/MM/YYYY-MM-DD.jsonl + * {"t":"2026-09-29T09:14:03.120Z","type":"task","canvasId":"k2x9…","blockId":"a1b2"} + * + * Only what the clock does is written here: start, task, stop and + * heartbeats. Locks, idle and sleep are the app's to record (they are the + * machine's state, not the clock's); the app's views put the two together. + */ +import type { ActivityNotice, DevlogContext, ExtensionCanvas, ExtensionFiles, TimeEvent } from '@devlog/extension-api' + +export const TASK_TYPE_ID = 'task' +/** How often a running clock says it is still running (the app's views count up to the last one when a log just ends). */ +export const HEARTBEAT_MS = 5 * 60_000 + +const FILE_RE = /^([^/]+)\/(\d{4})\/(\d{2})\/(\d{4}-\d{2}-\d{2})\.jsonl$/ +const pad = (n: number): string => String(n).padStart(2, '0') +export const localDate = (d: Date): string => `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}` + +export interface ClockStatus { + /** The active task, or null when the clock is stopped. */ + active: string | null + /** "Client / Project / Task". */ + label: string | null + /** When time last started accruing (null while stopped or paused). */ + since: string | null + paused: ActivityNotice['reason'] | null +} + +interface SavedState { + active: string | null +} + +type Kind = TimeEvent['type'] + +export class Clock { + private active: string | null = null + private since: string | null = null + private paused: ActivityNotice['reason'] | null = null + private canvases: ExtensionCanvas[] = [] + private heartbeat: ReturnType | null = null + private writes: Promise = Promise.resolve() + private listeners: Array<(st: ClockStatus) => void> = [] + readonly taskType: string + + constructor( + private readonly ctx: DevlogContext, + private readonly now: () => Date = () => new Date() + ) { + this.taskType = `${ctx.id}/${TASK_TYPE_ID}` + } + + /** Where the events go: the devlog (synced), or this machine only. */ + private get store(): ExtensionFiles { + return this.ctx.settings.get('in_repo') === 'false' ? this.ctx.files.local : this.ctx.files.repo + } + + onChange(cb: (st: ClockStatus) => void): void { + this.listeners.push(cb) + } + + status(): ClockStatus { + return { active: this.active, label: this.active ? this.label(this.active) : null, since: this.since, paused: this.paused } + } + + /** "Client / Project / Task" for a canvas. */ + label(canvasId: string): string { + const byId = new Map(this.canvases.map((c) => [c.id, c])) + const parts: string[] = [] + const seen = new Set() + for (let c = byId.get(canvasId); c && !seen.has(c.id); c = c.parentId ? byId.get(c.parentId) : undefined) { + seen.add(c.id) + parts.unshift(c.title) + } + return parts.join(' / ') || canvasId + } + + isTask(canvasId: string): boolean { + return this.canvases.some((c) => c.id === canvasId && c.type === this.taskType && !c.archived) + } + + /** Tasks that can be started, as the picker lists them (the canvas given, and what is inside it, first). */ + tasks(near?: string): Array<{ id: string; title: string; label: string; path: string }> { + const rank = (c: ExtensionCanvas): number => (c.id === near ? 2 : c.parentId && c.parentId === near ? 1 : 0) + return this.canvases + .filter((c) => c.type === this.taskType && !c.archived) + .map((c) => ({ c, label: this.label(c.id) })) + .sort((a, b) => rank(b.c) - rank(a.c) || a.label.localeCompare(b.label)) + .map(({ c, label }) => ({ id: c.id, title: c.title, label, path: label.split(' / ').slice(0, -1).join(' / ') })) + } + + async refreshCanvases(): Promise { + this.canvases = await this.ctx.devlog.canvases() + return this.canvases + } + + /** Start: restore the task that was active when the app last closed (if it still exists). */ + async start(): Promise { + await this.refreshCanvases() + const saved = await this.loadState() + this.active = saved.active && this.isTask(saved.active) ? saved.active : null + if (this.active !== saved.active) await this.saveState() + await this.record('start', { canvasId: this.active }) + this.since = this.active && !this.paused ? this.now().toISOString() : null + this.heartbeat = setInterval(() => void this.beat(), HEARTBEAT_MS) + ;(this.heartbeat as { unref?: () => void }).unref?.() + this.emit() + } + + async stop(): Promise { + if (this.heartbeat) clearInterval(this.heartbeat) + this.heartbeat = null + await this.record('stop', {}) + await this.writes + } + + /** Make a task active (null stops the clock). */ + async setTask(canvasId: string | null, blockId?: string): Promise { + if (canvasId) { + await this.refreshCanvases() + if (!this.isTask(canvasId)) throw new Error('That is not a task') + } + this.active = canvasId + await this.saveState() + await this.record('task', { canvasId, ...(blockId ? { blockId } : {}) }) + this.since = canvasId && !this.paused ? this.now().toISOString() : null + this.emit() + } + + /** The machine was locked, went idle or to sleep (the app says so), or is back. */ + notice(n: ActivityNotice): void { + if (n.type === 'pause') { + this.paused = n.reason ?? 'locked' + this.since = null + } else if (n.type === 'resume') { + this.paused = null + this.since = this.active ? n.t : null + } else return + this.emit() + } + + /** Still running: a heartbeat (only while time accrues), and a check that the task still exists. */ + private async beat(): Promise { + if (this.paused) return + await this.record('heartbeat', {}) + if (this.active) { + await this.refreshCanvases().catch(() => undefined) + if (!this.isTask(this.active)) await this.setTask(null) + } + } + + private async record(type: Kind, fields: { canvasId?: string | null; blockId?: string }): Promise { + const t = this.now().toISOString() + const line = `${JSON.stringify({ t, type, ...fields })}\n` + const date = localDate(new Date(t)) + const file = `${this.ctx.machine}/${date.slice(0, 4)}/${date.slice(5, 7)}/${date}.jsonl` + const store = this.store + const write = this.writes.then(() => store.append(file, line)) + this.writes = write.catch(() => undefined) + await write + } + + /** Every machine's events between two local dates (inclusive). */ + async events(fromDate: string, toDate: string): Promise { + await this.writes + const out: TimeEvent[] = [] + const store = this.store + for (const f of await store.list()) { + const m = FILE_RE.exec(f.path) + if (!m || m[4] < fromDate || m[4] > toDate) continue + const text = (await store.readText(f.path)) ?? '' + for (const line of text.split('\n')) { + if (!line.trim()) continue + try { + const o = JSON.parse(line) as Partial + if (typeof o.t !== 'string' || !['start', 'task', 'stop', 'heartbeat'].includes(String(o.type))) continue + out.push({ t: o.t, type: o.type as Kind, ...(o.canvasId !== undefined ? { canvasId: o.canvasId } : {}), ...(o.blockId ? { blockId: o.blockId } : {}), machine: m[1] }) + } catch { + // A torn last line (a crash mid-write): skip it. + } + } + } + return out.sort((a, b) => a.t.localeCompare(b.t)) + } + + private emit(): void { + const st = this.status() + for (const cb of this.listeners) cb(st) + } + + private async loadState(): Promise { + try { + const raw = JSON.parse((await this.ctx.files.local.readText('state.json')) ?? '{}') as Partial + return { active: typeof raw.active === 'string' ? raw.active : null } + } catch { + return { active: null } + } + } + + private async saveState(): Promise { + await this.ctx.files.local.write('state.json', JSON.stringify({ active: this.active })) + } +} diff --git a/builtin-extensions/devlog-time/src/ui/Page.tsx b/builtin-extensions/devlog-time/src/ui/Page.tsx new file mode 100644 index 0000000..b4d18fb --- /dev/null +++ b/builtin-extensions/devlog-time/src/ui/Page.tsx @@ -0,0 +1,30 @@ +/** What both time pages share: the canvases, today's date, and a line for errors. */ +import { useEffect, useState } from 'react' +import { localDate } from '@devlog/core' +import type { CanvasMeta } from '@shared/types' +import { api } from './api' + +export function Page({ children }: { children: (props: { canvases: CanvasMeta[]; today: string; onError: (message: string) => void }) => React.ReactNode }): React.JSX.Element { + const [canvases, setCanvases] = useState(null) + const [error, setError] = useState(null) + const [today, setToday] = useState(() => localDate(new Date())) + useEffect(() => { + api.canvases().then(setCanvases, (err: Error) => setError(err.message)) + // Past midnight, today moves on. + const t = setInterval(() => setToday(localDate(new Date())), 60_000) + return () => clearInterval(t) + }, []) + return ( + <> + {error && ( +

+ {error}{' '} + +

+ )} + {canvases ? children({ canvases, today, onError: setError }) :

Loading…

} + + ) +} diff --git a/builtin-extensions/devlog-time/src/ui/SendDialog.tsx b/builtin-extensions/devlog-time/src/ui/SendDialog.tsx new file mode 100644 index 0000000..93953e8 --- /dev/null +++ b/builtin-extensions/devlog-time/src/ui/SendDialog.tsx @@ -0,0 +1,132 @@ +import { useEffect, useState } from 'react' +import type { DestinationLine, SendResult } from '@devlog/extension-api' +import { api, type DestinationRef } from './api' + +interface Props { + to: DestinationRef + label: string + week: string + onClose: () => void +} + +const ACTION_LABEL: Record = { create: 'New', update: 'Change', delete: 'Remove', unchanged: 'Sent', skip: 'Not sent' } + +const hm = (minutes: number): string => `${Math.floor(minutes / 60)}:${String(Math.round(minutes) % 60).padStart(2, '0')}` +const when = (l: DestinationLine): string => { + if (!l.start) return l.date + const d = new Date(l.start) + return `${d.toLocaleDateString(undefined, { weekday: 'short', day: 'numeric', month: 'short' })} ${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}` +} + +/** Preview what a destination would do with the week's timesheet, then send it. */ +export function SendDialog({ to, label, week, onClose }: Props): React.JSX.Element { + const [lines, setLines] = useState(null) + const [error, setError] = useState(null) + const [busy, setBusy] = useState(false) + const [result, setResult] = useState(null) + const [showSkipped, setShowSkipped] = useState(false) + + const load = (): void => { + setLines(null) + setError(null) + api + .previewSend(to, week) + .then(setLines) + .catch((err: unknown) => setError(err instanceof Error ? err.message : String(err))) + } + useEffect(load, [to.extension, to.id, week]) // eslint-disable-line react-hooks/exhaustive-deps + + useEffect(() => { + const onKey = (ev: KeyboardEvent): void => { + if (ev.key === 'Escape' && !busy) onClose() + } + window.addEventListener('keydown', onKey) + return () => window.removeEventListener('keydown', onKey) + }, [onClose, busy]) + + const send = async (): Promise => { + setBusy(true) + setError(null) + try { + setResult(await api.send(to, week)) + load() + } catch (err) { + setError(err instanceof Error ? err.message : String(err)) + } finally { + setBusy(false) + } + } + + const shown = (lines ?? []).filter((l) => l.action !== 'skip') + const skipped = (lines ?? []).filter((l) => l.action === 'skip') + const todo = shown.filter((l) => l.action !== 'unchanged').length + const count = (a: DestinationLine['action']): number => (lines ?? []).filter((l) => l.action === a).length + + return ( +
ev.target === ev.currentTarget && !busy && onClose()}> +
+

Send to {label}

+

Week of {week}. Only what changed since the last send goes out.

+ {!lines && !error &&

Working out what to send…

} + {lines && ( +

+ {count('create')} new · {count('update')} changed · {count('delete')} removed · {count('unchanged')} already sent + {skipped.length ? ` · ${skipped.length} not for ${label}` : ''} +

+ )} + {shown.length > 0 && ( + + + {shown.map((l) => ( + + + + + + + + ))} + +
+ {ACTION_LABEL[l.action]} + {when(l)}{hm(l.minutes)}{l.target} + {l.description} + {l.reason ? ({l.reason}) : null} +
+ )} + {skipped.length > 0 && ( +
+ + {showSkipped && ( +
    + {skipped.map((l) => ( +
  • + {when(l)} · {hm(l.minutes)} · {l.description}: {l.reason} +
  • + ))} +
+ )} +
+ )} + {result && ( +

+ {result.summary} + {result.failed.length > 0 && <> · {result.failed.length} failed: {result.failed.map((f) => f.error).join('; ')}} +

+ )} + {error &&

{error}

} +
+ + + +
+
+
+ ) +} diff --git a/src/renderer/src/components/Summary.tsx b/builtin-extensions/devlog-time/src/ui/Summary.tsx similarity index 90% rename from src/renderer/src/components/Summary.tsx rename to builtin-extensions/devlog-time/src/ui/Summary.tsx index 4a753a9..44c02b0 100644 --- a/src/renderer/src/components/Summary.tsx +++ b/builtin-extensions/devlog-time/src/ui/Summary.tsx @@ -2,19 +2,18 @@ import { useEffect, useMemo, useState } from 'react' import { localDate } from '@devlog/core' import { addDays, buildReviewRows, computeWeekTime, formatHours, formatMinutes, roundMinutes, weekStart, type ReviewNote, type ReviewRow } from '@shared/review' import type { ActivityEvent, CanvasMeta } from '@shared/types' -import { api } from '@renderer/api' +import { devlog } from '@devlog/ui' +import { api } from './api' interface Props { canvases: CanvasMeta[] today: string - focusMinSeconds: number - onOpenCanvas: (id: string, date?: string) => void } type RangeKind = 'week' | 'lastWeek' | 'month' | 'lastMonth' | 'custom' const rangeFmt = new Intl.DateTimeFormat(undefined, { month: 'short', day: 'numeric', year: 'numeric' }) -const GRAN_KEY = 'devlog:summary:granularity' +const GRAN_KEY = 'summary.granularity' function parseLocal(date: string): Date { const [y, m, d] = date.split('-').map(Number) @@ -40,16 +39,15 @@ function datesBetween(from: string, to: string): string[] { return out } -export function Summary({ canvases, today, focusMinSeconds, onOpenCanvas }: Props): React.JSX.Element { +export function Summary({ canvases, today }: Props): React.JSX.Element { + const onOpenCanvas = (canvasId: string, date?: string): void => devlog.open({ canvasId, ...(date ? { date } : {}) }) const [kind, setKind] = useState('week') const [custom, setCustom] = useState<{ from: string; to: string }>({ from: weekStart(today), to: today }) - const [granularity, setGranularity] = useState(() => { - try { - return Number(localStorage.getItem(GRAN_KEY)) || 15 - } catch { - return 15 - } - }) + const [granularity, setGranularity] = useState(15) + // Remembered by the extension, on this machine. + useEffect(() => { + void api.pref(GRAN_KEY).then((v) => v && setGranularity(Number(v) || 15), () => undefined) + }, []) const [notes, setNotes] = useState(null) const [events, setEvents] = useState([]) const [expanded, setExpanded] = useState>(new Set()) @@ -75,11 +73,11 @@ export function Summary({ canvases, today, focusMinSeconds, onOpenCanvas }: Prop useEffect(() => { let cancelled = false setNotes(null) - void Promise.all([api.blocks.range(range.from, range.to), api.activity.range(range.from, range.to)]).then(([chunks, evs]) => { + void Promise.all([api.range(range.from, range.to), api.activity(range.from, range.to)]).then(([chunks, evs]) => { if (cancelled) return const flat: ReviewNote[] = [] - for (const { canvasId, day } of chunks) { - for (const entry of day.entries) { + for (const { canvasId, blocks } of chunks) { + for (const entry of blocks) { const date = localDate(new Date(entry.createdAt)) if (date >= range.from && date <= range.to) flat.push({ canvasId, date, entry }) } @@ -92,7 +90,7 @@ export function Summary({ canvases, today, focusMinSeconds, onOpenCanvas }: Prop } }, [range]) - const time = useMemo(() => computeWeekTime(notes ?? [], events, { dates, focusMinSeconds }), [notes, events, dates, focusMinSeconds]) + const time = useMemo(() => computeWeekTime(notes ?? [], events, { dates }), [notes, events, dates]) const rows = useMemo(() => buildReviewRows(canvases, notes ?? [], time.byCanvasDay), [canvases, notes, time]) const total = rows.reduce((n, r) => n + r.totalMinutes, 0) const roundedSum = rows.reduce((n, r) => n + roundMinutes(r.totalMinutes, granularity), 0) @@ -100,11 +98,7 @@ export function Summary({ canvases, today, focusMinSeconds, onOpenCanvas }: Prop const changeGranularity = (v: number): void => { const g = Math.max(1, Math.min(120, Math.round(v) || 15)) setGranularity(g) - try { - localStorage.setItem(GRAN_KEY, String(g)) - } catch { - /* ignore */ - } + void api.setPref(GRAN_KEY, String(g)).catch(() => undefined) } const toggle = (key: string): void => diff --git a/builtin-extensions/devlog-time/src/ui/Targets.tsx b/builtin-extensions/devlog-time/src/ui/Targets.tsx new file mode 100644 index 0000000..3f5983e --- /dev/null +++ b/builtin-extensions/devlog-time/src/ui/Targets.tsx @@ -0,0 +1,78 @@ +/** + * The targets that apply to the week on screen: each measured on its own, + * against the timesheet hours under its canvas in its own period (this + * week, or the months the week touches). Other weeks of those months count + * as saved, or as drafted from tracked time when not saved yet. + */ +import { useEffect, useMemo, useState } from 'react' +import { canvasLabel, localDate, type Timesheet as Sheet, type TimesheetEntry } from '@devlog/core' +import type { CanvasMeta } from '@shared/types' +import { monthsOfWeek, targetHours, targetsFor, weeksOfMonth } from '../targets' + +const monthFmt = new Intl.DateTimeFormat(undefined, { month: 'long', year: 'numeric' }) +const hm = (minutes: number): string => { + const m = Math.round(minutes) + return `${Math.floor(m / 60)}:${String(m % 60).padStart(2, '0')}` +} + +interface Props { + canvases: CanvasMeta[] + week: string + sheet: Sheet + /** Another week's timesheet: saved, or a draft from tracked time. */ + loadWeek: (week: string) => Promise +} + +export function Targets({ canvases, week, sheet, loadWeek }: Props): React.JSX.Element | null { + const anyMonthly = canvases.some((c) => targetHours(c.fields?.target_month) !== null) + const anyWeekly = canvases.some((c) => targetHours(c.fields?.target_week) !== null) + // The other weeks of the months this week touches (not future ones). + const [others, setOthers] = useState>(new Map()) + useEffect(() => { + if (!anyMonthly) return + let alive = true + const today = localDate(new Date()) + const weeks = [...new Set(monthsOfWeek(week).flatMap(weeksOfMonth))].filter((w) => w !== week && w <= today) + void Promise.all(weeks.map(async (w) => [w, (await loadWeek(w).catch(() => null))?.entries ?? []] as const)).then((list) => { + if (alive) setOthers(new Map(list)) + }) + return () => { + alive = false + } + }, [week, anyMonthly, loadWeek]) + + const rows = useMemo(() => { + const byMonth = new Map() + for (const month of monthsOfWeek(week)) byMonth.set(month, [...sheet.entries, ...[...others.values()].flat()]) + return targetsFor(canvases, week, sheet.entries, byMonth) + }, [canvases, week, sheet, others]) + + if (!anyMonthly && !anyWeekly) return null + return ( +
+

Targets

+ {rows.length === 0 &&

No target applies to this week.

} +
    + {rows.map((r) => { + const pct = r.target > 0 ? Math.round((r.actual / r.target) * 100) : 0 + const [y, m] = r.key.split('-').map(Number) + return ( +
  • r.target ? ' is-over' : ''}`} data-canvas={r.canvasId} data-period={r.period}> + + {canvasLabel(canvases, r.canvasId)} + + {r.period === 'week' ? 'this week' : monthFmt.format(new Date(y, m - 1, 1))} +
  • + ) + })} +
+
+ ) +} diff --git a/builtin-extensions/devlog-time/src/ui/Timesheet.tsx b/builtin-extensions/devlog-time/src/ui/Timesheet.tsx new file mode 100644 index 0000000..19c1d21 --- /dev/null +++ b/builtin-extensions/devlog-time/src/ui/Timesheet.tsx @@ -0,0 +1,567 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { + JOURNAL_ID, + QUARTER_MINUTES, + balanceDays, + buildCanvasTree, + canvasLabel, + flattenTree, + localDate, + roundToQuarterHour, + topLevelCanvasId, + type DayBalance, + type Timesheet as Sheet, + type TimesheetEntry +} from '@devlog/core' +import type { CanvasMeta } from '@shared/types' +import { addDays, weekDates, weekStart, type ReviewNote } from '@shared/review' +import { draftTimesheet, newEntryId } from '@shared/timesheet' +import type { DestinationInfo } from '@devlog/extension-api' +import { api } from './api' +import { SendDialog } from './SendDialog' +import { Targets } from './Targets' + +interface Props { + canvases: CanvasMeta[] + today: string + /** Something went wrong in the background (a save): say so. */ + onError: (message: string) => void +} + +const parseLocal = (d: string): Date => { + const [y, m, day] = d.split('-').map(Number) + return new Date(y, m - 1, day) +} +const dayHead = new Intl.DateTimeFormat(undefined, { weekday: 'short', day: 'numeric', month: 'short' }) +const rangeFmt = new Intl.DateTimeFormat(undefined, { month: 'short', day: 'numeric' }) +const weekdayFmt = new Intl.DateTimeFormat(undefined, { weekday: 'short' }) + +/** 90 → "1:30". */ +export function hm(minutes: number): string { + const m = Math.round(minutes) + return `${Math.floor(m / 60)}:${String(m % 60).padStart(2, '0')}` +} + +const timeOf = (iso: string): string => { + const d = new Date(iso) + return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}` +} +const atTime = (date: string, time: string): string => { + const [h, m] = time.split(':').map(Number) + const d = parseLocal(date) + d.setHours(h || 0, m || 0, 0, 0) + return roundToQuarterHour(d).toISOString() +} + +type SaveState = 'unsaved-draft' | 'saved' | 'pending' | 'saving' | 'error' + +const SOURCE_LABEL: Record = { tracked: 'tracked', estimated: 'estimated', manual: 'added' } + +/** + * The weekly timesheet: a draft from tracked time, rounded once to quarter + * hours, which you adjust (durations, tasks, days, notes; the suggested + * trims even out rounding per client and day) and mark final. It is saved + * as one block per week in the Timesheets canvas. + */ +export function Timesheet({ canvases, today, onError }: Props): React.JSX.Element { + const reported = (p: Promise): Promise => p.catch((err: unknown) => void onError(err instanceof Error ? err.message : String(err))) + const [start, setStart] = useState(() => weekStart(today)) + const [sheet, setSheet] = useState(null) + const [state, setState] = useState('saved') + const [confirmRebuild, setConfirmRebuild] = useState(false) + /** The cell whose entries are open for editing. */ + const [selected, setSelected] = useState<{ canvasId: string; date: string } | null>(null) + /** Tasks added to the grid that have no entries yet. */ + const [extraRows, setExtraRows] = useState([]) + const [sending, setSending] = useState(null) + // Destinations come and go as extensions start and stop (and get set up): look again now and then. + const [destinations, setDestinations] = useState([]) + useEffect(() => { + let alive = true + const load = (): void => { + api.destinations().then( + (list) => alive && setDestinations((cur) => (JSON.stringify(cur) === JSON.stringify(list) ? cur : list)), + () => undefined + ) + } + load() + const t = setInterval(load, 3000) + return () => { + alive = false + clearInterval(t) + } + }, []) + const dates = useMemo(() => weekDates(start), [start]) + const end = dates[6] + const saveTimer = useRef | null>(null) + const latest = useRef(null) + + const buildDraft = useCallback(async (week: string): Promise => { + const last = addDays(week, 6) + const [chunks, events] = await Promise.all([api.range(week, last), api.activity(week, last)]) + const notes: ReviewNote[] = [] + for (const { canvasId, blocks } of chunks) { + for (const entry of blocks) { + if (entry.kind === 'timesheet') continue + const date = localDate(new Date(entry.createdAt)) + if (date >= week && date <= last) notes.push({ canvasId, date, entry }) + } + } + return draftTimesheet(week, notes, events) + }, []) + + // Load the week: the saved timesheet, or a fresh draft (not saved until you change something). + useEffect(() => { + let cancelled = false + setSheet(null) + setConfirmRebuild(false) + setSelected(null) + setExtraRows([]) + void (async () => { + const saved = await api.timesheet(start) + const next = saved ?? (await buildDraft(start)) + if (cancelled) return + latest.current = next + setSheet(next) + setState(saved ? 'saved' : 'unsaved-draft') + })() + return () => { + cancelled = true + } + }, [start, buildDraft]) + + // Another week for the monthly targets: as saved, or drafted from tracked time. + const loadWeek = useCallback(async (week: string): Promise => (await api.timesheet(week)) ?? (await buildDraft(week)), [buildDraft]) + + const flush = useCallback(async () => { + if (saveTimer.current) clearTimeout(saveTimer.current) + saveTimer.current = null + const s = latest.current + if (!s) return + setState('saving') + try { + const saved = await api.saveTimesheet(s) + if (latest.current === s) { + latest.current = saved + setSheet(saved) + } + setState(latest.current === saved ? 'saved' : 'pending') + } catch (err) { + setState('error') + throw err + } + }, []) + + // Save shortly after the last change; and before leaving the week. + useEffect( + () => () => { + if (saveTimer.current) void reported(flush()) + }, + [start, flush] + ) + + const change = (fn: (s: Sheet) => Sheet): void => { + const cur = latest.current + if (!cur || cur.status === 'final') return + const next = fn(cur) + latest.current = next + setSheet(next) + setState('pending') + if (saveTimer.current) clearTimeout(saveTimer.current) + saveTimer.current = setTimeout(() => void reported(flush()), 600) + } + const updateEntry = (id: string, patch: Partial): void => + change((s) => ({ ...s, entries: s.entries.map((e) => (e.id === id ? { ...e, ...patch } : e)) })) + + const setStatus = (status: Sheet['status']): void => { + const cur = latest.current + if (!cur) return + latest.current = { ...cur, status } + setSheet(latest.current) + void reported(flush()) + } + + const rebuild = async (): Promise => { + setConfirmRebuild(false) + const draft = await buildDraft(start) + change((s) => ({ ...s, entries: draft.entries })) + } + + const tree = useMemo(() => flattenTree(buildCanvasTree(canvases.filter((c) => c.id !== JOURNAL_ID))), [canvases]) + const order = useMemo(() => new Map([JOURNAL_ID, ...tree.map((t) => t.canvas.id)].map((id, i) => [id, i])), [tree]) + const clientOf = useCallback((id: string) => topLevelCanvasId(canvases, id), [canvases]) + const label = (id: string): string => (id === JOURNAL_ID ? 'Journal' : canvasLabel(canvases, id)) + /** A task's name under its client: "Website / Fix login"; the client's own time is "General". */ + const rowLabel = (id: string): string => { + const client = clientOf(id) + if (id === client) return 'General' + const full = label(id) + const prefix = `${label(client)} / ` + return full.startsWith(prefix) ? full.slice(prefix.length) : full + } + const balances = useMemo(() => (sheet ? balanceDays(sheet.entries, clientOf) : []), [sheet, clientOf]) + const final = sheet?.status === 'final' + + // The grid: minutes per task per day, tasks grouped under their client. + const grid = useMemo(() => { + const cell = new Map() // `${canvasId}|${date}` → reported minutes + const workedCell = new Map() + const taskIds = new Set(extraRows) + for (const e of sheet?.entries ?? []) { + const k = `${e.canvasId}|${e.date}` + cell.set(k, (cell.get(k) ?? 0) + e.minutes) + workedCell.set(k, (workedCell.get(k) ?? 0) + e.worked) + taskIds.add(e.canvasId) + } + const groups = new Map() + for (const id of taskIds) { + const c = clientOf(id) + groups.set(c, [...(groups.get(c) ?? []), id]) + } + const sum = (ids: string[], date?: string, m: Map = cell): number => + ids.reduce((n, id) => n + (date ? (m.get(`${id}|${date}`) ?? 0) : dates.reduce((t, d) => t + (m.get(`${id}|${d}`) ?? 0), 0)), 0) + const clients = [...groups] + .map(([client, ids]) => ({ client, tasks: ids.sort((a, b) => (order.get(a) ?? 1e9) - (order.get(b) ?? 1e9) || a.localeCompare(b)) })) + .sort((a, b) => sum(b.tasks) - sum(a.tasks) || a.client.localeCompare(b.client)) + return { cell, workedCell, clients, sum } + }, [sheet, extraRows, clientOf, order, dates]) + + const dayTotal = (date: string): number => sheet?.entries.filter((e) => e.date === date).reduce((n, e) => n + e.minutes, 0) ?? 0 + const dayWorked = (date: string): number => sheet?.entries.filter((e) => e.date === date).reduce((n, e) => n + e.worked, 0) ?? 0 + const total = sheet?.entries.reduce((n, e) => n + e.minutes, 0) ?? 0 + const worked = sheet?.entries.reduce((n, e) => n + e.worked, 0) ?? 0 + + const addEntry = (canvasId: string, date: string): void => + change((s) => { + const dayEntries = s.entries.filter((e) => e.date === date) + const lastEnd = dayEntries.reduce((t, e) => Math.max(t, Date.parse(e.start) + e.minutes * 60_000), 0) + const startAt = lastEnd || parseLocal(date).setHours(9, 0, 0, 0) + const entry: TimesheetEntry = { id: newEntryId(s.entries), date, start: new Date(startAt).toISOString(), minutes: 60, canvasId, worked: 0, source: 'manual' } + return { ...s, entries: [...s.entries, entry] } + }) + + /** Edit an entry from the cell panel; moving it to another task or day moves the selection with it. */ + const editEntry = (e: TimesheetEntry, patch: Partial): void => { + updateEntry(e.id, patch) + const inCell = (sheet?.entries ?? []).filter((x) => x.canvasId === e.canvasId && x.date === e.date).length + if ((patch.canvasId && patch.canvasId !== e.canvasId) || (patch.date && patch.date !== e.date)) { + if (inCell <= 1) setSelected({ canvasId: patch.canvasId ?? e.canvasId, date: patch.date ?? e.date }) + } + } + + const applySuggestions = (b: DayBalance): void => + change((s) => ({ ...s, entries: s.entries.map((e) => ({ ...e, minutes: b.suggestions.find((x) => x.entryId === e.id)?.to ?? e.minutes })) })) + + const stateText: Record = { + 'unsaved-draft': 'Draft from tracked time, not saved yet', + saved: final ? 'Final' : 'Saved', + pending: 'Unsaved changes…', + saving: 'Saving…', + error: 'Could not save' + } + + const selectedEntries = selected && sheet ? sheet.entries.filter((e) => e.canvasId === selected.canvasId && e.date === selected.date).sort((a, b) => a.start.localeCompare(b.start)) : [] + const inGrid = new Set(grid.clients.flatMap((c) => c.tasks)) + const addableTasks = [{ id: JOURNAL_ID, depth: 0, title: 'Journal' }, ...tree.map(({ canvas: c, depth }) => ({ id: c.id, depth, title: c.title }))].filter((t) => !inGrid.has(t.id)) + + return ( +
+
+
+

Timesheet

+ + {rangeFmt.format(parseLocal(start))} – {rangeFmt.format(parseLocal(end))} + {sheet ? ` · ${hm(total)} reported · ${hm(worked)} worked` : ''} + + {sheet ? stateText[state] : ''} + +
+ + + +
+
+ {sheet && ( +
+ {final ? ( + <> + + {destinations.map((d) => ( + + ))} + + ) : ( + <> + {confirmRebuild ? ( + <> + Replace every entry with a fresh draft from tracked time? + + + + ) : ( + + )} + {state === 'unsaved-draft' && ( + + )} + + {destinations.length > 0 && Mark the week final to send it to {destinations.map((d) => d.label).join(', ')}.} + + )} +
+ )} +
+ + {!sheet &&

Loading…

} + {sheet && } + {sheet && ( +
+ + + + {dates.map((d) => ( + + ))} + + + + + + ))} + + + + + {grid.clients.map(({ client, tasks }) => ( + + + + {dates.map((d) => { + const b = balances.find((x) => x.date === d && x.group === client) + const trim = b ? b.suggestions.reduce((n, sg) => n + sg.from - sg.to, 0) : 0 + const minutes = grid.sum(tasks, d) + return ( + + ) + })} + + + {tasks.map((id) => ( + + + {dates.map((d) => { + const minutes = grid.cell.get(`${id}|${d}`) ?? 0 + const isSel = selected?.canvasId === id && selected.date === d + return ( + + ) + })} + + + ))} + + ))} + {grid.clients.length === 0 && ( + + + + )} + + + + + {dates.map((d) => ( + + ))} + + + + + {dates.map((d) => ( + + ))} + + + +
+ {dates.map((d) => ( + + {dayHead.format(parseLocal(d)).split(/[ ,]+/)[0]} + {parseLocal(d).getDate()} + + Week +
+ {label(client)} + + {minutes ? hm(minutes) : ''} + {b && b.suggestions.length > 0 && !final && ( + + )} + {hm(grid.sum(tasks))}
+ {(() => { + // The task's own name first; the project path after it, muted (and first to be cut off). + const parts = rowLabel(id).split(' / ') + return ( + <> + {parts.at(-1)} + {parts.length > 1 && · {parts.slice(0, -1).join(' / ')}} + + ) + })()} + + + {hm(grid.sum([id]))}
+ No tracked time this week. Add a task below and click a day to add time. +
Reported + {dayTotal(d) ? hm(dayTotal(d)) : ''} + {hm(total)}
Worked + {dayWorked(d) ? hm(Math.round(dayWorked(d))) : ''} + {hm(Math.round(worked))}
+
+ )} + + {sheet && !final && addableTasks.length > 0 && ( +
+ +
+ )} + + {sheet && selected && ( +
+
+ {label(selected.canvasId)} + + {dayHead.format(parseLocal(selected.date))} · {hm(selectedEntries.reduce((n, e) => n + e.minutes, 0))} + + + {!final && ( + + )} + +
+ {selectedEntries.length === 0 &&

No time here yet.{final ? '' : ' Add an entry.'}

} + {selectedEntries.length > 0 && ( + + + {selectedEntries.map((e) => ( + + + + + + + + + ))} + +
+ + ev.target.value && updateEntry(e.id, { start: atTime(e.date, ev.target.value) })} /> + + + {hm(e.minutes)} + + + + + updateEntry(e.id, { note: ev.target.value })} /> + + {e.source === 'manual' ? SOURCE_LABEL.manual : `${hm(e.worked)} ${SOURCE_LABEL[e.source]}`} + + {!final && ( + + )} +
+ )} +
+ )} + {sending && setSending(null)} />} +
+ ) +} + +/** Rows without a wrapper element (a client row followed by its task rows). */ +function FragmentRows({ children }: { children: React.ReactNode }): React.JSX.Element { + return <>{children} +} diff --git a/builtin-extensions/devlog-time/src/ui/api.ts b/builtin-extensions/devlog-time/src/ui/api.ts new file mode 100644 index 0000000..b5b4840 --- /dev/null +++ b/builtin-extensions/devlog-time/src/ui/api.ts @@ -0,0 +1,34 @@ +/** + * What the time pages ask of the extension (its `views.handle` handler in + * main.ts), in the shapes the app's own views used to get from the app. + */ +import { devlog } from '@devlog/ui' +import type { Timesheet } from '@devlog/core' +import type { DestinationInfo, DestinationLine, SendResult } from '@devlog/extension-api' +import type { ActivityEvent, CanvasMeta, Entry } from '@shared/types' + +export interface DayChunk { + canvasId: string + date: string + blocks: Entry[] +} + +export type DestinationRef = Pick + +export const api = { + /** Canvases (without the one timesheets are kept in). */ + canvases: (): Promise => devlog.call('canvases'), + /** Day files with blocks written in the range. */ + range: (from: string, to: string): Promise => devlog.call('range', from, to), + /** What the app recorded: time, locks, idle, sleep, corrections. */ + activity: (from: string, to: string): Promise => devlog.call('activity', from, to), + timesheet: (week: string): Promise => devlog.call('timesheet', week), + saveTimesheet: (sheet: Timesheet): Promise => devlog.call('saveTimesheet', sheet), + destinations: (): Promise => devlog.call('destinations'), + /** What sending the saved week would do. */ + previewSend: (to: DestinationRef, week: string): Promise => devlog.call('previewSend', to, week), + /** Send the saved (final) week, and keep a record under it. */ + send: (to: DestinationRef, week: string): Promise => devlog.call('send', to, week), + pref: (key: string): Promise => devlog.call('pref', key), + setPref: (key: string, value: string): Promise => devlog.call('setPref', key, value) +} diff --git a/builtin-extensions/devlog-time/src/ui/time.css b/builtin-extensions/devlog-time/src/ui/time.css new file mode 100644 index 0000000..d74c780 --- /dev/null +++ b/builtin-extensions/devlog-time/src/ui/time.css @@ -0,0 +1,872 @@ +/* + * The time pages' look: the app's own rules for its timesheet, summary and + * send dialog (from src/renderer/src/styles.css), on the colours the app + * sends each view. Keep them in step when the app's change. + */ +html, +body, +#root { + height: 100%; +} + +#root { + display: flex; + flex-direction: column; +} + +body { + font-family: var(--sans, var(--font)); + font-size: 15px; +} + +h2 { + margin: 0; + font-size: 18px; +} + +button { + cursor: pointer; +} + +.page-error { + margin: 8px 28px 0; +} + +.spacer { + flex: 1; +} + +.feed { + --feed-pad: 28px; + flex: 1; + overflow-y: auto; + padding: 0 var(--feed-pad) 16px; + min-height: 0; +} + +.feed-head { + position: sticky; + top: 0; + z-index: 4; + background: var(--bg); + margin: 0 calc(-1 * var(--feed-pad)) 8px; + padding: 16px var(--feed-pad) 10px; + border-bottom: 1px solid var(--border); + display: flex; + align-items: baseline; + gap: 10px; +} + +.feed-head h2 { + margin: 0; + font-size: 18px; +} + +.page-head { + display: block; +} + +.page-head-row { + display: flex; + align-items: center; + gap: 10px; +} + +.move-select { + font: inherit; + font-size: 12px; + padding: 1px 4px; + border-radius: 4px; + border: 1px solid var(--border-strong); + background: var(--bg); + color: var(--fg); + max-width: 220px; +} + +.feed-sub { + color: var(--fg-muted); + font-size: 13px; +} + +.feed-empty { + color: var(--fg-muted); + padding: 24px 0; +} + +.entry-confirm { + color: var(--danger); +} + +.btn { + border: 1px solid var(--border-strong); + border-radius: 6px; + padding: 5px 12px; + background: var(--bg); + font-size: 13px; + font-weight: 600; +} + +.btn:hover:not(:disabled) { + background: var(--bg-2); +} + +.btn-xs { + padding: 2px 8px; + font-size: 12px; + font-weight: 500; +} + +.btn-quiet { + border-color: transparent; + background: transparent; + color: var(--fg-muted); +} + +.btn-quiet:hover:not(:disabled) { + color: var(--fg); + background: var(--bg-3); +} + +.btn-primary { + background: var(--accent); + border-color: var(--accent); + color: var(--accent-fg); +} + +.btn-primary:hover:not(:disabled) { + filter: brightness(1.08); + background: var(--accent); +} + +.btn-danger { + background: var(--danger); + border-color: var(--danger); + color: #fff; +} + +.modal textarea { + width: 100%; + padding: 6px 10px; + border: 1px solid var(--border-strong); + border-radius: 6px; + background: var(--bg); + font: inherit; + resize: vertical; +} + +.hint { + font-size: 12px; + color: var(--fg-muted); + margin: 6px 0 0; +} + +.form-error { + color: var(--danger); + font-size: 13px; + margin: 12px 0 0; +} + +.modal-backdrop { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.45); + display: grid; + place-items: center; + z-index: 10; +} + +.modal { + width: min(560px, 92vw); + max-height: 90vh; + overflow-y: auto; + background: var(--bg); + border: 1px solid var(--border); + border-radius: 12px; + box-shadow: var(--shadow); + padding: 20px 24px; +} + +.modal h2 { + margin: 0 0 12px; + font-size: 18px; +} + +.modal section { + margin-bottom: 18px; +} + +.modal h3 { + font-size: 13px; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--fg-muted); + margin: 0 0 8px; +} + +.check { + display: flex; + align-items: center; + gap: 8px; + padding: 3px 0; +} + +.modal-actions { + display: flex; + justify-content: flex-end; + gap: 8px; + margin-top: 8px; +} + +.week-nav { + display: flex; + gap: 2px; +} + +.review-table-wrap { + overflow-x: auto; + margin: 12px 0 6px; +} + +.review-table { + border-collapse: collapse; + width: 100%; + font-size: 13px; +} + +.review-table th, +.review-table td { + border-bottom: 1px solid var(--border); + padding: 5px 8px; + text-align: left; + white-space: nowrap; +} + +.review-table thead th { + font-size: 11px; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--fg-muted); + border-bottom: 1px solid var(--border-strong); + text-align: center; +} + +.review-corner { + text-align: left !important; +} + +.review-day-name { + display: block; +} + +.review-day-num { + display: block; + font-weight: 400; +} + +.review-day.is-today .review-day-num { + color: var(--link); + font-weight: 700; +} + +.review-total { + border-left: 1px solid var(--border-strong); + font-weight: 600; +} + +.review-note { + font-size: 12px; + color: var(--fg-muted); + margin: 4px 0 18px; +} + +.check-inline { + font-size: 12px; + color: var(--fg-muted); + padding: 0; + -webkit-app-region: no-drag; +} + +.sum-list, +.sum-children { + list-style: none; + margin: 0; + padding: 0; +} + +.sum-list { + margin: 8px 0 16px; +} + +.sum-row { + padding: 8px 4px; + border-bottom: 1px solid var(--border); +} + +.sum-top, +.sum-child { + display: grid; + grid-template-columns: 22px minmax(0, 1fr) minmax(0, 160px) 70px 70px 64px; + align-items: center; + gap: 8px; +} + +.sum-child { + grid-template-columns: minmax(0, 1fr) 70px 70px 64px; + padding: 3px 0 3px 30px; + font-size: 13px; + color: var(--fg-muted); +} + +.sum-child .sum-children .sum-child { + padding-left: 0; +} + +.sum-child > .sum-children { + grid-column: 1 / -1; +} + +.sum-children { + margin-top: 4px; +} + +.sum-toggle { + width: 22px; + height: 22px; + border: 0; + background: transparent; + color: var(--fg-muted); + font: inherit; + cursor: pointer; + border-radius: 4px; +} + +.sum-toggle:disabled { + opacity: 0.3; + cursor: default; +} + +.sum-toggle:not(:disabled):hover { + background: var(--bg-3); +} + +.sum-label { + font-weight: 600; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.sum-child .sum-label { + font-weight: 400; +} + +.sum-bar { + height: 8px; + background: var(--bg-3); + border-radius: 4px; + overflow: hidden; +} + +.sum-bar > span { + display: block; + height: 100%; + background: var(--accent); + border-radius: 4px; +} + +.sum-notes, +.sum-exact { + font-size: 12px; + color: var(--fg-muted); + text-align: right; + font-variant-numeric: tabular-nums; + white-space: nowrap; +} + +.sum-hours { + text-align: right; + font-weight: 600; + font-variant-numeric: tabular-nums; + white-space: nowrap; +} + +.sum-child .sum-hours { + font-weight: 500; +} + +.sum-total { + border-bottom: 0; + border-top: 2px solid var(--border-strong); +} + +.sum-total .sum-bar { + background: transparent; +} + +.sum-gran { + width: 52px; + margin: 0 4px; + padding: 2px 4px; + font: inherit; + font-size: 12px; + border: 1px solid var(--border); + border-radius: 4px; + background: var(--bg); + color: var(--fg); +} + +.sum-custom { + display: inline-flex; + align-items: center; + gap: 6px; + font-size: 12px; + color: var(--fg-muted); +} + +.sum-custom input { + font: inherit; + font-size: 12px; + padding: 2px 4px; + border: 1px solid var(--border); + border-radius: 4px; + background: var(--bg); + color: var(--fg); +} + +.modal select { + padding: 6px 10px; + border: 1px solid var(--border-strong); + border-radius: 6px; + background: var(--bg); + color: var(--fg); + font: inherit; + width: 100%; +} + +.ts-state { + font-size: 12px; + color: var(--fg-muted); +} + +.ts-state.state-pending, +.ts-state.state-saving { + color: var(--warn); +} + +.ts-state.state-error { + color: var(--danger); +} + +.ts-state.is-final { + color: var(--ok); + font-weight: 600; +} + +.ts-actions { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 6px; + margin-top: 6px; +} + +.ts-day-sum { + font-size: 12px; + color: var(--fg-muted); +} + +.ts-table { + width: 100%; + border-collapse: collapse; + font-size: 13px; +} + +.ts-table td { + padding: 3px 6px 3px 0; + vertical-align: middle; + border-bottom: 1px solid color-mix(in srgb, var(--border) 50%, transparent); +} + +.ts-table select, +.ts-table input { + font: inherit; + font-size: 13px; + padding: 2px 4px; + border: 1px solid transparent; + border-radius: 4px; + background: transparent; + color: var(--fg); +} + +.ts-table select:hover, +.ts-table input:hover, +.ts-table select:focus, +.ts-table input:focus { + border-color: var(--border); + background: var(--bg); +} + +.ts-when { + white-space: nowrap; + width: 1%; +} + +.ts-minutes { + white-space: nowrap; + width: 1%; +} + +.ts-hours { + display: inline-block; + min-width: 3.2em; + text-align: center; + font-variant-numeric: tabular-nums; + font-weight: 600; +} + +.ts-step { + border: 0; + background: transparent; + color: var(--fg-muted); + width: 22px; + height: 22px; + border-radius: 4px; + cursor: pointer; +} + +.ts-step:hover:not(:disabled) { + background: var(--bg-2); + color: var(--fg); +} + +.ts-step:disabled { + opacity: 0.35; + cursor: default; +} + +.ts-task select { + max-width: 190px; +} + +.ts-when select { + width: 4.2em; +} + +.ts-when input[type='time'] { + width: 7.4em; +} + +.ts-note input { + width: 100%; + min-width: 60px; +} + +.ts-worked { + white-space: nowrap; + width: 1%; + font-size: 11px; + color: var(--fg-muted); +} + +.ts-row.source-estimated .ts-worked { + color: var(--warn); +} + +.ts-remove { + width: 1%; +} + +.modal-send { + width: min(720px, 94vw); +} + +.send-counts { + font-size: 13px; + margin: 0 0 8px; +} + +.send-table { + width: 100%; + border-collapse: collapse; + font-size: 13px; + margin-bottom: 8px; +} + +.send-table td { + padding: 4px 8px 4px 0; + border-bottom: 1px solid color-mix(in srgb, var(--border) 60%, transparent); + vertical-align: top; +} + +.send-when, +.send-hours, +.send-target { + white-space: nowrap; +} + +.send-hours { + font-variant-numeric: tabular-nums; + font-weight: 600; +} + +.send-target { + font-family: var(--mono, ui-monospace, monospace); + font-size: 12px; +} + +.send-line.action-unchanged { + color: var(--fg-muted); +} + +.send-action { + font-size: 11px; + padding: 1px 6px; + border-radius: 999px; + background: var(--bg-2); + color: var(--fg-muted); + white-space: nowrap; +} + +.send-action.action-create { + background: color-mix(in srgb, var(--ok) 16%, transparent); + color: var(--ok); +} + +.send-action.action-update { + background: color-mix(in srgb, var(--accent) 16%, transparent); + color: var(--accent); +} + +.send-action.action-delete { + background: color-mix(in srgb, var(--danger) 14%, transparent); + color: var(--danger); +} + +.send-skipped ul { + margin: 4px 0 8px; + padding-left: 18px; + font-size: 12px; +} + +.send-result.send-ok { + color: var(--ok); + font-size: 13px; +} + +.ts-grid { + table-layout: fixed; +} + +.ts-col-day { + width: 54px; +} + +.ts-col-week { + width: 62px; +} + +.ts-grid th[scope='row'] { + text-align: left; + overflow: hidden; + text-overflow: ellipsis; +} + +.ts-grid .ts-cell { + text-align: right; + font-variant-numeric: tabular-nums; + padding: 0; +} + +.ts-client-row th, +.ts-client-row td { + background: var(--bg-2); + font-weight: 600; +} + +.ts-client-cell { + padding: 5px 8px !important; + position: relative; +} + +.ts-task-label { + padding-left: 20px !important; + font-weight: 400; + color: var(--fg); +} + +.ts-task-path { + color: var(--fg-muted); + font-size: 12px; +} + +.ts-cell-btn { + display: block; + width: 100%; + border: 0; + background: transparent; + color: inherit; + font: inherit; + text-align: right; + padding: 5px 8px; + cursor: pointer; + border-radius: 4px; +} + +.ts-cell.is-empty .ts-cell-btn { + color: transparent; +} + +.ts-cell.is-empty .ts-cell-btn:hover, +.ts-cell.is-empty .ts-cell-btn:focus-visible { + color: var(--fg-muted); +} + +.ts-cell-btn:hover { + background: color-mix(in srgb, var(--accent) 8%, transparent); +} + +.ts-cell.is-selected .ts-cell-btn { + background: color-mix(in srgb, var(--accent) 18%, transparent); + color: var(--fg); + outline: 1px solid color-mix(in srgb, var(--accent) 55%, transparent); +} + +.ts-grid .review-total { + font-weight: 600; + padding: 5px 8px; +} + +.ts-total-row th, +.ts-total-row td { + font-weight: 700; + border-top: 2px solid var(--border-strong); + padding: 6px 8px; +} + +.ts-worked-row th, +.ts-worked-row td { + color: var(--fg-muted); + font-size: 12px; + padding: 3px 8px; +} + +.ts-trim { + display: block; + margin: 2px 0 0 auto; + border: 0; + border-radius: 999px; + padding: 0 6px; + font: inherit; + font-size: 10px; + font-weight: 600; + background: color-mix(in srgb, var(--warn) 18%, transparent); + color: var(--warn); + cursor: pointer; +} + +.ts-trim.is-add { + background: color-mix(in srgb, var(--accent) 16%, transparent); + color: var(--accent); +} + +.ts-none { + color: var(--fg-muted); + text-align: center !important; + padding: 16px !important; +} + +.ts-add-task { + margin: 4px 0 12px; +} + +.ts-add-task select { + font: inherit; + font-size: 13px; + padding: 3px 6px; + border: 1px dashed var(--border); + border-radius: 6px; + background: transparent; + color: var(--fg-muted); +} + +.ts-detail { + margin: 8px 0 16px; + padding: 8px 12px 10px; + border: 1px solid var(--border); + border-radius: 8px; + background: var(--bg); +} + +.ts-detail-head { + display: flex; + align-items: baseline; + gap: 10px; + margin-bottom: 4px; +} + +/* ---- Hour targets ---- */ +.ts-targets { + margin: 4px 0 14px; +} + +.ts-targets h3 { + margin: 0 0 6px; + font-size: 12px; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--fg-muted); +} + +.ts-targets ul { + list-style: none; + margin: 0; + padding: 0; +} + +.ts-target { + display: grid; + grid-template-columns: minmax(120px, 1.4fr) 130px minmax(80px, 1fr) 120px 48px; + align-items: center; + gap: 10px; + padding: 3px 0; + font-size: 13px; +} + +.ts-target-label { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.ts-target-period, +.ts-target-pct { + color: var(--fg-muted); +} + +.ts-target-hours, +.ts-target-pct { + font-variant-numeric: tabular-nums; + text-align: right; +} + +.ts-target-bar { + height: 6px; + border-radius: 3px; + background: var(--bg-3); + overflow: hidden; +} + +.ts-target-bar > span { + display: block; + height: 100%; + background: var(--accent); +} + +.ts-target.is-over .ts-target-bar > span { + background: var(--warn); +} + +.ts-target.is-over .ts-target-pct { + color: var(--warn); + font-weight: 600; +} diff --git a/builtin-extensions/devlog-time/src/views/common.ts b/builtin-extensions/devlog-time/src/views/common.ts new file mode 100644 index 0000000..0e33fbe --- /dev/null +++ b/builtin-extensions/devlog-time/src/views/common.ts @@ -0,0 +1,29 @@ +/** What the extension says about the clock (the `status` call, and its messages); see ../tracker.ts. */ +export interface ClockStatus { + active: string | null + label: string | null + since: string | null + paused: 'locked' | 'idle' | 'suspended' | null +} + +export interface TaskInfo { + id: string + title: string + label: string + path: string +} + +/** "1h 23m", "5m". */ +export function formatMinutes(minutes: number): string { + const m = Math.max(0, Math.round(minutes)) + const h = Math.floor(m / 60) + const rest = m % 60 + if (h === 0) return `${rest}m` + return rest === 0 ? `${h}h` : `${h}h ${rest}m` +} + +export function elapsed(st: ClockStatus | undefined, now: number): string | null { + return st?.since && !st.paused ? formatMinutes((now - new Date(st.since).getTime()) / 60_000) : null +} + +export const PAUSED: Record = { locked: 'locked', idle: 'idle', suspended: 'asleep' } diff --git a/builtin-extensions/devlog-time/src/views/header.tsx b/builtin-extensions/devlog-time/src/views/header.tsx new file mode 100644 index 0000000..d0014e6 --- /dev/null +++ b/builtin-extensions/devlog-time/src/views/header.tsx @@ -0,0 +1,40 @@ +/** On a task canvas: Start it, switch to it, or stop it. */ +import '@devlog/ui/styles.css' +import './views.css' +import { useEffect, useLayoutEffect, useRef, useState } from 'react' +import { Button, devlog, mount, useCall, useMessages, useNow, useViewContext } from '@devlog/ui' +import { PAUSED, elapsed, type ClockStatus } from './common' + +function Header(): React.JSX.Element { + const canvasId = useViewContext().canvasId + const initial = useCall('status') + const [st, setSt] = useState() + useEffect(() => { + if (initial.data) setSt(initial.data) + }, [initial.data]) + useMessages((d) => setSt(d as ClockStatus)) + const now = useNow(15_000) + const ref = useRef(null) + useLayoutEffect(() => { + if (ref.current) devlog.resize({ width: Math.ceil(ref.current.scrollWidth) + 2 }) + }) + const running = Boolean(canvasId && st?.active === canvasId) + return ( +
+ {running ? ( + <> + + {st?.paused ? `paused (${PAUSED[st.paused] ?? st.paused})` : (elapsed(st, now) ?? '')} + + ) : ( + + )} +
+ ) +} + +mount(
, { slot: true }) diff --git a/builtin-extensions/devlog-time/src/views/picker.tsx b/builtin-extensions/devlog-time/src/views/picker.tsx new file mode 100644 index 0000000..b8476c1 --- /dev/null +++ b/builtin-extensions/devlog-time/src/views/picker.tsx @@ -0,0 +1,46 @@ +/** The task picker (a popover): find a task and start it, stop the clock, or make a new task. */ +import '@devlog/ui/styles.css' +import './views.css' +import { useEffect, useState } from 'react' +import { Menu, devlog, mount, useCall, useMessages, useViewContext, type MenuEntry } from '@devlog/ui' +import type { ClockStatus, TaskInfo } from './common' + +function Picker(): React.JSX.Element { + const here = useViewContext().canvasId + const { data: tasks, error } = useCall('tasks', [here ?? ''], [here]) + const initial = useCall('status') + const [st, setSt] = useState() + useEffect(() => { + if (initial.data) setSt(initial.data) + }, [initial.data]) + useMessages((d) => setSt(d as ClockStatus)) + const [problem, setProblem] = useState(null) + + const run = (p: Promise): void => { + p.then( + () => devlog.close(), + (err: Error) => setProblem(err.message) + ) + } + const items: MenuEntry[] = [ + ...(st?.active ? [{ key: 'stop', label: '■ Stop the clock', hint: st.label ?? '', onSelect: () => run(devlog.call('stop')) }] : []), + ...(tasks ?? []) + .filter((t) => t.id !== st?.active) + .map((t) => ({ key: t.id, label: `◉ ${t.title}`, hint: t.path, onSelect: () => run(devlog.call('start', t.id)) })) + ] + return ( +
+ `${typeof it.label === 'string' ? it.label : ''} ${typeof it.hint === 'string' ? it.hint : ''}`} + empty={tasks ? 'No tasks yet. Type a name to make one.' : 'Loading…'} + extra={(q) => (q ? [{ key: 'new', label: `+ New task “${q}”`, hint: here ? 'inside the canvas on screen' : 'at the top level', onSelect: () => run(devlog.call('newTask', q, here ?? null)) }] : [])} + /> + {(problem || error) &&

{problem ?? error}

} +
+ ) +} + +mount() diff --git a/builtin-extensions/devlog-time/src/views/status.tsx b/builtin-extensions/devlog-time/src/views/status.tsx new file mode 100644 index 0000000..f909667 --- /dev/null +++ b/builtin-extensions/devlog-time/src/views/status.tsx @@ -0,0 +1,64 @@ +/** + * The status bar item: the active task and how long it has run, Stop, and + * Start for the task on screen; ▾ opens the picker. + */ +import '@devlog/ui/styles.css' +import './views.css' +import { useEffect, useLayoutEffect, useRef, useState } from 'react' +import { Button, devlog, mount, useCall, useMessages, useNow, useViewContext } from '@devlog/ui' +import { PAUSED, elapsed, type ClockStatus, type TaskInfo } from './common' + +function Status(): React.JSX.Element { + const initial = useCall('status') + const [st, setSt] = useState() + useEffect(() => { + if (initial.data) setSt(initial.data) + }, [initial.data]) + useMessages((d) => setSt(d as ClockStatus)) + const now = useNow(15_000) + const here = useViewContext().canvasId + const { data: task } = useCall('task', [here ?? ''], [here, st?.active]) + const offer = task && task.id !== st?.active ? task : null + + // Ask for the width the content needs. + const ref = useRef(null) + useLayoutEffect(() => { + if (ref.current) devlog.resize({ width: Math.ceil(ref.current.scrollWidth) + 2 }) + }) + + const picker = (): void => devlog.popover('picker', { width: 340, height: 380 }) + return ( +
+ + {st?.active ? ( + + ) : ( + No active task + )} + {st?.active && ( + + )} + {offer ? ( + + + + + ) : ( + + )} +
+ ) +} + +mount(, { slot: true }) diff --git a/builtin-extensions/devlog-time/src/views/summary.tsx b/builtin-extensions/devlog-time/src/views/summary.tsx new file mode 100644 index 0000000..d440894 --- /dev/null +++ b/builtin-extensions/devlog-time/src/views/summary.tsx @@ -0,0 +1,8 @@ +/** Hours per client over a range, as the extension's page. */ +import '@devlog/ui/styles.css' +import '../ui/time.css' +import { mount } from '@devlog/ui' +import { Page } from '../ui/Page' +import { Summary } from '../ui/Summary' + +mount({({ canvases, today }) => }) diff --git a/builtin-extensions/devlog-time/src/views/timesheet.tsx b/builtin-extensions/devlog-time/src/views/timesheet.tsx new file mode 100644 index 0000000..5af9658 --- /dev/null +++ b/builtin-extensions/devlog-time/src/views/timesheet.tsx @@ -0,0 +1,8 @@ +/** The weekly timesheet, as the extension's page. */ +import '@devlog/ui/styles.css' +import '../ui/time.css' +import { mount } from '@devlog/ui' +import { Page } from '../ui/Page' +import { Timesheet } from '../ui/Timesheet' + +mount({(p) => }) diff --git a/builtin-extensions/devlog-time/src/views/views.css b/builtin-extensions/devlog-time/src/views/views.css new file mode 100644 index 0000000..639bef4 --- /dev/null +++ b/builtin-extensions/devlog-time/src/views/views.css @@ -0,0 +1,66 @@ +.tt-row { + display: inline-flex; + align-items: center; + gap: 6px; + height: 22px; + padding: 0 2px; + white-space: nowrap; +} + +.tt-header { + height: 26px; +} + +.tt-link { + display: inline-flex; + align-items: center; + border: 0; + background: transparent; + padding: 0; + cursor: pointer; + max-width: 260px; + overflow: hidden; + text-overflow: ellipsis; +} + +.tt-link:hover .tt-label { + text-decoration: underline; +} + +.tt-label, +.tt-title { + display: inline-block; + max-width: 180px; + overflow: hidden; + text-overflow: ellipsis; + vertical-align: bottom; +} + +.tt-split { + display: inline-flex; +} + +.tt-split .dl-btn:first-child { + border-top-right-radius: 0; + border-bottom-right-radius: 0; +} + +.tt-split .dl-btn:last-child { + border-top-left-radius: 0; + border-bottom-left-radius: 0; + border-left: 0; + padding: 1px 5px; +} + +.tt-picker { + padding: 8px; +} + +.tt-picker .dl-menu { + max-height: 300px; +} + +/* The canvas header's slot is a little taller than the status bar's. */ +body.dl-slot:has(.tt-header) { + height: 26px; +} diff --git a/docs/BLOCK-PAGES.md b/docs/BLOCK-PAGES.md new file mode 100644 index 0000000..f2187d0 --- /dev/null +++ b/docs/BLOCK-PAGES.md @@ -0,0 +1,78 @@ +# Block pages and todos as blocks: design + +Status: done. Phase 1 (block pages) in 0.14.0, phase 2 (todos as blocks) +in 0.15.0, phase 3 (moving into and out of blocks, tasks taking what is +inside them, recent pages) in 0.16.0. + +## The idea + +Every block is also a page. Opening a block shows the block itself at the +top, as that page's surface, and the blocks written inside it as the page's +stream. A canvas and a block page are the same kind of view one level +apart; the breadcrumb runs Client / Project / Standup / Sep 28 / …, and you +can go up a level. + +In a stream, a block with blocks inside it no longer shows them inline as a +thread. It shows a chip ("3 notes") that opens its page. + +Todos become ordinary blocks that can sit anywhere in that tree (phase 2), and +the todo panel is a view over them. + +The standup, for example: in the Standup task, type "Sep 28" and post it with +Alt+Enter. That starts the task (posting on a task canvas does) and opens the +new block's page. Notes and action items go inside it. Anything worth keeping +at the top is an edit of the block, the page's surface. The Standup task then +reads as a list of dated blocks, each one openable. + +## Decisions + +1. **Storage: children live in their root block's day file,** as replies do + today. Notes written on a later day go into that older file with their + own timestamps. One file holds a whole subtree, so moving or deleting one + touches a single file, and nothing refers across files. Views that work + by date (review, summary, timeline, timesheet) attribute blocks by when + they were written. The date range query therefore also returns older day + files that hold blocks written in the range; the index keeps creation + times for that. No storage format change for pages. + - Considered and rejected: storing each block in the file of the day it + was written (edits to old blocks would land in old files anyway, and + loading a page would mean reading every later file), and transparently + splitting big threads into their own files (no gain for flat files in + git). +2. **Todos move out of `todos.md` into the streams** (phase 2): a todo is a + block with `kind=todo` at any depth, done or open through a `done` + field. A one-time conversion moves each old todo into the day file of + the day it was created, its comments becoming its children. The storage + format goes to 4, so an older app refuses the devlog instead of writing + to `todos.md`. +3. **Ticking a todo off no longer writes a "✓" block;** the todo shows as + done where it is, and the timeline shows when it was ticked. Existing + "✓" blocks stay as history. +4. **Todo panel order:** grouped by canvas (collapsible sections; a chain + of canvases with a single path collapses into one heading), then in the + order the todos appear in their streams. Drag to reorder within a group. + There is no ranking across groups. +5. **Opening a page:** double-click a block, click its chip, or its Open + action. On a page, double-clicking the surface edits it. +6. **Making a block with blocks inside it a task** moves those blocks into + the new task canvas; the block stays where it was as the link to it + (phase 3). +7. **A task is a canvas you can record time against.** Block pages do not + track time on their own; a page inside a task canvas belongs to that + task, so posting anywhere in it makes the task active. + +## Phases + +1. **Block pages.** The page view (surface, stream grouped by the day each + block was written, breadcrumb, up a level with Alt+↑), chips in place of + inline threads, the note box posting into the page on screen, Alt+Enter + to post and open, reordering among siblings at any depth, back and + forward through pages, search and timeline results opening the page, the + date range query finding blocks written in the range in older files. +2. **Todos as blocks.** The conversion (format 4), `[ ]` and pasted + checklists posting one todo per line, a checkbox in the stream, the todo + page (checkbox in the header), the panel built from the index (grouped + by canvas; Here is the page on screen and everything beneath it), + extension API 1.4 (`parentId` when adding, todo state, a todo query). +3. **Polish.** Move under another block, making a block with children a + task (decision 6), recent pages in the quick switcher. diff --git a/docs/DESIGN.md b/docs/DESIGN.md index 7b82b42..73503ee 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -27,7 +27,7 @@ │ RepoIndex SQLite cache: listings + full-text search│ │ SyncManager simple-git: commit / fetch / rebase / push│ │ ActivityLog per-machine JSON-lines log │ -│ migrate storage format upgrades │ +│ manifest storage format check │ │ Tracker, CommitWatcher, protocol, SettingsStore, updater │ └───────────────────────────────────────────────────────────┘ ``` @@ -35,7 +35,7 @@ The data layer is its own package, `@devlog/core`, with no Electron dependency: a pure entry (types, file formats, hierarchy helpers, also used by the renderer) and a Node entry (store, index, sync, activity log, -migrations). The desktop app is one client of it; an API server or CLI could +manifest). The desktop app is one client of it; an API server or CLI could be another. Keeping every file-format rule and every write in one unit-tested package is the point: nothing else opens a day file. @@ -50,6 +50,8 @@ outside the open repository. - `devlog.json` – `{ "format": 3 }`, the storage format version. - `.gitattributes` – `merge=union` for block files and activity logs. - `entries/YYYY/MM/YYYY-MM-DD.md` – the journal, one file per local day. + Retired in 0.13.0: the app no longer offers it as a place to write, and + lists it (read-only, under Archived) only while it still holds blocks. - `entries/YYYY/MM/assets/--.` – pasted images. - `canvases///canvas.md` + `…/entries/…` + `…/todos.md` – every other canvas, in the same day-file layout (see **Canvases** below). @@ -116,8 +118,7 @@ Reasons for this over alternatives: on concurrent inserts after the same block and on deleted predecessors. - **Compaction, built but not applied.** Files grow with every edit. `DevlogStore.compact({ quietSince, dryRun })` rewrites a file as one `add` - per live block carrying its current state (exactly what the migrations - write), dropping superseded edits, moves and deleted blocks. It is the + per live block carrying its current state, dropping superseded edits, moves and deleted blocks. It is the only operation that rewrites a block file, so it only touches format 3 files whose newest record is older than `quietSince` (another machine must not still be appending to a file rewritten under it), replays the @@ -125,9 +126,9 @@ Reasons for this over alternatives: deterministic (two machines compacting the same file agree), holds the file's lock, and keeps the index current. Git history keeps every dropped record. The app does not call it yet. -- Parsing tolerates hand edits: missing ids get generated (format 1/2), - CRLF is fine, and anything before the first record is ignored rather than - destroyed. Format 1 and 2 files are still read. +- Parsing tolerates hand edits: CRLF is fine, and anything before the first + record is ignored rather than destroyed. Records without an id are + skipped. ### Notes as nodes @@ -205,17 +206,15 @@ the anchor's thread. Timestamps are untouched, which is the answer to the written and its position is where it is kept; the file already separated the two. Dropping is refused across days because a day is a file. -**Todos** are blocks of kind `todo` in a per-canvas block file, `todos.md`, -not in the dated stream: they outlive the day they were written, and their -order is priority, not time. The file uses the same block format as a day -file (`parseBlockFile` / `serializeBlockFile`, which day files now go -through as well), so comments are ordinary replies and reordering is the same -`moveSubtree`. `meta.done` is when a todo was ticked off; ticking also -writes a `done` block into today's stream (`meta.todo` links back), which is -how completions reach the log, the timeline and the review. Unticking the -same day removes that block; on a later day it is left as history. The panel -reads one small file per canvas in scope, so it stays cheap regardless of -how long the log gets. +**Todos** are blocks of kind `todo` in the day files, at any depth (storage +format 4; see `BLOCK-PAGES.md`). `meta.done` is when one was ticked off; the +date range query finds day files by that time too, so the timeline shows +ticks on the day they happened. The panel asks the index for the day files +holding a todo (`todoDays`), reads those, and groups the open todos under the +canvases and blocks above them. Format 3 kept a per-canvas `todos.md`; +`upgradeStorage` moves each old todo (with its comments) into the day file +of the day it was written. "✓" blocks written by earlier versions when a +todo was ticked stay as history. **Moving** a block (with its thread) to another canvas rewrites nothing but the file it lives in: assets stay put and the serialised link becomes @@ -235,38 +234,14 @@ returns archived hits with a badge. Archiving a canvas flags every canvas beneath it; unarchiving reverses the same set. Keeping it a flag means git history stays linear and a mistaken archive is a one-line change. -**Migrations** (`packages/core/src/node/migrate.ts`) run when a repository -is opened, before anything reads it. Devlog 0.2's `pages/` + `categories/` -become format 1 canvases (category paths become chains of canvases, wikis -become surfaces); format 1 goes straight to format 3 (sharded layout and -append-only block files), and format 2 (0.4) has its block files rewritten -in place, one atomic write each, manifest last, so it resumes if -interrupted. The format 1 layout step is: - -- **Staged and swapped.** The new `canvases/` and `entries/` trees are built - in `.devlog-migrate/` (git-ignored); the old ones are moved aside to - `.devlog-migrate-old/`, the new ones moved in, and `devlog.json` written - last. On the next open, leftovers are rolled back (no manifest yet) or - cleaned up (manifest present), so an interrupted upgrade never leaves a - half-migrated tree. -- **Deterministic.** A migrated canvas's id is a hash of its old folder name - (collisions resolved in sorted order), and every file is re-serialised - canonically, so two machines migrating the same history get byte-identical - trees. That is what makes the multi-machine story work: - `upgradeRepository` pulls first when the remote is still on an older - format; pulls only, when another machine already pushed the upgrade and - this one has nothing unsynced; and otherwise merges the remote's last - pre-upgrade commit (found with `git log -G` on the manifest) - (an ordinary merge), migrates, records the remote's migration commit as - merged with `-s ours` (our tree is the same migration of a superset of its - history) and merges the remote. The obvious alternative, rebasing old - commits onto the migrated remote, "succeeds" because git follows the - renames, and splices old-format text into new files; tests cover it for - both older formats. -- **References rewritten**: parents, task and todo links, and root-relative - image paths into moved canvases; old ids become `alias` lines, which the - store (`resolveCanvasId`, `aliasMap`), the tracker's persisted task and the - activity reader follow. +**Older formats.** Devlog 0.5 upgraded formats 1 and 2 (and 0.2's `pages/` ++ `categories/`) to format 3 when it opened a repository; that code was +removed once every devlog had been upgraded (it is in git history, up to +release 0.5.3). The app now checks `devlog.json` on open +(`assertSupportedFormat`) and refuses anything but format 3 with a message +saying which Devlog version to use. Canvases that had slug ids keep them as +`alias` lines, which the store (`resolveCanvasId`, `aliasMap`), the +tracker's persisted task and the activity reader still follow. ### The local index @@ -297,11 +272,11 @@ running, which is why the window closes to the tray when tracking is on. Signals come from Electron's `powerMonitor`: `lock-screen`/`unlock-screen`, `suspend`/`resume`, and a 15-second poll of `getSystemIdleState` for idle. -Each is written as an event. Foreground windows come from a long-running -helper process per platform (`src/main/activity/foreground.ts`): PowerShell -with `GetForegroundWindow` on Windows, an `osascript` loop on macOS, `xdotool` -on Linux. They emit only on change; the tracker writes a `focus` event with -process name and title. There are no native modules. +Each is written as an event. Foreground windows are not the core's: the +devlog-focus extension (`builtin-extensions/devlog-focus`, see +`docs/EXTENSIONS.md`) runs its own helper and keeps `focus` events in its +own folder, and the app merges them back into the activity the views read. +There are no native modules. Events go to `ActivityLog`: JSON lines, one file per local day, one folder per machine (`activity/-/…`, the folder name kept in @@ -317,7 +292,9 @@ not count as unsaved work in the status (`quietPaths`); the interval sync commits them. A `heartbeat` every five minutes is the liveness signal: segment building treats a gap of more than two heartbeats as "the app was not running", so a crash cannot inflate a -task by a weekend. +task by a weekend. Heartbeats are skipped while paused: +the lock/idle/suspend event already closed the segment, and a locked machine +that kept writing would also commit and push every five minutes. Pauses are tracked per reason (locked, idle, suspended) both in the tracker and in the replay. The clock runs only when none applies: waking from sleep diff --git a/docs/EXTENSIONS.md b/docs/EXTENSIONS.md new file mode 100644 index 0000000..3687144 --- /dev/null +++ b/docs/EXTENSIONS.md @@ -0,0 +1,534 @@ +# Extensions: design (draft) + +Status: **milestone 1 built** (0.7.0): manifests and sources, the lockfile, +installing from GitHub releases / URLs / built-ins, consent with read and +write scopes, the sandboxed extension process, the file broker, secrets, +settings, canvas fields, commands and notifications. Cards, destinations and +devlog-focus are later milestones. Tracks issue #1 (with #2 time export, #3 +custom block types and #16 window tracking). How to write one: +`packages/extension-api/README.md`. + +## Goals + +- Let a devlog opt into integrations (pushing tracked time to Jira and CMS, + Outlook-backed blocks, link previews) without growing the core app. +- **The devlog stays Just A Notebook.** It *names* the extensions it wants + and holds their non-secret settings; it never contains their code, their + secrets, or anything they need at runtime besides that. +- Opening or pulling a devlog must never run code that arrived through git. +- A leaked or public devlog repository must not leak any credential. +- Extensions go through the same data layer as the app (`@devlog/core`), so + they cannot break the file-format rules (append-only block files, marker + escaping, image paths). +- **Extensions cannot see each other's data**, and see the devlog itself + only where you let them. +- **Safe without containers**: no Docker, no VM. Safety comes from what the + runtime and the API allow (see *Safety in v1*). +- Features that not everyone wants live in extensions, even first-party + ones. Window tracking is the first to move (see *Window tracking as an + extension*). + +Non-goals: two-way sync with trackers (Devlog records what happened; it is +not a project-management tool), a public extension marketplace, a package +manager, network sandboxing in v1. + +## What an extension is + +A folder (shipped as one `.zip`) with a manifest, `devlog-extension.json`, +and bundled code: + +```jsonc +{ + "name": "devlog-jira", + "displayName": "Jira time export", + "version": "1.2.0", + "api": "^1.0.0", // extension API version it was built for + "main": "main.js", // one bundled file, no dependencies + "cards": { "worklog": "cards/worklog.json" }, // Adaptive Card templates (see Cards) + "contributes": { + "destinations": [{ "id": "jira", "label": "Jira worklogs" }], + "canvasFields": [{ "key": "issue", "label": "Jira issue", "placeholder": "ACME-123" }], + "settings": [{ "key": "baseUrl", "label": "Jira URL" }], + "secrets": [{ "key": "token", "label": "API token" }], + "unfurl": ["*.atlassian.net"] // links it can turn into cards + }, + "permissions": { + "read": true, // wants to read blocks; you choose the scope + "write": false, // add blocks; you choose the scope + "network": ["*.atlassian.net"] // declared and shown; not enforced in v1 + }, + "appendOnly": ["**/*.jsonl"] // its own repo files that git should union-merge +} +``` + +- **Not an npm package.** Extensions are not JavaScript libraries and no + JavaScript project would ever depend on one, so they are not published to + npm. Authors can still use npm and a bundler (esbuild) to *build* one; + what ships is the bundle. +- **Bundled, dependency-free.** One `main.js`, no `node_modules`. Installing + is "download one zip, check its hash, unpack", with no dependency tree to + resolve, audit or trust, and nothing that resembles a package manager. +- **`contributes`** declares everything the app shows (destinations, canvas + fields, settings, secret prompts, link handlers) so the UI can be built + without running the extension. +- **`permissions`** are shown before first activation. Read and write are + granted with a scope you pick (see *Reading and writing the devlog*). +- Authors write against the extension API's types and a small test + harness, kept in this repository (`packages/extension-api`) and consumed + from git. The runtime object is injected by the app at activation. + +## Where extensions come from + +Releases on GitHub (or any HTTPS URL), not a registry: + +```json +{ + "format": 3, + "extensions": { + "sdeken/devlog-jira": "^1.2.0", + "sdeken/devlog-cms": "0.3.1", + "devlog-focus": "builtin" + } +} +``` + +- `owner/repo` with a version range: the app lists the repository's + releases (GitHub's API), picks the newest tag matching the range, and + downloads the release's `*.devlog-ext.zip` asset. Private repositories + work with a GitHub token from the secret store. +- A plain `https://…/x.zip` pins one exact file. +- `builtin` names a first-party extension that ships inside the app (no + download), like devlog-focus. +- A lockfile, `devlog.lock.json`, written by the app, records the exact + version, URL and SHA-256 each entry resolved to. Every machine runs + byte-identical code, and an update is a visible, committed change + ("devlog: update extensions"), never a silent drift. +- **Update extensions** (a command) re-checks the ranges, shows what + changed, rewrites the lockfile and commits it. Pulling a lockfile that + another machine updated leads to a download and a consent prompt, since + the code is new. +- Unpacked into `userData/extensions//`, shared by every devlog that + pins the same bytes; nothing is written into the devlog. +- **Development:** a machine-local override (in user data, never in the + devlog) maps an extension to a folder, for working on one without + releasing it. + +An extension's **id** comes from where it was fetched, not from its +manifest (`sdeken/devlog-jira` → `sdeken.devlog-jira`; built-ins are +`builtin.`). The id names its folders, secrets and settings, so one +extension cannot claim another's name to read its data. + +## Settings + +- **Per devlog** (Jira base URL, CMS project list): in `devlog.json` under + `"settings": { "sdeken.devlog-jira": { … } }`, edited through the app. +- **Per canvas** (this client's Jira issue, CMS client): in the canvas's own + `canvas.md`, as namespaced front-matter keys, e.g. + `ext.sdeken.devlog-jira.issue: ACME-123`. They follow the canvas through + renames and moves, and the canvas dialog shows them as ordinary fields + (from `contributes.canvasFields`). Task canvases inherit from the nearest + ancestor that sets one, as time already rolls up in the review. + *Prerequisite:* the `canvas.md` parser currently drops unknown keys; it + must keep them. +- **Secrets** (tokens, the CMS password): never in the repository. Kept in + user data per extension, encrypted with the OS keychain (Electron + `safeStorage`: DPAPI on Windows, Keychain on macOS, libsecret on Linux), + through `ctx.secrets`. + +## Reading and writing the devlog + +Reading is a permission, like writing. When an extension asks for `read` or +`write`, you grant it one of: + +- **the whole devlog**, or +- **specific canvases** (each with everything beneath it), picked in the + consent dialog and changeable later from the extension's settings. + +Everything the API returns is filtered by the grant: `canvases()` lists only +granted canvases (and the ancestors needed to name them), `blocks()` and +search return nothing outside them, `timesheet()` only entries on them, and +writes outside them fail. With no read grant an extension still gets what +it needs to work: its own settings, the canvas fields it declared on the +canvases it was granted, and its own files. + +Grants are kept on this machine with the consent (see *Trust*), not in the +repository, so nothing that arrives through git can widen them. + +Blocks an extension writes carry an `ext=` attribute and are automatic +blocks (read-only, muted brace), like captured commits. They are ordinary +markdown in the day files, so they survive the extension going away. + +## Its own files + +Besides the devlog, each extension has two private folders it manages +however it likes (JSON, JSON lines, SQLite, markdown): + +| Store | Where | Synced | For | +|---|---|---|---| +| `ctx.files.repo` | `extensions//` in the devlog | yes, committed with everything else | data every machine should see: logs, the record of what was sent | +| `ctx.files.local` | `userData/extension-data///` | no | machine-only state: cursors, caches, anything bulky or noisy | + +The core ignores `extensions/` when listing, indexing and searching. For +git, the manifest's `appendOnly` globs get a `merge=union` line in +`.gitattributes` (as activity logs do), so two machines appending never +conflict. Like activity logs, extension writes don't count as unsaved work +and ride along with the next interval commit. + +Uninstalling leaves both folders alone (the devlog keeps the data, and git +has it anyway). Removing the data is a separate, explicit action. + +## Safety in v1 + +The v1 line is: **no direct access to the file system**, and nothing else +that would get around it. An extension: + +- **cannot touch files.** Its main half runs in its own process, started + with Node's permission model (`--permission`) and *no* file allowances: + every `fs` call, from any code in the bundle, fails in the runtime. The + code itself is sent over the message channel and evaluated, so the + process needs no read access even to load it. Its own folders are reached + only through `ctx.files`, a broker in the app that takes relative paths, + refuses absolute paths, `..` and symlinks out of the folder, writes + atomically and caps sizes. +- **cannot start programs, load native code or spawn workers** (the same + permission model denies child processes, addons, workers and WASI). The + things that need them stay in the app and are offered as narrow + capabilities instead, or the extension asks to run unrestricted (below). +- **sees the devlog only through its grant**, and only its own folders, + settings and secrets. +- **cannot reach other extensions, the app's windows or Electron.** Each + extension has its own message channel to the app and nothing else; there + is no API to list or talk to other extensions. +- **cannot take the app down**: a crash or hang kills only its process + (memory is capped, and an unresponsive one is stopped and reported). +- **UI without code**: what extensions show inside the notebook is Adaptive + Cards (below), rendered by the app. No extension code runs in the + notebook's window. + +**Unrestricted extensions (trusted).** An extension that needs to do what +the sandbox forbids (start a platform helper, read files) says so with +`"permissions": { "unrestricted": true }`. It then runs only if you say, in +the consent dialog, that you trust it and its author, and it runs without +the permission model: full file-system and process access, the app's +environment, and `ctx.packageDir` (its own unpacked folder, for scripts it +ships). Everything else (consent per build, a new build asks again, its +private folders, grants for the devlog API) stays the same. This is the +middle ground: the sandbox is the default, and leaving it is an explicit, +per-extension decision you make, not something the app grants itself. + +**Not in v1: the network.** An extension can make any request; the domains +it declares are shown at consent but not enforced (enforcing needs all +traffic to go through the app). The consequence to keep in mind: an +extension can send out whatever it can read, which is one reason read +access is scoped. + +**How it is run.** Electron's `utilityProcess` silently ignores +`--permission` (checked: file reads, child processes and workers all +succeed in it), so each extension runs as a Node child of the app binary +(`ELECTRON_RUN_AS_NODE`) with `--permission`, read access to exactly one +file (the host script, copied out of `app.asar` into user data), a heap cap, +and an environment stripped to a few locale and Windows variables. The +builds must keep Electron's `RunAsNode` fuse on. Tests run a probe +extension in that process and check that file access, child processes and +workers are refused with `ERR_ACCESS_DENIED`. + +## Cards: rich blocks and link previews + +Slack and Teams don't share a card format: Teams (and Outlook, Webex, +Windows widgets) use **Adaptive Cards**, an open JSON format from +Microsoft; Slack uses its own Block Kit. What both do share is unfurling +links from a page's Open Graph / oEmbed metadata. Since Outlook is a likely +extension, Devlog uses Adaptive Cards: + +- **Templates in the extension, data in the block.** An extension ships card + templates (`cards` in the manifest). A block it writes has a namespaced + kind (`x-sdeken.devlog-outlook.meeting`), a readable markdown body, and the + card's data as a trailing fenced block (```` ```card ````). The app renders + the template with that data (Adaptive Cards templating) in the notebook's + theme; without the extension, the block shows its markdown and the data + fence stays folded away. Search and the index read the markdown. +- **Actions** go back to the extension: `Action.Execute` calls its main + half, which can answer with new card data (written as an `edit` record); + `Action.OpenUrl` opens the browser after the app checks the link. +- **Link previews**: an extension that declares `unfurl` domains is asked to + turn a pasted link on them into card data (a Jira issue key, title and + status), stored with the block like any other card. Generic Open Graph + previews for any link could be a small first-party extension of their own. +- A sandboxed-iframe renderer for things cards cannot express is possible + later, but not planned: cards keep extension code out of the window. + +## Window tracking as an extension + +Recording the foreground window is useful to some people and noise to +others, and it is the most privacy-sensitive thing Devlog records. It is a +first-party extension, **devlog-focus** ("Window tracking"), built into the +app (nothing to download) and off until a devlog adds it (#16). + +- **Nothing about windows is in the core.** devlog-focus runs unrestricted + (you are asked whether you trust it) and starts its own platform helper: + PowerShell with `GetForegroundWindow` on Windows, an `osascript` loop on + macOS, `xdotool` on Linux. 0.8 had the app run the helper and pass a + window feed to the extension; 0.9 moved it into the extension and removed + the feed from the API (1.2). +- **What stays core:** the task clock. Task switches, start/stop, + lock/unlock, idle/active, sleep/wake and heartbeats stay in the core + activity log, because tracked time, the review and timesheets depend on + them. Extensions hear about locks and sleep through `ctx.activity`, + straight from the OS whether or not time is tracked; devlog-focus records + nothing while paused. +- **Its data** is in its own synced folder, one JSON-lines file per machine + and day: `extensions/builtin.devlog-focus//YYYY/MM/.jsonl`, + `{"t", "app", "title"}` per line, append-only and union-merged. +- **What it gives back:** `ctx.provide.focus(from, to)` returns its events, + which the app merges into the activity it hands the timeline, review and + summary. Those views draw focus exactly as before. +- **Existing data:** `focus` events already in the core activity logs stay + there and still show. New ones come from the extension. + +## Trust + +The honest model: **you trust what you install**, like a VS Code extension, +but with the limits above enforced by the runtime rather than by +convention. The app makes that trust explicit and hard to grant by accident: + +- The first activation of a given extension *hash* in a given devlog asks, + showing name, version, source and requested permissions, and lets you set + the read and write scopes. Approvals and scopes are stored on this + machine, so a devlog pulled onto a new machine, or a lockfile changed by + another machine, asks again. +- You can revoke or narrow a grant at any time; the extension is restarted + without it. + +## Runtime and API + +One process per extension (the main half), talking to the app over a +message channel that carries the API below. What is built (milestone 1) is +defined in `packages/extension-api/src/index.ts`; the sketch also shows what +later milestones add (`timesheet`, `destinations`, `cards`, `unfurl`). + +```ts +export function activate(ctx: DevlogContext): void | Promise + +interface DevlogContext { + devlog: { // everything filtered by the grant + canvases(): Promise // with this extension's canvas fields + blocks(canvasId: string, date: string): Promise // needs read + search(query: string): Promise // needs read + timesheet(weekStart: string): Promise // the approved timesheet (see TIMESHEETS.md) + addBlock(canvasId: string, markdown: string, opts: { kind?: string; card?: unknown; meta?: Record }): Promise // needs write + } + settings: { get(key: string): T | undefined } // devlog-level, from devlog.json + secrets: { get(key: string): Promise; set(key: string, value: string): Promise } + files: { repo: ExtensionFiles; local: ExtensionFiles } // this extension's own folders + activity: { on(event: 'pause' | 'resume' | 'task', cb: (ev: ActivityEvent) => void): void } // read-only + ui: { notify(message: string): void; confirm(message: string): Promise } + destinations: { register(id: string, destination: Destination): void } + commands: { register(id: string, label: string, run: () => Promise): void } + cards: { onAction(verb: string, run: (data: unknown) => Promise): void } + unfurl: { register(run: (url: string) => Promise<{ kind: string; markdown: string; card: unknown } | null>): void } +} + +interface ExtensionFiles { + read(path: string): Promise + readText(path: string): Promise + write(path: string, data: string | Uint8Array): Promise // atomic + append(path: string, text: string): Promise + list(dir?: string): Promise> + remove(path: string): Promise +} + +interface Destination { + /** Group a final timesheet's entries into this system's lines (mapping and grouping only). */ + lines(sheet: Timesheet, sent: SentRecord[]): Promise + /** Send the lines; return an external id per line for the record. */ + submit(lines: DestinationLine[]): Promise +} +``` + +## Reading what the app recorded, kept canvases, sending through others (built, API 1.7) + +What the Timesheet and Summary needed to move into devlog-time: + +- `devlog.activity(from, to)`: what the app recorded (its own log of locks, + idle, sleep, git events and corrections, merged with what extensions + provide: time, focus), limited to what it may read (task time on canvases + it may not read shows as none; window titles only with read access to the + whole devlog). `devlog.range(from, to)`: the day files with blocks written + in a range. +- `devlog.managedCanvas(key, { title })`: a canvas the extension keeps + (`devlog.managed: /` in canvas.md): its own whatever it was + granted; `addBlock` there takes a `kind` of its own (not todo, task, + commit or done) and any `date`, and `editBlock` works on every block there. +- `destinations.list()` / `preview(to, sheet)` / `send(to, sheet)`: other + extensions' destinations, through the app, for an extension that declares + `permissions.send` (shown when you allow it). The app builds each + destination's sheet (labels, its own canvas fields) as before; the sender + keeps the record. +- `ui.openPage(viewId)`; canvas fields with `"inherited": false` (a target + applies to its canvas only). +- Views: a shortcut pressed inside a view that it does not use (Ctrl, Alt or + Cmd with a key, F-keys) is passed to the app (`key`), so Ctrl+K works + from an extension's page. +- A built-in extension keeps its grant when the app brings a new build. + +## Node types, commands, events and app state (built, API 1.6) + +What time tracking needed to move out of the app (`TIME-EXTENSION.md`); +nothing in it is specific to time. + +**Node types.** `contributes.nodeTypes` (`id`, `label`, `icon`, +`placeholder`) declares kinds of canvas. A canvas carries one as +`type: /` in canvas.md (the time extension's task keeps +the older `task: true`); the app shows its icon and label, offers it under +**Type** in canvas properties and as **New *label* inside…** on a canvas's +menu, and adds the placeholder to the note box on such canvases. A type +whose extension is gone reads as a plain canvas. `canvases()` reports each +canvas's `type`. + +**Commands** take a context: `commands.register(id, (context) => …)` gets +`{ source, canvasId?, date?, blockId? }` (where it was run from: the quick +switcher, a keybinding, a menu, the note box, a view or the tray; and the +canvas on screen or whose menu it was, and the block). Canvases outside the +grant are left out. In `contributes.commands`: + +- `keybinding` (`Mod+Shift+S`; Mod is Ctrl, or Cmd on a Mac; a plain key + or Shift+key is refused, F-keys excepted) runs it with the screen's + context; +- `nodeType` offers it only on canvases of that type (its own id, or a full + name); +- `menus`: `canvas` (a canvas's right-click menu), `block` (the actions on + a block), `tray`; +- `post: true` makes it a way to post from the note box: its keybinding (or + `#` on the first line, with `tag`, taken off before posting) posts the + note as usual, then runs the command on the new block (`source: 'post'`). + +**Writes.** `devlog.createCanvas({ title, parentId, type })` (inside a +canvas it may write to; top level only with write access everywhere), +`devlog.updateCanvas(id, { title, parentId, type, archived })` (types: its +own, and it may clear only its own), `devlog.editBlock(…)` (blocks it +added), and `devlog.promote(canvasId, date, blockId, { type })`: the block +becomes the link to a new canvas of that type just inside its canvas, and +what was written inside the block moves there. + +**Events.** `devlog.onBlockAdded(cb)` hears each block posted in the app, on +canvases it may read. `activity.on(cb)` hears pause (locked, idle, asleep) +and resume; `activity.idleAfter(minutes)` asks for idle detection (the app +uses the shortest any extension asks for). + +**The app around the window.** `ui.pick(items)` shows a quick pick and +resolves to the chosen id; `ui.open({ canvasId, date?, blockId? })` shows a +canvas or a block's page; `ui.highlight(canvasId)` marks its current canvas +(the running task) in the sidebar, and commits from a linked repository land +on it when it is inside the linked canvas; `app.setTrayLabel(text)` and +`app.keepRunning(true)` (close to the tray instead of quitting). + +**Time.** `provide.activity((from, to) => TimeEvent[])` hands the app +`start`, `task`, `stop` and `heartbeat` events (per machine); the timeline, +review, summary and timesheet replay them with the app's own record of +locks, idle and sleep, which the app keeps (in `activity/`) while some +extension provides time. Summary and Timesheet show only then. + +**Views** can also sit in a canvas's header (`placement: 'canvasHeader'`, +26 px high, optionally only for a `nodeType`). Every view gets a `context` +message with where it is: a header view its canvas, a status bar item the +canvas (and block page) on screen, a popover the context of the view that +opened it; a `command` a view runs carries that context. `@devlog/ui` has +`useViewContext()`. + +The `shaper` test fixture (`tests/fixtures/extensions/shaper`) exercises all +of this through the real host process; `devlog-time` is the real user. + +## Views (built, API 1.5) + +An extension can ship pages of its own, declared in `contributes.views` +(`id`, `title`, `entry`: an .html file in the package, `placement`, `icon`): + +- `page`: listed in the sidebar with the app's own views; fills the main area. +- `statusbar`: a slot in the status bar, 22 px high; the page asks for a + width (`resize`) and gets it within 24–360 px. +- `popover`: opened by another of its views (`popover`), anchored to it; + closes on Escape, a click outside, or `close`. + +Pages load from `devlog-ext:///`, only +from the extension's own folder and only while it runs, in a frame +sandboxed to scripts (no same origin: no access to the window around it or +its storage) under a CSP with no network (`connect-src 'none'`), no frames +and no forms. A view talks to its extension only through the app: +`postMessage` to the parent (`ready`, `call`, `resize`, `popover`, `close`, +`open` a canvas or block, run a `command`); the app answers (`reply`), +relays what the extension `views.post`s (`message`), and sends the app's +colours as CSS custom properties (`theme`) when the page is ready and +whenever they change. The extension answers with +`ctx.views.handle(viewId, (method, args) => …)`. The message types are in +`@devlog/extension-api/view`. + +## Todos and blocks inside blocks (built, API 1.4) + +Todos are blocks (`kind: 'todo'`, `meta.done` once ticked). `devlog.addBlock` +takes `{ parentId, date }` to add a block inside another (the block's day +file) and `{ todo: true }` to add a todo; `devlog.todos({ doneSince })` lists +the open todos (and recently ticked ones) on the canvases the extension may +read, with the blocks each sits inside. + +## Destinations (built, API 1.3) + +An extension declares `contributes.destinations` and registers each with +`ctx.destinations.register(id, { preview(sheet), send(sheet) })`. When you +press **Send to …** on a final week, the app builds the sheet: every entry +with its task and client names and this extension's own canvas fields, +inherited down the tree. Pressing Send is the consent for that week's +entries, so a destination needs no read grant. The app shows the preview +(new, changed, removed, already sent, not for this destination and why), +calls `send`, and records the summary as a reply under the week's +timesheet. The extension keeps its own ledger of what it sent. + +**devlog-jira** (built in, sandboxed) is the first: one worklog per entry +on the entry's Jira issue (canvas field `issue`), REST v2, Basic auth with an +account email and API token (Cloud) or a personal access token (Data +Center), ledger in `sent/.json`. + +**devlog-cms** (built in, sandboxed) drives the CMS web pages: form login +(`j_security_check`, cookies kept in memory for the send), the week page +parsed for assignment rows and each day's `timesheet_id` link, and the +day's form submitted with `hrs_worked` in decimal hours. Hours per +assignment (canvas field `assignment`) per day; the preview compares with +what CMS shows, so only differing days are sent. Ledger in +`sent/.json` (assignment|day → hours) so days it filled and that no +longer have time are set back to 0. The password is a secret, kept on the +computer. + +## Time export (#2) + +See `TIMESHEETS.md`. Time goes through a weekly **timesheet** (core): a +synopsis you review and shuffle, rounded once to quarter hours, stored in a +managed *Timesheets* canvas together with a record of every submission. +Extensions contribute **destinations** (Jira by task with start times, +built; CMS by assignment per day, built): mapping fields, grouping and +sending. No business +rules: you finalise the hours. **Sending is always something you do**, by +button, after a preview; there are no scheduled or automatic exports. + +## Milestones + +1. Core plumbing: `devlog.json` extensions + lockfile, GitHub-release + fetching, keeping unknown `canvas.md` keys, `safeStorage` secret store, + the file broker and per-extension folders, read/write grants and the + consent prompt, the extension process (with the `--permission` check), + built-in extensions, the API types and test harness. +2. **devlog-focus** (done, 0.8.0): window tracking moved out of the core, + which proves the loop end to end on a real feature. +3. Timesheets in core and the app: draft, grid, managed canvas, send + record (`TIMESHEETS.md`); CSV destination. +4. Jira and CMS destinations. +5. Cards: Adaptive Card blocks, actions and link unfurling; then Outlook as + the first real card-based extension. +6. **devlog-time** (done, 0.17.0): time tracking moved out of the core on + API 1.6 (node types, commands in menus and the note box, canvas writes, + events, app state, time events), with views built on `@devlog/ui`. + +## Decided + +- **Write grants** are scoped like read grants (the whole devlog or chosen + canvases), chosen separately in the consent dialog. +- **Card data** lives in the block (a trailing ```` ```card ```` fence), so a + day file is complete and readable in git on its own. diff --git a/docs/TIME-EXTENSION.md b/docs/TIME-EXTENSION.md new file mode 100644 index 0000000..46707e6 --- /dev/null +++ b/docs/TIME-EXTENSION.md @@ -0,0 +1,108 @@ +# Time tracking as an extension: design + +Status: done. Steps 1–3 in 0.17.0 (API 1.6, `devlog-time`, its Start/Stop +views); step 4 (the Timesheet and Summary as its pages, sending through the +app, API 1.7) and step 5 (hour targets) in 0.18.0. What was built follows +this plan with these differences: + +- The API stayed 1.x (1.6): everything is additive, so extensions written + for 1.0–1.5 keep loading. +- The clock's events live in `extensions/builtin.devlog-time//`; + the app keeps logging locks, idle and sleep (and git events) in + `activity//` while an extension provides time, and its views + replay both together with the older log. +- The Weekly review and the Timeline stay in the app, drawing their time + from `provide.activity`; the Timesheet and Summary are devlog-time's + pages, reading what the app recorded through `devlog.activity`. +- A built-in extension keeps its grant when the app brings a new build (it + is part of the app you updated), so an update never stops the clock. +- Targets are measured against the timesheet hours (reported), the week's + on screen and, for a month, the other weeks as saved or drafted. +- Quick pick is `ui.pick`; the canvas an extension considers current is + `ui.highlight` (the sidebar marks it, and commits route to it). + +## The idea + +Time tracking stops being part of the app. A built-in extension, +`devlog-time`, brings everything that goes with it; without it there is no +task type, no Start button, no timesheet, nothing recorded and no heartbeat +commits. + +| Moves to `devlog-time` | Stays in core | +|---|---| +| The **task** node type (icon, label, posting on it starts it) | Canvases, blocks, pages, todos | +| The active task, start/stop, idle/lock/sleep pauses, heartbeats | Notes counts in the Weekly review | +| The status bar Start/Stop, the tray label, the Stop hotkey | The Timeline's notes and git events | +| Post as task (Ctrl+Shift+Enter, `#task`), the Task block action, Make task on a todo | The destination registry (Jira and CMS are fed by the time extension instead of the app) | +| The Timesheet view, the managed Timesheets canvas, Send to… | Canvas fields, settings pages, the sandbox | +| Summary (hours per client), the Weekly review's time columns, task stretches on the Timeline | Machine state (lock, idle, sleep) for now | +| Tracking settings (idle minutes, activity log in the repository) | Git capture for now | + +Later, the same way: **machine state** (lock, idle, sleep) becomes its own +extension, separate from both core and `devlog-time`; **git** (commit +capture, branch events) becomes an extension too, for people who don't want +it in their streams. + +## Extension API 2.0 + +1. **Node types:** id, label, icon, a hint for the note box; stored as + `type:` in canvas.md; a Type choice in canvas properties. `task: true` + canvases count as the time extension's type when it is present and as + plain canvases when not; no files rewritten. +2. **Status bar items are rendered, not described:** an item is a small + view (below) in a slot of fixed height; it asks for a width and may not + get it. Menus can't fit in the slot, so an item can ask for a **popover** + anchored to it (another view) or a quick pick. +3. **Quick pick:** the quick-switcher list with items the extension gives. +4. **Keybindings and placements:** a keybinding on a command, optionally + only on canvases of a given type; items in the canvas right-click menu + and header; block actions; note-box post variants (Ctrl+Shift+Enter + becomes the time extension's "post as task"). +5. **Events and writes:** "block posted"; creating canvases and setting + their type; editing blocks the extension wrote (a timesheet save is an + edit). +6. **System state from the app:** lock, unlock, sleep, wake, idle past N + minutes. The extension stays sandboxed. +7. **Time providers:** "minutes per canvas per day" and "task stretches for a + day"; the Weekly review, Timeline and anything else draw their time parts + from these and hide them when there is no provider. +8. **Views:** an extension ships HTML and script, shown in a sandboxed frame + with no access to files, talking to its own process through the app. + The app injects its theme. A shared **`@devlog/ui`** package (React + components and the app's design tokens) that views bundle, so extensions + look like the app; each frame carries its own React. +9. **Tray and keeping running:** set the tray label; ask to keep running in + the tray when the window closes. + +## Data + +- Existing time lives in `activity//` (task switches, locks, + heartbeats). The extension reads it as history and writes its own events to + `extensions/builtin.devlog-time//` from then on; old files stay. +- Timesheets stay blocks in the managed Timesheets canvas (history in the + notebook, clean merges); the extension becomes their author. +- Jira and CMS are unchanged; the send flow moves into the time extension's + view with the app in between. +- Old task-link blocks keep rendering as links without the extension. +- One-time switch: with `trackingEnabled` on, the extension is added and + allowed, so nothing changes for you. + +## Hour targets + +Weekly or monthly targets on any node, as canvas fields of the time +extension, shown against actual hours in the timesheet. **Targets overlap +and each applies on its own**: 168 h for Drury in September and 20 h a week +for a task under Drury are two separate measures, each compared with the +hours under its own node for its own period. A target is not inherited by +the nodes below it. + +## Order + +1. Todos as blocks (`BLOCK-PAGES.md` phase 2): the Task block action and + Make task on a todo become the same hook. +2. Extension API 2.0, items 1–7 and 9, tested with a small made-up extension. +3. `devlog-time`, built in: tracker, task type, commands and hotkeys; the + app becomes provider-driven. Tracking leaves core here. +4. Views and `@devlog/ui` (item 8); the timesheet and Summary as the + extension's pages; status bar items as views; the send flow; smoke rewrite. +5. Hour targets. diff --git a/docs/TIMESHEETS.md b/docs/TIMESHEETS.md new file mode 100644 index 0000000..1b44587 --- /dev/null +++ b/docs/TIMESHEETS.md @@ -0,0 +1,233 @@ +# Timesheets: design (draft) + +Status: **the timesheet is built** (0.9.0): the arithmetic in +`@devlog/core` (`timesheet.ts`: rounding, sessions, draft entries, suggested +trims, the stored model), the draft (`src/shared/timesheet.ts`), the weekly +**Timesheet** view, and storage in the managed Timesheets canvas. +Sending is built too (0.10.0): destinations in the extension API and the +built-in **Jira worklogs** extension (devlog-jira); **CMS timesheets** +(devlog-cms, 0.12.0) fills in the CMS web form (it has no API). Split and +merge of entries are not built. Replaces the simple "export the review's +rows" idea (issue #2). + +## The problem + +Tracked time has to reach more than one outside system, and they disagree: + +| | Jira | CMS | +|---|---|---| +| Unit | a task (Jira issue) | an assignment (client / project) | +| Needs | when the work happened (start + duration) | decimal hours per assignment per day | +| Ids | Jira issue keys | CMS assignment numbers, unrelated to Jira's | +| Which clients | only some (the as-needed client) | all of them | + +Every reported duration follows one firm rounding rule (below). Anything +else, such as how many hours a client should be billed in a week, changes +too often to encode. Devlog reports what was worked, rounded, and **you +finalise it**. + +So the time log cannot go straight to a destination. It goes through a +**timesheet**: a weekly synopsis you review and adjust, then send to each +destination. The timesheet is also the permanent record of what was worked +and what was reported. + +## Shape + +``` +activity log + blocks ──► draft timesheet ──(you adjust)──► final timesheet + │ │ + sessions, rounding, per-destination views + suggested trims (Jira: per task, with times + CMS: per client per day) + │ + preview ──► submit ──► recorded +``` + +Sending is always something you do: pick a week and a destination, look at +the preview, press **Send**. There are no scheduled or automatic exports. + +- **The timesheet is core, destinations are extensions.** The synopsis comes + from Devlog's own tracking data, and several destinations share one + synopsis. It is useful on its own (history, CSV). Extensions (#1) + contribute destinations: which canvases they take, their ids, how lines + are grouped, and how to send them. Destinations contain **no business + rules** (caps, minimums): those are yours to apply while adjusting. +- **One timesheet per week**, Monday to Sunday like the review. +- **Using it (as built):** open **Timesheet** in the sidebar. A week with no + saved timesheet shows a draft from tracked time (not saved until you + change something, or press Save draft). The week is a grid: a column per + day plus the week, a row per task grouped under its client (with the + client's totals), and the day totals reported and worked at the bottom. + Clicking a cell opens its entries underneath: start, duration (− / + in + 15-minute steps), task, day, note, remove, add. A client's cell shows the + suggested trim for that day (click to apply). **Rebuild from tracked + time** replaces the entries with a fresh draft; **Mark final** approves + the week and makes it read-only until you Reopen it. Every change is saved + within a second. + +## The timesheet + +A list of **entries**, each one piece of work: + +| field | | +|---|---| +| date, start, minutes | when; `start` is local time, rounded to the quarter hour | +| canvas | the task (or client/project) canvas it belongs to | +| client | derived: the nearest ancestor canvas marked as a client | +| note | optional; empty by default (see *Comments*) | +| source | tracked / explicit (`[2h]` marker) / estimated / manual | +| worked | the unrounded minutes, kept for the record | + +### Rounding (the firm rule) + +For a duration of `m` minutes: + +- `m = 0` → 0; +- `0 < m < 22.5` → **15** (anything worked counts as at least a quarter hour); +- otherwise → the nearest multiple of 15, exact halves rounding up: + 22.5–37.49… → 30, 37.5–52.49… → 45, and so on. + +That is `m > 0 ? 15 · max(1, ⌊m / 15 + ½⌋) : 0`. Start times round to the +nearest quarter hour, halves up. + +### Building the draft + +1. Take the week's tracked task segments (what the timeline shows; removed + time already excluded, explicit durations already applied) and the + estimated time for untracked days. +2. **Merge into sessions:** segments on the same task, with no other task + in between, are one session unless a gap between them is **longer than + 30 minutes** (lunch). Shorter gaps (the screen locked for a break) count + as work: a session runs from its first segment's start to its last + segment's end, so its worked time can be a little more than the tracked + time the review shows. Sessions are also split at midnight. +3. **Round each session** with the rule above. Rounding happens once, here: + every destination total is a sum of rounded entries, so Jira, CMS and the + timesheet always agree, and every number is a multiple of 15 minutes by + construction. +4. Lay the rounded sessions out without overlaps: starts on quarter hours, + in the order they happened, each starting no earlier than the previous + one ends. + +### Rounding inflation, and suggested trims + +The rule inflates days full of short tasks: a dozen 2–5 minute tasks report +3 h for under an hour of work. It usually evens out because a long task +gets logged a bit short, but that's done by hand today. The draft does the +arithmetic and **suggests** the evening-out; you accept, change or ignore +each suggestion. + +- **Per client per day** (the CMS line; a client here is the top-level + canvas a task sits under): the target is the rounded total + actually worked for that client that day, `round(Σ worked)`. The draft's + line is `Σ round(each session)`. The difference is the inflation (or, + more rarely, a deficit). +- **Suggestion:** take the difference out of that client's longest sessions + that day, 15 minutes at a time (longest first, one step each in turn), + never taking a session below 15 minutes. A deficit adds to the longest + sessions the same way. Balancing within the same client and day keeps one + client's short tasks from being paid for out of another client's hours. +- If it can't be evened out (every session is already 15 minutes), the + grid says so: "Acme, Tue: 3 h reported for 55 min worked". +- The same numbers show per week, so a week can be checked at a glance. + +Suggestions are never applied silently. They appear as marked changes in +the grid. + +### Editing ("shuffling") + +A grid for the week: drag an entry to another task or day, change start or +duration (in 15-minute steps), split an entry, merge two, add a manual one +(e.g. a call that wasn't tracked), drop one, edit the note. The grid shows, +live and per destination, what would be sent, alongside the time actually +worked (for example "CMS · Acme · Tue: 9 h 15 m reported, 8 h 50 m worked"). + +Editing never changes the activity log or blocks. The timesheet is a +separate record, and the original tracked time stays available alongside it. + +## Destinations + +Each destination (from an extension) declares: + +- **Mapping:** which canvases it takes, and their external ids, as + per-canvas fields, looked up by **walking up the canvas tree** to the + nearest canvas that sets one: + - Jira: `jira.issue: ACME-123`, normally on each task canvas. A fallback + issue is just the key set on those tasks (or on a parent canvas, which + all tasks beneath it then inherit). + - CMS: `ext.builtin.devlog-cms.assignment: 12345` (or the project name), + normally on the client canvas. Where one real client has several CMS + assignments, sub-canvases (projects) set their own, which wins for + everything beneath them. + + A canvas with no mapping for a destination (nothing up the tree) is not + sent there, so the full-time client never reaches Jira. An entry under a + destination's canvases that can't be mapped (a task with no issue key) is + flagged in the grid before sending. +- **Grouping:** how entries become lines. Jira: one worklog per entry + (issue, start, duration, note). CMS: one line per assignment per day + (the sum of that day's entries). CMS weeks run Sunday to Saturday, so a + Devlog week touches two; a day only opens in CMS on the day itself (and + not outside the assignment's dates), so time on a later day is held back + until then. +- **Send:** submit the lines; return an external id per line. + +### Comments + +- **CMS** takes a description per assignment per day. devlog-cms sends the + day's entry notes joined with `; ` when there are any, and otherwise keeps + what CMS has. Later, an LLM could draft it from that day's notes. +- **Jira** worklog comments are optional too, and meetings often have none. + Empty by default, with a per-entry "fill from notes" that takes the + top-level blocks written on the task during that session. Revisit once + it's clear how the notes get used in practice. + +What was sent (lines, the ids they mapped to at the time, external ids) is +recorded with the timesheet, so an old week reads the same after mappings +change. No dated settings are needed. + +## Storage: a managed canvas + +Since 0.18 the timesheet is the time extension's (devlog-time): its page, +its storage, its send flow. The timesheets live in a canvas it keeps, +**Timesheets** (created on first use, not a task, shown in the sidebar like +any canvas; marked `devlog.managed: timesheets`, the mark the app gave it +before, which devlog-time takes as its own): + +- **One block per week** (`kind=timesheet`, `week=2026-09-21`): the body is + a readable markdown table of the entries (date, start, duration, task, + client, note), so the record is human-readable in git with no app needed. + Edits while drafting are ordinary `edit` records (append-only, so the + history of the shuffling is kept too). +- **What was sent** is kept by each destination extension in its own synced + folder (devlog-jira: `extensions/builtin.devlog-jira/sent/.json`, + entry id → issue, worklog id and what was sent; devlog-cms: assignment + and day → hours). Sending again compares + against it, so a second press, or a second machine, sends only what + changed, and corrections after the fact send differences. Each send also + leaves a read-only reply under the week's timesheet block ("Sent to Jira: 3 + worklogs created, 1 updated (4:15 on 2 issues)"), written by devlog-time, + so the history reads in the notebook itself. devlog-time reaches the + destinations through the app (`destinations.preview` / `send`, with + `permissions.send`), which gives each destination the week with labels + and its own canvas fields, as before. +- Timesheet blocks are automatic blocks (edited through the grid, not as + text). + +## Where the pieces live + +- `@devlog/core`: building the draft (sessions, rounding, layout), the + inflation arithmetic and suggested trims, the entry model, reading and + writing timesheet blocks, and resolving a canvas's mapping by walking up + the tree. Pure and unit-tested. +- devlog-time: the weekly timesheet grid and the Summary (pages built on + `@devlog/ui`), the Timesheets canvas, preview and send, hour targets. +- App: what it recorded (`devlog.activity`), block ranges, the kept canvas, + and passing sheets to the destinations. +- Extensions: Jira and CMS destinations (mapping fields, grouping, sending, + credentials). A built-in CSV destination (#4) needs no extension. + +## Open questions + +None blocking. To revisit with use: what, if anything, fills Jira and CMS +comments by default. diff --git a/electron-builder.yml b/electron-builder.yml index 5d317b8..018b857 100644 --- a/electron-builder.yml +++ b/electron-builder.yml @@ -14,6 +14,10 @@ files: - package.json asarUnpack: - resources/** +extraResources: + - from: builtin-extensions + to: extensions + filter: ['**/*', '!README.md', '!*/src/**'] mac: category: public.app-category.productivity target: [dmg, zip] diff --git a/electron.vite.config.ts b/electron.vite.config.ts index 43493a4..f1e3a1b 100644 --- a/electron.vite.config.ts +++ b/electron.vite.config.ts @@ -6,7 +6,13 @@ export default defineConfig({ main: { plugins: [externalizeDepsPlugin()], resolve: { - alias: { '@shared': resolve('src/shared'), '@devlog/core': resolve('packages/core/src') } + alias: { '@shared': resolve('src/shared'), '@devlog/core': resolve('packages/core/src'), '@devlog/extension-api': resolve('packages/extension-api/src') } + }, + build: { + rollupOptions: { + // The extension process is its own, self-contained file (see hostProcess.ts). + input: { index: resolve('src/main/index.ts'), extensionHost: resolve('src/main/extensions/hostProcess.ts') } + } } }, preload: { diff --git a/package-lock.json b/package-lock.json index 846a118..1af2467 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "devlog", - "version": "0.5.0", + "version": "0.18.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "devlog", - "version": "0.5.0", + "version": "0.18.0", "license": "MIT", "dependencies": { "@tiptap/core": "^3.31.3", @@ -19,6 +19,7 @@ "@tiptap/starter-kit": "^3.31.3", "dompurify": "^3.4.15", "electron-updater": "^6.8.9", + "fflate": "^0.8.3", "highlight.js": "^11.12.0", "lowlight": "^3.3.0", "marked": "^18.0.13", @@ -34,6 +35,7 @@ "electron": "^44.4.3", "electron-builder": "^26.15.3", "electron-vite": "^5.0.0", + "esbuild": "^0.25.12", "playwright-core": "^1.63.0", "typescript": "^5.9.3", "vite": "^7.0.0", @@ -3996,6 +3998,12 @@ } } }, + "node_modules/fflate": { + "version": "0.8.3", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.3.tgz", + "integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==", + "license": "MIT" + }, "node_modules/filelist": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/filelist/-/filelist-1.0.6.tgz", diff --git a/package.json b/package.json index 3363438..3ca7f1f 100644 --- a/package.json +++ b/package.json @@ -1,14 +1,14 @@ { "name": "devlog", - "version": "0.5.0", + "version": "0.18.0", "description": "A git-backed developer log with a Slack-style WYSIWYG Markdown composer", "main": "./out/main/index.js", "author": "Stephen Deken", "license": "MIT", "private": true, "scripts": { - "dev": "electron-vite dev", - "build": "electron-vite build", + "dev": "node scripts/build-builtins.mjs && electron-vite dev", + "build": "node scripts/build-builtins.mjs && electron-vite build", "preview": "electron-vite preview", "typecheck": "tsc --noEmit -p tsconfig.node.json && tsc --noEmit -p tsconfig.web.json", "test": "vitest run", @@ -17,9 +17,9 @@ "package:mac": "electron-vite build && electron-builder --mac", "package:win": "electron-vite build && electron-builder --win", "package:linux": "electron-vite build && electron-builder --linux", - "smoke": "electron-vite build && node scripts/smoke.mjs", + "smoke": "npm run build && node scripts/smoke.mjs", "screens": "electron-vite build && node scripts/screens.mjs", - "smoke:ci": "node scripts/smoke.mjs && node scripts/smoke-migrate.mjs" + "smoke:ci": "node scripts/smoke.mjs" }, "dependencies": { "@tiptap/core": "^3.31.3", @@ -32,6 +32,7 @@ "@tiptap/starter-kit": "^3.31.3", "dompurify": "^3.4.15", "electron-updater": "^6.8.9", + "fflate": "^0.8.3", "highlight.js": "^11.12.0", "lowlight": "^3.3.0", "marked": "^18.0.13", @@ -47,6 +48,7 @@ "electron": "^44.4.3", "electron-builder": "^26.15.3", "electron-vite": "^5.0.0", + "esbuild": "^0.25.12", "playwright-core": "^1.63.0", "typescript": "^5.9.3", "vite": "^7.0.0", diff --git a/packages/core/README.md b/packages/core/README.md index e564882..8de1202 100644 --- a/packages/core/README.md +++ b/packages/core/README.md @@ -9,7 +9,7 @@ Two entry points: | Import | Contents | Runs in | | ------------------- | ------------------------------------------------------------- | ---------------- | | `@devlog/core` | Types, block and canvas file formats, pure hierarchy helpers | Anywhere | -| `@devlog/core/node` | `DevlogStore`, `RepoIndex`, `SyncManager` (git), `ActivityLog`, migrations (`upgradeRepository`) | Node 22.13+ only | +| `@devlog/core/node` | `DevlogStore`, `RepoIndex`, `SyncManager` (git), `ActivityLog`, `assertSupportedFormat` | Node 22.13+ only | ## Rules @@ -33,12 +33,12 @@ Two entry points: ## Typical use ```ts -import { DevlogStore, RepoIndex, SyncManager, upgradeRepository } from '@devlog/core/node' +import { DevlogStore, RepoIndex, SyncManager, assertSupportedFormat } from '@devlog/core/node' const store = new DevlogStore(root) await store.initLayout() +await assertSupportedFormat(root) // format 3 only; throws with a message otherwise const sync = new SyncManager(root, { intervalMinutes: 5, debounceSeconds: 30, autoPush: true, pullOnStart: true }) -await upgradeRepository(root, sync) // older layouts: pull, migrate, commit (or merge) const index = RepoIndex.open(dbPath, root) // optional cache; files stay the truth store.attachIndex(index) void index.refresh() @@ -53,7 +53,7 @@ const hits = await store.search('dana') ## Storage See the main README (*Repository layout*) and `docs/DESIGN.md` for the -format; `migrate.ts` documents the upgrades to format 3. The index uses +format. The index uses `node:sqlite`, which prints an experimental warning on Node 22; Electron 44 (Node 24) is the target. @@ -61,8 +61,7 @@ format; `migrate.ts` documents the upgrades to format 3. The index uses The tests cover the file formats (including a fuzz test of marker escaping), every store operation, indexed-vs-scanned equivalence, git sync against local -bare remotes, migrations (including two machines upgrading the same history) -and the per-machine activity log. +bare remotes and the per-machine activity log. ```sh npx vitest run packages/core diff --git a/packages/core/src/extensions.ts b/packages/core/src/extensions.ts new file mode 100644 index 0000000..11a732c --- /dev/null +++ b/packages/core/src/extensions.ts @@ -0,0 +1,582 @@ +/** + * Extensions: the pure parts (no I/O). See docs/EXTENSIONS.md. + * + * - the extension manifest (`devlog-extension.json`) and its validation; + * - where an extension comes from (the entries of `devlog.json`'s + * `extensions`), the id derived from that, and version ranges; + * - read/write grants and what they let an extension see. + */ +import { descendantCanvasIds, JOURNAL_ID, NODE_TYPE_RE } from './format/canvases' +import type { CanvasMeta } from './types' + +/** The extension API this build of Devlog provides. Manifests declare the range they were built for. */ +export const EXTENSION_API_VERSION = '1.7.0' +export const EXTENSION_MANIFEST_FILE = 'devlog-extension.json' +export const EXTENSIONS_DIR = 'extensions' +export const LOCK_FILE = 'devlog.lock.json' + +// --------------------------------------------------------------------------- +// Manifest +// --------------------------------------------------------------------------- + +export type ExtensionFieldType = 'text' | 'url' | 'email' | 'number' | 'select' | 'checkbox' | 'textarea' +export const FIELD_TYPES: ExtensionFieldType[] = ['text', 'url', 'email', 'number', 'select', 'checkbox', 'textarea'] + +export interface ExtensionField { + key: string + label: string + placeholder?: string + /** A sentence or two under the field. */ + description?: string + /** How to edit it (settings and canvas fields; secrets are always hidden text). Default text. */ + type?: ExtensionFieldType + /** For `select`. */ + options?: Array<{ value: string; label: string }> + /** The extension cannot work without it; the app says so until it is set. */ + required?: boolean + /** A canvas field that applies to its canvas only; canvases inside it do not take it on (1.7). Default: they do. */ + inherited?: false +} + +/** + * Whether a value is acceptable for a field (empty is fine unless required). + * Returns the problem, or null. + */ +export function fieldProblem(field: ExtensionField, value: string | undefined): string | null { + const v = (value ?? '').trim() + if (!v) return field.required ? `${field.label} is required` : null + switch (field.type) { + case 'url': + return /^https?:\/\/[^\s/]+/i.test(v) ? null : `${field.label}: enter a web address starting with https://` + case 'email': + return /^[^\s@]+@[^\s@]+$/.test(v) ? null : `${field.label}: enter an email address` + case 'number': + return Number.isFinite(Number(v)) ? null : `${field.label}: enter a number` + case 'select': + return field.options?.some((o) => o.value === v) ? null : `${field.label}: pick one of the options` + case 'checkbox': + return v === 'true' || v === 'false' ? null : `${field.label}: true or false` + default: + return null + } +} + +export interface ExtensionCommand { + id: string + label: string + /** A key combination that runs it, like `Mod+Shift+S` (Mod is Ctrl, or Cmd on a Mac). (1.6) */ + keybinding?: string + /** Offered only on canvases of this node type (the full name). (1.6) */ + nodeType?: string + /** Menus it appears in, besides the quick switcher. (1.6) */ + menus?: CommandMenu[] + /** + * A way to post from the note box (1.6): posting with its keybinding (or + * with `#` on the first line) posts the note, then runs the command + * on the new block. + */ + post?: boolean + tag?: string +} + +/** `canvas`: a canvas's right-click menu. `block`: a block's menu. `tray`: the tray icon's menu. */ +export type CommandMenu = 'canvas' | 'block' | 'tray' +export const COMMAND_MENUS: CommandMenu[] = ['canvas', 'block', 'tray'] + +const KEY_NAMES = new Set(['Enter', 'Space', 'Escape', 'Tab', 'Backspace', 'Delete', 'Home', 'End', 'PageUp', 'PageDown', 'ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight']) +const MODIFIERS = ['Mod', 'Ctrl', 'Alt', 'Shift'] as const + +/** + * A keybinding in one spelling (`Mod+Shift+S`): modifiers in the order Mod, + * Ctrl, Alt, Shift, then one key (a letter or digit, upper-cased; a + * punctuation mark; F1–F24; or a named key). Null if it is not one, or has + * no modifier (a plain key would steal typing), except for F-keys. + */ +export function normalizeKeybinding(raw: string): string | null { + const parts = String(raw).split('+').map((p) => p.trim()) + // "Mod++" means the plus key. + if (parts.length >= 2 && parts[parts.length - 1] === '' && parts[parts.length - 2] === '') parts.splice(parts.length - 2, 2, '+') + const key = parts.pop() ?? '' + const mods = new Set() + for (const p of parts) { + const m = { mod: 'Mod', cmdorctrl: 'Mod', ctrl: 'Ctrl', control: 'Ctrl', alt: 'Alt', option: 'Alt', shift: 'Shift' }[p.toLowerCase()] + if (!m || mods.has(m)) return null + mods.add(m) + } + let k: string + if (/^[a-z0-9]$/i.test(key)) k = key.toUpperCase() + else if (/^f([1-9]|1\d|2[0-4])$/i.test(key)) k = key.toUpperCase() + else if (/^[`\-=[\]\\;',./+]$/.test(key)) k = key + else { + const named = [...KEY_NAMES].find((n) => n.toLowerCase() === key.toLowerCase()) + if (!named) return null + k = named + } + if (mods.size === 0 && !/^F\d/.test(k)) return null + if (mods.size === 1 && mods.has('Shift') && !/^F\d/.test(k)) return null + return [...MODIFIERS.filter((m) => mods.has(m)), k].join('+') +} + +/** + * A view (1.5): an HTML page from the extension's package, shown by the app + * in a sandboxed frame. `page` views are listed in the sidebar and fill the + * main area; `statusbar` views sit in a slot of the status bar (fixed + * height; they ask for a width); `popover` views open on request, anchored + * to the view that asked. + */ +export interface ExtensionView { + id: string + title: string + /** The HTML file, relative to the extension folder. */ + entry: string + placement: 'page' | 'statusbar' | 'popover' | 'canvasHeader' + /** One character or emoji for the sidebar. */ + icon?: string + /** A `canvasHeader` view shows only on canvases of this node type (the full name). (1.6) */ + nodeType?: string +} +export const VIEW_PLACEMENTS: ExtensionView['placement'][] = ['page', 'statusbar', 'popover', 'canvasHeader'] + +/** + * A node type (1.6): a kind of canvas the extension gives meaning to (the + * time extension's "task"). Stored in canvas.md as `type: /`; + * the app shows its icon and label, and offers it in canvas properties. + */ +export interface ExtensionNodeType { + id: string + label: string + /** One character or emoji, shown before the canvas's name. */ + icon?: string + /** Placeholder for the note box on canvases of this type. */ + placeholder?: string +} + +/** The full node type name for one of an extension's types. */ +export function nodeTypeName(extensionId: string, typeId: string): string { + return `${extensionId}/${typeId}` +} + +/** Somewhere a finished timesheet can be sent (Jira worklogs, a CSV file, …). */ +export interface ExtensionDestination { + id: string + label: string +} + +export interface ExtensionPermissions { + /** Wants to read blocks; you choose the scope. */ + read?: boolean + /** Wants to add blocks; you choose the scope. */ + write?: boolean + /** Domains it talks to: shown at consent, not enforced in v1. */ + network?: string[] + /** Sends finished timesheets through other extensions' destinations (Jira, CMS…). (1.7) */ + send?: boolean + /** + * Runs without the sandbox: it can read and change your files, start + * programs and see everything you can. It runs only if you say you trust it. + */ + unrestricted?: boolean +} + +export interface ExtensionManifest { + name: string + displayName: string + version: string + description?: string + /** Range of the extension API it was built for. */ + api: string + /** Bundled main file, relative to the extension folder. */ + main?: string + contributes: { + canvasFields: ExtensionField[] + settings: ExtensionField[] + secrets: ExtensionField[] + commands: ExtensionCommand[] + destinations: ExtensionDestination[] + views: ExtensionView[] + /** Node types (1.6). */ + nodeTypes: ExtensionNodeType[] + /** A command that tests the settings (its return value is shown); offered on the settings page. */ + check?: string + } + permissions: ExtensionPermissions + /** Globs (within its repo folder) that git should union-merge. */ + appendOnly: string[] +} + +const NAME_RE = /^[a-z0-9][a-z0-9._-]{0,63}$/ +const KEY_RE = /^[a-z][a-z0-9_-]{0,63}$/ +/** The part after the "/" of NODE_TYPE_RE. */ +const TYPE_ID_RE = /^[a-z][a-z0-9_-]{0,63}$/ +const GLOB_RE = /^[A-Za-z0-9_.*/{}-]{1,128}$/ + +/** Validate a parsed `devlog-extension.json`. Throws with every problem found. */ +export function parseExtensionManifest(raw: unknown): ExtensionManifest { + const errors: string[] = [] + const o = (raw ?? {}) as Record + if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) throw new Error('The manifest is not a JSON object') + const str = (k: string, required: boolean): string => { + const v = o[k] + if (v === undefined && !required) return '' + if (typeof v !== 'string' || !v.trim()) { + errors.push(`"${k}" must be a non-empty string`) + return '' + } + return v.trim() + } + const name = str('name', true) + if (name && !NAME_RE.test(name)) errors.push('"name" must be lowercase letters, digits, ".", "_" or "-"') + const version = str('version', true) + if (version && !parseVersion(version)) errors.push(`"version" is not a version: ${version}`) + const api = str('api', true) + if (api && !parseRange(api)) errors.push(`"api" is not a version range: ${api}`) + const main = str('main', false) + if (main && !isSafeRelativePath(main)) errors.push('"main" must be a relative path inside the extension') + + const c = (o.contributes ?? {}) as Record + const fields = (k: string): ExtensionField[] => { + const list = c[k] ?? [] + if (!Array.isArray(list)) { + errors.push(`"contributes.${k}" must be a list`) + return [] + } + const out: ExtensionField[] = [] + for (const f of list as Array>) { + if (typeof f?.key !== 'string' || !KEY_RE.test(f.key)) { + errors.push(`"contributes.${k}": key ${JSON.stringify(f?.key)} must be lowercase letters, digits, "_" or "-"`) + continue + } + if (out.some((x) => x.key === f.key)) { + errors.push(`"contributes.${k}": duplicate key ${f.key}`) + continue + } + const type = f.type === undefined ? undefined : FIELD_TYPES.includes(f.type as ExtensionFieldType) ? (f.type as ExtensionFieldType) : null + if (type === null) { + errors.push(`"contributes.${k}": ${f.key} has an unknown type ${JSON.stringify(f.type)}`) + continue + } + const options = Array.isArray(f.options) + ? (f.options as Array>).filter((o) => typeof o?.value === 'string').map((o) => ({ value: String(o.value), label: typeof o.label === 'string' ? o.label : String(o.value) })) + : undefined + if (type === 'select' && !options?.length) { + errors.push(`"contributes.${k}": ${f.key} is a select with no options`) + continue + } + out.push({ + key: f.key, + label: typeof f.label === 'string' && f.label.trim() ? f.label.trim() : f.key, + ...(typeof f.placeholder === 'string' ? { placeholder: f.placeholder } : {}), + ...(typeof f.description === 'string' && f.description.trim() ? { description: f.description.trim() } : {}), + ...(type && type !== 'text' ? { type } : {}), + ...(options?.length ? { options } : {}), + ...(f.required === true ? { required: true } : {}), + ...(f.inherited === false ? { inherited: false as const } : {}) + }) + } + return out + } + const commands: ExtensionCommand[] = [] + for (const cmd of (Array.isArray(c.commands) ? c.commands : []) as Array>) { + if (typeof cmd?.id !== 'string' || !KEY_RE.test(cmd.id)) { + errors.push(`"contributes.commands": id ${JSON.stringify(cmd?.id)} is not valid`) + continue + } + const out: ExtensionCommand = { id: cmd.id, label: typeof cmd.label === 'string' && cmd.label.trim() ? cmd.label.trim() : cmd.id } + if (cmd.keybinding !== undefined) { + const k = typeof cmd.keybinding === 'string' ? normalizeKeybinding(cmd.keybinding) : null + if (!k) errors.push(`"contributes.commands": ${cmd.id} has keybinding ${JSON.stringify(cmd.keybinding)}; use something like "Mod+Shift+S"`) + else out.keybinding = k + } + if (cmd.nodeType !== undefined) { + if (typeof cmd.nodeType !== 'string' || !(TYPE_ID_RE.test(cmd.nodeType) || NODE_TYPE_RE.test(cmd.nodeType))) errors.push(`"contributes.commands": ${cmd.id} has nodeType ${JSON.stringify(cmd.nodeType)}`) + else out.nodeType = cmd.nodeType + } + if (cmd.menus !== undefined) { + if (!Array.isArray(cmd.menus) || cmd.menus.some((m) => !COMMAND_MENUS.includes(m as CommandMenu))) errors.push(`"contributes.commands": ${cmd.id} has menus ${JSON.stringify(cmd.menus)}; use canvas, block or tray`) + else if (cmd.menus.length) out.menus = [...new Set(cmd.menus as CommandMenu[])] + } + if (cmd.post === true) out.post = true + if (cmd.tag !== undefined) { + if (typeof cmd.tag !== 'string' || !TYPE_ID_RE.test(cmd.tag) || cmd.post !== true) errors.push(`"contributes.commands": ${cmd.id}: "tag" is a lowercase word, on a "post" command`) + else out.tag = cmd.tag + } + commands.push(out) + } + + const p = (o.permissions ?? {}) as Record + const permissions: ExtensionPermissions = {} + if (p.read === true) permissions.read = true + if (p.write === true) permissions.write = true + if (p.unrestricted === true) permissions.unrestricted = true + if (p.send === true) permissions.send = true + if (p.network !== undefined) { + if (!Array.isArray(p.network) || p.network.some((d) => typeof d !== 'string' || !/^(\*\.)?[a-z0-9.-]+(:\d+)?$/i.test(d))) errors.push('"permissions.network" must be a list of domains') + else permissions.network = (p.network as string[]).map((d) => d.toLowerCase()) + } + + const appendOnly: string[] = [] + for (const g of (Array.isArray(o.appendOnly) ? o.appendOnly : []) as unknown[]) { + if (typeof g !== 'string' || !GLOB_RE.test(g) || g.includes('..') || g.startsWith('/')) errors.push(`"appendOnly": ${JSON.stringify(g)} is not a relative glob`) + else appendOnly.push(g) + } + + const destinations: ExtensionDestination[] = [] + for (const d of (Array.isArray(c.destinations) ? c.destinations : []) as Array>) { + if (typeof d?.id !== 'string' || !KEY_RE.test(d.id)) errors.push(`"contributes.destinations": id ${JSON.stringify(d?.id)} is not valid`) + else destinations.push({ id: d.id, label: typeof d.label === 'string' && d.label.trim() ? d.label.trim() : d.id }) + } + const views: ExtensionView[] = [] + for (const v of (Array.isArray(c.views) ? c.views : []) as Array>) { + const placement = v?.placement as ExtensionView['placement'] + if (typeof v?.id !== 'string' || !KEY_RE.test(v.id)) errors.push(`"contributes.views": id ${JSON.stringify(v?.id)} is not valid`) + else if (views.some((x) => x.id === v.id)) errors.push(`"contributes.views": duplicate id ${v.id}`) + else if (typeof v.entry !== 'string' || !isSafeRelativePath(v.entry) || !/\.html?$/i.test(v.entry)) errors.push(`"contributes.views": ${v.id} needs an "entry" .html file inside the extension`) + else if (!VIEW_PLACEMENTS.includes(placement)) errors.push(`"contributes.views": ${v.id} has placement ${JSON.stringify(v.placement)}; use page, statusbar, popover or canvasHeader`) + else + views.push({ + id: v.id, + title: typeof v.title === 'string' && v.title.trim() ? v.title.trim() : v.id, + entry: v.entry, + placement, + ...(typeof v.icon === 'string' && v.icon.trim() ? { icon: [...v.icon.trim()].slice(0, 2).join('') } : {}), + ...(typeof v.nodeType === 'string' && (TYPE_ID_RE.test(v.nodeType) || NODE_TYPE_RE.test(v.nodeType)) ? { nodeType: v.nodeType } : {}) + }) + } + const nodeTypes: ExtensionNodeType[] = [] + for (const t of (Array.isArray(c.nodeTypes) ? c.nodeTypes : []) as Array>) { + if (typeof t?.id !== 'string' || !TYPE_ID_RE.test(t.id)) errors.push(`"contributes.nodeTypes": id ${JSON.stringify(t?.id)} is not valid`) + else if (nodeTypes.some((x) => x.id === t.id)) errors.push(`"contributes.nodeTypes": duplicate id ${t.id}`) + else + nodeTypes.push({ + id: t.id, + label: typeof t.label === 'string' && t.label.trim() ? t.label.trim().slice(0, 40) : t.id, + ...(typeof t.icon === 'string' && t.icon.trim() ? { icon: [...t.icon.trim()].slice(0, 2).join('') } : {}), + ...(typeof t.placeholder === 'string' && t.placeholder.trim() ? { placeholder: t.placeholder.trim().slice(0, 120) } : {}) + }) + } + let check: string | undefined + if (c.check !== undefined) { + if (typeof c.check !== 'string' || !commands.some((x) => x.id === c.check)) errors.push('"contributes.check" must name one of its commands') + else check = c.check + } + const contributes = { canvasFields: fields('canvasFields'), settings: fields('settings'), secrets: fields('secrets'), commands, destinations, views, nodeTypes, ...(check ? { check } : {}) } + if (errors.length) throw new Error(`Invalid extension manifest: ${errors.join('; ')}`) + const displayName = typeof o.displayName === 'string' && o.displayName.trim() ? o.displayName.trim() : name + return { + name, + displayName, + version, + ...(typeof o.description === 'string' ? { description: o.description } : {}), + api, + ...(main ? { main } : {}), + contributes, + permissions, + appendOnly + } +} + +/** True for `a/b.js`-style paths: relative, no `..`, no backslashes or drive letters. */ +export function isSafeRelativePath(p: string): boolean { + if (!p || p.length > 512 || p.includes('\0') || p.includes('\\') || p.startsWith('/') || /^[A-Za-z]:/.test(p)) return false + return p.split('/').every((seg) => seg !== '' && seg !== '.' && seg !== '..') +} + +// --------------------------------------------------------------------------- +// Sources and ids +// --------------------------------------------------------------------------- + +export type ExtensionSource = + | { kind: 'github'; owner: string; repo: string; range: string } + | { kind: 'url'; name: string; url: string } + | { kind: 'builtin'; name: string } + +const GH_PART = /^[A-Za-z0-9][A-Za-z0-9._-]{0,99}$/ + +/** + * Read one `devlog.json` `extensions` entry: + * - `"owner/repo": "^1.2.0"` a GitHub repository's releases, newest matching tag; + * - `"name": "https://…/x.zip"` one file; + * - `"name": "builtin"` an extension that ships with the app. + */ +export function parseExtensionEntry(key: string, value: unknown): ExtensionSource { + if (typeof value !== 'string' || !value.trim()) throw new Error(`Extension ${key}: expected a version, a URL or "builtin"`) + const v = value.trim() + const slash = key.split('/') + if (slash.length === 2) { + if (!GH_PART.test(slash[0]) || !GH_PART.test(slash[1])) throw new Error(`Extension ${key}: not an owner/repository name`) + if (!parseRange(v)) throw new Error(`Extension ${key}: "${v}" is not a version range`) + return { kind: 'github', owner: slash[0], repo: slash[1], range: v } + } + if (!NAME_RE.test(key)) throw new Error(`Extension ${key}: names are lowercase letters, digits, ".", "_" or "-"`) + if (v === 'builtin') return { kind: 'builtin', name: key } + if (/^https:\/\//i.test(v)) { + try { + new URL(v) + } catch { + throw new Error(`Extension ${key}: not a URL`) + } + return { kind: 'url', name: key, url: v } + } + throw new Error(`Extension ${key}: expected owner/repo with a version, a https:// URL or "builtin"`) +} + +/** + * The id an extension's folders, settings, secrets and grants are keyed by. + * It comes from where the extension is fetched, never from its manifest, so + * one extension cannot take another's name. + */ +export function extensionId(source: ExtensionSource): string { + if (source.kind === 'github') return `${source.owner}.${source.repo}`.toLowerCase() + if (source.kind === 'builtin') return `builtin.${source.name}` + return `url.${source.name}` +} + +// --------------------------------------------------------------------------- +// Versions and ranges (a small subset of semver) +// --------------------------------------------------------------------------- + +export interface Version { + major: number + minor: number + patch: number + pre: string +} + +export function parseVersion(s: string): Version | null { + const m = /^v?(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?$/.exec(s.trim()) + if (!m) return null + return { major: Number(m[1]), minor: Number(m[2]), patch: Number(m[3]), pre: m[4] ?? '' } +} + +export function compareVersions(a: Version, b: Version): number { + return a.major - b.major || a.minor - b.minor || a.patch - b.patch || (a.pre === b.pre ? 0 : a.pre === '' ? 1 : b.pre === '' ? -1 : a.pre < b.pre ? -1 : 1) +} + +type Range = (v: Version) => boolean + +/** + * Supported: `1.2.3` (exact), `^1.2.3`, `~1.2.3`, `1.2.x`, `1.x`, `*`, + * `latest`. Pre-releases only match an exact range. + */ +export function parseRange(s: string): Range | null { + const r = s.trim() + if (r === '*' || r === 'latest' || r === 'x') return (v) => !v.pre + const exact = parseVersion(r) + if (exact) return (v) => compareVersions(v, exact) === 0 + const caret = /^\^(.+)$/.exec(r) + if (caret) { + const b = parseVersion(caret[1]) + if (!b) return null + return (v) => { + if (v.pre || compareVersions(v, b) < 0) return false + if (b.major > 0) return v.major === b.major + if (b.minor > 0) return v.major === 0 && v.minor === b.minor + return v.major === 0 && v.minor === 0 && v.patch === b.patch + } + } + const tilde = /^~(.+)$/.exec(r) + if (tilde) { + const b = parseVersion(tilde[1]) + if (!b) return null + return (v) => !v.pre && compareVersions(v, b) >= 0 && v.major === b.major && v.minor === b.minor + } + const xr = /^v?(\d+)(?:\.(\d+|x|\*))?(?:\.(x|\*))?$/.exec(r) + if (xr) { + const major = Number(xr[1]) + const minor = xr[2] === undefined || xr[2] === 'x' || xr[2] === '*' ? null : Number(xr[2]) + return (v) => !v.pre && v.major === major && (minor === null || v.minor === minor) + } + return null +} + +export function satisfies(version: string, range: string): boolean { + const v = parseVersion(version) + const r = parseRange(range) + return Boolean(v && r && r(v)) +} + +/** The newest of `versions` (tags such as `v1.2.3`) matching `range`, or null. */ +export function newestMatching(versions: string[], range: string): string | null { + const r = parseRange(range) + if (!r) return null + let best: { tag: string; v: Version } | null = null + for (const tag of versions) { + const v = parseVersion(tag) + if (v && r(v) && (!best || compareVersions(v, best.v) > 0)) best = { tag, v } + } + return best?.tag ?? null +} + +// --------------------------------------------------------------------------- +// Grants +// --------------------------------------------------------------------------- + +/** The whole devlog, or chosen canvases with everything beneath them. */ +export type GrantScope = { all: true } | { canvases: string[] } + +export interface Grant { + read: GrantScope | null + write: GrantScope | null + /** You said you trust it to run unrestricted (only for extensions that ask). */ + trusted?: boolean +} + +export const NO_GRANT: Grant = { read: null, write: null } + +/** Canvas ids a scope covers (a chosen canvas brings everything beneath it; the journal only itself). */ +export function scopeCanvasIds(canvases: CanvasMeta[], scope: GrantScope | null): Set { + if (!scope) return new Set() + if ('all' in scope) return new Set([JOURNAL_ID, ...canvases.map((c) => c.id)]) + const known = new Set(canvases.map((c) => c.id)) + const out = new Set() + for (const id of scope.canvases) { + if (id === JOURNAL_ID) { + out.add(JOURNAL_ID) + continue + } + if (!known.has(id)) continue + out.add(id) + for (const d of descendantCanvasIds(canvases, id)) out.add(d) + } + return out +} + +/** + * What an extension may list: every canvas it can read or write, plus their + * ancestors (so it can name them), and nothing else. + */ +export function visibleCanvases(canvases: CanvasMeta[], grant: Grant): CanvasMeta[] { + const covered = new Set([...scopeCanvasIds(canvases, grant.read), ...scopeCanvasIds(canvases, grant.write)]) + const byId = new Map(canvases.map((c) => [c.id, c])) + const show = new Set() + for (const id of covered) { + let cur = byId.get(id) + show.add(id) + while (cur?.parentId && !show.has(cur.parentId)) { + show.add(cur.parentId) + cur = byId.get(cur.parentId) + } + } + return canvases.filter((c) => show.has(c.id)) +} + +/** Describe a scope for people: "the whole devlog", "Acme, Globex". */ +export function describeScope(canvases: CanvasMeta[], scope: GrantScope | null): string { + if (!scope) return 'nothing' + if ('all' in scope) return 'the whole devlog' + const names = scope.canvases.map((id) => canvases.find((c) => c.id === id)?.title ?? (id === JOURNAL_ID ? 'Journal' : null)).filter(Boolean) + return names.length ? names.join(', ') : 'nothing' +} + +/** Keep a stored grant well-formed (it comes from a JSON file). */ +export function sanitizeGrant(raw: unknown): Grant { + const scope = (s: unknown): GrantScope | null => { + if (!s || typeof s !== 'object') return null + const o = s as Record + if (o.all === true) return { all: true } + if (Array.isArray(o.canvases)) return { canvases: o.canvases.filter((x): x is string => typeof x === 'string').slice(0, 500) } + return null + } + const o = (raw ?? {}) as Record + return { read: scope(o.read), write: scope(o.write), ...(o.trusted === true ? { trusted: true } : {}) } +} diff --git a/packages/core/src/format/blocks.ts b/packages/core/src/format/blocks.ts index db374c6..d3de73d 100644 --- a/packages/core/src/format/blocks.ts +++ b/packages/core/src/format/blocks.ts @@ -15,19 +15,16 @@ * renderer and asset server can resolve them without knowing which day they * belong to. */ -import { ENTRY_KINDS, type Entry, type EntryKind, type EntryPosition } from '../types' +import type { Entry, EntryPosition } from '../types' export const ENTRIES_DIR = 'entries' export const ASSETS_DIR = 'assets' -const MARKER_RE = /^\s*$/ const FORMAT_RE = /^\s*$/ -/** v1 files carry a derived `### HH:MM` heading after each marker; v2 files do not. */ -const TIME_HEADING_RE = /^#{3,6}\s+(?:↳\s+)?\d{1,2}:\d{2}(?::\d{2})?\s*$/ /** A body line that could be mistaken for one of our markers (optionally already escaped). */ const MARKERISH_RE = /^(\s*)(\\*)(` comment that carries its id, parent and timestamps.', + '- Day files are append-only logs of ``, `edit`, `set` and `delete` records; the app replays them into blocks.', '' ].join('\n') ) @@ -169,9 +172,7 @@ export class DevlogStore extends EventEmitter { } private async hasContent(): Promise { - for (const dir of [CANVASES_DIR, LEGACY_PAGES_DIR, LEGACY_CATEGORIES_DIR]) { - if ((await readdirSafe(path.join(this.root, dir))).length > 0) return true - } + if ((await readdirSafe(path.join(this.root, CANVASES_DIR))).length > 0) return true return (await readdirSafe(path.join(this.root, ENTRIES_DIR))).some((d) => d.isDirectory() || d.name === 'todos.md') } @@ -196,7 +197,7 @@ export class DevlogStore extends EventEmitter { return out } - /** Old canvas ids (from before the format 2 migration, or merged canvases) → current ids. */ + /** Old canvas ids (from before canvases got random ids, or merged canvases) → current ids. */ async aliasMap(): Promise> { const map = new Map() for (const c of await this.listCanvases()) for (const a of c.aliases ?? []) map.set(a, c.id) @@ -238,13 +239,14 @@ export class DevlogStore extends EventEmitter { id, title, parentId, - task: Boolean(input.task), + task: false, createdAt: now.toISOString(), updatedAt: '', repos: cleanRepos(input.repos), archived: false, hasSurface: false } + applyType(meta, input) await fs.mkdir(this.resolve(canvasEntriesBase(id)), { recursive: true }) await this.writeCanvas(meta, '') return meta @@ -264,8 +266,19 @@ export class DevlogStore extends EventEmitter { if (parentId && isWithin(all, parentId, id)) throw new Error('A canvas cannot be moved inside itself') canvas.parentId = parentId } - if (patch.task !== undefined) canvas.task = Boolean(patch.task) + applyType(canvas, patch) if (patch.repos !== undefined) canvas.repos = cleanRepos(patch.repos) + if (patch.fields !== undefined) { + const fields = { ...(canvas.fields ?? {}) } + for (const [rawKey, value] of Object.entries(patch.fields)) { + const key = rawKey.toLowerCase() + if (!isFieldKey(key)) throw new Error(`Not a canvas field name: ${rawKey}`) + if (value === null || value.trim() === '') delete fields[key] + else fields[key] = value.trim() + } + if (Object.keys(fields).length) canvas.fields = fields + else delete canvas.fields + } if (!canvas.createdAt) canvas.createdAt = new Date().toISOString() await this.writeCanvas(stripSurface(canvas), canvas.surface) return stripSurface(canvas) @@ -326,6 +339,15 @@ export class DevlogStore extends EventEmitter { * titled from the block's first line, and the block becomes the link to it. */ async promoteToTask(canvasId: string, date: string, entryId: string, now: Date = new Date()): Promise { + return this.promoteBlock(canvasId, date, entryId, TASK_TYPE, now) + } + + /** + * Turn a block into a canvas of a node type (`null`: a plain canvas) just + * beneath the block's canvas, titled from its first line. What was written + * inside the block moves there; the block becomes the link to it. + */ + async promoteBlock(canvasId: string, date: string, entryId: string, type: string | null, now: Date = new Date()): Promise { const day = await this.readDay(canvasId, date) const entry = day.entries.find((e) => e.id === entryId) if (!entry) throw new Error(`Entry ${entryId} not found on ${date}`) @@ -334,8 +356,10 @@ export class DevlogStore extends EventEmitter { const existing = await this.readCanvas(entry.meta.canvas).catch(() => null) if (existing) return { canvas: stripSurface(existing), entry } } - const canvas = await this.createCanvas({ title: titleFromMarkdown(entry.markdown), parentId: canvasId === JOURNAL_ID ? null : canvasId, task: true }, now) + const canvas = await this.createCanvas({ title: titleFromMarkdown(entry.markdown), parentId: canvasId === JOURNAL_ID ? null : canvasId, type }, now) await this.mutateDay(canvasId, date, (log) => ({ ops: planSet(log, entryId, { kind: 'task', canvas: canvas.id }, stampFor(log, now)), result: null })) + // What was written inside the block moves into the task's own stream; the block stays as the link to it. + for (const child of day.entries.filter((e) => e.parentId === entryId)) await this.moveBlock({ canvasId, date, id: child.id }, { canvasId: canvas.id, date }, now) entry.kind = 'task' entry.meta = { ...(entry.meta ?? {}), canvas: canvas.id } return { canvas, entry } @@ -358,127 +382,154 @@ export class DevlogStore extends EventEmitter { } // ------------------------------------------------------------------------- - // Todos: a per-canvas list of todo blocks (with reply threads) in todos.md + // Todos: blocks with kind=todo, anywhere in the streams (docs/BLOCK-PAGES.md) // ------------------------------------------------------------------------- - private todoDir(canvasId: string): string { - return canvasId === JOURNAL_ID ? ENTRIES_DIR : canvasDir(canvasId) - } - - private todoPath(canvasId: string): string { - return `${this.todoDir(canvasId)}/todos.md` - } - - /** The canvas's todo list in order (open and done), replies included. */ - async readTodos(canvasId: string): Promise { - assertCanvasId(canvasId) - try { - return parseBlockFile(await fs.readFile(this.resolve(this.todoPath(canvasId)), 'utf8'), this.todoDir(canvasId)) - } catch (err) { - if ((err as NodeJS.ErrnoException).code === 'ENOENT') return [] - throw err - } - } - - private mutateTodos(canvasId: string, plan: (log: BlockLog) => { ops: Op[]; result: T }): Promise { - assertCanvasId(canvasId) - return this.mutate(this.todoPath(canvasId), '# Todos', undefined, plan, { kind: 'todos', canvasId }) - } - - /** Append todos (one per string) to the end of the canvas's list. */ - async addTodos(canvasId: string, texts: string[], now: Date = new Date()): Promise { - if (canvasId !== JOURNAL_ID) await this.readCanvas(canvasId) + /** + * Add todos (one block each, in order) to a canvas: at the end of today, or + * inside a block (`parentId`, with the block's day file as `date`). + */ + async addTodos(canvasId: string, texts: string[], position: { date?: string; parentId?: string } = {}, now: Date = new Date(), opts: { done?: boolean[] } = {}): Promise<{ date: string; entries: Entry[] }> { const items = texts.map((x) => x.trim()).filter(Boolean) if (items.length === 0) throw new Error('Nothing to add') - return this.mutateTodos(canvasId, (log) => { + const date = position.date ?? localDate(now) + const entries = await this.mutateDay(canvasId, date, (log) => { const ops: Op[] = [] const added: Entry[] = [] let cur = log - for (const t of items) { + items.forEach((t, i) => { const entry: Entry = { id: uniqueId(cur.ids), createdAt: now.toISOString(), kind: 'todo', markdown: t } - const planned = planAdd(cur, entry, {}, entry.createdAt) + if (opts.done?.[i]) entry.meta = { done: now.toISOString() } + const planned = planAdd(cur, entry, position.parentId ? { parentId: position.parentId } : {}, entry.createdAt) ops.push(...planned) added.push(entry) cur = advance(cur, planned) - } + }) return { ops, result: added } }) + return { date, entries } } - /** Comment on a todo (or on a comment in its thread). */ - async addTodoReply(canvasId: string, parentId: string, markdown: string, now: Date = new Date()): Promise { - if (isBlankMarkdown(markdown)) throw new Error('Cannot add an empty comment') - return this.mutateTodos(canvasId, (log) => { - const entry: Entry = { id: uniqueId(log.ids), createdAt: now.toISOString(), markdown: markdown.trim() } - return { ops: planAdd(log, entry, { parentId }, entry.createdAt), result: entry } - }) - } - - async updateTodoEntry(canvasId: string, id: string, markdown: string, now: Date = new Date()): Promise { - if (isBlankMarkdown(markdown)) throw new Error('A todo needs some text') - return this.mutateTodos(canvasId, (log) => { - const entry = log.entries.find((e) => e.id === id) - if (!entry) throw new Error('Todo not found') - const at = stampFor(log, now) - return { ops: planEdit(log, id, markdown.trim(), at), result: { ...entry, markdown: markdown.trim(), updatedAt: at } } - }) - } - - /** Delete a todo (or a comment) and its thread. */ - async deleteTodoEntry(canvasId: string, id: string, now: Date = new Date()): Promise { - return this.mutateTodos(canvasId, (log) => { - if (!log.entries.some((e) => e.id === id)) throw new Error('Todo not found') - const ops = planDelete(log, id, stampFor(log, now)) - return { ops, result: ops.length } + /** Tick a todo off, or back on: a `done` time on the block, where it is. */ + async setTodoDone(canvasId: string, date: string, id: string, done: boolean, now: Date = new Date()): Promise { + return this.mutateDay(canvasId, date, (log) => { + const todo = log.entries.find((e) => e.id === id) + if (!todo || todo.kind !== 'todo') throw new Error('Todo not found') + if (Boolean(todo.meta?.done) === done) return { ops: [], result: todo } + // The record sorts after the file's latest; the done time is when it was ticked. + const when = now.toISOString() + const meta = { ...(todo.meta ?? {}) } + if (done) meta.done = when + else delete meta.done + const next: Entry = { ...todo } + if (Object.keys(meta).length) next.meta = meta + else delete next.meta + return { ops: planSet(log, id, { done: done ? when : '' }, stampFor(log, now)), result: next } }) } - async reorderTodo(canvasId: string, id: string, position: { afterId?: string; beforeId?: string }, now: Date = new Date()): Promise { - await this.mutateTodos(canvasId, (log) => ({ ops: planMove(log, id, position, stampFor(log, now)), result: null })) - return this.readTodos(canvasId) + /** + * Every todo in the devlog that is open, or was ticked off at or after + * `doneSince`, with where it lives. Archived canvases are left out. + */ + async listTodos(opts: { doneSince?: string } = {}): Promise { + const canvases = await this.listCanvases() + const live = new Set(canvases.filter((c) => !c.archived).map((c) => c.id)) + const index = this.liveIndex + const files: Array<{ canvasId: string; date: string }> = [] + if (index) files.push(...index.todoDays()) + else for (const c of canvases) for (const date of await this.listDayFiles(c.id)) files.push({ canvasId: c.id, date }) + // Oldest day first, then canvas; within a file, the order they are shown in. + files.sort((a, b) => a.date.localeCompare(b.date) || a.canvasId.localeCompare(b.canvasId)) + const out: TodoRef[] = [] + for (const { canvasId, date } of files) { + if (!live.has(canvasId)) continue + const { entries } = await this.readDay(canvasId, date) + if (!entries.some((e) => e.kind === 'todo')) continue + const byId = new Map(entries.map((e) => [e.id, e])) + const inside = new Map() + for (const e of entries) { + const seen = new Set() + for (let p = e.parentId; p && !seen.has(p); p = byId.get(p)?.parentId) { + seen.add(p) + inside.set(p, (inside.get(p) ?? 0) + 1) + } + } + for (const entry of entries) { + if (entry.kind !== 'todo') continue + const done = entry.meta?.done + if (done && (!opts.doneSince || done < opts.doneSince)) continue + const trail: TodoRef['trail'] = [] + const seen = new Set([entry.id]) + for (let p = entry.parentId ? byId.get(entry.parentId) : undefined; p && !seen.has(p.id); p = p.parentId ? byId.get(p.parentId) : undefined) { + seen.add(p.id) + trail.unshift({ id: p.id, title: previewText(p.markdown, 60), ...(p.kind ? { kind: p.kind } : {}) }) + } + out.push({ canvasId, date, entry, trail, inside: inside.get(entry.id) ?? 0 }) + } + } + return out } /** - * Tick a todo off (or back on). Ticking writes a read-only "done" block into - * today's stream on the same canvas; unticking the same day removes it again. + * Bring an older devlog up to the current storage format: 3 → 4 moves the + * todo lists into the streams. Returns the format it upgraded from, or null. */ - async setTodoDone(canvasId: string, id: string, done: boolean, now: Date = new Date()): Promise<{ todo: Entry; date: string }> { - const date = localDate(now) - const { todo, changed } = await this.mutateTodos(canvasId, (log) => { - const todo = log.entries.find((e) => e.id === id && e.kind === 'todo') - if (!todo) throw new Error('Todo not found') - if (Boolean(todo.meta?.done) === done) return { ops: [], result: { todo, changed: false } } - const meta = { ...(todo.meta ?? {}) } - if (done) meta.done = now.toISOString() - else delete meta.done - const next: Entry = { ...todo } - if (Object.keys(meta).length) next.meta = meta - else delete next.meta - return { ops: planSet(log, id, { done: done ? now.toISOString() : '' }, stampFor(log, now)), result: { todo: next, changed: true } } + async upgradeStorage(): Promise { + if ((await readStorageFormat(this.root)) !== 3) return null + await this.convertTodoLists() + await updateManifest(this.root, (m) => { + m.format = STORAGE_FORMAT }) - if (!changed) return { todo, date } - if (done) { - await this.addEntry(canvasId, `✓ ${todoTitle(todo.markdown)}`, { date }, now, { kind: 'done', meta: { todo: id } }) - } else { - await this.mutateDay(canvasId, date, (log) => { - const mark = log.entries.find((e) => e.kind === 'done' && e.meta?.todo === id) - return { ops: mark ? planDelete(log, mark.id, stampFor(log, now)) : [], result: null } - }) - } - return { todo, date } + return 3 } /** - * A todo that turned out to be real work: make it a task canvas beneath this - * one, record a task block in today's stream, and close the todo. + * Storage format 3 kept each canvas's todos in its own `todos.md`. Format 4 + * puts every todo in the stream: each old todo (with its comments, which + * become blocks inside it) moves to the day file of the day it was + * written, then the old file is removed. Safe to run again after a crash: + * a todo already moved (same id and time) is not moved twice. */ - async promoteTodo(canvasId: string, id: string, now: Date = new Date()): Promise { - const todo = (await this.readTodos(canvasId)).find((e) => e.id === id && e.kind === 'todo') - if (!todo) throw new Error('Todo not found') - const canvas = await this.createCanvas({ title: titleFromMarkdown(todo.markdown), parentId: canvasId === JOURNAL_ID ? null : canvasId, task: true }, now) - await this.mutateTodos(canvasId, (log) => ({ ops: planSet(log, id, { done: now.toISOString(), task: canvas.id }, stampFor(log, now)), result: null })) - const { entry } = await this.addEntry(canvasId, todo.markdown, { date: localDate(now) }, now, { kind: 'task', meta: { canvas: canvas.id } }) - return { canvas, entry } + async convertTodoLists(): Promise { + let moved = 0 + for (const canvas of await this.listCanvases()) { + const dir = canvas.id === JOURNAL_ID ? ENTRIES_DIR : canvasDir(canvas.id) + const rel = `${dir}/todos.md` + let text: string + try { + text = await fs.readFile(this.resolve(rel), 'utf8') + } catch (err) { + if ((err as NodeJS.ErrnoException).code === 'ENOENT') continue + throw err + } + const entries = parseBlockFile(text, dir) + for (const root of entries.filter((e) => !e.parentId)) { + const thread = [root, ...entries.filter((e) => descendantIds(entries, root.id).has(e.id))] + await this.mutateDay(canvas.id, localDate(new Date(root.createdAt)), (log) => { + if (log.entries.some((e) => e.id === root.id && e.createdAt === root.createdAt)) return { ops: [], result: null } + const rename = new Map() + const ops: Op[] = [] + let cur = log + for (const e of thread) { + const id = cur.ids.has(e.id) ? uniqueId(cur.ids) : e.id + rename.set(e.id, id) + const entry: Entry = { ...e, id } + delete entry.parentId + const parent = e.parentId ? rename.get(e.parentId) : undefined + const planned = planAdd(cur, entry, parent ? { parentId: parent } : {}, entry.createdAt) + ops.push(...planned) + cur = advance(cur, planned) + } + return { ops, result: null } + }) + moved++ + } + await fs.rm(this.resolve(rel), { force: true }) + this.index?.noteFile(rel, null) + this.emit('change', { kind: 'todos', canvasId: canvas.id }) + } + return moved } // ------------------------------------------------------------------------- @@ -534,14 +585,42 @@ export class DevlogStore extends EventEmitter { return { canvasId, days, hasMore: i < dates.length } } - /** Every day file across all canvases within [fromDate, toDate], inclusive. */ + /** + * Every day file across all canvases within [fromDate, toDate], inclusive, + * plus older ones holding blocks written in that range (index only). + */ async getRange(fromDate: string, toDate: string): Promise> { assertDate(fromDate) assertDate(toDate) const out: Array<{ canvasId: string; day: Day }> = [] + // Blocks written inside an older block live in that block's day file; the + // index finds those files by creation time (a day of margin either side + // for time zones; callers attribute each block by when it was written). + const older = new Map>() + const index = this.liveIndex + if (index) { + const margin = (d: string, n: number): string => { + const [y, m, dd] = d.split('-').map(Number) + return new Date(Date.UTC(y, m - 1, dd + n)).toISOString() + } + for (const { canvasId, date } of index.daysWrittenIn(margin(fromDate, -1), margin(toDate, 2))) { + if (date >= fromDate) continue + if (!older.has(canvasId)) older.set(canvasId, new Set()) + older.get(canvasId)!.add(date) + } + } + const inRange = (iso: string | undefined): boolean => { + if (!iso) return false + const d = localDate(new Date(iso)) + return d >= fromDate && d <= toDate + } + const writtenInRange = (e: Entry): boolean => inRange(e.createdAt) || (e.kind === 'todo' && inRange(e.meta?.done)) for (const canvas of await this.listCanvases()) { - const dates = (await this.listDayFiles(canvas.id)).filter((d) => d >= fromDate && d <= toDate).sort() - for (const date of dates) { + for (const date of [...(older.get(canvas.id) ?? [])].sort()) { + const day = await this.readDay(canvas.id, date) + if (day.entries.some(writtenInRange)) out.push({ canvasId: canvas.id, day }) + } + for (const date of (await this.listDayFiles(canvas.id)).filter((d) => d >= fromDate && d <= toDate).sort()) { const day = await this.readDay(canvas.id, date) if (day.entries.length > 0) out.push({ canvasId: canvas.id, day }) } @@ -573,9 +652,6 @@ export class DevlogStore extends EventEmitter { } const hits: Array = [] for (const canvas of canvases) { - ;(await this.readTodos(canvas.id)).forEach((entry, seq) => { - if (entry.markdown.toLowerCase().includes(q)) hits.push({ canvasId: canvas.id, date: localDate(new Date(entry.createdAt)), entry, archived: canvas.archived, seq, todo: 1 }) - }) for (const date of await this.listDayFiles(canvas.id)) { ;(await this.readDay(canvas.id, date)).entries.forEach((entry, seq) => { if (entry.markdown.toLowerCase().includes(q)) hits.push({ canvasId: canvas.id, date, entry, archived: canvas.archived, seq, todo: 0 }) @@ -629,17 +705,77 @@ export class DevlogStore extends EventEmitter { }) } + /** + * A block written by an extension: a note marked `ext=` (plus any + * metadata it adds), read-only in the app like other automatic blocks. + */ + async addExtensionBlock( + canvasId: string, + extensionId: string, + markdown: string, + meta: Record = {}, + now: Date = new Date(), + opts: { parentId?: string; date?: string; todo?: boolean; kind?: string; anyDate?: boolean } = {} + ): Promise<{ date: string; entry: Entry }> { + if (isBlankMarkdown(markdown)) throw new Error('Cannot add an empty block') + if (markdown.length > 100_000) throw new Error('That block is too long') + const clean: Record = {} + for (const [k, v] of Object.entries(meta)) { + if (!/^[a-z][a-z0-9_-]{0,31}$/.test(k) || ['id', 'at', 'parent', 'pos', 'kind', 'hidden', 'updated', 'ext'].includes(k)) throw new Error(`Not a metadata key: ${k}`) + clean[k] = String(v).replace(/[\r\n]+/g, ' ').slice(0, 500) + } + // Inside a block: that block's day file. On a canvas the extension keeps, any day it names. + const date = opts.date && (opts.parentId || opts.anyDate) ? opts.date : localDate(now) + assertDate(date) + return this.mutateDay(canvasId, date, (log) => { + const entry: Entry = { id: uniqueId(log.ids), createdAt: now.toISOString(), markdown: markdown.trim(), meta: { ext: extensionId, ...clean } } + if (opts.todo) entry.kind = 'todo' + else if (opts.kind) entry.kind = opts.kind as Entry['kind'] + return { ops: planAdd(log, entry, opts.parentId ? { parentId: opts.parentId } : {}, entry.createdAt), result: { date, entry } } + }) + } + + /** Change the text of a block an extension added (only that extension may; any block on a canvas it keeps, with `any`). */ + async updateExtensionBlock(canvasId: string, date: string, id: string, extensionId: string, markdown: string, now: Date = new Date(), opts: { any?: boolean } = {}): Promise { + if (isBlankMarkdown(markdown)) throw new Error('A block cannot be empty') + if (markdown.length > 100_000) throw new Error('That block is too long') + return this.mutateDay(canvasId, date, (log) => { + const entry = log.entries.find((e) => e.id === id) + if (!entry) throw new Error(`Entry ${id} not found on ${date}`) + if (entry.meta?.ext !== extensionId && !opts.any) throw new Error('Only blocks it added') + const at = stampFor(log, now) + const md = markdown.trim() + return { ops: planEdit(log, id, md, at), result: { ...entry, markdown: md, updatedAt: at } } + }) + } + async updateEntry(canvasId: string, date: string, id: string, markdown: string, now: Date = new Date()): Promise { return this.mutateDay(canvasId, date, (log) => { const entry = log.entries.find((e) => e.id === id) if (!entry) throw new Error(`Entry ${id} not found on ${date}`) - if (entry.kind === 'commit' || entry.kind === 'done') throw new Error('Automatic blocks are read-only; reply, move or delete instead') + if (entry.kind === 'commit' || entry.kind === 'done' || entry.kind === 'timesheet' || entry.meta?.ext) throw new Error('Automatic blocks are read-only; reply, move or delete instead') const at = stampFor(log, now) const md = normalizeDurationMarker(markdown.trim()) return { ops: planEdit(log, id, md, at), result: { ...entry, markdown: md, updatedAt: at } } }) } + // ------------------------------------------------------------------------- + // Canvases extensions keep (the time extension's Timesheets; see docs/TIMESHEETS.md) + // ------------------------------------------------------------------------- + + /** + * A canvas kept by an extension (or the app), found by the owner named in + * its `devlog.managed` field; made (with `title`) if it is missing and a + * title is given. + */ + async managedCanvas(owner: string, title: string | null = null): Promise { + const found = (await this.listCanvases()).find((c) => c.fields?.[MANAGED_FIELD] === owner) + if (found || !title) return found ?? null + const c = await this.createCanvas({ title }) + return this.updateCanvas(c.id, { fields: { [MANAGED_FIELD]: owner } }) + } + /** Hide (or reveal) a block. Hidden blocks stay in the file and in search; the stream collapses them. */ async setEntryHidden(canvasId: string, date: string, id: string, hidden: boolean, now: Date = new Date()): Promise { return this.mutateDay(canvasId, date, (log) => { @@ -673,34 +809,79 @@ export class DevlogStore extends EventEmitter { * stay where they are; links are rewritten relative to the new file. */ async moveEntry(fromCanvasId: string, date: string, id: string, toCanvasId: string, now: Date = new Date()): Promise<{ date: string; entry: Entry }> { - assertCanvasId(toCanvasId) if (fromCanvasId === toCanvasId) throw new Error('Block is already on that canvas') - await this.readCanvas(toCanvasId) - const source = await this.readDay(fromCanvasId, date) - if (!source.entries.some((e) => e.id === id)) throw new Error(`Entry ${id} not found on ${date}`) - const thread = new Set([id, ...descendantIds(source.entries, id)]) + return this.moveBlock({ canvasId: fromCanvasId, date, id }, { canvasId: toCanvasId }, now) + } + + /** + * Move a block, with everything inside it: to the top level of a canvas + * (on the block's own date), inside another block (`parentId`, with that + * block's day file as `date`), or out beside a block (`afterId`, same file). + * Within one file this is a single `set` record; across files the blocks + * are added to the target (re-id'd where the target already uses an id, + * images still linked where they are) and then deleted from the source. + */ + async moveBlock( + from: { canvasId: string; date: string; id: string }, + to: { canvasId: string; date?: string; parentId?: string; afterId?: string }, + now: Date = new Date() + ): Promise<{ date: string; entry: Entry }> { + assertCanvasId(to.canvasId) + if (to.canvasId !== JOURNAL_ID) await this.readCanvas(to.canvasId) + if ((to.parentId || to.afterId) && !to.date) throw new Error('Moving inside or beside a block needs its date') + const date = to.date ?? from.date + if (to.canvasId === from.canvasId && date === from.date) { + return this.mutateDay(from.canvasId, date, (log) => { + const at = stampFor(log, now) + const ops = to.parentId + ? planNest(log, from.id, { parentId: to.parentId }, at) + : to.afterId + ? planNest(log, from.id, { afterId: to.afterId }, at) + : (() => { + const e = log.entries.find((x) => x.id === from.id) + if (!e) throw new Error(`Entry ${from.id} not found on ${date}`) + if (!e.parentId) throw new Error('Block is already there') + // Out to the top level, right after the top-level block it was inside. + let top = e + while (top.parentId) top = log.entries.find((x) => x.id === top.parentId) ?? { ...top, parentId: undefined } + return planNest(log, from.id, { afterId: top.id }, at) + })() + const moved = log.entries.find((x) => x.id === from.id)! + const entry: Entry = { ...moved } + const parent = ops.at(-1)?.attrs.parent + if (parent) entry.parentId = parent + else delete entry.parentId + return { ops, result: { date, entry } } + }) + } + if (to.afterId) throw new Error('A block moves beside another only within the same day') + const source = await this.readDay(from.canvasId, from.date) + if (!source.entries.some((e) => e.id === from.id)) throw new Error(`Entry ${from.id} not found on ${from.date}`) + const thread = new Set([from.id, ...descendantIds(source.entries, from.id)]) const moving = source.entries.filter((e) => thread.has(e.id)).map((e) => ({ ...e })) // Added to the target first (re-id'd where the target already used an id), then deleted from the source. - const movedRoot = await this.mutateDay(toCanvasId, date, (log) => { + const movedRoot = await this.mutateDay(to.canvasId, date, (log) => { + if (to.parentId && !log.entries.some((e) => e.id === to.parentId)) throw new Error(`Entry ${to.parentId} not found on ${date}`) const rename = new Map() const ops: Op[] = [] let cur = log for (const e of moving) { const newId = cur.ids.has(e.id) ? uniqueId(cur.ids) : e.id rename.set(e.id, newId) - const parent = e.id === id ? undefined : rename.get(e.parentId ?? '') + const parent = e.id === from.id ? to.parentId : rename.get(e.parentId ?? '') const entry: Entry = { ...e, id: newId } delete entry.parentId const planned = planAdd(cur, entry, parent ? { parentId: parent } : {}, entry.createdAt) ops.push(...planned) cur = advance(cur, planned) } - const root = { ...moving[0], id: rename.get(id)! } - delete root.parentId + const root: Entry = { ...moving[0], id: rename.get(from.id)! } + if (to.parentId) root.parentId = to.parentId + else delete root.parentId return { ops, result: root } }) - await this.mutateDay(fromCanvasId, date, (log) => ({ ops: planDelete(log, id, stampFor(log, now)), result: null })) + await this.mutateDay(from.canvasId, from.date, (log) => ({ ops: planDelete(log, from.id, stampFor(log, now)), result: null })) return { date, entry: movedRoot } } @@ -896,13 +1077,21 @@ function cleanRepos(repos?: string[]): string[] { return out } -function assertDate(date: string): void { - if (!isValidDate(date)) throw new Error(`Invalid date: ${date}`) +/** Set a canvas's node type from `type` (wins) or the older `task` flag; `task` follows the type. */ +function applyType(meta: CanvasMeta, input: { type?: string | null; task?: boolean }): void { + if (input.type !== undefined) { + if (input.type && !NODE_TYPE_RE.test(input.type)) throw new Error(`Not a node type: ${input.type}`) + if (input.type) meta.type = input.type + else delete meta.type + } else if (input.task !== undefined) { + if (input.task) meta.type = TASK_TYPE + else if (meta.type === TASK_TYPE) delete meta.type + } + meta.task = meta.type === TASK_TYPE } -/** First line of a todo as plain text, for the "done" block. */ -function todoTitle(markdown: string): string { - return previewText(markdown.split('\n')[0] ?? markdown, 200) +function assertDate(date: string): void { + if (!isValidDate(date)) throw new Error(`Invalid date: ${date}`) } function assertCanvasId(canvasId: string): void { diff --git a/packages/core/src/node/sync.ts b/packages/core/src/node/sync.ts index a630774..d4dfbd3 100644 --- a/packages/core/src/node/sync.ts +++ b/packages/core/src/node/sync.ts @@ -132,122 +132,6 @@ export class SyncManager extends EventEmitter { return run } - /** Fetch and merge (not rebase) the remote branch. A conflicting merge is aborted and reported. */ - async mergeRemote(): Promise<{ merged: boolean; error?: string }> { - const ref = await this.fetchRemoteRef() - if (!ref) return { merged: false } - return this.mergeRef(ref) - } - - /** - * Merge `ref` (a commit or remote-tracking branch). With `ours`, record it as - * merged while keeping this branch's tree as is. A conflicting merge is - * aborted and reported. Serialised with sync runs. - */ - async mergeRef(ref: string, opts: { ours?: boolean; message?: string } = {}): Promise<{ merged: boolean; error?: string }> { - const run = this.queue.then(async () => { - const args = [ref, '--no-edit', '--no-ff'] - if (opts.ours) args.push('-s', 'ours') - if (opts.message) args.push('-m', opts.message) - try { - await this.git.merge(args) - } catch (err) { - await this.git.merge(['--abort']).catch(() => undefined) - return { merged: false, error: `Merge failed: ${shortError(err)}` } - } - this.setStatus({ lastPullAt: new Date().toISOString(), lastError: null }) - await this.refreshStatus() - return { merged: true } - }) - this.queue = run.catch(() => undefined) - return run - } - - /** Fetch and return the remote-tracking ref for this branch (`origin/main`), or null without one. */ - async fetchRemoteRef(): Promise { - const run = this.queue.then(async () => { - const remote = await this.primaryRemote() - const branch = (await this.git.status()).current - if (!remote || !branch) return null - try { - await this.git.fetch(remote.name) - } catch { - return null - } - return (await this.remoteBranchExists(remote.name, branch)) ? `${remote.name}/${branch}` : null - }) - this.queue = run.catch(() => undefined) - return run - } - - /** - * The oldest commit reachable from `ref` whose change to `file` adds or - * removes a line containing `text` (`git log -G`), or null. - */ - async firstCommitMatching(ref: string, file: string, text: string): Promise { - const run = this.queue.then(async () => { - try { - const pattern = text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') - const out = await this.git.raw(['log', '--format=%H', `-G${pattern}`, ref, '--', file]) - const all = out.trim().split('\n').filter(Boolean) - return all.length ? all[all.length - 1] : null - } catch { - return null - } - }) - this.queue = run.catch(() => undefined) - return run - } - - /** The first parent of `commit`, or null for a root commit. */ - async parentOf(commit: string): Promise { - const run = this.queue.then(async () => { - try { - return (await this.git.raw(['rev-parse', '--verify', '--quiet', `${commit}^`])).trim() || null - } catch { - return null - } - }) - this.queue = run.catch(() => undefined) - return run - } - - /** - * Fetch, then read `file` from the remote branch. Null when there is no - * remote, it cannot be reached, or the file (or branch) does not exist there. - */ - async readRemoteFile(file: string): Promise { - const ref = await this.fetchRemoteRef() - if (!ref) return null - const run = this.queue.then(async () => { - try { - return await this.git.show([`${ref}:${file}`]) - } catch { - return null - } - }) - this.queue = run.catch(() => undefined) - return run - } - - /** True when the working tree has changes or HEAD has commits the remote branch lacks (after the last fetch). */ - async hasUnsyncedWork(): Promise { - const run = this.queue.then(async () => { - const status = await this.git.status() - if (status.files.length > 0) return true - const remote = await this.primaryRemote() - if (!remote || !status.current) return false - try { - const out = await this.git.raw(['rev-list', '--count', `${remote.name}/${status.current}..HEAD`]) - return Number(out.trim()) > 0 - } catch { - return true // no remote branch yet: everything is unsynced - } - }) - this.queue = run.catch(() => undefined) - return run - } - getStatus(): SyncStatus { return { ...this.status } } diff --git a/packages/core/src/timesheet.ts b/packages/core/src/timesheet.ts new file mode 100644 index 0000000..90e7ccf --- /dev/null +++ b/packages/core/src/timesheet.ts @@ -0,0 +1,326 @@ +/** + * Timesheets: turning tracked time into the entries reported to outside + * systems (see docs/TIMESHEETS.md). Pure functions, no I/O. + * + * 1. `buildSessions`: tracked segments → sessions (same task, gaps up to + * 30 minutes count as work, split at midnight and at a task switch). + * 2. `draftEntries`: sessions → entries, each rounded once with the firm + * rounding rule and laid out on quarter hours without overlaps. + * 3. `balanceDays`: per group (client) per day, compare what the rounded + * entries report with the rounded total actually worked, and suggest + * which entries to trim (or extend) to even it out. + */ +import { localDate } from './format/blocks' + +export const QUARTER_MINUTES = 15 +const QUARTER_MS = QUARTER_MINUTES * 60_000 +const MINUTE_MS = 60_000 + +/** + * The firm rounding rule: 0 stays 0; anything above 0 is at least 15 + * minutes; beyond that, the nearest multiple of 15 with exact halves + * rounding up (22.5 → 30, 37.5 → 45). + */ +export function roundWorkMinutes(minutes: number): number { + if (!(minutes > 0)) return 0 + return roundWorkMs(Math.round(minutes * MINUTE_MS)) / MINUTE_MS +} + +/** The rounding rule on milliseconds (exact integer arithmetic), returning milliseconds. */ +export function roundWorkMs(ms: number): number { + if (!(ms > 0)) return 0 + const quarters = Math.floor((2 * ms + QUARTER_MS) / (2 * QUARTER_MS)) // nearest, halves up + return Math.max(1, quarters) * QUARTER_MS +} + +/** An instant rounded to the nearest quarter hour of local time (halves up). */ +export function roundToQuarterHour(date: Date): Date { + const local = new Date(date) + const msIntoHour = local.getMinutes() * MINUTE_MS + local.getSeconds() * 1000 + local.getMilliseconds() + const quarters = Math.floor((2 * msIntoHour + QUARTER_MS) / (2 * QUARTER_MS)) + local.setMinutes(0, 0, 0) + return new Date(local.getTime() + quarters * QUARTER_MS) +} + +// --------------------------------------------------------------------------- +// Sessions +// --------------------------------------------------------------------------- + +/** A stretch of tracked time on one canvas (what the tracker replays into). */ +export interface WorkSegment { + canvasId: string + start: string + end: string +} + +export interface Session { + canvasId: string + /** Local date the session is on. */ + date: string + start: string + end: string + /** From first start to last end, short gaps included. */ + workedMinutes: number +} + +export interface SessionOptions { + /** Gaps up to this long on the same task still count as work. Default 30. */ + maxGapMinutes?: number +} + +/** + * Merge segments into sessions: consecutive segments on the same canvas, + * with no other canvas in between, are one session unless a gap between + * them is longer than `maxGapMinutes`. Sessions never cross local midnight. + */ +export function buildSessions(segments: WorkSegment[], opts: SessionOptions = {}): Session[] { + const maxGap = (opts.maxGapMinutes ?? 30) * MINUTE_MS + const pieces: Array<{ canvasId: string; start: number; end: number }> = [] + for (const seg of segments) { + let s = Date.parse(seg.start) + const e = Date.parse(seg.end) + if (!(e > s)) continue + // Split at local midnight. + while (s < e) { + const d = new Date(s) + const midnight = new Date(d.getFullYear(), d.getMonth(), d.getDate() + 1).getTime() + const pieceEnd = Math.min(e, midnight) + pieces.push({ canvasId: seg.canvasId, start: s, end: pieceEnd }) + s = pieceEnd + } + } + pieces.sort((a, b) => a.start - b.start || a.end - b.end) + + const out: Session[] = [] + let cur: { canvasId: string; start: number; end: number } | null = null + const close = (): void => { + if (!cur) return + out.push({ + canvasId: cur.canvasId, + date: localDate(new Date(cur.start)), + start: new Date(cur.start).toISOString(), + end: new Date(cur.end).toISOString(), + workedMinutes: (cur.end - cur.start) / MINUTE_MS + }) + cur = null + } + for (const p of pieces) { + if ( + cur && + p.canvasId === cur.canvasId && + p.start - cur.end <= maxGap && + localDate(new Date(p.start)) === localDate(new Date(cur.start)) + ) { + cur.end = Math.max(cur.end, p.end) + continue + } + close() + cur = { ...p } + } + close() + return out +} + +// --------------------------------------------------------------------------- +// Draft entries +// --------------------------------------------------------------------------- + +/** Where an entry came from: tracked time, a note's estimate (untracked day), or typed in. */ +export type EntrySource = 'tracked' | 'estimated' | 'manual' + +export interface TimesheetEntry { + id: string + date: string + /** Rounded start (ISO), on a local quarter hour. */ + start: string + /** Reported minutes: a multiple of 15, at least 15. */ + minutes: number + canvasId: string + /** Unrounded minutes actually worked, kept for the record. */ + worked: number + source: EntrySource + note?: string +} + +/** + * Round sessions into entries. Each duration is rounded once with the firm + * rule; starts go to the nearest quarter hour, then move later where needed + * so that entries on the same day never overlap (in the order they happened). + */ +export function draftEntries(sessions: Session[]): TimesheetEntry[] { + const sorted = [...sessions].sort((a, b) => a.start.localeCompare(b.start)) + const out: TimesheetEntry[] = [] + const dayEnd = new Map() + sorted.forEach((s, i) => { + const minutes = roundWorkMinutes(s.workedMinutes) + if (minutes === 0) return + let start = roundToQuarterHour(new Date(s.start)).getTime() + // 23:53 rounds to midnight: keep the start on the session's own day. + if (localDate(new Date(start)) !== s.date) start = new Date(new Date(s.start).setHours(23, 45, 0, 0)).getTime() + const busyUntil = dayEnd.get(s.date) + if (busyUntil !== undefined && start < busyUntil) start = busyUntil + dayEnd.set(s.date, start + minutes * MINUTE_MS) + out.push({ id: `e${i + 1}`, date: s.date, start: new Date(start).toISOString(), minutes, canvasId: s.canvasId, worked: s.workedMinutes, source: 'tracked' }) + }) + return out +} + +// --------------------------------------------------------------------------- +// Balancing rounding inflation +// --------------------------------------------------------------------------- + +export interface Adjustment { + entryId: string + from: number + to: number +} + +export interface DayBalance { + date: string + /** The grouping key, normally the client (top-level canvas). */ + group: string + /** Σ unrounded minutes worked. */ + worked: number + /** The rounded total actually worked: what the day should report. */ + target: number + /** Σ entry minutes as they stand. */ + reported: number + /** Suggested changes that bring `reported` to `target` (never below 15 per entry). */ + suggestions: Adjustment[] + /** What the suggestions could not even out (positive: still over-reported). */ + residual: number +} + +/** + * For each group and day, suggest how to even out rounding: take the + * difference between what the entries report and the rounded total worked + * out of (or add it to) the longest entries, 15 minutes at a time, never + * taking an entry below 15 minutes. Suggestions only; nothing is changed. + */ +export function balanceDays(entries: TimesheetEntry[], groupOf: (canvasId: string) => string): DayBalance[] { + const groups = new Map() + for (const e of entries) { + const key = `${e.date}\u0000${groupOf(e.canvasId)}` + groups.set(key, [...(groups.get(key) ?? []), e]) + } + const out: DayBalance[] = [] + for (const [key, list] of [...groups].sort(([a], [b]) => a.localeCompare(b))) { + const [date, group] = key.split('\u0000') + const worked = list.reduce((n, e) => n + e.worked, 0) + const target = roundWorkMinutes(worked) + const reported = list.reduce((n, e) => n + e.minutes, 0) + const proposed = new Map(list.map((e) => [e.id, e.minutes])) + // Longest first; ties by more time worked, then by id, so suggestions are stable. + const pick = (canShrink: boolean): TimesheetEntry | undefined => + list + .filter((e) => !canShrink || proposed.get(e.id)! > QUARTER_MINUTES) + .sort((a, b) => proposed.get(b.id)! - proposed.get(a.id)! || b.worked - a.worked || a.id.localeCompare(b.id))[0] + let diff = reported - target + while (diff > 0) { + const e = pick(true) + if (!e) break + proposed.set(e.id, proposed.get(e.id)! - QUARTER_MINUTES) + diff -= QUARTER_MINUTES + } + while (diff < 0) { + const e = pick(false)! + proposed.set(e.id, proposed.get(e.id)! + QUARTER_MINUTES) + diff += QUARTER_MINUTES + } + const suggestions = list.filter((e) => proposed.get(e.id) !== e.minutes).map((e) => ({ entryId: e.id, from: e.minutes, to: proposed.get(e.id)! })) + out.push({ date, group, worked, target, reported, suggestions, residual: diff }) + } + return out +} + +// --------------------------------------------------------------------------- +// The stored timesheet +// --------------------------------------------------------------------------- + +export interface Timesheet { + /** Monday of the week (local date). */ + week: string + /** `final` once you have approved it (what destinations send). */ + status: 'draft' | 'final' + entries: TimesheetEntry[] + updatedAt: string +} + +/** The fenced block that holds a timesheet's exact data under its readable table. */ +export const TIMESHEET_FENCE = 'devlog-timesheet' +/** Canvas field that marks the canvas Devlog keeps timesheets in. */ +export const MANAGED_FIELD = 'devlog.managed' +export const TIMESHEETS_MANAGED = 'timesheets' + +const DATE_RE = /^\d{4}-\d{2}-\d{2}$/ +const SOURCES: EntrySource[] = ['tracked', 'estimated', 'manual'] + +/** Keep a timesheet well-formed (it comes from a file, or from the UI). Throws on what cannot be repaired. */ +export function sanitizeTimesheet(raw: unknown): Timesheet { + const o = (raw ?? {}) as Record + const week = String(o.week ?? '') + if (!DATE_RE.test(week)) throw new Error('A timesheet needs its week (YYYY-MM-DD)') + const [y, m, d] = week.split('-').map(Number) + const monday = new Date(y, m - 1, d) + if (monday.getDay() !== 1) throw new Error('A timesheet week starts on a Monday') + const dates = new Set(Array.from({ length: 7 }, (_, i) => localDate(new Date(y, m - 1, d + i)))) + const seen = new Set() + const entries: TimesheetEntry[] = [] + for (const e of (Array.isArray(o.entries) ? o.entries : []) as Array>) { + const id = String(e?.id ?? '') + const date = String(e?.date ?? '') + const start = String(e?.start ?? '') + const minutes = Number(e?.minutes) + const canvasId = String(e?.canvasId ?? '') + if (!/^[A-Za-z0-9_-]{1,40}$/.test(id) || seen.has(id)) throw new Error(`Bad or repeated entry id: ${id}`) + if (!dates.has(date)) throw new Error(`Entry ${id}: ${date} is not in the week of ${week}`) + if (Number.isNaN(Date.parse(start))) throw new Error(`Entry ${id}: bad start time`) + if (!Number.isInteger(minutes) || minutes < QUARTER_MINUTES || minutes > 24 * 60 || minutes % QUARTER_MINUTES !== 0) throw new Error(`Entry ${id}: minutes must be a multiple of 15, at least 15`) + if (!canvasId || !/^[a-z0-9-]{1,64}$/.test(canvasId)) throw new Error(`Entry ${id}: bad canvas`) + seen.add(id) + const worked = Number(e?.worked) + const source = SOURCES.includes(e?.source as EntrySource) ? (e.source as EntrySource) : 'manual' + const note = typeof e?.note === 'string' ? e.note.replace(/[\r\n]+/g, ' ').trim().slice(0, 500) : '' + entries.push({ id, date, start: new Date(start).toISOString(), minutes, canvasId, worked: Number.isFinite(worked) && worked >= 0 ? Math.round(worked * 100) / 100 : 0, source, ...(note ? { note } : {}) }) + } + entries.sort((a, b) => a.date.localeCompare(b.date) || a.start.localeCompare(b.start) || a.id.localeCompare(b.id)) + return { week, status: o.status === 'final' ? 'final' : 'draft', entries, updatedAt: typeof o.updatedAt === 'string' ? o.updatedAt : '' } +} + +const hm = (minutes: number): string => `${Math.floor(minutes / 60)}:${String(minutes % 60).padStart(2, '0')}` +const cell = (s: string): string => s.replace(/\|/g, '\\|').replace(/[\r\n]+/g, ' ') + +/** + * The block body for a timesheet: a readable table (so git and any editor + * show what was worked and reported) followed by the exact data, which is + * what the app reads back. + */ +export function serializeTimesheet(sheet: Timesheet, labelOf: (canvasId: string) => string, clientOf: (canvasId: string) => string): string { + const total = sheet.entries.reduce((n, e) => n + e.minutes, 0) + const worked = sheet.entries.reduce((n, e) => n + e.worked, 0) + const lines = [ + `**Timesheet, week of ${sheet.week}** (${sheet.status}): ${hm(total)} reported, ${hm(Math.round(worked))} worked`, + '', + '| Date | Start | Hours | Task | Client | Note |', + '|---|---|---|---|---|---|' + ] + for (const e of sheet.entries) { + const d = new Date(e.start) + const start = `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}` + lines.push(`| ${e.date} | ${start} | ${hm(e.minutes)} | ${cell(labelOf(e.canvasId))} | ${cell(labelOf(clientOf(e.canvasId)))} | ${cell(e.note ?? '')} |`) + } + if (sheet.entries.length === 0) lines.push('| | | | (no entries) | | |') + lines.push('', '```' + TIMESHEET_FENCE, JSON.stringify({ week: sheet.week, status: sheet.status, updatedAt: sheet.updatedAt, entries: sheet.entries }), '```') + return lines.join('\n') +} + +/** Read a timesheet back from its block body (the fenced data), or null. */ +export function parseTimesheet(markdown: string): Timesheet | null { + const m = new RegExp('```' + TIMESHEET_FENCE + '\\n([\\s\\S]*?)\\n```').exec(markdown) + if (!m) return null + try { + return sanitizeTimesheet(JSON.parse(m[1])) + } catch { + return null + } +} diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 01966e7..99bfb58 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -12,7 +12,7 @@ * read-only), `task` (a note that was turned into a task; `meta.canvas` is * the task canvas it opened). */ -export const ENTRY_KINDS = ['note', 'commit', 'task', 'todo', 'done'] as const +export const ENTRY_KINDS = ['note', 'commit', 'task', 'todo', 'done', 'timesheet'] as const /** * `todo` blocks live in a canvas's todo list (`todos.md`), not in the dated * stream; `meta.done` is the ISO time they were ticked off, `meta.task` the @@ -61,6 +61,21 @@ export interface EntryPosition { beforeId?: string } +/** + * A todo somewhere in the devlog: a block with `kind: 'todo'`, at any depth, + * with the blocks it sits inside (outermost first) so it can be grouped and + * opened where it lives. + */ +export interface TodoRef { + canvasId: string + /** The day file it lives in. */ + date: string + entry: Entry + trail: Array<{ id: string; title: string; kind?: EntryKind }> + /** Blocks written inside it. */ + inside: number +} + export interface DaySummary { date: string count: number @@ -78,8 +93,13 @@ export interface CanvasMeta { title: string /** Enclosing canvas, or null at the top level. */ parentId: string | null - /** Tasks are what the tracker times. Posting on a task makes it the active task. */ + /** Tasks are what the tracker times. Posting on a task makes it the active task. Same as `type === TASK_TYPE`. */ task: boolean + /** + * A node type an extension contributes ("/"): its + * icon and label come from that extension; without it, a plain canvas. + */ + type?: string createdAt: string /** Last surface edit. */ updatedAt: string @@ -91,6 +111,11 @@ export interface CanvasMeta { hasSurface: boolean /** Former ids (e.g. the folder name before storage format 2); references to them resolve here. */ aliases?: string[] + /** + * Other front-matter keys, kept as written: extension fields such as + * `ext.sdeken.devlog-jira.issue`. Keys are lowercase. + */ + fields?: Record } export interface Canvas extends CanvasMeta { @@ -102,7 +127,11 @@ export interface CanvasInput { title: string parentId?: string | null task?: boolean + /** A node type ("/"), or null for a plain canvas. Wins over `task`. */ + type?: string | null repos?: string[] + /** Extra front-matter fields to set; an empty string or null removes one. */ + fields?: Record } /** A slice of a canvas's stream: whole days, oldest first. */ diff --git a/packages/core/tests/activitylog.test.ts b/packages/core/tests/activitylog.test.ts index a75f252..501a776 100644 --- a/packages/core/tests/activitylog.test.ts +++ b/packages/core/tests/activitylog.test.ts @@ -54,21 +54,4 @@ describe('ActivityLog', () => { expect(await fs.readFile(desk.fileFor('2026-09-14'), 'utf8')).not.toContain('machine') }) - it('adopts a log written in the old layout into this machine folder', async () => { - const local = path.join(root, 'userData', 'activity') - const repo = path.join(root, 'repo') - const log = new ActivityLog(() => repo, 'desk-1a2b') - const line = (h: number, type: string): string => `${JSON.stringify({ t: new Date(2026, 8, 14, h).toISOString(), type })}\n` - await fs.mkdir(path.join(local, '2026/09'), { recursive: true }) - await fs.writeFile(path.join(local, '2026/09/2026-09-14.jsonl'), line(8, 'start') + line(9, 'lock')) - await fs.mkdir(path.join(local, 'desk-1a2b/2026/09'), { recursive: true }) - await fs.writeFile(path.join(local, 'desk-1a2b/2026/09/2026-09-15.jsonl'), line(33, 'unlock')) - await log.append({ t: new Date(2026, 8, 14, 11).toISOString(), type: 'unlock' }) - - expect(await log.adoptLegacyLog(local)).toBe(2) - expect((await log.read('2026-09-14', '2026-09-15')).map((e) => e.type)).toEqual(['start', 'lock', 'unlock', 'unlock']) - await expect(fs.stat(path.join(local, '2026/09/2026-09-14.jsonl'))).rejects.toThrow() - expect(await log.adoptLegacyLog(local)).toBe(0) - expect(await log.adoptLegacyLog(path.join(repo, 'activity'))).toBe(0) // never onto itself - }) }) diff --git a/packages/core/tests/canvases.test.ts b/packages/core/tests/canvases.test.ts index 497fc8d..a702af2 100644 --- a/packages/core/tests/canvases.test.ts +++ b/packages/core/tests/canvases.test.ts @@ -10,15 +10,12 @@ import { flattenTree, isValidCanvasId, isWithin, - legacyCategoryId, newCanvasId, parseCanvasFile, - parseLegacyPageFile, - parseLegacyWikiFile, serializeCanvasFile, - slugify + TASK_TYPE } from '../src/format/canvases' -import { hasTaskTag, stripTaskTag, titleFromMarkdown } from '../src/format/blocks' +import { hasTag, hasTaskTag, stripTag, stripTaskTag, titleFromMarkdown } from '../src/format/blocks' import type { CanvasMeta } from '../src/types' const c = (id: string, title: string, parentId: string | null = null, extra: Partial = {}): CanvasMeta => ({ @@ -35,13 +32,6 @@ const c = (id: string, title: string, parentId: string | null = null, extra: Par }) describe('canvases', () => { - it('slugifies titles', () => { - expect(slugify('Acme Corp')).toBe('acme-corp') - expect(slugify(' Ünïcödé & Co. ')).toBe('unicode-co') - expect(slugify('!!!')).toBe('canvas') - expect(legacyCategoryId(['Acme Corp', 'Web'])).toBe('acme-corp-web') - }) - it('validates ids and maps bases', () => { expect(isValidCanvasId('journal')).toBe(true) expect(isValidCanvasId('acme-corp')).toBe(true) @@ -90,10 +80,22 @@ describe('canvases', () => { '---\ntitle: "Website: relaunch"\nparent: acme-corp\ntask: true\ncreated: 2026-09-19T10:00:00.000Z\nupdated: 2026-09-20T10:00:00.000Z\nrepo: "C:\\\\src\\\\acme"\nrepo: /home/me/src/acme site\narchived: true\n---\n\n# Links\n\n- [Tracker](https://x)\n' ) const parsed = parseCanvasFile('website', text) - expect(parsed.meta).toEqual(meta) + // `task: true` reads as the time extension's node type. + expect(parsed.meta).toEqual({ ...meta, type: TASK_TYPE }) expect(parsed.surface).toBe('# Links\n\n- [Tracker](https://x)') }) + it('keeps an extension node type in canvas.md; the task type stays `task: true`', () => { + const base: CanvasMeta = { id: 'k3m9x2q7vd', title: 'Standup', parentId: null, task: false, createdAt: '2026-09-19T10:00:00.000Z', updatedAt: '', repos: [], archived: false, hasSurface: false } + const meeting = serializeCanvasFile({ ...base, type: 'sdeken.meetings/series' }, '') + expect(meeting).toContain('type: sdeken.meetings/series') + expect(parseCanvasFile(base.id, meeting).meta).toMatchObject({ type: 'sdeken.meetings/series', task: false }) + const task = serializeCanvasFile({ ...base, type: TASK_TYPE, task: true }, '') + expect(task).toContain('task: true') + expect(task).not.toContain('type:') + expect(parseCanvasFile(base.id, 'title: x\ntype: not a type\n').meta.type).toBeUndefined() + }) + it('tolerates a missing or partial front matter', () => { expect(parseCanvasFile('x', 'just a surface')).toEqual({ meta: { id: 'x', title: 'x', parentId: null, task: false, createdAt: '', updatedAt: '', repos: [], archived: false, hasSurface: true }, @@ -134,18 +136,6 @@ describe('canvases', () => { expect(buildCanvasTree(all, { includeArchived: true })[0].children.map((n) => n.canvas.id)).toEqual(['mobile', 'old', 'web']) }) - it('reads legacy page and wiki files', () => { - expect(parseLegacyPageFile('acme', '---\ntitle: Acme\ncategory: Clients / Big\nrepo: /x\narchived: yes\n---\n\nDesc')).toEqual({ - id: 'acme', - title: 'Acme', - category: 'Clients / Big', - description: 'Desc', - createdAt: '', - repos: ['/x'], - archived: true - }) - expect(parseLegacyWikiFile('---\npath: Acme Corp / Web\nupdated: t\n---\n\n# Hi\n', ['x'])).toEqual({ path: ['Acme Corp', 'Web'], archived: false, updatedAt: 't', markdown: '# Hi' }) - }) }) describe('task tags and titles', () => { @@ -160,6 +150,15 @@ describe('task tags and titles', () => { expect(stripTaskTag('Fix \\#task it')).toBe('Fix it') }) + it("recognises any post command's #tag on the first line (1.6)", () => { + expect(hasTag('#job Rebuild the widget', 'job')).toBe(true) + expect(hasTag('Rebuild the widget \\#job', 'job')).toBe(true) + expect(hasTag('Rebuild the #jobs page', 'job')).toBe(false) + expect(hasTag('Rebuild\n#job', 'job')).toBe(false) + expect(stripTag('#job Rebuild the widget', 'job')).toBe('Rebuild the widget') + expect(stripTag('Rebuild the widget #job\nmore', 'job')).toBe('Rebuild the widget\nmore') + }) + it('titles a task from the first sentence', () => { expect(titleFromMarkdown('#task **Fix** the login redirect. It loops on Safari.\n\nDetails')).toBe('Fix the login redirect') expect(titleFromMarkdown('[45m] Retro with the team')).toBe('Retro with the team') diff --git a/packages/core/tests/compact.test.ts b/packages/core/tests/compact.test.ts index 39a8cb7..b33e4c2 100644 --- a/packages/core/tests/compact.test.ts +++ b/packages/core/tests/compact.test.ts @@ -24,7 +24,7 @@ afterEach(async () => { const aug = (d: number, h = 9, m = 0): Date => new Date(2026, 7, d, h, m) const QUIET = new Date(2026, 8, 1) -/** A day with a history: edits, a hide, a move, a reply, deletes; and a todo list. */ +/** A day with a history: edits, a hide, a move, a reply, deletes; and a day of todos. */ async function busyHistory(): Promise<{ canvas: string; file: string; todos: string }> { const canvas = (await store.createCanvas({ title: 'Acme' })).id const a = await store.addEntry(canvas, 'first', {}, aug(3, 9)) @@ -36,31 +36,33 @@ async function busyHistory(): Promise<{ canvas: string; file: string; todos: str await store.reorderEntry(canvas, a.date, c.entry.id, { beforeId: a.entry.id }, aug(3, 15)) const gone = await store.addEntry(canvas, 'deleted later', {}, aug(3, 16)) await store.deleteEntry(canvas, a.date, gone.entry.id, aug(3, 17)) - const [t1, t2] = await store.addTodos(canvas, ['one', 'two'], aug(4)) - await store.addTodoReply(canvas, t1.id, 'a comment', aug(4, 10)) - await store.setTodoDone(canvas, t2.id, true, aug(4, 11)) - await store.reorderTodo(canvas, t2.id, { beforeId: t1.id }, aug(4, 12)) + const { + date, + entries: [t1, t2] + } = await store.addTodos(canvas, ['one', 'two'], {}, aug(4)) + await store.addEntry(canvas, 'a comment', { date, parentId: t1.id }, aug(4, 10)) + await store.setTodoDone(canvas, date, t2.id, true, aug(4, 11)) + await store.reorderEntry(canvas, date, t2.id, { beforeId: t1.id }, aug(4, 12)) const dir = `canvases/${canvas.slice(0, 2)}/${canvas}` - return { canvas, file: `${dir}/entries/2026/08/2026-08-03.md`, todos: `${dir}/todos.md` } + return { canvas, file: `${dir}/entries/2026/08/2026-08-03.md`, todos: `${dir}/entries/2026/08/2026-08-04.md` } } describe('compaction', () => { it('rewrites quiet files to one record per block, with exactly the same blocks', async () => { const { canvas, file, todos } = await busyHistory() const dayBefore = await store.readDay(canvas, '2026-08-03') - const todosBefore = await store.readTodos(canvas) + const todosBefore = await store.readDay(canvas, '2026-08-04') const textBefore = await fs.readFile(path.join(root, file), 'utf8') expect(textBefore).toMatch(/devlog:edit /) const report = await store.compact({ quietSince: QUIET }) const paths = report.compacted.map((c) => c.path).sort() - // 2026-08-04 holds a single record (the done block): already compact, so untouched. expect(paths).toEqual([file, todos].sort()) for (const c of report.compacted) expect(c.after).toBeLessThan(c.before) expect(report.skipped).toEqual([]) expect(await store.readDay(canvas, '2026-08-03')).toEqual(dayBefore) - expect(await store.readTodos(canvas)).toEqual(todosBefore) + expect(await store.readDay(canvas, '2026-08-04')).toEqual(todosBefore) const text = await fs.readFile(path.join(root, file), 'utf8') expect(text).not.toMatch(/devlog:(edit|set|delete) /) expect(text.match(/devlog:add /g)).toHaveLength(dayBefore.entries.length) @@ -105,7 +107,7 @@ describe('compaction', () => { expect(await store.search('deleted later')).toEqual({ blocks: [], surfaces: [] }) expect(await index.refresh()).toMatchObject({ indexed: 0, removed: 0 }) // write-through kept it current expect(await store.listDays(canvas)).toEqual([ - { date: '2026-08-04', count: 1 }, + { date: '2026-08-04', count: 3 }, { date: '2026-08-03', count: 4 } ]) index.close() diff --git a/packages/core/tests/entries.test.ts b/packages/core/tests/entries.test.ts index cad67a5..f58bb67 100644 --- a/packages/core/tests/entries.test.ts +++ b/packages/core/tests/entries.test.ts @@ -57,12 +57,12 @@ describe('image src rewriting', () => { expect(collectImageSrcs(root)).toEqual(['entries/2026/09/assets/2026-09-19-1.png', 'entries/2026/08/assets/x.png']) }) - it('rewrites paths relative to any directory, including category wikis', () => { + it('rewrites paths relative to any directory, including canvas files', () => { const md = '![d](assets/x.png) ![e](https://x/y.png)' - const root = toRootRelativeFrom(md, 'categories/acme-corp/web') - expect(root).toBe('![d](categories/acme-corp/web/assets/x.png) ![e](https://x/y.png)') - expect(toRelativeFrom(root, 'categories/acme-corp/web')).toBe(md) - expect(toRelativeFrom('![n](entries/2026/09/assets/n.png)', 'categories/acme-corp')).toBe('![n](../../entries/2026/09/assets/n.png)') + const root = toRootRelativeFrom(md, 'canvases/k3/k3m9x2q7vd') + expect(root).toBe('![d](canvases/k3/k3m9x2q7vd/assets/x.png) ![e](https://x/y.png)') + expect(toRelativeFrom(root, 'canvases/k3/k3m9x2q7vd')).toBe(md) + expect(toRelativeFrom('![n](entries/2026/09/assets/n.png)', 'canvases/k3/k3m9x2q7vd')).toBe('![n](../../../entries/2026/09/assets/n.png)') }) it('places a cross-month asset correctly when posted on a later day', () => { @@ -99,28 +99,26 @@ describe('day file format', () => { expect(parsed).toEqual(day) }) - it('ignores a hand-written preamble and tolerates missing time headings', () => { + it('ignores a hand-written preamble and keeps headings in bodies', () => { const text = [ + '', '# 2026-09-19', '', 'Some notes someone typed by hand.', '', - '', - 'No heading here', - '', + '', '### 03:00', '', - 'a user heading that looks like a time, kept because it is not first', - '', - '### 00:30', + 'a heading that looks like a time is just text', '', - 'earlier entry, kept in file order', + '', + 'earlier entry, placed by its order key', '' ].join('\n') const parsed = parseDayFile('2026-09-19', text) expect(parsed.entries.map((e) => e.id)).toEqual(['zzzzzzzz', 'yyyyyyyy']) - expect(parsed.entries[0].markdown).toBe('No heading here\n\n### 03:00\n\na user heading that looks like a time, kept because it is not first') - expect(parsed.entries[1].markdown).toBe('earlier entry, kept in file order') + expect(parsed.entries[0].markdown).toBe('### 03:00\n\na heading that looks like a time is just text') + expect(parsed.entries[1].markdown).toBe('earlier entry, placed by its order key') }) it('handles CRLF files', () => { @@ -128,9 +126,9 @@ describe('day file format', () => { expect(parseDayFile('2026-09-19', text)).toEqual(day) }) - it('assigns ids to markers that lack one', () => { - const parsed = parseDayFile('2026-09-19', '\nhi\n') - expect(parsed.entries[0].id).toMatch(/^[a-z0-9]{8}$/) + it('skips records without an id', () => { + const parsed = parseDayFile('2026-09-19', '\n\nhi\n') + expect(parsed.entries).toEqual([]) }) }) @@ -222,7 +220,7 @@ describe('threads and ordering', () => { }) it('drops dangling parent links', () => { - const parsed = parseDayFile('2026-09-19', '\nhi\n') + const parsed = parseDayFile('2026-09-19', '\n\nhi\n') expect(parsed.entries[0].parentId).toBeUndefined() }) }) @@ -247,7 +245,7 @@ describe('hidden blocks and reordering', () => { expect(moveSubtree(list, 'b', { beforeId: 'a1' }).map((x) => x.id)).toEqual(['b', 'a', 'a1', 'c', 'c1']) // No-ops and errors. expect(moveSubtree(list, 'a', { afterId: 'a1' })).toBe(list) - expect(() => moveSubtree(list, 'a1', { afterId: 'b' })).toThrow(/top-level/) + expect(() => moveSubtree(list, 'a1', { afterId: 'b' })).toThrow(/beside it/) expect(() => moveSubtree(list, 'a', { afterId: 'zz' })).toThrow(/not found/) expect(moveSubtree(list, 'a', { afterId: 'b' })[1].createdAt).toBe('2026-09-19T09:00:00.000Z') }) @@ -315,15 +313,8 @@ describe('marker safety', () => { } }) - it('reads v1 files (derived time headings) and tells the formats apart', () => { - const v1 = '# 2026-09-19\n\n\n### 14:32\n\nhello\n' - expect(blockFileFormat(v1)).toBe(1) - expect(parseDayFile('2026-09-19', v1).entries.map((x) => x.markdown)).toEqual(['hello']) - const v3 = serializeDayFile(parseDayFile('2026-09-19', v1)) - expect(blockFileFormat(v3)).toBe(3) - expect(v3).not.toContain('### 14:32') - const v2 = '\n# 2026-09-19\n\n\n### 14:32\n\n\\\n' - expect(blockFileFormat(v2)).toBe(2) - expect(parseDayFile('2026-09-19', v2).entries.map((x) => x.markdown)).toEqual(['### 14:32\n\n']) + it('tells formatted files from others', () => { + expect(blockFileFormat(serializeDayFile({ date: '2026-09-19', entries: [] }))).toBe(3) + expect(blockFileFormat('# 2026-09-19\n\nhand-written\n')).toBe(0) }) }) diff --git a/packages/core/tests/extensions.test.ts b/packages/core/tests/extensions.test.ts new file mode 100644 index 0000000..d148dc8 --- /dev/null +++ b/packages/core/tests/extensions.test.ts @@ -0,0 +1,345 @@ +import { promises as fs } from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { + describeScope, + NODE_TYPE_RE, + nodeTypeName, + normalizeKeybinding, + extensionId, + fieldProblem, + inheritedField, + newestMatching, + parseCanvasFile, + parseExtensionEntry, + parseExtensionManifest, + sanitizeGrant, + satisfies, + scopeCanvasIds, + serializeCanvasFile, + visibleCanvases +} from '../src/index' +import { DevlogStore, ExtensionFileStore, ensureExtensionAttributes, readLockFile, readManifest, updateManifest, writeLockFile } from '../src/node' +import type { CanvasMeta } from '../src/types' + +const canvas = (id: string, parentId: string | null = null, title = id): CanvasMeta => ({ + id, + title, + parentId, + task: false, + createdAt: '', + updatedAt: '', + repos: [], + archived: false, + hasSurface: false +}) + +describe('extension manifests', () => { + const good = { + name: 'devlog-jira', + displayName: 'Jira time export', + version: '1.2.0', + api: '^1.0.0', + main: 'main.js', + contributes: { + canvasFields: [{ key: 'issue', label: 'Jira issue', placeholder: 'ACME-123' }], + settings: [{ key: 'baseurl', label: 'Jira URL' }], + secrets: [{ key: 'token', label: 'API token' }], + commands: [{ id: 'send', label: 'Send worklogs' }], + destinations: [{ id: 'jira', label: 'Jira worklogs' }] + }, + permissions: { read: true, network: ['*.atlassian.net'] }, + appendOnly: ['**/*.jsonl'] + } + + it('reads a valid manifest and fills in defaults', () => { + const m = parseExtensionManifest(good) + expect(m).toMatchObject({ name: 'devlog-jira', api: '^1.0.0', permissions: { read: true, network: ['*.atlassian.net'] }, appendOnly: ['**/*.jsonl'] }) + expect(m.contributes.commands).toEqual([{ id: 'send', label: 'Send worklogs' }]) + expect(m.contributes.destinations).toEqual([{ id: 'jira', label: 'Jira worklogs' }]) + expect(parseExtensionManifest({ name: 'x', version: '0.1.0', api: '1.x' })).toMatchObject({ displayName: 'x', contributes: { canvasFields: [], settings: [], secrets: [], commands: [], destinations: [] }, permissions: {} }) + }) + + it('lists every problem with a bad one', () => { + expect(() => parseExtensionManifest({ name: 'Bad Name', version: 'one', api: 'whenever', main: '../escape.js', appendOnly: ['/etc/*'] })).toThrow( + /"name".*"version".*"api".*"main".*"appendOnly"/ + ) + expect(() => parseExtensionManifest([])).toThrow(/not a JSON object/) + expect(() => parseExtensionManifest({ ...good, contributes: { canvasFields: [{ key: 'Issue Key' }] } })).toThrow(/key/) + }) +}) + +describe('extension settings fields', () => { + it('reads types, options, descriptions, required marks and the check command', () => { + const m = parseExtensionManifest({ + name: 'x', + version: '1.0.0', + api: '^1.0.0', + contributes: { + settings: [ + { key: 'url', label: 'Address', type: 'url', required: true, description: 'Where it lives' }, + { key: 'mode', label: 'Mode', type: 'select', options: [{ value: 'a', label: 'A' }, { value: 'b' }] } + ], + commands: [{ id: 'check', label: 'Check' }], + check: 'check' + } + }) + expect(m.contributes.settings).toEqual([ + { key: 'url', label: 'Address', type: 'url', required: true, description: 'Where it lives' }, + { key: 'mode', label: 'Mode', type: 'select', options: [{ value: 'a', label: 'A' }, { value: 'b', label: 'b' }] } + ]) + expect(m.contributes.check).toBe('check') + expect(() => parseExtensionManifest({ name: 'x', version: '1.0.0', api: '1.x', contributes: { settings: [{ key: 'a', type: 'colour' }] } })).toThrow(/unknown type/) + expect(() => parseExtensionManifest({ name: 'x', version: '1.0.0', api: '1.x', contributes: { settings: [{ key: 'a', type: 'select' }] } })).toThrow(/no options/) + expect(() => parseExtensionManifest({ name: 'x', version: '1.0.0', api: '1.x', contributes: { check: 'nope' } })).toThrow(/check/) + }) + + it('reads canvas fields that are not taken on by canvases inside (1.7)', () => { + const m = parseExtensionManifest({ name: 'x', version: '1.0.0', api: '^1.7.0', contributes: { canvasFields: [{ key: 'target', type: 'number', inherited: false }, { key: 'issue' }] } }) + expect(m.contributes.canvasFields).toEqual([{ key: 'target', label: 'target', type: 'number', inherited: false }, { key: 'issue', label: 'issue' }]) + }) + + it('reads node types', () => { + const m = parseExtensionManifest({ + name: 'x', + version: '1.0.0', + api: '^1.6.0', + contributes: { nodeTypes: [{ id: 'task', label: 'Task', icon: '◉ extra', placeholder: 'What now?' }, { id: 'plain' }] } + }) + expect(m.contributes.nodeTypes).toEqual([ + { id: 'task', label: 'Task', icon: '◉ ', placeholder: 'What now?' }, + { id: 'plain', label: 'plain' } + ]) + expect(nodeTypeName('builtin.x', 'task')).toBe('builtin.x/task') + expect(NODE_TYPE_RE.test(nodeTypeName('sdeken.devlog-time', 'task'))).toBe(true) + expect(() => parseExtensionManifest({ name: 'x', version: '1.0.0', api: '1.x', contributes: { nodeTypes: [{ id: 'Bad Id' }] } })).toThrow(/nodeTypes/) + expect(() => parseExtensionManifest({ name: 'x', version: '1.0.0', api: '1.x', contributes: { nodeTypes: [{ id: 'a' }, { id: 'a' }] } })).toThrow(/duplicate/) + }) + + it('reads command keybindings, node types, menus and post commands (1.6)', () => { + const m = parseExtensionManifest({ + name: 'x', + version: '1.0.0', + api: '^1.6.0', + contributes: { + commands: [ + { id: 'go', label: 'Go', keybinding: 'ctrl+shift+s', nodeType: 'task', menus: ['canvas', 'tray', 'canvas'] }, + { id: 'post', label: 'Post as task', keybinding: 'CmdOrCtrl+Shift+Enter', post: true, tag: 'task' } + ], + views: [{ id: 'head', entry: 'head.html', placement: 'canvasHeader', nodeType: 'task' }] + } + }) + expect(m.contributes.commands).toEqual([ + { id: 'go', label: 'Go', keybinding: 'Ctrl+Shift+S', nodeType: 'task', menus: ['canvas', 'tray'] }, + { id: 'post', label: 'Post as task', keybinding: 'Mod+Shift+Enter', post: true, tag: 'task' } + ]) + expect(m.contributes.views[0]).toMatchObject({ placement: 'canvasHeader', nodeType: 'task' }) + const bad = (cmd: Record): unknown => parseExtensionManifest({ name: 'x', version: '1.0.0', api: '1.x', contributes: { commands: [{ id: 'a', ...cmd }] } }) + expect(() => bad({ keybinding: 'S' })).toThrow(/keybinding/) + expect(() => bad({ keybinding: 'Shift+S' })).toThrow(/keybinding/) + expect(() => bad({ keybinding: 'Mod+Hyper+S' })).toThrow(/keybinding/) + expect(() => bad({ menus: ['toolbar'] })).toThrow(/menus/) + expect(() => bad({ tag: 'task' })).toThrow(/tag/) + expect(normalizeKeybinding('F5')).toBe('F5') + expect(normalizeKeybinding('shift+mod+.')).toBe('Mod+Shift+.') + expect(normalizeKeybinding('Alt+arrowup')).toBe('Alt+ArrowUp') + expect(normalizeKeybinding('Mod++')).toBe('Mod++') + }) + + it('checks values by type', () => { + expect(fieldProblem({ key: 'u', label: 'Address', type: 'url' }, 'https://x.example')).toBeNull() + expect(fieldProblem({ key: 'u', label: 'Address', type: 'url' }, 'x.example')).toMatch(/https/) + expect(fieldProblem({ key: 'u', label: 'Address', type: 'url', required: true }, '')).toMatch(/required/) + expect(fieldProblem({ key: 'e', label: 'Email', type: 'email' }, 'me@x')).toBeNull() + expect(fieldProblem({ key: 'e', label: 'Email', type: 'email' }, 'me')).toMatch(/email/) + expect(fieldProblem({ key: 'n', label: 'N', type: 'number' }, '7.5')).toBeNull() + expect(fieldProblem({ key: 's', label: 'S', type: 'select', options: [{ value: 'a', label: 'A' }] }, 'b')).toMatch(/options/) + expect(fieldProblem({ key: 't', label: 'T' }, 'anything')).toBeNull() + }) +}) + +describe('extension sources and ids', () => { + it('reads GitHub, URL and built-in entries', () => { + expect(parseExtensionEntry('sdeken/devlog-jira', '^1.2.0')).toEqual({ kind: 'github', owner: 'sdeken', repo: 'devlog-jira', range: '^1.2.0' }) + expect(parseExtensionEntry('tools', 'https://example.com/tools.devlog-ext.zip')).toEqual({ kind: 'url', name: 'tools', url: 'https://example.com/tools.devlog-ext.zip' }) + expect(parseExtensionEntry('devlog-focus', 'builtin')).toEqual({ kind: 'builtin', name: 'devlog-focus' }) + expect(() => parseExtensionEntry('sdeken/devlog-jira', 'soon')).toThrow(/version range/) + expect(() => parseExtensionEntry('tools', 'http://insecure.example/x.zip')).toThrow() + expect(() => parseExtensionEntry('../x', 'builtin')).toThrow() + }) + + it('derives the id from the source, so a name cannot be borrowed', () => { + expect(extensionId(parseExtensionEntry('SDeken/Devlog-Jira', '1.0.0'))).toBe('sdeken.devlog-jira') + expect(extensionId(parseExtensionEntry('devlog-jira', 'https://evil.example/x.zip'))).toBe('url.devlog-jira') + expect(extensionId(parseExtensionEntry('devlog-focus', 'builtin'))).toBe('builtin.devlog-focus') + }) + + it('matches version ranges like npm does, for the common forms', () => { + const tags = ['v0.9.0', 'v1.0.0', 'v1.2.0', 'v1.2.5', 'v1.3.0-beta.1', 'v1.3.0', 'v2.0.0', 'nightly'] + expect(newestMatching(tags, '^1.2.0')).toBe('v1.3.0') + expect(newestMatching(tags, '~1.2.0')).toBe('v1.2.5') + expect(newestMatching(tags, '1.2.x')).toBe('v1.2.5') + expect(newestMatching(tags, '1.x')).toBe('v1.3.0') + expect(newestMatching(tags, '*')).toBe('v2.0.0') + expect(newestMatching(tags, 'latest')).toBe('v2.0.0') + expect(newestMatching(tags, '1.3.0-beta.1')).toBe('v1.3.0-beta.1') + expect(newestMatching(tags, '^3.0.0')).toBeNull() + expect(satisfies('0.2.3', '^0.2.0')).toBe(true) + expect(satisfies('0.3.0', '^0.2.0')).toBe(false) + expect(satisfies('1.0.0', '^1.0.0')).toBe(true) + }) +}) + +describe('grants', () => { + const all = [canvas('acme', null, 'Acme'), canvas('web', 'acme', 'Web'), canvas('fix', 'web', 'Fix'), canvas('globex', null, 'Globex')] + + it('covers a chosen canvas and everything beneath it', () => { + expect([...scopeCanvasIds(all, { canvases: ['web'] })].sort()).toEqual(['fix', 'web']) + expect([...scopeCanvasIds(all, { all: true })].sort()).toEqual(['acme', 'fix', 'globex', 'journal', 'web']) + expect([...scopeCanvasIds(all, { canvases: ['gone', 'journal'] })]).toEqual(['journal']) + expect(scopeCanvasIds(all, null).size).toBe(0) + }) + + it('shows granted canvases and the ancestors needed to name them, nothing else', () => { + expect(visibleCanvases(all, { read: { canvases: ['fix'] }, write: null }).map((c) => c.id)).toEqual(['acme', 'web', 'fix']) + expect(visibleCanvases(all, { read: null, write: null })).toEqual([]) + expect(describeScope(all, { canvases: ['web', 'globex'] })).toBe('Web, Globex') + expect(describeScope(all, { all: true })).toBe('the whole devlog') + }) + + it('sanitises stored grants', () => { + expect(sanitizeGrant({ read: { all: true }, write: { canvases: ['a', 3] }, trusted: 'yes' })).toEqual({ read: { all: true }, write: { canvases: ['a'] } }) + expect(sanitizeGrant({ read: null, write: null, trusted: true })).toEqual({ read: null, write: null, trusted: true }) + expect(sanitizeGrant(null)).toEqual({ read: null, write: null }) + }) +}) + +describe('canvas fields', () => { + it('keeps unknown front-matter keys and writes them back', () => { + const text = '---\ntitle: Acme\ncreated: t\next.sdeken.devlog-jira.issue: ACME-123\next.url.cms.client: "7731"\n---\n\nSurface\n' + const { meta, surface } = parseCanvasFile('acme', text) + expect(meta.fields).toEqual({ 'ext.sdeken.devlog-jira.issue': 'ACME-123', 'ext.url.cms.client': '7731' }) + const again = serializeCanvasFile(meta, surface) + expect(again).toContain('ext.sdeken.devlog-jira.issue: ACME-123') + expect(parseCanvasFile('acme', again).meta.fields).toEqual(meta.fields) + }) + + it('inherits a field from the nearest ancestor that sets it', () => { + const list = [{ ...canvas('acme'), fields: { 'ext.x.client': '1' } }, { ...canvas('web', 'acme'), fields: { 'ext.x.client': '2' } }, canvas('fix', 'web'), canvas('other')] + expect(inheritedField(list, 'fix', 'ext.x.client')).toEqual({ value: '2', from: 'web' }) + expect(inheritedField(list, 'acme', 'ext.x.client')).toEqual({ value: '1', from: 'acme' }) + expect(inheritedField(list, 'other', 'ext.x.client')).toBeNull() + }) +}) + +describe('on disk', () => { + let root: string + beforeEach(async () => { + root = await fs.mkdtemp(path.join(os.tmpdir(), 'devlog-ext-')) + }) + afterEach(async () => { + await fs.rm(root, { recursive: true, force: true }) + }) + + it('sets and clears canvas fields through the store', async () => { + const store = new DevlogStore(root) + await store.initLayout() + const c = await store.createCanvas({ title: 'Acme' }) + await store.updateCanvas(c.id, { fields: { 'ext.sdeken.devlog-jira.issue': 'ACME-1' } }) + expect((await store.readCanvas(c.id)).fields).toEqual({ 'ext.sdeken.devlog-jira.issue': 'ACME-1' }) + await store.updateCanvas(c.id, { title: 'Acme Corp' }) + expect((await store.readCanvas(c.id)).fields).toEqual({ 'ext.sdeken.devlog-jira.issue': 'ACME-1' }) + await store.updateCanvas(c.id, { fields: { 'ext.sdeken.devlog-jira.issue': null } }) + expect((await store.readCanvas(c.id)).fields).toBeUndefined() + await expect(store.updateCanvas(c.id, { fields: { title: 'x' } })).rejects.toThrow(/field/) + }) + + it('updates devlog.json without losing other keys, and keeps a sorted lockfile', async () => { + await fs.writeFile(path.join(root, 'devlog.json'), '{ "format": 3, "note": "keep me" }\n') + await Promise.all([ + updateManifest(root, (m) => { + m.extensions = { ...(m.extensions ?? {}), 'sdeken/devlog-jira': '^1.0.0' } + }), + updateManifest(root, (m) => { + m.settings = { 'sdeken.devlog-jira': { baseurl: 'https://acme.atlassian.net' } } + }) + ]) + expect(await readManifest(root)).toEqual({ + format: 3, + note: 'keep me', + extensions: { 'sdeken/devlog-jira': '^1.0.0' }, + settings: { 'sdeken.devlog-jira': { baseurl: 'https://acme.atlassian.net' } } + }) + expect(await readLockFile(root)).toEqual({ lockfileVersion: 1, extensions: {} }) + const entry = { id: 'x', spec: '1.0.0', version: '1.0.0', url: 'u', sha256: 'h' } + await writeLockFile(root, { lockfileVersion: 1, extensions: { zed: entry, alpha: entry } }) + expect(Object.keys((await readLockFile(root)).extensions)).toEqual(['alpha', 'zed']) + }) + + it('union-merges an extension’s append-only files', async () => { + await ensureExtensionAttributes(root, 'builtin.devlog-focus', ['**/*.jsonl']) + await ensureExtensionAttributes(root, 'builtin.devlog-focus', ['**/*.jsonl']) + const attrs = await fs.readFile(path.join(root, '.gitattributes'), 'utf8') + expect(attrs.match(/extensions\/builtin\.devlog-focus\/\*\*\/\*\.jsonl merge=union/g)).toHaveLength(1) + }) + + describe('the extension file broker', () => { + const files = (limits = {}): ExtensionFileStore => new ExtensionFileStore(path.join(root, 'extensions', 'a.b'), { anchor: root, ...limits }) + + it('reads, writes, appends, lists and removes inside its folder', async () => { + const f = files() + expect(await f.readText('state.json')).toBeUndefined() + await f.write('state.json', '{"n":1}') + await f.append('logs/2026/09/2026-09-26.jsonl', '{"a":1}\n') + await f.append('logs/2026/09/2026-09-26.jsonl', '{"a":2}\n') + expect(await f.readText('state.json')).toBe('{"n":1}') + expect(await f.readText('logs/2026/09/2026-09-26.jsonl')).toBe('{"a":1}\n{"a":2}\n') + expect((await f.list()).map((x) => x.path)).toEqual(['logs/2026/09/2026-09-26.jsonl', 'state.json']) + expect((await f.list('logs')).map((x) => x.path)).toEqual(['logs/2026/09/2026-09-26.jsonl']) + await f.remove('state.json') + expect(await f.stat('state.json')).toBeUndefined() + }) + + it('refuses every way out of the folder', async () => { + const f = files() + for (const bad of ['../x', 'a/../../x', '/etc/passwd', 'C:/x', 'a\\..\\x', 'con', 'nul.txt', 'x:stream', 'trailing.', '', '.', 'a//b']) { + await expect(f.write(bad, 'x'), bad).rejects.toThrow() + } + await expect(f.read('../../devlog.json')).rejects.toThrow() + expect((await fs.readdir(root)).filter((n) => n !== 'extensions')).toEqual([]) + }) + + it('does not follow links, including a linked folder committed to git', async () => { + const outside = await fs.mkdtemp(path.join(os.tmpdir(), 'devlog-outside-')) + try { + await fs.writeFile(path.join(outside, 'secret.txt'), 'secret') + const f = files() + await f.write('ok.txt', 'ok') + await fs.symlink(outside, path.join(root, 'extensions', 'a.b', 'link')) + await expect(f.read('link/secret.txt')).rejects.toThrow(/Links/) + await expect(f.write('link/new.txt', 'x')).rejects.toThrow(/Links/) + expect((await f.list()).map((x) => x.path)).toEqual(['ok.txt']) + + // The extensions/ folder itself arrives as a link. + await fs.rm(path.join(root, 'extensions'), { recursive: true }) + await fs.symlink(outside, path.join(root, 'extensions')) + await expect(files().read('secret.txt')).rejects.toThrow(/Links/) + } finally { + await fs.rm(outside, { recursive: true, force: true }) + } + }) + + it('caps file and folder sizes', async () => { + const f = files({ maxFileBytes: 10, maxTotalBytes: 25, maxFiles: 3 }) + await expect(f.write('big', 'x'.repeat(11))).rejects.toThrow(/too large/) + await f.write('a', 'x'.repeat(10)) + await f.write('b', 'x'.repeat(10)) + await expect(f.write('c', 'x'.repeat(10))).rejects.toThrow(/full/) + await f.write('a', 'x'.repeat(5)) // replacing frees room + await f.write('c', 'x'.repeat(10)) + await expect(f.write('d', '')).rejects.toThrow(/Too many/) + await expect(f.append('a', 'x'.repeat(6))).rejects.toThrow(/too large/) + }) + }) +}) diff --git a/packages/core/tests/manifest.test.ts b/packages/core/tests/manifest.test.ts new file mode 100644 index 0000000..2075ed6 --- /dev/null +++ b/packages/core/tests/manifest.test.ts @@ -0,0 +1,47 @@ +import { promises as fs } from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { DevlogStore, assertSupportedFormat, readStorageFormat } from '../src/node' + +let root: string + +beforeEach(async () => { + root = await fs.mkdtemp(path.join(os.tmpdir(), 'devlog-manifest-')) +}) + +afterEach(async () => { + await fs.rm(root, { recursive: true, force: true }) +}) + +describe('the storage format check', () => { + it('accepts a new devlog, stamped at format 4', async () => { + await new DevlogStore(root).initLayout() + expect(await readStorageFormat(root)).toBe(4) + await expect(assertSupportedFormat(root)).resolves.toBeUndefined() + }) + + it('refuses a devlog from before format 3 and points at Devlog 0.5', async () => { + await fs.mkdir(path.join(root, 'canvases/acme'), { recursive: true }) + await fs.writeFile(path.join(root, 'canvases/acme/canvas.md'), '---\ntitle: Acme\n---\n') + await new DevlogStore(root).initLayout() // never stamps a devlog that has content + expect(await readStorageFormat(root)).toBeNull() + await expect(assertSupportedFormat(root)).rejects.toThrow(/Devlog 0\.5/) + await fs.writeFile(path.join(root, 'devlog.json'), '{ "format": 2 }\n') + await expect(assertSupportedFormat(root)).rejects.toThrow(/older storage format \(2\)/) + }) + + it('refuses a devlog written by a newer Devlog', async () => { + await fs.writeFile(path.join(root, 'devlog.json'), '{ "format": 5 }\n') + await expect(assertSupportedFormat(root)).rejects.toThrow(/newer/) + }) + + it('upgrades format 3 when the store opens it, and only then accepts it', async () => { + await fs.writeFile(path.join(root, 'devlog.json'), '{ "format": 3 }\n') + await expect(assertSupportedFormat(root)).rejects.toThrow(/older storage format \(3\)/) + const store = new DevlogStore(root) + await store.initLayout() + expect(await store.upgradeStorage()).toBe(3) + await expect(assertSupportedFormat(root)).resolves.toBeUndefined() + }) +}) diff --git a/packages/core/tests/migrate.test.ts b/packages/core/tests/migrate.test.ts deleted file mode 100644 index ff209b1..0000000 --- a/packages/core/tests/migrate.test.ts +++ /dev/null @@ -1,431 +0,0 @@ -import { promises as fs } from 'node:fs' -import os from 'node:os' -import path from 'node:path' -import { simpleGit } from 'simple-git' -import { afterEach, beforeEach, describe, expect, it } from 'vitest' -import { canvasDir } from '../src/format/canvases' -import { migratedCanvasId, migrateRepository, needsMigration, OLD_DIR, readStorageFormat, recoverInterruptedMigration, upgradeRepository } from '../src/node/migrate' -import { DevlogStore } from '../src/node/store' -import { SyncManager } from '../src/node/sync' - -let tmp: string -let root: string - -beforeEach(async () => { - tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'devlog-migrate-')) - root = path.join(tmp, 'repo') - await fs.mkdir(root) -}) - -afterEach(async () => { - await fs.rm(tmp, { recursive: true, force: true }) -}) - -async function write(base: string, rel: string, text: string): Promise { - await fs.mkdir(path.join(base, path.dirname(rel)), { recursive: true }) - await fs.writeFile(path.join(base, rel), text) -} - -/** A format 1 repository as written by Devlog 0.3. */ -async function formatOneRepo(base: string): Promise { - await write(base, 'README.md', '# Devlog\n') - await write(base, 'canvases/acme-corp/canvas.md', '---\ntitle: Acme Corp\ncreated: 2026-09-01T00:00:00.000Z\nrepo: "C:\\\\src\\\\acme"\n---\n\n# Acme\n\n![logo](assets/logo.png)\n') - await write(base, 'canvases/acme-corp/assets/logo.png', 'logo') - await write( - base, - 'canvases/acme-corp/todos.md', - '# Todos\n\n\nCheck the CDN rules\n' - ) - await write(base, 'canvases/website/canvas.md', '---\ntitle: Website\nparent: acme-corp\ntask: true\ncreated: 2026-09-02T00:00:00.000Z\n---\n') - await write( - base, - 'canvases/website/entries/2026/09/2026-09-19.md', - [ - '# 2026-09-19', - '', - '', - '### 09:00', - '', - 'kickoff ![s](assets/pic.png) with ![logo](../../../../acme-corp/assets/logo.png) and ![j](../../../../../entries/2026/09/assets/j.png)', - '', - '', - '#### ↳ 09:30', - '', - 'Fix redirect (`deadbeef`)', - '' - ].join('\n') - ) - await write(base, 'canvases/website/entries/2026/09/assets/pic.png', 'pic') - await write( - base, - 'entries/2026/09/2026-09-19.md', - [ - '# 2026-09-19', - '', - '', - '### 08:00', - '', - 'Website rebuild ![p](../../../canvases/website/entries/2026/09/assets/pic.png)', - '' - ].join('\n') - ) - await write(base, 'entries/2026/09/assets/j.png', 'j') -} - -const V2_ID = 'k3m9x2q7vd' - -/** A format 2 devlog, as Devlog 0.4 wrote it. */ -async function formatTwoRepo(base: string): Promise { - await write(base, 'devlog.json', '{\n "format": 2\n}\n') - await write(base, `${canvasDir(V2_ID)}/canvas.md`, '---\ntitle: Acme\ncreated: 2026-09-01T00:00:00.000Z\nalias: acme\n---\n') - await write( - base, - `${canvasDir(V2_ID)}/entries/2026/09/2026-09-19.md`, - [ - '', - '# 2026-09-19', - '', - '', - 'kickoff ![s](assets/pic.png)', - '', - '\\', - '', - '', - 'a hidden reply', - '', - '', - '✓ Check the CDN', - '' - ].join('\n') - ) - await write(base, `${canvasDir(V2_ID)}/todos.md`, '\n# Todos\n\n\nCheck the CDN\n') - await write(base, 'entries/2026/09/2026-09-19.md', '\n# 2026-09-19\n\n\nAcme work\n') -} - -async function tree(base: string): Promise> { - const out: Record = {} - const walk = async (dir: string): Promise => { - for (const d of await fs.readdir(dir, { withFileTypes: true })) { - const abs = path.join(dir, d.name) - if (d.isDirectory()) await walk(abs) - else out[path.relative(base, abs).split(path.sep).join('/')] = await fs.readFile(abs, 'utf8') - } - } - await walk(base) - return out -} - -describe('storage migration', () => { - it('moves format 1 canvases to sharded ids, rewriting references, links and block files', async () => { - await formatOneRepo(root) - expect(await readStorageFormat(root)).toBe(1) - expect(await needsMigration(root)).toBe(true) - - const report = await migrateRepository(root) - const acme = migratedCanvasId('acme-corp') - const web = migratedCanvasId('website') - expect(report).toEqual({ from: 1, to: 3, canvases: { 'acme-corp': acme, website: web }, files: 3 }) - expect(acme).toMatch(/^[0-9a-hjkmnp-tv-z]{10}$/) - expect(await readStorageFormat(root)).toBe(3) - expect(await needsMigration(root)).toBe(false) - await expect(fs.stat(path.join(root, 'canvases/acme-corp'))).rejects.toThrow() - await expect(fs.stat(path.join(root, OLD_DIR))).rejects.toThrow() - expect(await fs.readFile(path.join(root, '.gitignore'), 'utf8')).toContain('.devlog-migrate/') - - const store = new DevlogStore(root) - const canvases = await store.listCanvases() - expect(canvases.map((c) => c.id)).toEqual(['journal', acme, web]) - expect(canvases[1]).toMatchObject({ title: 'Acme Corp', parentId: null, repos: ['C:\\src\\acme'], aliases: ['acme-corp'], hasSurface: true }) - expect(canvases[2]).toMatchObject({ title: 'Website', parentId: acme, task: true, aliases: ['website'] }) - expect(await store.resolveCanvasId('website')).toBe(web) - expect(await store.resolveCanvasId(web)).toBe(web) - expect(await store.resolveCanvasId('nope')).toBeNull() - expect(Object.fromEntries(await store.aliasMap())).toEqual({ 'acme-corp': acme, website: web }) - - // Surface and its assets moved with the canvas. - expect((await store.readCanvas(acme)).surface).toBe(`# Acme\n\n![logo](${canvasDir(acme)}/assets/logo.png)`) - expect(await fs.readFile(path.join(root, canvasDir(acme), 'assets/logo.png'), 'utf8')).toBe('logo') - - // Blocks: links into moved canvases rewritten, threads and automatic blocks intact. - const day = await store.readDay(web, '2026-09-19') - expect(day.entries.map((e) => [e.id, e.parentId ?? null, e.kind ?? 'note'])).toEqual([ - ['aaaaaaaa', null, 'note'], - ['bbbbbbbb', 'aaaaaaaa', 'commit'] - ]) - expect(day.entries[0].markdown).toBe( - `kickoff ![s](${canvasDir(web)}/entries/2026/09/assets/pic.png) with ![logo](${canvasDir(acme)}/assets/logo.png) and ![j](entries/2026/09/assets/j.png)` - ) - expect(day.entries[1].meta).toEqual({ hash: 'deadbeef', repo: 'acme' }) - const file = await fs.readFile(path.join(root, canvasDir(web), 'entries/2026/09/2026-09-19.md'), 'utf8') - expect(file.startsWith('\n# 2026-09-19\n')).toBe(true) - expect(file).not.toContain('### 09:00') - expect(await fs.readFile(path.join(root, canvasDir(web), 'entries/2026/09/assets/pic.png'), 'utf8')).toBe('pic') - - // Task links (journal block → canvas, todo → task canvas) follow the new ids. - const journal = await store.readDay('journal', '2026-09-19') - expect(journal.entries[0]).toMatchObject({ kind: 'task', meta: { canvas: web } }) - expect(journal.entries[0].markdown).toBe(`Website rebuild ![p](${canvasDir(web)}/entries/2026/09/assets/pic.png)`) - expect((await store.readTodos(acme))[0]).toMatchObject({ kind: 'todo', meta: { task: web, done: '2026-09-19T09:00:00.000Z' } }) - - // Idempotent. - expect(await migrateRepository(root)).toBeNull() - }) - - it('is deterministic, so two machines migrating the same history agree byte for byte', async () => { - const a = path.join(tmp, 'a') - const b = path.join(tmp, 'b') - await formatOneRepo(a) - await formatOneRepo(b) - await migrateRepository(a, new Date(2026, 8, 25)) - await migrateRepository(b, new Date(2026, 8, 26)) - expect(await tree(b)).toEqual(await tree(a)) - }) - - it('leaves a fresh repository alone', async () => { - const store = new DevlogStore(root) - await store.initLayout() - expect(await needsMigration(root)).toBe(false) - const c = await store.createCanvas({ title: 'New' }) - expect(await migrateRepository(root)).toBeNull() - expect((await store.listCanvases()).map((x) => x.id)).toEqual(['journal', c.id]) - }) - - it('does not stamp an existing format 1 repository as current on open', async () => { - await formatOneRepo(root) - await new DevlogStore(root).initLayout() - expect(await readStorageFormat(root)).toBe(1) - expect(await needsMigration(root)).toBe(true) - }) - - it('rolls back a migration interrupted before the manifest was written', async () => { - await formatOneRepo(root) - const before = await tree(root) - // Crash mid-swap: old trees set aside, a partial new tree in place, no manifest. - await fs.mkdir(path.join(root, OLD_DIR), { recursive: true }) - await fs.rename(path.join(root, 'canvases'), path.join(root, OLD_DIR, 'canvases')) - await fs.rename(path.join(root, 'entries'), path.join(root, OLD_DIR, 'entries')) - await write(root, 'canvases/zz/zzzzzzzzzz/canvas.md', 'partial') - await write(root, '.devlog-migrate/entries/x.md', 'staged') - expect(await needsMigration(root)).toBe(true) - - expect(await recoverInterruptedMigration(root)).toBe('rolled-back') - expect(await tree(root)).toEqual(before) - // …and the next open migrates normally. - expect((await migrateRepository(root))?.to).toBe(3) - }) - - it('finishes a migration interrupted after the manifest was written', async () => { - await formatOneRepo(root) - await migrateRepository(root) - const after = await tree(root) - await write(root, `${OLD_DIR}/canvases/acme-corp/canvas.md`, 'old') - expect(await recoverInterruptedMigration(root)).toBe('completed') - expect(await tree(root)).toEqual(after) - }) - - it('upgrades a Devlog 0.4 (format 2) repository by rewriting its block files in place', async () => { - await formatTwoRepo(root) - const before = await new DevlogStore(root).listCanvases() - const beforeDay = await new DevlogStore(root).readDay(V2_ID, '2026-09-19') - const beforeTodos = await new DevlogStore(root).readTodos(V2_ID) - const report = await migrateRepository(root) - expect(report).toEqual({ from: 2, to: 3, canvases: {}, files: 3 }) - expect(await readStorageFormat(root)).toBe(3) - const store = new DevlogStore(root) - expect(await store.listCanvases()).toEqual(before) - expect(await store.readDay(V2_ID, '2026-09-19')).toEqual(beforeDay) - expect(await store.readTodos(V2_ID)).toEqual(beforeTodos) - const file = await fs.readFile(path.join(root, canvasDir(V2_ID), 'entries/2026/09/2026-09-19.md'), 'utf8') - expect(file).toContain('') - expect(await fs.readFile(path.join(root, '.gitattributes'), 'utf8')).toContain('**/entries/**/*.md merge=union') - // Resumable: an interrupted run (manifest not yet written) just finishes. - await fs.writeFile(path.join(root, 'devlog.json'), '{ "format": 2 }\n') - expect(await migrateRepository(root)).toEqual({ from: 2, to: 3, canvases: {}, files: 0 }) - expect(await migrateRepository(root)).toBeNull() - }) - - it('never rolls back a finished 0.4 layout migration that only had cleanup left', async () => { - await formatTwoRepo(root) - const before = await tree(root) - await write(root, `${OLD_DIR}/canvases/acme-corp/canvas.md`, 'old') - expect(await recoverInterruptedMigration(root)).toBe('completed') - expect(await tree(root)).toEqual(before) - }) - - it('upgrades the Devlog 0.2 pages/ + categories/ layout all the way', async () => { - await write(root, 'pages/acme-corp-web-website/page.md', '---\ntitle: Website\ncategory: Acme Corp / Web\ncreated: 2026-09-01T00:00:00.000Z\nrepo: /src/site\n---\n\nMarketing site rebuild.\n') - await write( - root, - 'pages/acme-corp-web-website/entries/2026/09/2026-09-19.md', - '# 2026-09-19\n\n\n### 09:00\n\nkickoff ![s](assets/pic.png) and ![j](../../../../../entries/2026/09/assets/j.png)\n' - ) - await write(root, 'pages/acme-corp-web-website/entries/2026/09/assets/pic.png', 'png') - await write(root, 'pages/loose/page.md', '---\ntitle: Loose page\narchived: true\n---\n') - await write(root, 'categories/acme-corp/wiki.md', '---\npath: Acme Corp\nupdated: 2026-09-02T00:00:00.000Z\n---\n\n# Acme\n\n![logo](assets/logo.png)\n') - await write(root, 'categories/acme-corp/assets/logo.png', 'png') - await write(root, 'categories/globex/ops/wiki.md', '---\npath: Globex / Ops\narchived: true\n---\n\nRunbooks\n') - expect(await needsMigration(root)).toBe(true) - - const report = await migrateRepository(root, new Date(2026, 8, 20)) - expect(Object.keys(report!.canvases).sort()).toEqual(['acme-corp', 'acme-corp-web', 'acme-corp-web-website', 'globex', 'globex-ops', 'loose']) - await expect(fs.stat(path.join(root, 'pages'))).rejects.toThrow() - await expect(fs.stat(path.join(root, 'categories'))).rejects.toThrow() - expect(await needsMigration(root)).toBe(false) - - const store = new DevlogStore(root) - const id = (old: string): string => report!.canvases[old] - const byId = new Map((await store.listCanvases()).map((c) => [c.id, c])) - expect(byId.get(id('acme-corp'))).toMatchObject({ title: 'Acme Corp', parentId: null, hasSurface: true }) - expect(byId.get(id('acme-corp-web'))).toMatchObject({ title: 'Web', parentId: id('acme-corp') }) - expect(byId.get(id('acme-corp-web-website'))).toMatchObject({ - title: 'Website', - parentId: id('acme-corp-web'), - repos: ['/src/site'], - createdAt: '2026-09-01T00:00:00.000Z', - hasSurface: true - }) - expect(byId.get(id('loose'))).toMatchObject({ title: 'Loose page', parentId: null, archived: true }) - expect(byId.get(id('globex-ops'))).toMatchObject({ title: 'Ops', parentId: id('globex'), archived: true, hasSurface: true }) - - expect((await store.readCanvas(id('acme-corp'))).surface).toBe(`# Acme\n\n![logo](${canvasDir(id('acme-corp'))}/assets/logo.png)`) - expect(await fs.readFile(path.join(root, canvasDir(id('acme-corp')), 'assets/logo.png'), 'utf8')).toBe('png') - const web = id('acme-corp-web-website') - expect((await store.readCanvas(web)).surface).toBe('Marketing site rebuild.') - const day = await store.readDay(web, '2026-09-19') - expect(day.entries[0].markdown).toBe(`kickoff ![s](${canvasDir(web)}/entries/2026/09/assets/pic.png) and ![j](entries/2026/09/assets/j.png)`) - }) -}) - -describe('upgrading repositories that sync between machines', () => { - const cfg = (name: string): string[] => [`user.name=${name}`, `user.email=${name}@example.com`] - const options = { intervalMinutes: 60, debounceSeconds: 60, autoPush: true, pullOnStart: false, authorName: 'b', authorEmail: 'b@example.com' } - let bare: string - let a: string - let b: string - - /** A pushed format 1 history, cloned onto machines A and B. */ - async function twoMachines(fixture: (base: string) => Promise = formatOneRepo): Promise { - bare = path.join(tmp, 'remote.git') - a = path.join(tmp, 'a') - b = path.join(tmp, 'b') - await simpleGit().init(true, [bare, '--initial-branch=main']) - await fs.mkdir(a) - const gitA = simpleGit({ baseDir: a, config: cfg('a') }) - await gitA.init(['--initial-branch=main']) - await fixture(a) - await gitA.add('-A') - await gitA.commit('format 1 history') - await gitA.addRemote('origin', bare) - await gitA.push('origin', 'main', ['--set-upstream']) - await simpleGit().clone(bare, b) - } - - async function upgradeA(): Promise { - const sync = new SyncManager(a, { ...options, authorName: 'a', authorEmail: 'a@example.com' }) - const res = await upgradeRepository(a, sync) - expect(res.error).toBeUndefined() - expect(res.remote).toBe('none') - expect(res.report?.to).toBe(3) - expect((await sync.syncNow('manual')).pushed).toBe(true) - sync.stop() - } - - it('the first machine pulls, migrates, commits', async () => { - await twoMachines() - await upgradeA() - const log = await simpleGit({ baseDir: bare }).log() - expect(log.all.map((c) => c.message)).toEqual(['devlog: migrate to storage format 3', 'format 1 history']) - }) - - it('a machine with nothing unsynced just pulls the upgrade', async () => { - await twoMachines() - await upgradeA() - const sync = new SyncManager(b, options) - const res = await upgradeRepository(b, sync) - expect(res).toEqual({ report: null, remote: 'pulled', error: undefined }) - expect(await readStorageFormat(b)).toBe(3) - expect(await tree(path.join(b, 'canvases'))).toEqual(await tree(path.join(a, 'canvases'))) - sync.stop() - }) - - it('a machine with unsynced old-format work migrates it and merges, instead of splicing it into moved files', async () => { - await twoMachines() - const gitB = simpleGit({ baseDir: b, config: cfg('b') }) - // Machine B (still on the old version, offline) writes a note, commits it, and leaves another uncommitted. - const dayB = path.join(b, 'canvases/website/entries/2026/09/2026-09-19.md') - await fs.appendFile(dayB, '\n\n### 17:00\n\nwritten offline on machine B\n') - await gitB.add('-A') - await gitB.commit('offline note') - await write(b, 'canvases/website/entries/2026/09/2026-09-20.md', '# 2026-09-20\n\n\n### 08:00\n\nnot yet committed\n') - await upgradeA() - - const sync = new SyncManager(b, options) - const res = await upgradeRepository(b, sync) - expect(res.error).toBeUndefined() - expect(res.remote).toBe('merged') - expect(res.report?.to).toBe(3) - expect((await gitB.status()).files).toEqual([]) - - const storeA = new DevlogStore(a) - const storeB = new DevlogStore(b) - expect(await storeB.listCanvases()).toEqual(await storeA.listCanvases()) - const web = migratedCanvasId('website') - const day = await storeB.readDay(web, '2026-09-19') - expect(day.entries.map((e) => e.markdown)).toEqual([ - expect.stringContaining('kickoff'), - 'Fix redirect (`deadbeef`)', - 'written offline on machine B' // format 1 time heading dropped, not spliced in - ]) - expect((await storeB.readDay(web, '2026-09-20')).entries.map((e) => e.markdown)).toEqual(['not yet committed']) - await expect(fs.stat(path.join(b, 'canvases/website'))).rejects.toThrow() - - // The next sync pushes it all, and machine A pulls it cleanly. - const res2 = await sync.syncNow('manual') - expect(res2.error).toBeUndefined() - expect(res2.pushed).toBe(true) - sync.stop() - await simpleGit({ baseDir: a, config: cfg('a') }).pull('origin', 'main', { '--rebase': 'true' }) - expect(await tree(path.join(a, 'canvases'))).toEqual(await tree(path.join(b, 'canvases'))) - }) - - it('upgrades format 2 across machines the same way, finding the commit that reached format 3', async () => { - await twoMachines(formatTwoRepo) - const gitB = simpleGit({ baseDir: b, config: cfg('b') }) - // B, still on 0.4 and offline, rewrites a day file the 0.4 way. - const dayB = path.join(b, canvasDir(V2_ID), 'entries/2026/09/2026-09-19.md') - await fs.appendFile(dayB, '\n\nwritten offline on machine B\n') - await gitB.add('-A') - await gitB.commit('offline note') - await upgradeA() - - const sync = new SyncManager(b, options) - const res = await upgradeRepository(b, sync) - expect(res.error).toBeUndefined() - expect(res.remote).toBe('merged') - expect((await gitB.status()).files).toEqual([]) - const day = await new DevlogStore(b).readDay(V2_ID, '2026-09-19') - expect(day.entries.map((e) => e.markdown)).toContain('written offline on machine B') - expect(await tree(path.join(b, 'canvases'))).not.toEqual({}) - expect(await readStorageFormat(b)).toBe(3) - sync.stop() - }) - - it('backs out a pull that conflicts instead of leaving the repository mid-rebase', async () => { - await twoMachines() - const gitB = simpleGit({ baseDir: b, config: cfg('b') }) - await write(b, 'README.md', 'machine B\n') - await gitB.add('-A') - await gitB.commit('b readme') - await write(a, 'README.md', 'machine A\n') - await simpleGit({ baseDir: a, config: cfg('a') }).add('-A') - await simpleGit({ baseDir: a, config: cfg('a') }).commit('a readme') - await simpleGit({ baseDir: a }).push('origin', 'main') - const sync = new SyncManager(b, options) - const res = await sync.syncNow('manual') - expect(res.error).toMatch(/^Pull failed/) - await expect(fs.stat(path.join(b, '.git/rebase-merge'))).rejects.toThrow() - await expect(fs.stat(path.join(b, '.git/rebase-apply'))).rejects.toThrow() - expect((await gitB.log()).latest?.message).toBe('b readme') - sync.stop() - }) -}) diff --git a/packages/core/tests/oplog.test.ts b/packages/core/tests/oplog.test.ts index a215bd1..690d960 100644 --- a/packages/core/tests/oplog.test.ts +++ b/packages/core/tests/oplog.test.ts @@ -164,7 +164,7 @@ describe('op-log records', () => { ]) }) - it('compacts deterministically and reads older formats through the same path', () => { + it('compacts deterministically', () => { const entries: Entry[] = [ { id: 'a', createdAt: T(0), markdown: 'one', hidden: true }, { id: 'r', createdAt: T(1), markdown: 'reply', parentId: 'a', kind: 'commit', meta: { hash: 'h' } }, @@ -174,8 +174,6 @@ describe('op-log records', () => { expect(text).toBe(serializeBlockFile(entries.map((e) => ({ ...e })), DIR, '# 2026-09-19')) expect(compactOps(entries).map((o) => o.attrs.pos)).toEqual(['a0', 'a0', 'a1']) expect(parseBlockFile(text, DIR)).toEqual(entries) - const v1 = '# 2026-09-19\n\n\n### 09:00\n\nold\n' - expect(readBlockLog(v1, DIR).entries).toEqual([{ id: 'a', createdAt: T(0), markdown: 'old' }]) }) }) @@ -206,12 +204,18 @@ describe('planning records', () => { list = insertEntry(list, { ...entry }, position) ops = planAdd(current, entry, position, at) } else if (r < 0.8) { - const tops = list.filter((e) => !e.parentId) - const mover = tops[Math.floor(rnd() * tops.length)] + const mover = pick()! const anchor = pick()! const position = rnd() < 0.5 ? { afterId: anchor.id } : { beforeId: anchor.id } - if (!mover) continue - list = moveSubtree(list, mover.id, position) + let moved: Entry[] + try { + moved = moveSubtree(list, mover.id, position) + } catch { + // Not a sibling (or an ancestor of one): the planner refuses it too. + expect(() => planMove(current, mover.id, position, at)).toThrow(/beside it/) + continue + } + list = moved ops = planMove(current, mover.id, position, at) } else { const victim = pick()! @@ -274,6 +278,24 @@ describe('planning records', () => { expect(parseBlockFile(text + serializeOps(ops, DIR), DIR).map((e) => e.id)).toEqual(['a', 'b', 'x', 'c']) }) + it('reorders blocks among their siblings at any depth', () => { + const entries: Entry[] = [ + { id: 'a', createdAt: T(0), markdown: 'a' }, + { id: 'r1', createdAt: T(1), markdown: 'r1', parentId: 'a' }, + { id: 'r2', createdAt: T(2), markdown: 'r2', parentId: 'a' }, + { id: 'r2x', createdAt: T(3), markdown: 'r2x', parentId: 'r2' }, + { id: 'r3', createdAt: T(4), markdown: 'r3', parentId: 'a' } + ] + const current = log(entries) + let text = serializeOps(compactOps(current.entries), DIR) + text += serializeOps(planMove(current, 'r3', { beforeId: 'r1' }, T(5)), DIR) + const after = readBlockLog(text, DIR) + expect(after.entries.map((e) => `${e.id}<${e.parentId ?? ''}`)).toEqual(['a<', 'r3 e.id)).toEqual(['a', 'r1', 'r2', 'r2x', 'r3']) + }) + it('stamps new records no earlier than the latest one in the file', () => { const current = log([{ id: 'a', createdAt: T(30), markdown: 'a' }]) expect(stampFor(current, new Date(T(10)))).toBe(T(30)) @@ -284,7 +306,7 @@ describe('planning records', () => { const current = log([{ id: 'a', createdAt: T(0), markdown: 'a' }, { id: 'r', createdAt: T(0), markdown: 'r', parentId: 'a' }]) expect(() => planAdd(current, { id: 'x', createdAt: T(1), markdown: 'x' }, { afterId: 'zz' }, T(1))).toThrow(/not found/) expect(() => planEdit(current, 'zz', 'x', T(1))).toThrow(/not found/) - expect(() => planMove(current, 'r', { afterId: 'a' }, T(1))).toThrow(/top-level/) + expect(() => planMove(current, 'r', { afterId: 'a' }, T(1))).toThrow(/beside it/) expect(planMove(current, 'a', { afterId: 'r' }, T(1))).toEqual([]) expect(planDelete(current, 'a', T(1)).map((o) => o.id)).toEqual(['a', 'r']) }) diff --git a/packages/core/tests/repoIndex.test.ts b/packages/core/tests/repoIndex.test.ts index 6c36cd9..86efa25 100644 --- a/packages/core/tests/repoIndex.test.ts +++ b/packages/core/tests/repoIndex.test.ts @@ -42,8 +42,8 @@ async function corpus(): Promise<{ acme: string; web: string; old: string }> { await plain.addEntry(web.id, 'quotes "needle" and ab', {}, new Date(2026, 0, 7, 9)) await plain.addEntry(old.id, 'archived needle', {}, new Date(2025, 5, 2, 9)) await plain.setCanvasArchived(old.id, true) - await plain.addTodos(acme.id, ['Send Dana the needle list', 'Check CDN'], new Date(2026, 0, 8, 9)) - await plain.addTodos('journal', ['Renew the needle cert'], new Date(2026, 0, 9, 9)) + await plain.addTodos(acme.id, ['Send Dana the needle list', 'Check CDN'], {}, new Date(2026, 0, 8, 9)) + await plain.addTodos('journal', ['Renew the needle cert'], {}, new Date(2026, 0, 9, 9)) return { acme: acme.id, web: web.id, old: old.id } } @@ -89,6 +89,20 @@ describe('RepoIndex', () => { expect(await index.refresh()).toMatchObject({ indexed: 0, removed: 0 }) // nothing changed }) + it('finds blocks written inside an older block when asked for a date range', async () => { + const { acme } = await corpus() + index = RepoIndex.open(dbPath, root) + indexed.attachIndex(index) + await index.refresh() + const standup = await indexed.addEntry(acme, 'Standup Jan 12', {}, new Date(2026, 0, 12, 9)) + await indexed.addEntry(acme, 'a note two days later', { date: standup.date, parentId: standup.entry.id }, new Date(2026, 0, 14, 9)) + const range = await indexed.getRange('2026-01-14', '2026-01-14') + const found = range.find((r) => r.canvasId === acme && r.day.date === '2026-01-12') + expect(found?.day.entries.map((e) => e.markdown)).toContain('a note two days later') + // Without an index only the range's own files are read. + expect((await plain.getRange('2026-01-14', '2026-01-14')).some((r) => r.canvasId === acme)).toBe(false) + }) + it('stays current through the store without a refresh', async () => { const { acme, web, old } = await corpus() index = RepoIndex.open(dbPath, root) @@ -102,8 +116,9 @@ describe('RepoIndex', () => { const day = await indexed.readDay('journal', '2026-01-03') await indexed.deleteEntry('journal', '2026-01-03', day.entries[0].id) await indexed.moveEntry(acme, '2026-01-05', (await indexed.readDay(acme, '2026-01-05')).entries[0].id, c.id) - const [todo] = await indexed.readTodos(acme) - await indexed.setTodoDone(acme, todo.id, true, new Date(2026, 1, 3, 9)) + const [todo] = (await indexed.listTodos()).filter((t) => t.canvasId === acme) + await indexed.setTodoDone(acme, todo.date, todo.entry.id, true, new Date(2026, 1, 3, 9)) + expect(await indexed.listTodos()).toEqual(await plain.listTodos()) await indexed.deleteCanvas(old) await expectSameAnswers() expect(await index.refresh()).toMatchObject({ indexed: 0, removed: 0 }) @@ -123,7 +138,7 @@ describe('RepoIndex', () => { await fs.rm(path.join(root, 'entries/2026/01/2026-01-04.md')) // A canvas folder with blocks but no canvas.md. await fs.mkdir(path.join(root, canvasDir('bare000000'), 'entries/2026/01'), { recursive: true }) - await fs.writeFile(path.join(root, canvasDir('bare000000'), 'entries/2026/01/2026-01-10.md'), '\nbare needle\n') + await fs.writeFile(path.join(root, canvasDir('bare000000'), 'entries/2026/01/2026-01-10.md'), '\n\nbare needle\n') const report = await index.refresh() // The new canvas's canvas.md and day file, the edited day, the bare canvas's day; one day gone. diff --git a/packages/core/tests/store.test.ts b/packages/core/tests/store.test.ts index d9da47b..a122656 100644 --- a/packages/core/tests/store.test.ts +++ b/packages/core/tests/store.test.ts @@ -22,9 +22,9 @@ describe('DevlogStore', () => { expect((await fs.stat(path.join(root, 'entries'))).isDirectory()).toBe(true) expect(await fs.readFile(path.join(root, 'README.md'), 'utf8')).toContain('# Devlog') // A fresh repository starts at the current storage format. - expect(JSON.parse(await fs.readFile(path.join(root, 'devlog.json'), 'utf8'))).toEqual({ format: 3 }) + expect(JSON.parse(await fs.readFile(path.join(root, 'devlog.json'), 'utf8'))).toEqual({ format: 4 }) expect(await fs.readFile(path.join(root, '.gitattributes'), 'utf8')).toContain('**/todos.md merge=union') - expect(await fs.readFile(path.join(root, '.gitignore'), 'utf8')).toContain('.devlog-migrate/') + expect(await fs.readFile(path.join(root, '.gitignore'), 'utf8')).toContain('Thumbs.db') }) it('adds, lists, updates and deletes entries', async () => { @@ -88,19 +88,22 @@ describe('DevlogStore', () => { await check('promote') await store.moveEntry('journal', a.date, a.entry.id, acme.id) await check('move') - const [t1, t2] = await store.addTodos(acme.id, ['one', 'two'], when) - await store.addTodoReply(acme.id, t1.id, 'note', when) - await store.reorderTodo(acme.id, t2.id, { beforeId: t1.id }) - await store.setTodoDone(acme.id, t1.id, true, when) - await store.setTodoDone(acme.id, t1.id, false, when) - await store.updateTodoEntry(acme.id, t2.id, 'two, edited') - await store.promoteTodo(acme.id, t2.id) - await store.deleteTodoEntry(acme.id, t1.id) + const { + date: tdate, + entries: [t1, t2] + } = await store.addTodos(acme.id, ['one', 'two'], {}, when) + await store.addEntry(acme.id, 'note', { date: tdate, parentId: t1.id }, when) + await store.reorderEntry(acme.id, tdate, t2.id, { beforeId: t1.id }) + await store.setTodoDone(acme.id, tdate, t1.id, true, when) + await store.setTodoDone(acme.id, tdate, t1.id, false, when) + await store.updateEntry(acme.id, tdate, t2.id, 'two, edited') + await store.promoteToTask(acme.id, tdate, t2.id) + await store.deleteEntry(acme.id, tdate, t1.id) await check('todos') await store.deleteEntry('journal', a.date, b.entry.id) await check('delete') expect((await store.readDay('journal', a.date)).entries).toEqual([]) - expect((await store.readDay(acme.id, a.date)).entries.map((e) => e.markdown)).toEqual(['A edited', 'reply']) + expect((await store.readDay(acme.id, a.date)).entries.map((e) => e.markdown)).toEqual(['A edited', 'reply', 'two, edited']) expect(r.entry.parentId).toBe(a.entry.id) }) @@ -392,50 +395,151 @@ describe('canvases in the store', () => { await expect(store.writeSurface('journal', 'x')).rejects.toThrow(/journal/) }) - it('keeps a todo list per canvas with comments, reorders it, and ticks items off into the stream', async () => { + it('keeps todos as blocks in the stream, at any depth, and lists them with where they live', async () => { const acme = await store.createCanvas({ title: 'Acme' }) const when = new Date(2026, 8, 25, 9) - const added = await store.addTodos(acme.id, ['Send Dana the redirect list', 'Check the CDN rules', ' '], when) + const { date, entries: added } = await store.addTodos(acme.id, ['Send Dana the redirect list', 'Check the CDN rules', ' '], {}, when) + expect(date).toBe('2026-09-25') expect(added.map((t) => [t.kind, t.markdown])).toEqual([ ['todo', 'Send Dana the redirect list'], ['todo', 'Check the CDN rules'] ]) - const file = await fs.readFile(path.join(root, `canvases/${acme.id.slice(0, 2)}/${acme.id}/todos.md`), 'utf8') - expect(file.startsWith('\n# Todos')).toBe(true) - expect(file).toContain('kind=todo') - - const reply = await store.addTodoReply(acme.id, added[0].id, 'Waiting on their ops team', when) - expect((await store.readTodos(acme.id)).map((e) => [e.markdown, e.parentId ?? null])).toEqual([ - ['Send Dana the redirect list', null], - ['Waiting on their ops team', added[0].id], - ['Check the CDN rules', null] + expect(await fs.readFile(path.join(root, `canvases/${acme.id.slice(0, 2)}/${acme.id}/entries/2026/09/2026-09-25.md`), 'utf8')).toContain('kind=todo') + + // Todos inside a block ("asked Claude to review" → a pasted list), and a note inside a todo. + const review = await store.addEntry(acme.id, 'Asked Claude to review the redirect', {}, new Date(2026, 8, 25, 10)) + const inner = await store.addTodos(acme.id, ['Handle the trailing slash'], { date: review.date, parentId: review.entry.id }, new Date(2026, 8, 25, 10, 5)) + await store.addEntry(acme.id, 'Tried a rewrite rule; no luck', { date, parentId: inner.entries[0].id }, new Date(2026, 8, 26, 9)) + let todos = await store.listTodos() + expect(todos.map((t) => [t.entry.markdown, t.trail.map((x) => x.title), t.inside])).toEqual([ + ['Send Dana the redirect list', [], 0], + ['Check the CDN rules', [], 0], + ['Handle the trailing slash', ['Asked Claude to review the redirect'], 1] ]) - // Reordering moves the thread with the todo. - await store.reorderTodo(acme.id, added[1].id, { beforeId: added[0].id }) - expect((await store.readTodos(acme.id)).map((e) => e.id)).toEqual([added[1].id, added[0].id, reply.id]) - // Ticking off writes a read-only done block into today's stream; unticking removes it. - const { date } = await store.setTodoDone(acme.id, added[0].id, true, new Date(2026, 8, 25, 11)) - expect(date).toBe('2026-09-25') - expect((await store.readTodos(acme.id)).find((e) => e.id === added[0].id)?.meta?.done).toBe(new Date(2026, 8, 25, 11).toISOString()) - const day = await store.readDay(acme.id, '2026-09-25') - expect(day.entries.map((e) => [e.kind, e.markdown, e.meta?.todo])).toEqual([['done', '✓ Send Dana the redirect list', added[0].id]]) - await expect(store.updateEntry(acme.id, '2026-09-25', day.entries[0].id, 'x')).rejects.toThrow(/read-only/) - await store.setTodoDone(acme.id, added[0].id, false, new Date(2026, 8, 25, 11, 5)) - expect((await store.readTodos(acme.id)).find((e) => e.id === added[0].id)?.meta?.done).toBeUndefined() - expect((await store.readDay(acme.id, '2026-09-25')).entries).toEqual([]) - - // Promoting a todo makes a task canvas, records a task block, and closes the todo. - const { canvas, entry } = await store.promoteTodo(acme.id, added[1].id, new Date(2026, 8, 25, 12)) - expect(canvas).toMatchObject({ title: 'Check the CDN rules', parentId: acme.id, task: true }) - expect(entry).toMatchObject({ kind: 'task', meta: { canvas: canvas.id }, markdown: 'Check the CDN rules' }) - expect((await store.readTodos(acme.id)).find((e) => e.id === added[1].id)?.meta).toMatchObject({ task: canvas.id }) - - // Journal todos live next to the journal; search finds todos; deleting removes the thread. - await store.addTodos('journal', ['Renew the certificate'], when) - expect((await fs.stat(path.join(root, 'entries/todos.md'))).isFile()).toBe(true) - expect((await store.search('renew the cert')).blocks.map((h) => [h.canvasId, h.entry.kind])).toEqual([['journal', 'todo']]) - expect(await store.deleteTodoEntry(acme.id, added[0].id)).toBe(2) - expect(await store.listDays('journal')).toEqual([]) + // Ticking off marks the todo where it is; no separate block is written. + const ticked = await store.setTodoDone(acme.id, date, added[0].id, true, new Date(2026, 8, 25, 11)) + expect(ticked.meta?.done).toBe(new Date(2026, 8, 25, 11).toISOString()) + expect((await store.readDay(acme.id, date)).entries.filter((e) => e.kind === 'done')).toEqual([]) + todos = await store.listTodos() + expect(todos.map((t) => t.entry.id)).not.toContain(added[0].id) + expect((await store.listTodos({ doneSince: new Date(2026, 8, 25).toISOString() })).map((t) => t.entry.id)).toContain(added[0].id) + await store.setTodoDone(acme.id, date, added[0].id, false, new Date(2026, 8, 25, 11, 5)) + expect((await store.readDay(acme.id, date)).entries.find((e) => e.id === added[0].id)?.meta?.done).toBeUndefined() + await expect(store.setTodoDone(acme.id, date, review.entry.id, true)).rejects.toThrow(/Todo not found/) + // A todo ticked off counts on the day it was ticked for the date range views. + await store.setTodoDone(acme.id, date, added[1].id, true, new Date(2026, 8, 25, 12)) + expect((await store.getRange('2026-09-25', '2026-09-25')).some((r) => r.day.entries.some((e) => e.id === added[1].id))).toBe(true) + + // Search finds todos like any block; archived canvases drop out of the list. + expect((await store.search('trailing slash')).blocks.map((h) => [h.canvasId, h.entry.kind])).toEqual([[acme.id, 'todo']]) + await store.setCanvasArchived(acme.id, true) + expect(await store.listTodos()).toEqual([]) + }) + + it('moves a block inside another, out again, and across days and canvases', async () => { + const acme = await store.createCanvas({ title: 'Acme' }) + const web = await store.createCanvas({ title: 'Web', parentId: acme.id }) + const standup = await store.addEntry(acme.id, 'Standup Sep 28', {}, new Date(2026, 8, 28, 9)) + const note = await store.addEntry(acme.id, 'Remember the DNS', {}, new Date(2026, 8, 28, 9, 30)) + const inside = await store.addEntry(acme.id, 'with a reply', { date: note.date, parentId: note.entry.id }, new Date(2026, 8, 28, 9, 31)) + // Same file: one set record, and what is inside it follows. + const moved = await store.moveBlock({ canvasId: acme.id, date: note.date, id: note.entry.id }, { canvasId: acme.id, date: note.date, parentId: standup.entry.id }, new Date(2026, 8, 28, 10)) + expect(moved.entry.parentId).toBe(standup.entry.id) + let day = await store.readDay(acme.id, note.date) + expect(day.entries.map((e) => [e.markdown, e.parentId ?? null])).toEqual([ + ['Standup Sep 28', null], + ['Remember the DNS', standup.entry.id], + ['with a reply', note.entry.id] + ]) + await expect(store.moveBlock({ canvasId: acme.id, date: note.date, id: standup.entry.id }, { canvasId: acme.id, date: note.date, parentId: inside.entry.id })).rejects.toThrow(/inside itself/) + // Out again: to the top level, right after the block it was in. + await store.moveBlock({ canvasId: acme.id, date: note.date, id: note.entry.id }, { canvasId: acme.id }, new Date(2026, 8, 28, 11)) + day = await store.readDay(acme.id, note.date) + expect(day.entries.filter((e) => !e.parentId).map((e) => e.markdown)).toEqual(['Standup Sep 28', 'Remember the DNS']) + // Across days: into a block written on another day, in its file. + const older = await store.addEntry(acme.id, 'Kickoff Sep 21', {}, new Date(2026, 8, 21, 9)) + const across = await store.moveBlock({ canvasId: acme.id, date: note.date, id: note.entry.id }, { canvasId: acme.id, date: older.date, parentId: older.entry.id }, new Date(2026, 8, 28, 12)) + expect(across).toMatchObject({ date: '2026-09-21', entry: { markdown: 'Remember the DNS', parentId: older.entry.id } }) + expect((await store.readDay(acme.id, older.date)).entries.map((e) => e.markdown)).toEqual(['Kickoff Sep 21', 'Remember the DNS', 'with a reply']) + expect((await store.readDay(acme.id, note.date)).entries.map((e) => e.markdown)).toEqual(['Standup Sep 28']) + // Across canvases, from inside a block to the top level (on its own date). + const out = await store.moveBlock({ canvasId: acme.id, date: older.date, id: note.entry.id }, { canvasId: web.id }) + expect(out.date).toBe('2026-09-21') + expect((await store.readDay(web.id, '2026-09-21')).entries.map((e) => [e.markdown, e.parentId ? 'inside' : 'top'])).toEqual([ + ['Remember the DNS', 'top'], + ['with a reply', 'inside'] + ]) + }) + + it('moves what was written inside a block into the task it becomes', async () => { + const acme = await store.createCanvas({ title: 'Acme' }) + const b = await store.addEntry(acme.id, 'Migrate the CDN', {}, new Date(2026, 8, 28, 9)) + await store.addEntry(acme.id, 'first finding', { date: b.date, parentId: b.entry.id }, new Date(2026, 8, 28, 9, 5)) + await store.addTodos(acme.id, ['check the headers'], { date: b.date, parentId: b.entry.id }, new Date(2026, 8, 28, 9, 6)) + const { canvas } = await store.promoteToTask(acme.id, b.date, b.entry.id, new Date(2026, 8, 28, 10)) + expect((await store.readDay(acme.id, b.date)).entries.map((e) => [e.kind, e.markdown])).toEqual([['task', 'Migrate the CDN']]) + expect((await store.readDay(canvas.id, b.date)).entries.map((e) => [e.kind ?? 'note', e.markdown, e.parentId ?? null])).toEqual([ + ['note', 'first finding', null], + ['todo', 'check the headers', null] + ]) + }) + + it('moves storage format 3 todo lists into the streams (format 4)', async () => { + const acme = await store.createCanvas({ title: 'Acme' }) + const dir = `canvases/${acme.id.slice(0, 2)}/${acme.id}` + const at = (d: number, h: number): string => new Date(2026, 8, d, h).toISOString() + await fs.writeFile( + path.join(root, dir, 'todos.md'), + [ + '', + '# Todos', + '', + ``, + 'Send Dana the list', + '', + ``, + 'Waiting on ops', + '', + ``, + 'Renew the cert', + '', + ``, + '' + ].join('\n') + ) + await fs.writeFile(path.join(root, 'devlog.json'), JSON.stringify({ format: 3 })) + expect(await store.upgradeStorage()).toBe(3) + expect(JSON.parse(await fs.readFile(path.join(root, 'devlog.json'), 'utf8')).format).toBe(4) + await expect(fs.stat(path.join(root, dir, 'todos.md'))).rejects.toThrow() + const day20 = await store.readDay(acme.id, '2026-09-20') + expect(day20.entries.map((e) => [e.id, e.kind ?? 'note', e.markdown, e.parentId ?? null])).toEqual([ + ['t1aaaaaa', 'todo', 'Send Dana the list', null], + ['c1aaaaaa', 'note', 'Waiting on ops', 't1aaaaaa'] + ]) + expect((await store.readDay(acme.id, '2026-09-22')).entries[0]).toMatchObject({ id: 't2aaaaaa', kind: 'todo', meta: { done: at(23, 9) } }) + expect((await store.listTodos()).map((t) => [t.entry.id, t.inside])).toEqual([['t1aaaaaa', 1]]) + // Running it again changes nothing. + expect(await store.upgradeStorage()).toBeNull() + expect(await store.convertTodoLists()).toBe(0) + }) +}) + +describe('canvases extensions keep', () => { + it('are found by their owner, made once, and take blocks of their kinds on any day', async () => { + expect(await store.managedCanvas('builtin.x/sheets')).toBeNull() + const kept = (await store.managedCanvas('builtin.x/sheets', 'Sheets'))! + expect(kept).toMatchObject({ title: 'Sheets', fields: { 'devlog.managed': 'builtin.x/sheets' } }) + expect((await store.managedCanvas('builtin.x/sheets', 'Sheets'))!.id).toBe(kept.id) + const { date, entry } = await store.addExtensionBlock(kept.id, 'builtin.x', 'week one', { week: '2026-09-21' }, new Date(2026, 8, 26, 10), { kind: 'timesheet', date: '2026-09-21', anyDate: true }) + expect(date).toBe('2026-09-21') + expect(entry).toMatchObject({ kind: 'timesheet', meta: { ext: 'builtin.x', week: '2026-09-21' } }) + // A block the app wrote there before (no extension mark) can be changed by its keeper, and only with `any`. + const old = await store.addEntry(kept.id, 'written by the app', { date: '2026-09-21' }) + await expect(store.updateExtensionBlock(kept.id, '2026-09-21', old.entry.id, 'builtin.x', 'x')).rejects.toThrow(/Only blocks it added/) + expect((await store.updateExtensionBlock(kept.id, '2026-09-21', old.entry.id, 'builtin.x', 'rewritten', new Date(), { any: true })).markdown).toBe('rewritten') + // Elsewhere, a date without a parent block is ignored: blocks land on today. + const acme = await store.createCanvas({ title: 'Acme' }) + expect((await store.addExtensionBlock(acme.id, 'builtin.x', 'note', {}, new Date(2026, 8, 26, 10), { date: '2026-09-21' })).date).toBe('2026-09-26') }) }) diff --git a/packages/core/tests/timesheet.test.ts b/packages/core/tests/timesheet.test.ts new file mode 100644 index 0000000..bf5a51d --- /dev/null +++ b/packages/core/tests/timesheet.test.ts @@ -0,0 +1,207 @@ +import { describe, expect, it } from 'vitest' +import { + balanceDays, + buildSessions, + draftEntries, + roundToQuarterHour, + roundWorkMinutes, + topLevelCanvasId, + parseTimesheet, + sanitizeTimesheet, + serializeTimesheet, + type Timesheet, + type TimesheetEntry, + type WorkSegment +} from '../src/index' +import type { CanvasMeta } from '../src/types' + +/** A local time on Tuesday 2026-09-22 (or another day), as ISO. */ +const at = (h: number, m = 0, s = 0, day = 22): string => new Date(2026, 8, day, h, m, s).toISOString() +const seg = (canvasId: string, start: string, end: string): WorkSegment => ({ canvasId, start, end }) +const localHM = (iso: string): string => { + const d = new Date(iso) + return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}` +} + +describe('the rounding rule', () => { + it('counts anything above zero as a quarter hour, then rounds to the nearest quarter, halves up', () => { + const cases: Array<[number, number]> = [ + [0, 0], + [-3, 0], + [0.01, 15], + [0.5, 15], + [7.5, 15], + [22.49, 15], + [22.5, 30], + [30, 30], + [37.49, 30], + [37.5, 45], + [52.49, 45], + [52.5, 60], + [420, 420], + [427.4, 420], + [427.5, 435] + ] + for (const [m, want] of cases) expect(roundWorkMinutes(m), `${m} min`).toBe(want) + }) + + it('rounds start times to the nearest local quarter hour, halves up', () => { + expect(localHM(roundToQuarterHour(new Date(at(9, 7, 29))).toISOString())).toBe('09:00') + expect(localHM(roundToQuarterHour(new Date(at(9, 7, 30))).toISOString())).toBe('09:15') + expect(localHM(roundToQuarterHour(new Date(at(9, 53))).toISOString())).toBe('10:00') + expect(localHM(roundToQuarterHour(new Date(at(23, 55))).toISOString())).toBe('00:00') + }) +}) + +describe('sessions', () => { + it('counts short breaks on the same task as work and splits at longer ones', () => { + const sessions = buildSessions([ + seg('a', at(9), at(10)), + seg('a', at(10, 20), at(11)), // a 20-minute bathroom/coffee lock: still one session + seg('a', at(12, 0), at(13)), // a 60-minute lunch: a new session + seg('a', at(13, 30), at(13, 45)) // exactly 30 minutes: still the same session + ]) + expect(sessions.map((s) => [localHM(s.start), localHM(s.end), s.workedMinutes])).toEqual([ + ['09:00', '11:00', 120], + ['12:00', '13:45', 105] + ]) + expect(buildSessions([seg('a', at(9), at(10)), seg('a', at(10, 31), at(11))])).toHaveLength(2) + }) + + it('never joins across a switch to another task, and splits at midnight', () => { + const sessions = buildSessions([seg('a', at(9), at(9, 30)), seg('b', at(9, 35), at(9, 40)), seg('a', at(9, 45), at(10))]) + expect(sessions.map((s) => s.canvasId)).toEqual(['a', 'b', 'a']) + const late = buildSessions([seg('a', at(23, 30), at(0, 30, 0, 23))]) + expect(late.map((s) => [s.date, s.workedMinutes])).toEqual([ + ['2026-09-22', 30], + ['2026-09-23', 30] + ]) + }) +}) + +describe('draft entries', () => { + it('rounds each session once and lays starts out on quarter hours without overlaps', () => { + const entries = draftEntries( + buildSessions([ + seg('a', at(9, 2), at(9, 6)), // 4 min → 15, starts 09:00 + seg('b', at(9, 7), at(9, 10)), // 3 min → 15, would start 09:15 (09:07 rounds to 09:00, but a holds it) + seg('c', at(9, 11), at(10, 40)) // 89 min → 90, 09:11 rounds to 09:15 but b holds it → 09:30 + ]) + ) + expect(entries.map((e) => [e.canvasId, localHM(e.start), e.minutes, e.worked])).toEqual([ + ['a', '09:00', 15, 4], + ['b', '09:15', 15, 3], + ['c', '09:30', 90, 89] + ]) + for (const e of entries) expect(e.minutes % 15).toBe(0) + }) +}) + +describe('draft entries near midnight', () => { + it('keeps a start that would round past midnight on its own day', () => { + const [e] = draftEntries(buildSessions([seg('a', at(23, 53), at(23, 59))])) + expect([e.date, localHM(e.start)]).toEqual(['2026-09-22', '23:45']) + }) +}) + +describe('balancing rounding inflation', () => { + const entry = (id: string, canvasId: string, minutes: number, worked: number, date = '2026-09-22'): TimesheetEntry => ({ + id, + date, + start: at(9), + minutes, + canvasId, + worked, + source: 'tracked' + }) + const sameGroup = (): string => 'acme' + + it('suggests logging the long task shorter when many short tasks inflate the day', () => { + // A dozen 4-minute tasks and a 7-hour task, all for the same client: 7 h 48 m worked. + const entries = [...Array.from({ length: 12 }, (_, i) => entry(`s${i}`, `t${i}`, 15, 4)), entry('long', 'big', 420, 420)] + const [day] = balanceDays(entries, sameGroup) + expect(day).toMatchObject({ worked: 468, target: 465, reported: 600, residual: 0 }) + expect(day.suggestions).toEqual([{ entryId: 'long', from: 420, to: 285 }]) // 7 h → 4 h 45 m + }) + + it('spreads trims over the longest entries in turn and never goes below 15 minutes', () => { + const entries = [entry('a', 'x', 60, 55), entry('b', 'y', 60, 52), ...Array.from({ length: 6 }, (_, i) => entry(`s${i}`, `z${i}`, 15, 2))] + const [day] = balanceDays(entries, sameGroup) + // worked 119 → target 120; reported 210; trim 90 minutes from a and b, 15 at a time, longest first. + expect(day.target).toBe(120) + expect(day.suggestions).toEqual([ + { entryId: 'a', from: 60, to: 15 }, + { entryId: 'b', from: 60, to: 15 } + ]) + expect(day.residual).toBe(0) + }) + + it('says so when the inflation cannot be evened out', () => { + const entries = Array.from({ length: 5 }, (_, i) => entry(`s${i}`, `t${i}`, 15, 2)) + const [day] = balanceDays(entries, sameGroup) + expect(day).toMatchObject({ worked: 10, target: 15, reported: 75, suggestions: [], residual: 60 }) + }) + + it('adds to the longest entry when rounding under-reports', () => { + const entries = [entry('a', 'x', 30, 37), entry('b', 'y', 30, 36)] // 73 worked → 75, reported 60 + const [day] = balanceDays(entries, sameGroup) + expect(day.suggestions).toEqual([{ entryId: 'a', from: 30, to: 45 }]) + expect(day.residual).toBe(0) + }) + + it('balances each client and each day separately', () => { + const canvases = [ + { id: 'acme', parentId: null }, + { id: 'acme-web', parentId: 'acme' }, + { id: 'fix-login', parentId: 'acme-web' }, + { id: 'globex', parentId: null } + ] as CanvasMeta[] + const client = (id: string): string => topLevelCanvasId(canvases, id) + expect(client('fix-login')).toBe('acme') + expect(client('globex')).toBe('globex') + const entries = [ + entry('a1', 'fix-login', 15, 3), + entry('a2', 'acme-web', 15, 3), + entry('a3', 'acme', 120, 118), + entry('g1', 'globex', 240, 240), + entry('a4', 'fix-login', 60, 58, '2026-09-23') + ] + const days = balanceDays(entries, client) + expect(days.map((d) => [d.date, d.group, d.reported, d.target])).toEqual([ + ['2026-09-22', 'acme', 150, 120], + ['2026-09-22', 'globex', 240, 240], + ['2026-09-23', 'acme', 60, 60] + ]) + // Acme's short tasks are paid for out of Acme's long entry, never Globex's. + expect(days[0].suggestions).toEqual([{ entryId: 'a3', from: 120, to: 90 }]) + expect(days[1].suggestions).toEqual([]) + }) +}) + +describe('stored timesheets', () => { + const sheet = (entries: Array> = []): Timesheet => ({ + week: '2026-09-21', + status: 'draft', + updatedAt: '', + entries: entries.map((e, i) => ({ id: `e${i + 1}`, date: '2026-09-22', start: at(9), minutes: 60, canvasId: 'acme', worked: 58, source: 'tracked', ...e }) as TimesheetEntry) + }) + + it('keeps timesheets well-formed', () => { + expect(sanitizeTimesheet(sheet([{ note: 'line one\nline two' }])).entries[0].note).toBe('line one line two') + expect(() => sanitizeTimesheet({ ...sheet(), week: '2026-09-22' })).toThrow(/Monday/) + expect(() => sanitizeTimesheet(sheet([{ minutes: 20 }]))).toThrow(/multiple of 15/) + expect(() => sanitizeTimesheet(sheet([{ date: '2026-09-28' }]))).toThrow(/not in the week/) + expect(() => sanitizeTimesheet(sheet([{ id: 'x' }, { id: 'x' }]))).toThrow(/repeated/) + expect(sanitizeTimesheet({ ...sheet(), status: 'weird' }).status).toBe('draft') + }) + + it('writes a readable table and reads the exact data back', () => { + const s = sheet([{ note: 'a | b' }, { id: 'm1', canvasId: 'fix', start: at(11), minutes: 15, worked: 4, source: 'manual' }]) + const md = serializeTimesheet(s, (id) => ({ acme: 'Acme', fix: 'Acme / Fix login' })[id] ?? id, () => 'acme') + expect(md).toContain('| 2026-09-22 | 09:00 | 1:00 | Acme | Acme | a \\| b |') + expect(md).toContain('| 2026-09-22 | 11:00 | 0:15 | Acme / Fix login | Acme | |') + expect(md).toContain('1:15 reported, 1:02 worked') + expect(parseTimesheet(md)).toEqual(sanitizeTimesheet(s)) + expect(parseTimesheet('no data here')).toBeNull() + }) +}) diff --git a/packages/extension-api/README.md b/packages/extension-api/README.md new file mode 100644 index 0000000..9b085a1 --- /dev/null +++ b/packages/extension-api/README.md @@ -0,0 +1,135 @@ +# Writing a Devlog extension + +An extension adds something to Devlog without growing the app: sending time +to Jira, fields on canvases, commands in the quick switcher. It runs in its +own process with **no access to files, other programs or other extensions**; +everything goes through the `ctx` object it is given, filtered by what the +user allowed. The design is in `docs/EXTENSIONS.md`. + +## The files + +``` +my-extension/ + devlog-extension.json the manifest + main.js one bundled CommonJS file exporting activate(ctx) +``` + +```jsonc +{ + "name": "devlog-jira", // lowercase, digits, . _ - + "displayName": "Jira time export", + "version": "1.2.0", + "api": "^1.3.0", // the extension API it was built for + "main": "main.js", + "contributes": { + "canvasFields": [{ "key": "issue", "label": "Jira issue", "placeholder": "ACME-123" }], + "settings": [{ "key": "baseurl", "label": "Jira URL" }], + "secrets": [{ "key": "token", "label": "API token" }], + "commands": [{ "id": "send", "label": "Send this week's worklogs" }] + }, + "permissions": { "read": true, "write": false, "network": ["*.atlassian.net"] }, + "appendOnly": ["log/*.jsonl"] // its own repo files git should union-merge +} +``` + +- **Fields** (`settings`, `secrets`, `canvasFields`) take `key`, `label`, + and optionally `description` (shown under the field), `placeholder`, + `required: true` (the extension's card says "Needs: …" until it is set), + and for settings and canvas fields a `type`: `text` (default), `url`, + `email`, `number`, `select` (with `options: [{ value, label }]`), + `checkbox` or `textarea`. Values are checked by type when saved. +- **`check`** names one of your commands that tests the settings (a login, + say); the settings page offers **Test** / **Save and test** and shows the + text the command returns, or its error. +- **`contributes`** is what the app shows without running your code: canvas + fields (in the canvas dialog, stored in `canvas.md` as + `ext..`), devlog-wide settings (stored in `devlog.json`), secrets + (the OS keychain on each machine) and commands (the quick switcher). +- **`permissions`**: `read` / `write` ask for access to notes; the user + picks the whole devlog or chosen canvases. `network` lists the domains you + talk to; it is shown to the user (not enforced in v1). + +## The code + +```js +exports.activate = async (ctx) => { + ctx.commands.register('send', async () => { + const base = ctx.settings.get('baseurl') + const token = await ctx.secrets.get('token') + for (const canvas of await ctx.devlog.canvases()) { + const issue = await ctx.devlog.field(canvas.id, 'issue') // inherited from parents + // … + } + await ctx.files.repo.append('log/sent.jsonl', JSON.stringify({ at: new Date().toISOString() }) + '\n') + ctx.ui.notify('Sent') + }) +} +``` + +The full API, with comments, is `src/index.ts`. In short: + +| | | +|---|---| +| `ctx.devlog` | `canvases()`, `field(canvasId, key)`, `days(canvasId)`, `blocks(canvasId, date)`, `search(q)`, `addBlock(canvasId, markdown, { meta, parentId, date, todo })`, `todos({ doneSince })`: all limited to what the user granted. Adding inside a block, adding todos and `todos()` are 1.4 | +| `ctx.files.repo` / `ctx.files.local` | private folders (synced with the devlog / this machine only): `read`, `readText`, `write`, `append`, `list`, `stat`, `remove`, relative paths only | +| `ctx.settings` | `get(key)`, `onChange(cb)` | +| `ctx.secrets` | `get`, `set`, `delete` | +| `ctx.activity.on(cb)` | pause (locked, idle, asleep), resume, task changes | +| `ctx.ui` | `notify(message)`, `confirm(message)` | +| `ctx.commands.register(id, run)` | for commands declared in the manifest | +| `ctx.machine` | this machine's folder name, for per-machine files (1.1) | +| `ctx.packageDir` | the extension's unpacked folder, for scripts it ships; only when it runs unrestricted, else null (1.2) | +| `ctx.provide.focus(fn)` | focus events for the app's timeline, review and summary (1.1) | +| `ctx.views.handle(viewId, (method, args) => …)`, `ctx.views.post(viewId, message)` | answer calls from your views' pages and push messages to them (1.5; see docs/EXTENSIONS.md, Views) | +| `ctx.destinations.register(id, { preview, send })` | a place finished timesheets can be sent, declared in `contributes.destinations` (1.3) | + +`builtin-extensions/devlog-jira` is a complete destination (sandboxed: +settings, a secret, a canvas field, a ledger in its own files, fetch). +`builtin-extensions/devlog-focus` is a complete, small example (window +tracking): an unrestricted extension with its own platform helper, +per-machine JSON-lines files, and data given back to the app. + +What does **not** work, by design: `fs`, `child_process`, `worker_threads`, +native addons (all refused by the runtime), and `require` of anything but +Node's built-ins, so bundle your dependencies. If your extension really +needs files or programs (a platform helper, say), declare +`"permissions": { "unrestricted": true }`: it then runs without the sandbox, +but only after the user says they trust it (esbuild: +`esbuild src/main.ts --bundle --platform=node --format=cjs --outfile=main.js`). +`fetch` works. + +## Testing + +`@devlog/extension-api/testing` gives you an in-memory `ctx`: + +```ts +import { createTestContext } from '@devlog/extension-api/testing' +import * as ext from '../src/main' + +const t = createTestContext({ settings: { baseurl: 'https://acme.atlassian.net' }, secrets: { token: 'x' }, canvases: [/* … */], read: ['acme'] }) +await ext.activate(t.ctx) +await t.run('send') +expect(t.notifications).toEqual(['Sent']) +``` + +## Trying it in Devlog + +Point Devlog at your folder without releasing anything: in the app's user +data folder, create `extension-dev.json`: + +```json +{ "devlog-jira": "C:\\src\\devlog-jira" } +``` + +then add `devlog-jira` with version `builtin` in **Extensions** (quick +switcher → Extensions). The folder is used as is, and every change to it asks +for consent again (the code changed). + +## Releasing + +Zip the folder (the manifest at the top, or inside one folder) as +`.devlog-ext.zip` and attach it to a GitHub release tagged with the +version (`v1.2.0`). Users add `owner/repo` with a range (`^1.2.0`); Devlog +picks the newest matching release, pins its SHA-256 in `devlog.lock.json`, +and asks before running it. Private repositories work with a GitHub token +(Extensions → GitHub token). diff --git a/packages/extension-api/package.json b/packages/extension-api/package.json new file mode 100644 index 0000000..55e9e3f --- /dev/null +++ b/packages/extension-api/package.json @@ -0,0 +1,14 @@ +{ + "name": "@devlog/extension-api", + "version": "1.0.0", + "description": "Types and a test harness for writing Devlog extensions", + "license": "MIT", + "type": "module", + "private": true, + "exports": { + ".": "./src/index.ts", + "./testing": "./src/testing.ts", + "./protocol": "./src/protocol.ts", + "./view": "./src/view.ts" + } +} diff --git a/packages/extension-api/src/index.ts b/packages/extension-api/src/index.ts new file mode 100644 index 0000000..d4c0f52 --- /dev/null +++ b/packages/extension-api/src/index.ts @@ -0,0 +1,412 @@ +/** + * @devlog/extension-api: what a Devlog extension sees. + * + * An extension is a bundled `main.js` exporting `activate(ctx)`. It runs in + * its own process with no file-system access, no child processes and no + * native code; everything it can do goes through `ctx`. See + * docs/EXTENSIONS.md in the Devlog repository. + * + * ```js + * exports.activate = async (ctx) => { + * ctx.commands.register('hello', async () => { + * const n = Number((await ctx.files.local.readText('count')) ?? 0) + 1 + * await ctx.files.local.write('count', String(n)) + * ctx.ui.notify(`Hello #${n}`) + * }) + * } + * ``` + */ + +/** The API version this package describes. Declare a matching range as `api` in devlog-extension.json. */ +export const API_VERSION = '1.7.0' + +export interface ExtensionCanvas { + id: string + title: string + parentId: string | null + task: boolean + /** Its node type ("/"), if it has one (1.6). */ + type?: string + archived: boolean + /** This extension's own canvas fields set on this canvas (keys as declared in `contributes.canvasFields`). */ + fields: Record +} + +export interface ExtensionBlock { + id: string + createdAt: string + updatedAt?: string + markdown: string + parentId?: string + kind?: string + hidden?: boolean + meta?: Record +} + +/** A todo (a block with `kind: 'todo'`; `meta.done` is set once ticked off) and where it lives. */ +export interface ExtensionTodo { + canvasId: string + /** The day file it lives in. */ + date: string + block: ExtensionBlock + /** The blocks it sits inside, outermost first. */ + trail: Array<{ id: string; title: string }> +} + +/** Where a new block goes (1.4): the end of today by default, or inside a block (its day file's date and id). */ +export interface AddBlockOptions { + meta?: Record + /** Add it inside this block (1.4). Needs `date`: the day file the block lives in. */ + parentId?: string + date?: string + /** Add it as a todo (1.4). */ + todo?: boolean + /** On a canvas it keeps (`managedCanvas`, 1.7): the block's kind (not todo, task, commit or done), and the day file (`date`) even outside a block. */ + kind?: string +} + +/** + * Something the app recorded (1.7): its own log (locks, idle, sleep, git + * events, corrections) merged with what extensions provide (time, focus). + * Canvases it may not read show as none. + */ +export interface ActivityRecord { + t: string + type: 'start' | 'stop' | 'heartbeat' | 'lock' | 'unlock' | 'idle' | 'active' | 'suspend' | 'resume' | 'task' | 'focus' | 'git' | 'exclude' + canvasId?: string | null + entryId?: string + /** focus: the app and window title (only with read access to the whole devlog). */ + app?: string + title?: string + /** git */ + repo?: string + action?: string + branch?: string + from?: string + detail?: string + /** exclude (a correction): no task time between start and end; or undoes the one it `cancels`. */ + start?: string + end?: string + id?: string + cancels?: string + machine?: string +} + +/** A day file's blocks (1.7). */ +export interface ExtensionDayBlocks { + canvasId: string + date: string + blocks: ExtensionBlock[] +} + +/** A destination some extension registered (1.7). */ +export interface DestinationInfo { + /** The extension's devlog.json key. */ + extension: string + /** Its display name. */ + from: string + id: string + label: string +} + +/** A week's timesheet handed to another extension's destination (1.7): what `DestinationSheet` is built from. */ +export interface SheetToSend { + week: string + status: 'draft' | 'final' + entries: Array<{ id: string; date: string; start: string; minutes: number; canvasId: string; note?: string; worked?: number; source?: string }> +} + +export interface ExtensionSearchResult { + blocks: Array<{ canvasId: string; date: string; block: ExtensionBlock }> +} + +/** Where a command was run from, and what it was run on (1.6). Canvases it cannot see are left out. */ +export interface CommandContext { + source: 'switcher' | 'keybinding' | 'menu' | 'post' | 'view' | 'tray' + /** The canvas on screen, or the one whose menu it was. */ + canvasId?: string + /** With `blockId`: the day file the block lives in. */ + date?: string + /** The block whose menu it was, the page on screen, or the block just posted (`post`). */ + blockId?: string +} + +/** A block someone posted in the app (1.6). Only for canvases it may read. */ +export interface BlockAddedEvent { + canvasId: string + date: string + block: ExtensionBlock +} + +/** A new canvas (1.6). `type`: one of its own node types (the id from the manifest, or the full name). */ +export interface NewCanvas { + title: string + parentId?: string | null + type?: string +} + +export interface CanvasPatch { + title?: string + parentId?: string | null + /** One of its own node types, or null to make it a plain canvas (only from one of its own types). */ + type?: string | null + archived?: boolean +} + +/** One entry of a quick pick (1.6). */ +export interface PickItem { + id: string + label: string + hint?: string +} + +/** + * Time-tracking events for the app's views (1.6): which canvas time goes + * to, from when. `start`/`stop`: the clock started or stopped with the app; + * `task`: the active canvas changed (null stops it); `heartbeat`: still + * running (the app counts time up to the last one when the log just ends). + */ +export interface TimeEvent { + t: string + type: 'start' | 'task' | 'stop' | 'heartbeat' + canvasId?: string | null + /** The block that started it, if one did. */ + blockId?: string + /** The machine folder it was recorded on (`ctx.machine` there). */ + machine: string +} + +export interface ActivityNotice { + /** When it happened (ISO). */ + t: string + /** `pause`: locked, idle or asleep; `resume`: back; `task`: the app's own tracker changed task (before 1.6). */ + type: 'pause' | 'resume' | 'task' + reason?: 'locked' | 'idle' | 'suspended' + /** For `task`: the new active task (null when the clock stopped), if the extension can see it. */ + canvasId?: string | null +} + +/** A focus change for the app's timeline, review and summary. */ +export interface FocusEvent { + t: string + app: string + title: string + /** The machine folder it was recorded on (`ctx.machine` there). */ + machine: string +} + +/** One timesheet entry as a destination sees it. (1.3) */ +export interface DestinationEntry { + /** Stable within the week's timesheet; use it to remember what you sent for this entry. */ + id: string + date: string + /** Start (ISO), on a local quarter hour. */ + start: string + /** A multiple of 15. */ + minutes: number + note?: string + canvasId: string + /** "Client / Project / Task". */ + task: string + /** The top-level canvas's title. */ + client: string + /** This extension's canvas fields for the entry's canvas, inherited from the nearest ancestor that sets each. */ + fields: Record +} + +/** A finished week, handed to a destination when you press Send (your consent for this week's entries). (1.3) */ +export interface DestinationSheet { + week: string + status: 'draft' | 'final' + entries: DestinationEntry[] +} + +/** One line of what a destination would do, for the preview. (1.3) */ +export interface DestinationLine { + id: string + /** The timesheet entries it covers. */ + entryIds: string[] + date: string + start?: string + minutes: number + /** Where it goes: an issue key, a client id… */ + target: string + description?: string + /** What sending does with it. `skip`: not for this destination (say why in `reason`). */ + action: 'create' | 'update' | 'delete' | 'unchanged' | 'skip' + reason?: string +} + +export interface SendResult { + /** Lines that went through, by line id. */ + done: string[] + failed: Array<{ lineId: string; error: string }> + /** A sentence for the record ("3 worklogs created, 1 updated"). */ + summary: string +} + +export interface Destination { + /** What sending `sheet` would do, without doing it. */ + preview(sheet: DestinationSheet): Promise + /** Do it. Called only for final timesheets, after the user saw the preview. */ + send(sheet: DestinationSheet): Promise +} + +export interface ExtensionFileInfo { + path: string + size: number + mtime: string +} + +/** One of the extension's private folders. Paths are relative, `/`-separated, and cannot leave the folder. */ +export interface ExtensionFiles { + read(path: string): Promise + readText(path: string): Promise + /** Replace a file, atomically. */ + write(path: string, data: string | Uint8Array): Promise + append(path: string, text: string): Promise + list(dir?: string): Promise + stat(path: string): Promise + remove(path: string): Promise +} + +export interface DevlogContext { + /** This extension's id (from where it was installed, e.g. `sdeken.devlog-jira`). */ + readonly id: string + /** The Devlog app's extension API version. */ + readonly apiVersion: string + /** This machine's folder name in the devlog (host name plus a short id), for per-machine files. (1.1) */ + readonly machine: string + /** + * Where the extension's own files are unpacked, for helper scripts it + * ships. Only for extensions that run unrestricted (you trusted them); + * null in the sandbox, which cannot read files anyway. (1.2) + */ + readonly packageDir: string | null + + /** The devlog, filtered by what you granted this extension. */ + devlog: { + /** Canvases it may read or write, plus their ancestors (so they can be named). */ + canvases(): Promise + /** One of this extension's canvas fields, from the canvas or the nearest ancestor that sets it. */ + field(canvasId: string, key: string): Promise + /** Days that have blocks on a canvas (needs read access to it). */ + days(canvasId: string): Promise + /** A day's blocks on a canvas (needs read access to it). */ + blocks(canvasId: string, date: string): Promise + /** Full-text search, limited to what it can read. */ + search(query: string): Promise + /** + * Add a block to a canvas (needs write access): at the end of today, or + * inside a block (1.4); optionally as a todo (1.4). It is marked as this + * extension's and its text is read-only in the app (a todo can still be + * ticked off). + */ + addBlock(canvasId: string, markdown: string, opts?: AddBlockOptions): Promise<{ date: string; block: ExtensionBlock }> + /** Open todos (and those ticked off since `doneSince`) on the canvases it may read (1.4). */ + todos(opts?: { doneSince?: string }): Promise + /** Make a canvas (1.6): inside one it may write to, or at the top level if it may write everywhere. */ + createCanvas(input: NewCanvas): Promise + /** Change a canvas it may write to (1.6). */ + updateCanvas(canvasId: string, patch: CanvasPatch): Promise + /** Change the text of a block it added (1.6). */ + editBlock(canvasId: string, date: string, blockId: string, markdown: string): Promise + /** + * Turn a block into a canvas of one of its node types, just inside the + * block's canvas (1.6): what was written inside the block moves there, + * and the block becomes the link to it. A block that already links to a + * canvas returns that one. + */ + promote(canvasId: string, date: string, blockId: string, opts: { type: string }): Promise<{ canvas: ExtensionCanvas; block: ExtensionBlock }> + /** Blocks posted in the app, as they are posted (1.6). */ + onBlockAdded(cb: (ev: BlockAddedEvent) => void): void + /** What the app recorded between two local dates, inclusive (needs read access). (1.7) */ + activity(fromDate: string, toDate: string): Promise + /** The day files with blocks written between two local dates (on canvases it may read). (1.7) */ + range(fromDate: string, toDate: string): Promise + /** + * A canvas this extension keeps (made on first use, with `title`): it may + * read and write every block on it whatever it was granted, add blocks of + * its own kinds on any day, and edit any block there. (1.7) + */ + managedCanvas(key: string, opts?: { title?: string }): Promise + } + /** Devlog-wide settings from devlog.json (as declared in `contributes.settings`). */ + settings: { + get(key: string): string | undefined + onChange(cb: (settings: Record) => void): void + } + /** Secrets kept in the OS keychain on this machine, never in the devlog. */ + secrets: { + get(key: string): Promise + set(key: string, value: string): Promise + delete(key: string): Promise + } + /** Private folders: `repo` is synced with the devlog, `local` stays on this machine. */ + files: { repo: ExtensionFiles; local: ExtensionFiles } + /** Pause/resume (locked, idle, asleep), as they happen. */ + activity: { + on(cb: (notice: ActivityNotice) => void): void + /** Count the machine idle after this many minutes without input (0: never). The app uses the shortest any extension asks for. (1.6) */ + idleAfter(minutes: number): void + } + ui: { + notify(message: string): void + confirm(message: string): Promise + /** A list to choose from, like the quick switcher (1.6). The chosen item's id, or null. */ + pick(items: PickItem[], opts?: { placeholder?: string }): Promise + /** Show a canvas, or a block's page, in the app (1.6). */ + open(target: { canvasId: string; date?: string; blockId?: string }): void + /** Show one of its `page` views (1.7). */ + openPage(viewId: string): void + /** Mark one canvas as this extension's current one (the running task): the sidebar highlights it (1.6). */ + highlight(canvasId: string | null): void + } + /** The app around the window (1.6). */ + app: { + /** Text beside the tray icon and in its tooltip (null clears it). */ + setTrayLabel(label: string | null): void + /** Keep the app running in the tray when its window is closed (so the extension keeps going). */ + keepRunning(on: boolean): void + } + /** + * Commands appear in the quick switcher (declared in `contributes.commands`), + * and in the menus, keybindings and note box the manifest names (1.6). + */ + commands: { register(id: string, run: (context: CommandContext) => unknown | Promise): void } + /** + * Places finished timesheets can be sent (declared in `contributes.destinations`). (1.3) + * With `permissions.send` (1.7), it can also send through other extensions' destinations. + */ + destinations: { + register(id: string, destination: Destination): void + /** Destinations registered by running extensions (1.7). */ + list(): Promise + /** What sending would do (1.7). */ + preview(to: { extension: string; id: string }, sheet: SheetToSend): Promise + /** Send a final week (1.7). */ + send(to: { extension: string; id: string }, sheet: SheetToSend): Promise + } + /** + * Views declared in `contributes.views` (1.5): pages from your package the + * app shows in a sandboxed frame. The page talks to this process through + * the app: its `call(method, ...args)` arrives at your handler, and `post` + * sends a message to every open copy of the view. + */ + views: { + handle(viewId: string, handler: (method: string, args: unknown[]) => unknown | Promise): void + post(viewId: string, message: unknown): void + } + /** Data the app draws in its own views. (1.1) */ + provide: { + /** Focus changes between two local dates (inclusive), for the timeline, review and summary. */ + focus(fn: (fromDate: string, toDate: string) => Promise): void + /** Time-tracking events between two local dates (inclusive), for the review, summary, timeline and timesheet (1.6). */ + activity(fn: (fromDate: string, toDate: string) => Promise): void + } +} + +export interface ExtensionModule { + activate(ctx: DevlogContext): void | Promise + deactivate?(): void | Promise +} diff --git a/packages/extension-api/src/protocol.ts b/packages/extension-api/src/protocol.ts new file mode 100644 index 0000000..0413318 --- /dev/null +++ b/packages/extension-api/src/protocol.ts @@ -0,0 +1,98 @@ +/** + * The wire protocol between the Devlog app and an extension's process. + * Messages go over Node's IPC channel with structured-clone serialisation. + * + * App → extension: `init` once, then `call` (run a command, deliver a + * notice), `res` (answers to the extension's calls), `settings`, `stop`. + * Extension → app: `ready`, `call` (API requests), `res`, `failed`. + */ +import type { ActivityNotice } from './index' + +export interface InitMessage { + t: 'init' + id: string + apiVersion: string + machine: string + /** Only for unrestricted (trusted) extensions. */ + packageDir: string | null + /** The bundle's source; evaluated in the extension process (which cannot read files). */ + code: string + filename: string + settings: Record +} + +export interface CallMessage { + t: 'call' + id: number + method: string + args: unknown[] +} + +export interface ResultMessage { + t: 'res' + id: number + ok: boolean + value?: unknown + error?: string +} + +export type ToExtension = InitMessage | CallMessage | ResultMessage | { t: 'settings'; settings: Record } | { t: 'stop' } + +export type FromExtension = + | { t: 'ready'; commands: string[] } + | { t: 'failed'; error: string } + | CallMessage + | ResultMessage + | { t: 'registered'; command: string } + +/** Methods the app calls on the extension. */ +export type ExtensionSideMethod = 'command.run' | 'activity.notice' | 'block.added' | 'provide.focus' | 'provide.activity' | 'destination.preview' | 'destination.send' | 'view.call' + +export type { ActivityNotice } + +/** Methods the extension calls on the app (all return promises). */ +export const APP_METHODS = [ + 'devlog.canvases', + 'devlog.field', + 'devlog.days', + 'devlog.blocks', + 'devlog.search', + 'devlog.addBlock', + 'devlog.todos', + 'devlog.createCanvas', + 'devlog.updateCanvas', + 'devlog.editBlock', + 'devlog.promote', + 'devlog.subscribe', + 'devlog.activity', + 'devlog.range', + 'devlog.managedCanvas', + 'destinations.list', + 'destinations.preview', + 'destinations.send', + 'ui.openPage', + 'secrets.get', + 'secrets.set', + 'secrets.delete', + 'files.read', + 'files.write', + 'files.append', + 'files.list', + 'files.stat', + 'files.remove', + 'activity.subscribe', + 'activity.idleAfter', + 'ui.notify', + 'ui.confirm', + 'ui.pick', + 'ui.open', + 'ui.highlight', + 'app.trayLabel', + 'app.keepRunning', + 'provide.register', + 'destination.register', + 'views.handle', + 'views.post' +] as const + +export type AppMethod = (typeof APP_METHODS)[number] diff --git a/packages/extension-api/src/testing.ts b/packages/extension-api/src/testing.ts new file mode 100644 index 0000000..3187b66 --- /dev/null +++ b/packages/extension-api/src/testing.ts @@ -0,0 +1,501 @@ +/** + * An in-memory `ctx` for unit-testing an extension without Devlog. + * + * ```ts + * import { createTestContext } from '@devlog/extension-api/testing' + * import * as ext from '../src/main' + * + * const t = createTestContext({ settings: { greeting: 'Hi' } }) + * await ext.activate(t.ctx) + * await t.run('hello') + * expect(t.notifications).toEqual(['Hi #1']) + * ``` + * + * It follows the real rules where they matter to an extension: files are + * reached by relative paths only, reads and writes respect the grant you + * give it, and blocks it adds are marked as its own. + */ +import { API_VERSION, type ActivityRecord, type DestinationInfo, type ActivityNotice, type BlockAddedEvent, type CommandContext, type DevlogContext, type PickItem, type TimeEvent, type ExtensionBlock, type ExtensionCanvas, type ExtensionFileInfo, type ExtensionFiles, type Destination, type DestinationLine, type DestinationSheet, type FocusEvent, type SendResult } from './index' + +export interface TestCanvas extends ExtensionCanvas { + /** Blocks by date. */ + days?: Record +} + +export interface TestOptions { + id?: string + /** `ctx.machine`. Default `test-machine-0000`. */ + machine?: string + /** `ctx.packageDir`. Default null (sandboxed). */ + packageDir?: string | null + canvases?: TestCanvas[] + settings?: Record + secrets?: Record + /** Canvas ids it may read (with what is beneath them), or 'all'. Default: all. */ + read?: string[] | 'all' | null + /** Canvas ids it may write to, or 'all'. Default: all. */ + write?: string[] | 'all' | null + /** What devlog.activity answers (1.7): filtered to the dates asked for. */ + activity?: ActivityRecord[] + /** Other extensions' destinations it can send through (1.7). */ + destinations?: Array + /** Answer for ui.confirm. Default true. */ + confirm?: boolean + now?: () => Date +} + +export interface TestHarness { + ctx: DevlogContext + /** Messages passed to ui.notify, in order. */ + notifications: string[] + /** Questions passed to ui.confirm. */ + confirmations: string[] + /** Blocks added through devlog.addBlock. */ + added: Array<{ canvasId: string; date: string; block: ExtensionBlock }> + /** The two private folders, as path → content. */ + files: { repo: Map; local: Map } + secrets: Map + /** Run a registered command (with a context, as the app would give it; 1.6). */ + run(commandId: string, context?: Partial): Promise + /** Tell onBlockAdded listeners that someone posted a block (1.6); it is added to the canvas's day. */ + post(canvasId: string, date: string, block: ExtensionBlock): void + /** Ask the registered activity provider (1.6). */ + timeEvents(fromDate: string, toDate: string): Promise + /** Canvases made with devlog.createCanvas (they join `canvases`). (1.6) */ + created: string[] + /** Quick picks shown, and how to answer them (default: the first item). (1.6) */ + picks: Array<{ items: PickItem[]; placeholder?: string }> + answerPick: (items: PickItem[]) => string | null + /** Pages shown with ui.openPage (1.7). */ + openedPages: string[] + /** ui.open targets, in order. (1.6) */ + opened: Array<{ canvasId: string; date?: string; blockId?: string }> + /** What the extension asked of the app (1.6). */ + app: { trayLabel: string | null; keepRunning: boolean; idleMinutes: number; highlight: string | null } + /** Deliver an activity notice to listeners. */ + notice(n: ActivityNotice): void + /** Change devlog-wide settings (listeners are told). */ + setSettings(s: Record): void + /** Ask a registered destination for its preview, as the Send dialog would. */ + preview(destinationId: string, sheet: DestinationSheet): Promise + /** Send through a registered destination. */ + send(destinationId: string, sheet: DestinationSheet): Promise + /** Ask the registered focus provider, as the app's views would. */ + focus(fromDate: string, toDate: string): Promise + /** Call a view's handler, as its page would (1.5). */ + viewCall(viewId: string, method: string, ...args: unknown[]): Promise + /** Messages sent with views.post, per view. */ + viewMessages: Map + commands(): string[] +} + +function checkPath(p: string, allowRoot = false): string { + const t = String(p).replace(/^\.\/+/, '').replace(/\/+$/, '') + if (t === '' && allowRoot) return '' + if (!t || t.startsWith('/') || /^[A-Za-z]:/.test(t) || t.includes('\\') || t.split('/').some((s) => s === '' || s === '.' || s === '..')) { + throw new Error(`Not allowed in a path: ${p}`) + } + return t +} + +function memoryFiles(store: Map, now: () => Date): ExtensionFiles { + const enc = new TextEncoder() + const dec = new TextDecoder() + const info = (p: string, b: Uint8Array): ExtensionFileInfo => ({ path: p, size: b.byteLength, mtime: now().toISOString() }) + return { + read: async (p) => store.get(checkPath(p)), + readText: async (p) => { + const b = store.get(checkPath(p)) + return b === undefined ? undefined : dec.decode(b) + }, + write: async (p, data) => { + store.set(checkPath(p), typeof data === 'string' ? enc.encode(data) : new Uint8Array(data)) + }, + append: async (p, text) => { + const k = checkPath(p) + const cur = store.get(k) ?? new Uint8Array() + const add = enc.encode(String(text)) + const next = new Uint8Array(cur.byteLength + add.byteLength) + next.set(cur) + next.set(add, cur.byteLength) + store.set(k, next) + }, + list: async (dir = '') => { + const d = checkPath(dir, true) + return [...store.entries()] + .filter(([k]) => !d || k.startsWith(`${d}/`)) + .map(([k, b]) => info(k, b)) + .sort((a, b) => a.path.localeCompare(b.path)) + }, + stat: async (p) => { + const k = checkPath(p) + const b = store.get(k) + return b ? info(k, b) : undefined + }, + remove: async (p) => { + store.delete(checkPath(p)) + } + } +} + +export function createTestContext(opts: TestOptions = {}): TestHarness { + const id = opts.id ?? 'test.extension' + const now = opts.now ?? ((): Date => new Date()) + const canvases = opts.canvases ?? [] + let settings = { ...(opts.settings ?? {}) } + const settingsListeners: Array<(s: Record) => void> = [] + const activityListeners: Array<(n: ActivityNotice) => void> = [] + const blockListeners: Array<(ev: BlockAddedEvent) => void> = [] + const commandMap = new Map unknown>() + let focusProvider: ((from: string, to: string) => Promise) | null = null + let activityProvider: ((from: string, to: string) => Promise) | null = null + const destinationMap = new Map() + const viewHandlers = new Map unknown>() + const destination = (id: string): Destination => { + const d = destinationMap.get(id) + if (!d) throw new Error(`No destination "${id}"`) + return d + } + const h: Omit & { ctx?: DevlogContext } = { + notifications: [], + confirmations: [], + added: [], + files: { repo: new Map(), local: new Map() }, + secrets: new Map(Object.entries(opts.secrets ?? {})), + run: async (commandId, context) => { + const fn = commandMap.get(commandId) + if (!fn) throw new Error(`No command "${commandId}"`) + return fn({ source: 'switcher', ...(context ?? {}) }) + }, + post: (canvasId, date, block) => { + const c = canvases.find((x) => x.id === canvasId) + if (c) (c.days ??= {})[date] = [...(c.days[date] ?? []), block] + if (!within(opts.read, canvasId)) return + for (const cb of blockListeners) cb({ canvasId, date, block }) + }, + timeEvents: async (from, to) => { + if (!activityProvider) throw new Error('No activity provider registered') + return activityProvider(from, to) + }, + created: [], + picks: [], + answerPick: (items) => items[0]?.id ?? null, + opened: [], + openedPages: [], + app: { trayLabel: null, keepRunning: false, idleMinutes: 0, highlight: null }, + notice: (n) => { + for (const cb of activityListeners) cb(n) + }, + setSettings: (s) => { + settings = { ...s } + for (const cb of settingsListeners) cb(settings) + }, + commands: () => [...commandMap.keys()], + preview: (id, sheet) => destination(id).preview(sheet), + send: (id, sheet) => destination(id).send(sheet), + focus: async (from, to) => { + if (!focusProvider) throw new Error('No focus provider registered') + return focusProvider(from, to) + }, + viewCall: async (viewId, method, ...args) => { + const handler = viewHandlers.get(viewId) + if (!handler) throw new Error(`The view "${viewId}" has no handler`) + return handler(method, args) + }, + viewMessages: new Map() + } + + const within = (scope: string[] | 'all' | null | undefined, canvasId: string): boolean => { + if (scope === undefined || scope === 'all') return true + if (!scope) return false + const byId = new Map(canvases.map((c) => [c.id, c])) + let cur = byId.get(canvasId) + const seen = new Set() + if (scope.includes(canvasId)) return true + while (cur?.parentId && !seen.has(cur.id)) { + seen.add(cur.id) + if (scope.includes(cur.parentId)) return true + cur = byId.get(cur.parentId) + } + return false + } + const localDay = (d: Date): string => `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}` + const other = (to: { extension: string; id: string }): Destination => { + const d = opts.destinations?.find((x) => x.extension === to.extension && x.id === to.id) + if (!d) throw new Error(`No destination ${to.extension}/${to.id}`) + return d.destination + } + // Labels as the app would give them (the canvas's title path; its top-level canvas as the client). + const toDestinationSheet = (sheet: { week: string; status: 'draft' | 'final'; entries: Array<{ id: string; date: string; start: string; minutes: number; canvasId: string; note?: string }> }): DestinationSheet => { + const byId = new Map(canvases.map((c) => [c.id, c])) + const path = (cid: string): TestCanvas[] => { + const out: TestCanvas[] = [] + for (let c = byId.get(cid); c && out.length < 50; c = c.parentId ? byId.get(c.parentId) : undefined) out.unshift(c) + return out + } + return { + week: sheet.week, + status: sheet.status, + entries: sheet.entries.map((e) => ({ + id: e.id, + date: e.date, + start: e.start, + minutes: e.minutes, + ...(e.note ? { note: e.note } : {}), + canvasId: e.canvasId, + task: path(e.canvasId).map((c) => c.title).join(' / ') || e.canvasId, + client: path(e.canvasId)[0]?.title ?? e.canvasId, + fields: {} + })) + } + } + const ownType = (t: unknown): string => { + const name = String(t ?? '') + if (!name) throw new Error('A node type is needed') + return name.includes('/') ? name : `${id}/${name}` + } + const toCanvas = ({ days: _d, ...c }: TestCanvas): ExtensionCanvas => ({ ...c, fields: { ...c.fields } }) + const canvas = (canvasId: string): TestCanvas => { + const c = canvases.find((x) => x.id === canvasId) + if (!c) throw new Error('No access to that canvas') + return c + } + + h.ctx = { + id, + apiVersion: API_VERSION, + machine: opts.machine ?? 'test-machine-0000', + packageDir: opts.packageDir ?? null, + devlog: { + canvases: async () => canvases.filter((c) => within(opts.read, c.id) || within(opts.write, c.id)).map(({ days: _d, ...c }) => ({ ...c, fields: { ...c.fields } })), + field: async (canvasId, key) => { + let cur: TestCanvas | undefined = canvas(canvasId) + const seen = new Set() + while (cur && !seen.has(cur.id)) { + seen.add(cur.id) + if (cur.fields[key]) return cur.fields[key] + cur = cur.parentId ? canvases.find((c) => c.id === cur!.parentId) : undefined + } + return null + }, + days: async (canvasId) => { + if (!within(opts.read, canvasId)) throw new Error('No read access to that canvas') + return Object.keys(canvas(canvasId).days ?? {}).sort() + }, + blocks: async (canvasId, date) => { + if (!within(opts.read, canvasId)) throw new Error('No read access to that canvas') + return [...(canvas(canvasId).days?.[date] ?? [])] + }, + search: async (query) => { + const q = query.toLowerCase() + const blocks: Array<{ canvasId: string; date: string; block: ExtensionBlock }> = [] + for (const c of canvases) { + if (!within(opts.read, c.id)) continue + for (const [date, list] of Object.entries(c.days ?? {})) for (const b of list) if (b.markdown.toLowerCase().includes(q)) blocks.push({ canvasId: c.id, date, block: b }) + } + return { blocks } + }, + addBlock: async (canvasId, markdown, o) => { + if (!within(opts.write, canvasId)) throw new Error('No write access to that canvas') + const at = now() + const date = `${at.getFullYear()}-${String(at.getMonth() + 1).padStart(2, '0')}-${String(at.getDate()).padStart(2, '0')}` + const managed = Boolean(canvases.find((x) => x.id === canvasId)?.fields['devlog.managed']) + if (o?.kind && !managed) throw new Error('devlog.addBlock: a kind only on a canvas it keeps') + const day = o?.date && (o.parentId || managed) ? o.date : date + const block: ExtensionBlock = { + id: `b${h.added.length + 1}`, + createdAt: at.toISOString(), + markdown: markdown.trim(), + meta: { ext: id, ...(o?.meta ?? {}) }, + ...(o?.parentId ? { parentId: o.parentId } : {}), + ...(o?.todo ? { kind: 'todo' } : o?.kind ? { kind: o.kind } : {}) + } + const c = canvases.find((x) => x.id === canvasId) + if (c) (c.days ??= {})[day] = [...(c.days[day] ?? []), block] + h.added.push({ canvasId, date: day, block }) + return { date: day, block } + }, + todos: async (o) => { + const out: Array<{ canvasId: string; date: string; block: ExtensionBlock; trail: Array<{ id: string; title: string }> }> = [] + for (const c of canvases) { + if (!within(opts.read, c.id)) continue + for (const [date, list] of Object.entries(c.days ?? {})) { + for (const b of list) { + if (b.kind !== 'todo') continue + const done = b.meta?.done + if (done && (!o?.doneSince || done < o.doneSince)) continue + const trail: Array<{ id: string; title: string }> = [] + for (let p = b.parentId ? list.find((x) => x.id === b.parentId) : undefined; p; p = p.parentId ? list.find((x) => x.id === p!.parentId) : undefined) + trail.unshift({ id: p.id, title: p.markdown.split('\n')[0].slice(0, 60) }) + out.push({ canvasId: c.id, date, block: b, trail }) + } + } + } + return out + }, + createCanvas: async (input) => { + const parentId = input.parentId ?? null + if (parentId ? !within(opts.write, parentId) : opts.write !== undefined && opts.write !== 'all') throw new Error('No write access there') + const type = input.type ? ownType(input.type) : undefined + const c: TestCanvas = { id: `c${canvases.length + 1}`, title: input.title.trim(), parentId, task: type?.endsWith('/task') ?? false, ...(type ? { type } : {}), archived: false, fields: {} } + canvases.push(c) + h.created.push(c.id) + return toCanvas(c) + }, + updateCanvas: async (canvasId, patch) => { + if (!within(opts.write, canvasId)) throw new Error('No write access to that canvas') + const c = canvas(canvasId) + if (patch.title !== undefined) c.title = patch.title + if (patch.parentId !== undefined) c.parentId = patch.parentId + if (patch.type !== undefined) { + if (patch.type === null) delete c.type + else c.type = ownType(patch.type) + c.task = c.type?.endsWith('/task') ?? false + } + if (patch.archived !== undefined) c.archived = patch.archived + return toCanvas(c) + }, + editBlock: async (canvasId, date, blockId, markdown) => { + if (!within(opts.write, canvasId)) throw new Error('No write access to that canvas') + const b = canvas(canvasId).days?.[date]?.find((x) => x.id === blockId) + if (!b) throw new Error(`Entry ${blockId} not found on ${date}`) + if (b.meta?.ext !== id && !canvas(canvasId).fields['devlog.managed']) throw new Error('Only blocks it added') + b.markdown = markdown.trim() + b.updatedAt = now().toISOString() + return { ...b } + }, + promote: async (canvasId, date, blockId, o) => { + if (!within(opts.write, canvasId)) throw new Error('No write access to that canvas') + const list = canvas(canvasId).days?.[date] ?? [] + const b = list.find((x) => x.id === blockId) + if (!b) throw new Error(`Entry ${blockId} not found on ${date}`) + if (b.kind === 'task' && b.meta?.canvas) return { canvas: toCanvas(canvas(b.meta.canvas)), block: { ...b } } + const type = ownType(o.type) + const c: TestCanvas = { id: `c${canvases.length + 1}`, title: b.markdown.split('\n')[0].replace(/^#+\s*/, '').slice(0, 80), parentId: canvasId, task: type.endsWith('/task'), type, archived: false, fields: {} } + canvases.push(c) + h.created.push(c.id) + b.kind = 'task' + b.meta = { ...(b.meta ?? {}), canvas: c.id } + return { canvas: toCanvas(c), block: { ...b } } + }, + onBlockAdded: (cb) => { + blockListeners.push(cb) + }, + activity: async (from, to) => (opts.activity ?? []).filter((e) => { + const d = localDay(new Date(e.t)) + return d >= from && d <= to + }), + range: async (from, to) => { + const out: Array<{ canvasId: string; date: string; blocks: ExtensionBlock[] }> = [] + for (const c of canvases) { + if (!within(opts.read, c.id)) continue + for (const [date, list] of Object.entries(c.days ?? {})) { + const written = list.some((b) => { + const d = localDay(new Date(b.createdAt)) + return d >= from && d <= to + }) + if ((date >= from && date <= to) || written) out.push({ canvasId: c.id, date, blocks: [...list] }) + } + } + return out + }, + managedCanvas: async (key, o) => { + const owner = `${id}/${key}` + let c = canvases.find((x) => x.fields['devlog.managed'] === owner) + if (!c) { + c = { id: `c${canvases.length + 1}`, title: o?.title ?? key, parentId: null, task: false, archived: false, fields: { 'devlog.managed': owner } } + canvases.push(c) + h.created.push(c.id) + } + return toCanvas(c) + } + }, + settings: { + get: (key) => settings[key], + onChange: (cb) => { + settingsListeners.push(cb) + } + }, + secrets: { + get: async (key) => h.secrets.get(key), + set: async (key, value) => { + h.secrets.set(key, value) + }, + delete: async (key) => { + h.secrets.delete(key) + } + }, + files: { repo: memoryFiles(h.files.repo, now), local: memoryFiles(h.files.local, now) }, + activity: { + on: (cb) => { + activityListeners.push(cb) + }, + idleAfter: (minutes) => { + h.app.idleMinutes = Math.max(0, Number(minutes) || 0) + } + }, + ui: { + notify: (message) => { + h.notifications.push(String(message)) + }, + confirm: async (message) => { + h.confirmations.push(String(message)) + return opts.confirm ?? true + }, + pick: async (items, o) => { + h.picks.push({ items, ...(o?.placeholder ? { placeholder: o.placeholder } : {}) }) + return h.answerPick(items) + }, + open: (target) => { + h.opened.push({ ...target }) + }, + openPage: (viewId) => { + h.openedPages.push(viewId) + }, + highlight: (canvasId) => { + h.app.highlight = canvasId + } + }, + app: { + setTrayLabel: (label) => { + h.app.trayLabel = label + }, + keepRunning: (on) => { + h.app.keepRunning = on + } + }, + commands: { + register: (cmdId, run) => { + commandMap.set(cmdId, run) + } + }, + destinations: { + register: (id, d) => { + destinationMap.set(id, d) + }, + list: async () => (opts.destinations ?? []).map(({ destination: _d, ...info }) => info), + preview: async (to, sheet) => other(to).preview(toDestinationSheet(sheet)), + send: async (to, sheet) => { + if (sheet.status !== 'final') throw new Error('Mark the timesheet final before sending it') + return other(to).send(toDestinationSheet(sheet)) + } + }, + views: { + handle: (viewId, handler) => { + viewHandlers.set(viewId, handler) + }, + post: (viewId, message) => { + h.viewMessages!.set(viewId, [...(h.viewMessages!.get(viewId) ?? []), message]) + } + }, + provide: { + focus: (fn) => { + focusProvider = fn + }, + activity: (fn) => { + activityProvider = fn + } + } + } + return h as TestHarness +} diff --git a/packages/extension-api/src/view.ts b/packages/extension-api/src/view.ts new file mode 100644 index 0000000..69085b7 --- /dev/null +++ b/packages/extension-api/src/view.ts @@ -0,0 +1,42 @@ +/** + * The messages between an extension's view (a page in a sandboxed frame) and + * the Devlog window that shows it, sent with `postMessage`. Every message + * carries `devlog: 1`. `@devlog/ui` wraps these; a plain page can send them + * itself (`window.parent.postMessage({ devlog: 1, … }, '*')`). + */ + +/** Where a view is shown (1.6): a `canvasHeader` view's canvas; a popover gets the context of the view that opened it. */ +export interface ViewContext { + canvasId?: string + date?: string + blockId?: string +} + +/** From the page to the app. */ +export type ViewToApp = + /** The page is listening; the app answers with `theme` (and sends it again when it changes). */ + | { devlog: 1; type: 'ready' } + /** Ask the extension (its `views.handle` handler); answered by `reply` with the same id. */ + | { devlog: 1; type: 'call'; id: number; method: string; args: unknown[] } + /** The size the page would like: status bar items get their height and may not get the width. */ + | { devlog: 1; type: 'resize'; width?: number; height?: number } + /** Open one of the extension's popover views, anchored to this one. */ + | { devlog: 1; type: 'popover'; view: string; width?: number; height?: number } + /** Close this view (a popover). */ + | { devlog: 1; type: 'close' } + /** Show a canvas, or a block's page, in the app. */ + | { devlog: 1; type: 'open'; canvasId: string; date?: string; blockId?: string } + /** Run one of the extension's commands (with the view's context, 1.6). */ + | { devlog: 1; type: 'command'; command: string } + /** A shortcut the page did not use (Ctrl, Alt or Cmd with a key; F-keys), for the app's own (1.7). */ + | { devlog: 1; type: 'key'; key: string; code: string; ctrlKey: boolean; metaKey: boolean; altKey: boolean; shiftKey: boolean } + +/** From the app to the page. */ +export type AppToView = + /** The app's colours as CSS custom properties (`--bg`, `--fg`, `--accent`, …), and whether it is dark. */ + | { devlog: 1; type: 'theme'; vars: Record; dark: boolean } + | { devlog: 1; type: 'reply'; id: number; ok: boolean; value?: unknown; error?: string } + /** Something the extension sent with `views.post`. */ + | { devlog: 1; type: 'message'; data: unknown } + /** Where the view is shown (1.6); sent after `theme`, and again when it changes. */ + | { devlog: 1; type: 'context'; context: ViewContext } diff --git a/packages/extension-api/tests/testing.test.ts b/packages/extension-api/tests/testing.test.ts new file mode 100644 index 0000000..f1ba166 --- /dev/null +++ b/packages/extension-api/tests/testing.test.ts @@ -0,0 +1,78 @@ +import { EventEmitter } from 'node:events' +import { createRequire } from 'node:module' +import path from 'node:path' +import { describe, expect, it } from 'vitest' +import type { ExtensionModule } from '../src/index' +import { createTestContext } from '../src/testing' + +// The probe fixture is the same bundle the app's own tests run in the sandbox. +const probe = createRequire(import.meta.url)(path.resolve(__dirname, '../../../tests/fixtures/extensions/probe/main.js')) as ExtensionModule + +describe('the test harness', () => { + it('runs an extension against an in-memory devlog', async () => { + const t = createTestContext({ + id: 'builtin.probe', + settings: { greeting: 'Hi' }, + canvases: [ + { id: 'acme', title: 'Acme', parentId: null, task: false, archived: false, fields: { code: 'ACME-1' } }, + { id: 'web', title: 'Web', parentId: 'acme', task: false, archived: false, fields: {}, days: { '2026-09-26': [{ id: 'a', createdAt: 't', markdown: 'a needle' }] } }, + { id: 'globex', title: 'Globex', parentId: null, task: false, archived: false, fields: {} } + ], + read: ['acme'], + write: ['web'] + }) + await probe.activate(t.ctx) + expect(t.commands()).toEqual(['hello', 'probe']) + await t.run('hello') + await t.run('hello') + expect(t.notifications).toEqual(['Hi #1', 'Hi #2']) + expect(new TextDecoder().decode(t.files.repo.get('log/hello.jsonl'))).toBe('{"n":1}\n{"n":2}\n') + await t.run('probe') + const report = JSON.parse(new TextDecoder().decode(t.files.repo.get('probe.json'))) + expect(report.canvases.map((c: { id: string }) => c.id)).toEqual(['acme', 'web']) + expect(report['field:Web']).toBe('ACME-1') + expect(report['add:Acme'].ok).toBe(false) + expect(report['add:Web']).toEqual({ ok: true, value: { ext: 'builtin.probe', probe: '1' } }) + expect(report.search).toEqual(['web']) + expect(report.escapeFiles.ok).toBe(false) + }) + + it('delivers notices and settings changes', async () => { + const t = createTestContext() + const seen: string[] = [] + t.ctx.activity.on((n) => seen.push(n.type)) + t.ctx.settings.onChange((s) => seen.push(`settings:${s.a}`)) + t.notice({ t: 'x', type: 'pause', reason: 'locked' }) + t.setSettings({ a: '1' }) + expect(seen).toEqual(['pause', 'settings:1']) + expect(t.ctx.settings.get('a')).toBe('1') + }) + + it('runs devlog-focus with a fake window watcher: changes in, nothing while locked, focus events out', async () => { + const focus = createRequire(import.meta.url)(path.resolve(__dirname, '../../../builtin-extensions/devlog-focus/main.js')) as { + run: (ctx: unknown, watcher: EventEmitter & { start(): void; stop(): void }) => void + } + const watcher = Object.assign(new EventEmitter(), { started: 0, start() { this.started++ }, stop() {} }) + const t = createTestContext({ id: 'builtin.devlog-focus', machine: 'desk-1a2b' }) + focus.run(t.ctx, watcher) + expect(watcher.started).toBe(1) + watcher.emit('change', { app: 'Code', title: 'store.ts' }) + t.notice({ t: new Date().toISOString(), type: 'pause', reason: 'locked' }) + watcher.emit('change', { app: 'LockApp', title: 'Lock screen' }) + const back = new Date(Date.now() + 1000).toISOString() + t.notice({ t: back, type: 'resume' }) + watcher.emit('change', { app: 'chrome', title: 'Jira' }) + const d = new Date() + const date = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}` + const events = await t.focus(date, date) + // The lock screen is not recorded; coming back says again which window was in front. + expect(events.map((e) => [e.app, e.machine])).toEqual([ + ['Code', 'desk-1a2b'], + ['Code', 'desk-1a2b'], + ['chrome', 'desk-1a2b'] + ]) + expect(events[1].t).toBe(back) + expect([...t.files.repo.keys()]).toEqual([`desk-1a2b/${date.slice(0, 4)}/${date.slice(5, 7)}/${date}.jsonl`]) + expect(await t.focus('2000-01-01', '2000-01-02')).toEqual([]) + }) +}) diff --git a/packages/ui/package.json b/packages/ui/package.json new file mode 100644 index 0000000..59485d1 --- /dev/null +++ b/packages/ui/package.json @@ -0,0 +1,17 @@ +{ + "name": "@devlog/ui", + "version": "1.0.0", + "description": "For Devlog extension views: the bridge to the app, its design tokens and a few React components", + "license": "MIT", + "type": "module", + "private": true, + "exports": { + ".": "./src/index.tsx", + "./bridge": "./src/bridge.ts", + "./styles.css": "./src/styles.css" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + } +} diff --git a/packages/ui/src/bridge.ts b/packages/ui/src/bridge.ts new file mode 100644 index 0000000..2a9c2de --- /dev/null +++ b/packages/ui/src/bridge.ts @@ -0,0 +1,114 @@ +/** + * The bridge between an extension's view and the Devlog window showing it + * (see `@devlog/extension-api/view` for the messages). Importing this module + * tells the app the page is ready and applies the app's colours as they + * arrive, so a plain page gets the theme with no further code. + */ +import type { AppToView, ViewContext, ViewToApp } from '@devlog/extension-api/view' + +type Outgoing = ViewToApp extends infer M ? (M extends { devlog: 1 } ? Omit : never) : never + +const waiting = new Map void; reject: (e: Error) => void }>() +const messageListeners = new Set<(data: unknown) => void>() +const themeListeners = new Set<(dark: boolean) => void>() +const contextListeners = new Set<(context: ViewContext) => void>() +let nextId = 1 +let dark = false +let context: ViewContext = {} + +function send(msg: Outgoing): void { + window.parent.postMessage({ devlog: 1, ...msg }, '*') +} + +window.addEventListener('message', (ev: MessageEvent) => { + if (ev.source !== window.parent) return + const m = ev.data as AppToView + if (!m || m.devlog !== 1) return + switch (m.type) { + case 'theme': { + const root = document.documentElement + for (const [k, v] of Object.entries(m.vars)) if (/^--[a-z0-9-]+$/i.test(k)) root.style.setProperty(k, v) + dark = m.dark + root.dataset.theme = m.dark ? 'dark' : 'light' + for (const cb of themeListeners) cb(dark) + break + } + case 'reply': { + const w = waiting.get(m.id) + if (!w) return + waiting.delete(m.id) + if (m.ok) w.resolve(m.value) + else w.reject(new Error(m.error ?? 'Failed')) + break + } + case 'message': + for (const cb of messageListeners) cb(m.data) + break + case 'context': + context = m.context ?? {} + for (const cb of contextListeners) cb(context) + break + } +}) + +export const devlog = { + /** Ask your extension: arrives at its `ctx.views.handle(viewId, …)` handler. */ + call(method: string, ...args: unknown[]): Promise { + return new Promise((resolve, reject) => { + const id = nextId++ + waiting.set(id, { resolve: resolve as (v: unknown) => void, reject }) + send({ type: 'call', id, method, args }) + }) + }, + /** Messages your extension sends with `ctx.views.post`. Returns a function that stops listening. */ + onMessage(cb: (data: unknown) => void): () => void { + messageListeners.add(cb) + return () => messageListeners.delete(cb) + }, + /** Whether the app is in dark mode now, and when it changes. */ + get dark(): boolean { + return dark + }, + onTheme(cb: (dark: boolean) => void): () => void { + themeListeners.add(cb) + return () => themeListeners.delete(cb) + }, + /** Where the view is shown (a canvas-header view's canvas), and when that changes. */ + get context(): ViewContext { + return context + }, + onContext(cb: (context: ViewContext) => void): () => void { + contextListeners.add(cb) + return () => contextListeners.delete(cb) + }, + /** Ask for a size. A status bar item gets its height from the bar; the width may be clamped. */ + resize(size: { width?: number; height?: number }): void { + send({ type: 'resize', ...size }) + }, + /** Open one of your popover views, anchored to this view. */ + popover(view: string, size: { width?: number; height?: number } = {}): void { + send({ type: 'popover', view, ...size }) + }, + /** Close this view (a popover). */ + close(): void { + send({ type: 'close' }) + }, + /** Show a canvas, or a block's page, in the app. */ + open(target: { canvasId: string; date?: string; blockId?: string }): void { + send({ type: 'open', ...target }) + }, + /** Run one of your commands. */ + command(command: string): void { + send({ type: 'command', command }) + } +} + +// Shortcuts the page does not use (Ctrl+K, Alt+←, …) belong to the app around it. +window.addEventListener('keydown', (ev) => { + if (ev.defaultPrevented) return + if (!(ev.ctrlKey || ev.metaKey || ev.altKey || /^F\d+$/.test(ev.key))) return + if (['Control', 'Meta', 'Alt', 'Shift'].includes(ev.key)) return + send({ type: 'key', key: ev.key, code: ev.code, ctrlKey: ev.ctrlKey, metaKey: ev.metaKey, altKey: ev.altKey, shiftKey: ev.shiftKey }) +}) + +send({ type: 'ready' }) diff --git a/packages/ui/src/index.tsx b/packages/ui/src/index.tsx new file mode 100644 index 0000000..6aaffc0 --- /dev/null +++ b/packages/ui/src/index.tsx @@ -0,0 +1,167 @@ +/** + * @devlog/ui: for extension views. Import the styles once in your view's + * entry (`import '@devlog/ui/styles.css'`), then use `mount` and the + * components; `devlog` is the bridge to the app and your extension. + */ +import { StrictMode, useCallback, useEffect, useRef, useState, type ReactNode } from 'react' +import { createRoot } from 'react-dom/client' +import type { ViewContext } from '@devlog/extension-api/view' +import { devlog } from './bridge' + +export { devlog } + +/** Render a view's root component into the page (a `
`, made if missing). */ +export function mount(node: ReactNode, opts: { slot?: boolean } = {}): void { + if (opts.slot) document.body.classList.add('dl-slot') + let el = document.getElementById('root') + if (!el) { + el = document.createElement('div') + el.id = 'root' + document.body.append(el) + } + createRoot(el).render({node}) +} + +// --------------------------------------------------------------------------- +// Hooks +// --------------------------------------------------------------------------- + +/** Call your extension and keep the answer; `reload` asks again. */ +export function useCall(method: string, args: unknown[] = [], deps: unknown[] = []): { data: T | undefined; error: string | null; loading: boolean; reload: () => void } { + const [state, setState] = useState<{ data: T | undefined; error: string | null; loading: boolean }>({ data: undefined, error: null, loading: true }) + const [n, setN] = useState(0) + useEffect(() => { + let alive = true + setState((s) => ({ ...s, loading: true })) + devlog.call(method, ...args).then( + (data) => alive && setState({ data, error: null, loading: false }), + (err: Error) => alive && setState((s) => ({ data: s.data, error: err.message, loading: false })) + ) + return () => { + alive = false + } + }, [method, n, ...deps]) // eslint-disable-line react-hooks/exhaustive-deps + return { ...state, reload: useCallback(() => setN((x) => x + 1), []) } +} + +/** Messages your extension sends with `ctx.views.post`. */ +export function useMessages(cb: (data: unknown) => void): void { + const ref = useRef(cb) + ref.current = cb + useEffect(() => devlog.onMessage((d) => ref.current(d)), []) +} + +/** Whether the app is in dark mode. */ +export function useDark(): boolean { + const [dark, setDark] = useState(devlog.dark) + useEffect(() => devlog.onTheme(setDark), []) + return dark +} + +/** Where the view is shown: a canvas-header view's canvas (1.6). */ +export function useViewContext(): ViewContext { + const [ctx, setCtx] = useState(devlog.context) + useEffect(() => devlog.onContext(setCtx), []) + return ctx +} + +/** A time that updates on its own (for running clocks). */ +export function useNow(everyMs = 1000): number { + const [now, setNow] = useState(Date.now()) + useEffect(() => { + const t = setInterval(() => setNow(Date.now()), everyMs) + return () => clearInterval(t) + }, [everyMs]) + return now +} + +// --------------------------------------------------------------------------- +// Components +// --------------------------------------------------------------------------- + +type ButtonProps = React.ButtonHTMLAttributes & { variant?: 'default' | 'primary' | 'quiet' | 'danger'; small?: boolean } + +export function Button({ variant = 'default', small, className, type, ...rest }: ButtonProps): React.JSX.Element { + const cls = ['dl-btn', variant !== 'default' ? `dl-btn-${variant}` : '', small ? 'dl-btn-sm' : '', className ?? ''].filter(Boolean).join(' ') + return + + ))} + {shown.length === 0 &&
  • {empty ?? 'Nothing here.'}
  • } + +
    + ) +} diff --git a/packages/ui/src/styles.css b/packages/ui/src/styles.css new file mode 100644 index 0000000..e8d07ea --- /dev/null +++ b/packages/ui/src/styles.css @@ -0,0 +1,280 @@ +/* + * Devlog's look for extension views. The app sends its colours as custom + * properties when the view is ready (see bridge.ts); these defaults only + * show for a moment before that, or outside the app. + */ +:root { + --bg: #ffffff; + --bg-2: #f6f7f9; + --bg-3: #eceef2; + --fg: #1d1c1d; + --fg-muted: #616061; + --border: #dddddd; + --border-strong: #bbbbbb; + --accent: #1264a3; + --accent-fg: #ffffff; + --warn: #b7791f; + --danger: #c53030; + --ok: #2bac76; + --link: #1264a3; + --code-bg: #f3f3f3; + --shadow: 0 8px 24px rgba(0, 0, 0, 0.18); + --mono: ui-monospace, 'SF Mono', Menlo, Consolas, monospace; + --font: -apple-system, 'Segoe UI', system-ui, sans-serif; + color-scheme: light; +} + +:root[data-theme='dark'] { + color-scheme: dark; +} + +*, +*::before, +*::after { + box-sizing: border-box; +} + +html, +body { + margin: 0; + padding: 0; + font-family: var(--font); + font-size: 14px; + line-height: 1.45; + color: var(--fg); + background: var(--bg); +} + +button, +input, +select, +textarea { + font: inherit; + color: inherit; +} + +/* A status bar item: the bar's height, its background, one line. */ +body.dl-slot { + background: transparent; + font-size: 12px; + height: 22px; + overflow: hidden; + white-space: nowrap; +} + +.dl-row { + display: flex; + align-items: center; + gap: 8px; + min-width: 0; +} + +.dl-stack { + display: flex; + flex-direction: column; + gap: 8px; + min-width: 0; +} + +.dl-spacer { + flex: 1; +} + +.dl-muted { + color: var(--fg-muted); +} + +.dl-page { + padding: 16px 24px; +} + +.dl-title { + font-size: 18px; + font-weight: 600; + margin: 0; +} + +.dl-empty { + color: var(--fg-muted); + padding: 16px 0; +} + +.dl-error { + color: var(--danger); +} + +/* ---- Buttons ---- */ +.dl-btn { + display: inline-flex; + align-items: center; + gap: 4px; + border: 1px solid var(--border); + background: var(--bg); + border-radius: 6px; + padding: 5px 12px; + cursor: pointer; + white-space: nowrap; +} + +.dl-btn:hover:not(:disabled) { + background: var(--bg-3); +} + +.dl-btn:disabled { + opacity: 0.55; + cursor: default; +} + +.dl-btn-primary { + background: var(--accent); + border-color: var(--accent); + color: var(--accent-fg); +} + +.dl-btn-primary:hover:not(:disabled) { + background: color-mix(in srgb, var(--accent) 88%, black); +} + +.dl-btn-quiet { + border-color: transparent; + background: transparent; +} + +.dl-btn-danger { + color: var(--danger); +} + +.dl-btn-sm { + padding: 1px 8px; + font-size: 12px; + border-radius: 5px; +} + +/* ---- Fields ---- */ +.dl-field { + display: flex; + flex-direction: column; + gap: 4px; +} + +.dl-field > span { + font-size: 12px; + color: var(--fg-muted); +} + +.dl-input { + border: 1px solid var(--border); + border-radius: 6px; + background: var(--bg-2); + padding: 6px 8px; + outline: none; + width: 100%; +} + +.dl-input:focus { + border-color: var(--accent); + background: var(--bg); +} + +/* ---- Menus (popovers, pickers) ---- */ +.dl-menu { + list-style: none; + margin: 0; + padding: 4px; + overflow-y: auto; +} + +.dl-menu-item { + display: flex; + flex-direction: column; + align-items: flex-start; + width: 100%; + border: 0; + background: transparent; + text-align: left; + padding: 5px 8px; + border-radius: 6px; + cursor: pointer; +} + +.dl-menu-item:hover, +.dl-menu-item.is-active { + background: var(--accent); + color: var(--accent-fg); +} + +.dl-menu-item:hover .dl-menu-hint, +.dl-menu-item.is-active .dl-menu-hint { + color: inherit; + opacity: 0.8; +} + +.dl-menu-hint { + font-size: 11px; + color: var(--fg-muted); +} + +.dl-menu-sep { + height: 1px; + background: var(--border); + margin: 4px 6px; +} + +/* ---- Tables ---- */ +.dl-table { + border-collapse: collapse; + width: 100%; + font-size: 13px; +} + +.dl-table th, +.dl-table td { + padding: 5px 8px; + border-bottom: 1px solid var(--border); + text-align: left; +} + +.dl-table th { + font-size: 11px; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--fg-muted); + font-weight: 600; +} + +.dl-table td.num, +.dl-table th.num { + text-align: right; + font-variant-numeric: tabular-nums; +} + +.dl-badge { + display: inline-block; + font-size: 11px; + padding: 1px 6px; + border-radius: 999px; + background: var(--bg-3); + color: var(--fg-muted); +} + +.dl-badge.is-accent { + background: var(--accent); + color: var(--accent-fg); +} + +.dl-dot { + display: inline-block; + width: 8px; + height: 8px; + border-radius: 50%; + background: var(--fg-muted); + flex: none; +} + +.dl-dot.is-busy { + background: var(--accent); +} + +.dl-dot.is-warn { + background: var(--warn); +} diff --git a/scripts/build-builtins.mjs b/scripts/build-builtins.mjs new file mode 100644 index 0000000..cd5b745 --- /dev/null +++ b/scripts/build-builtins.mjs @@ -0,0 +1,77 @@ +// Bundles the built-in extensions written in TypeScript: for each +// builtin-extensions//src, src/main.ts → dist/main.js (the extension +// process: CommonJS, Node built-ins only) and each src/views/.tsx → +// dist/views/.{html,js,css} (a page for a sandboxed frame: one +// script, one stylesheet, React and @devlog/ui included). +import { promises as fs } from 'node:fs' +import path from 'node:path' +import { fileURLToPath } from 'node:url' +import { build } from 'esbuild' + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..') +const builtins = path.join(root, 'builtin-extensions') +const alias = { + // A folder, so '@devlog/core/types' resolves too. + '@devlog/core': path.join(root, 'packages/core/src'), + '@devlog/ui/styles.css': path.join(root, 'packages/ui/src/styles.css'), + '@devlog/ui': path.join(root, 'packages/ui/src/index.tsx'), + '@shared': path.join(root, 'src/shared') +} +const exists = (p) => fs.stat(p).then(() => true, () => false) +const html = (name, title) => + `\n\n\n\n${title}\n\n\n\n
    \n\n\n\n` + +export async function buildBuiltins({ only } = {}) { + const built = [] + for (const name of await fs.readdir(builtins)) { + if (only && !only.includes(name)) continue + const src = path.join(builtins, name, 'src') + if (!(await exists(src))) continue + const dist = path.join(builtins, name, 'dist') + await fs.rm(dist, { recursive: true, force: true }) + for (const main of ['main.ts', 'main.tsx']) { + if (!(await exists(path.join(src, main)))) continue + await build({ + entryPoints: [path.join(src, main)], + outfile: path.join(dist, 'main.js'), + bundle: true, + platform: 'node', + format: 'cjs', + target: 'node20', + alias, + logLevel: 'warning' + }) + } + const views = path.join(src, 'views') + if (await exists(views)) { + for (const file of await fs.readdir(views)) { + if (!/\.tsx$/.test(file)) continue + const view = file.replace(/\.tsx$/, '') + await build({ + entryPoints: [path.join(views, file)], + outfile: path.join(dist, 'views', `${view}.js`), + bundle: true, + platform: 'browser', + format: 'iife', + target: 'chrome120', + jsx: 'automatic', + minify: true, + alias, + define: { 'process.env.NODE_ENV': '"production"' }, + loader: { '.css': 'css' }, + logLevel: 'warning' + }) + // esbuild writes the imported CSS next to the script; make sure the page links a file that exists. + if (!(await exists(path.join(dist, 'views', `${view}.css`)))) await fs.writeFile(path.join(dist, 'views', `${view}.css`), '') + await fs.writeFile(path.join(dist, 'views', `${view}.html`), html(view, view)) + } + } + built.push(name) + } + return built +} + +if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + const built = await buildBuiltins() + console.log(built.length ? `built-in extensions bundled: ${built.join(', ')}` : 'no built-in extensions to bundle') +} diff --git a/scripts/screens.mjs b/scripts/screens.mjs index 33c9a7d..367212c 100644 --- a/scripts/screens.mjs +++ b/scripts/screens.mjs @@ -22,7 +22,7 @@ await fs.mkdir(out, { recursive: true }) execFileSync('git', ['init', '--initial-branch=main'], { cwd: repo }) await fs.writeFile( path.join(userData, 'settings.json'), - JSON.stringify({ repoPath: repo, syncIntervalMinutes: 60, commitDebounceSeconds: 600, autoPush: false, pullOnStart: false, commitOnQuit: false, authorName: 'Dev', authorEmail: 'd@e.com', trackingEnabled: true, trackFocus: false, idleMinutes: 0, activityInRepo: false, captureCommits: false }) + JSON.stringify({ repoPath: repo, syncIntervalMinutes: 60, commitDebounceSeconds: 600, autoPush: false, pullOnStart: false, commitOnQuit: false, authorName: 'Dev', authorEmail: 'd@e.com', trackingEnabled: true, trackFocus: false, idleMinutes: 0, activityInRepo: true, captureCommits: false }) ) const app = await electron.launch({ args: [appRoot, '--no-sandbox', '--disable-gpu'], env: { ...process.env, DEVLOG_USER_DATA: userData } }) diff --git a/scripts/smoke-migrate.mjs b/scripts/smoke-migrate.mjs deleted file mode 100644 index 1361182..0000000 --- a/scripts/smoke-migrate.mjs +++ /dev/null @@ -1,186 +0,0 @@ -/** - * Upgrade smoke test: opens a devlog written by Devlog 0.3 (storage format 1, - * activity log kept locally) with the built app and checks that it is - * migrated, committed and pushed, and that nothing is lost on the way. - * - * npm run build && xvfb-run -a node scripts/smoke-migrate.mjs - */ -import { _electron as electron } from 'playwright-core' -import { promises as fs } from 'node:fs' -import { execFileSync } from 'node:child_process' -import os from 'node:os' -import path from 'node:path' - -const here = path.dirname(new URL(import.meta.url).pathname) -const appRoot = path.resolve(here, '..') -const tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'devlog-smoke-migrate-')) -const repo = path.join(tmp, 'repo') -const bare = path.join(tmp, 'remote.git') -const userData = path.join(tmp, 'userData') -const git = (args, cwd = repo) => execFileSync('git', args, { cwd, encoding: 'utf8' }).trim() -const write = async (base, rel, text) => { - await fs.mkdir(path.dirname(path.join(base, rel)), { recursive: true }) - await fs.writeFile(path.join(base, rel), text) -} -const pad = (n) => String(n).padStart(2, '0') -const now = new Date() -const ymd = `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}` -const [yy, mm] = ymd.split('-') -const at = (hoursAgo) => new Date(now.getTime() - hoursAgo * 3600_000).toISOString() -const PNG = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==', 'base64') - -// A format 1 devlog, as Devlog 0.3 wrote it, pushed to a remote. -await fs.mkdir(repo, { recursive: true }) -git(['init', '--bare', '--initial-branch=main', bare]) -git(['init', '--initial-branch=main']) -git(['config', 'user.name', 'Old Version']) -git(['config', 'user.email', 'old@example.com']) -git(['remote', 'add', 'origin', bare]) -await write(repo, 'README.md', '# Devlog\n') -await write(repo, 'canvases/acme-corp/canvas.md', '---\ntitle: Acme Corp\ncreated: 2026-01-01T00:00:00.000Z\n---\n\n# Acme\n\n![logo](assets/logo.png)\n') -await fs.mkdir(path.join(repo, 'canvases/acme-corp/assets'), { recursive: true }) -await fs.writeFile(path.join(repo, 'canvases/acme-corp/assets/logo.png'), PNG) -await write(repo, 'canvases/website/canvas.md', '---\ntitle: Website\nparent: acme-corp\ntask: true\ncreated: 2026-01-02T00:00:00.000Z\n---\n') -await write( - repo, - `canvases/website/entries/${yy}/${mm}/${ymd}.md`, - `# ${ymd}\n\n\n### 09:00\n\nkickoff notes ![s](assets/shot.png)\n` -) -await fs.mkdir(path.join(repo, `canvases/website/entries/${yy}/${mm}/assets`), { recursive: true }) -await fs.writeFile(path.join(repo, `canvases/website/entries/${yy}/${mm}/assets/shot.png`), PNG) -await write(repo, `entries/${yy}/${mm}/${ymd}.md`, `# ${ymd}\n\n\n### 08:00\n\nRebuild the website\n`) -git(['add', '-A']) -git(['commit', '-m', 'format 1 history']) -git(['push', '-u', 'origin', 'main']) - -// 0.3 user data: settings without a revision, a local activity log, the active task by its old id. -await fs.mkdir(userData, { recursive: true }) -await fs.writeFile( - path.join(userData, 'settings.json'), - JSON.stringify({ repoPath: repo, syncIntervalMinutes: 60, commitDebounceSeconds: 2, autoPush: true, pullOnStart: false, commitOnQuit: true, authorName: 'Smoke Test', authorEmail: 'smoke@example.com', trackingEnabled: true, trackFocus: false, idleMinutes: 0, activityInRepo: false, captureCommits: true }) -) -await fs.writeFile(path.join(userData, 'tracker-state.json'), JSON.stringify({ activeCanvasId: 'website' })) -await write( - userData, - `activity/${yy}/${mm}/${ymd}.jsonl`, - [ - { t: at(2), type: 'start', canvasId: 'website' }, - { t: at(1.5), type: 'heartbeat' }, - { t: at(1), type: 'stop' } - ] - .map((e) => JSON.stringify(e)) - .join('\n') + '\n' -) - -const app = await electron.launch({ - args: [appRoot, '--no-sandbox', '--disable-gpu'], - env: { ...process.env, DEVLOG_USER_DATA: userData, NODE_ENV: 'production' } -}) -const failures = [] -const check = (cond, msg) => { - console.log(`${cond ? 'ok ' : 'FAIL'} ${msg}`) - if (!cond) failures.push(msg) -} -app.on('console', (m) => console.log(' [main]', m.text())) - -try { - const page = await app.firstWindow() - page.on('pageerror', (e) => console.log(' [pageerror]', e.message)) - await page.waitForSelector('.composer-editor', { timeout: 30_000 }) - - const manifest = JSON.parse(await fs.readFile(path.join(repo, 'devlog.json'), 'utf8')) - check(manifest.format === 3, 'the repository is at storage format 3') - await fs - .stat(path.join(repo, 'canvases/website')) - .then(() => check(false, 'old canvas folders are gone')) - .catch(() => check(true, 'old canvas folders are gone')) - const canvases = await page.evaluate(() => window.devlog.canvases.list()) - const website = canvases.find((c) => c.title === 'Website') - const acme = canvases.find((c) => c.title === 'Acme Corp') - check(/^[0-9a-z]{10}$/.test(website?.id ?? '') && website.aliases?.includes('website'), `canvases got new ids and keep the old one as an alias (${website?.id})`) - check(website?.parentId === acme?.id && website?.task === true, 'the hierarchy and task flag survive') - const migratedDay = await fs.readFile(path.join(repo, 'canvases', website.id.slice(0, 2), website.id, 'entries', yy, mm, `${ymd}.md`), 'utf8') - check(migratedDay.startsWith('') && migratedDay.includes('') && text1.includes('\n$/.test(appended), `a reorder appends one record (${JSON.stringify(appended).slice(0, 80)})`) - // The hover "Task" action promotes an existing block. + // The block action "Make task" (the time extension's) promotes an existing block. const plain = page.locator('.entry:not(.entry-task):not(.entry-commit)').first() await plain.hover() - await plain.locator('button', { hasText: 'Task' }).click() + await plain.locator('button', { hasText: 'Make task' }).click() await page.waitForFunction(() => document.querySelectorAll('.entry-task').length === 3, null, { timeout: 10_000 }) - check(true, 'the Task action turns an existing block into a task') - await page.locator('.task-status button', { hasText: 'Stop' }).click() - await page.waitForFunction(() => document.querySelector('.task-status')?.textContent?.includes('No active task'), null, { timeout: 10_000 }) + check(true, 'the Make task action turns an existing block into a task') + await stopClock() - await page.locator('.sidebar-views .view-link', { hasText: 'Journal' }).click() - await page.waitForSelector('.page-head .crumb.is-current:has-text("Journal")') - await page.waitForFunction(() => document.querySelectorAll('.entry').length === 4, null, { timeout: 10_000 }) + await openCanvasNamed('Scratch') + await page.waitForFunction(() => document.querySelectorAll('.entry').length === 3, null, { timeout: 10_000 }) const journalLast = page.locator('.note-slot > .note > .entry').last() await journalLast.hover() await journalLast.locator('button', { hasText: 'Move' }).click() await page.locator('.move-select').selectOption(websiteId) - await page.waitForFunction(() => document.querySelectorAll('.entry').length === 3, null, { timeout: 10_000 }) - check((await fs.readFile(acmeFile, 'utf8')).includes('Screenshot:'), 'block moved from the journal into the canvas file') - check((await fs.readFile(acmeFile, 'utf8')).includes('![shot](../../../../../../entries/'), 'moved block keeps its image via a relative link') - const journalAfterMove = await page.evaluate((d) => window.devlog.blocks.getDay('journal', d), ymd) - check(!journalAfterMove.entries.some((e) => e.markdown.includes('Screenshot:')) && /