diff --git a/.github/workflows/wiki.yml b/.github/workflows/wiki.yml new file mode 100644 index 0000000..5324802 --- /dev/null +++ b/.github/workflows/wiki.yml @@ -0,0 +1,48 @@ +name: Wiki + +# Publishes wiki/ to the repository's GitHub wiki whenever it changes on main, +# so the wiki's pages are reviewed in pull requests like everything else. +# Pages deleted from wiki/ are deleted from the wiki too; edits made in the +# GitHub wiki UI are overwritten by the next publish. + +on: + push: + branches: [main] + paths: ['wiki/**', '.github/workflows/wiki.yml'] + workflow_dispatch: + +permissions: + contents: write + +concurrency: + group: wiki + cancel-in-progress: false + +jobs: + publish: + name: Publish wiki + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Clone the wiki + env: + TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: git clone --depth 1 "https://x-access-token:${TOKEN}@github.com/${{ github.repository }}.wiki.git" "$RUNNER_TEMP/wiki" + + - name: Copy the pages + run: | + find "$RUNNER_TEMP/wiki" -mindepth 1 -maxdepth 1 ! -name .git -exec rm -rf {} + + cp -R wiki/. "$RUNNER_TEMP/wiki/" + + - name: Commit and push + working-directory: ${{ runner.temp }}/wiki + run: | + git add -A + if git diff --cached --quiet; then + echo "The wiki is up to date." + exit 0 + fi + git -c user.name='github-actions[bot]' -c user.email='41898282+github-actions[bot]@users.noreply.github.com' \ + commit -m "Publish wiki from ${{ github.repository }}@${GITHUB_SHA::7}" + git push origin HEAD diff --git a/README.md b/README.md index 4fe26df..5025c8c 100644 --- a/README.md +++ b/README.md @@ -721,3 +721,4 @@ Code map: | `docs/TIMESHEETS.md` | Timesheets: rounding, trims, destinations, storage | | `docs/TIME-EXTENSION.md` | How time tracking became the devlog-time extension | | `docs/BLOCK-PAGES.md` | Block pages and todos as blocks | +| `wiki/` | The GitHub wiki's pages: user guide, extension developer docs and API reference (see `wiki/Development.md` to publish) | diff --git a/wiki/API-Versions.md b/wiki/API-Versions.md new file mode 100644 index 0000000..b9f2ca7 --- /dev/null +++ b/wiki/API-Versions.md @@ -0,0 +1,104 @@ +# API versions + +The extension API is versioned separately from the app +(`EXTENSION_API_VERSION` in `packages/core/src/extensions.ts`, `API_VERSION` +in `@devlog/extension-api`). Every change so far has been **additive**: there +has been no 2.0, and an extension built for 1.0 still loads in an app that +provides 1.7. + +- Declare the **lowest** version you need in the manifest: `"api": "^1.6.0"`. +- `ctx.apiVersion` tells you what the running app provides, for optional + features. + +| API | Devlog | Headline | +|---|---|---| +| 1.7 | 0.18 | Activity and ranges, managed canvases, sending through destinations, `openPage`, non-inherited fields | +| 1.6 | 0.17 | Node types, canvas writes, command context/keys/menus/post, events, pick/open/highlight, tray, time providers, canvas-header views | +| 1.5 | 0.16 | Views and `@devlog/ui` | +| 1.4 | — | Todos; blocks inside blocks | +| 1.3 | — | Timesheet destinations | +| 1.2 | — | Unrestricted (trusted) extensions, `packageDir` | +| 1.1 | — | `machine`, focus providers | +| 1.0 | — | The base API | + +## 1.7 + +- `devlog.activity(from, to)`: what the app recorded, merged with providers. +- `devlog.range(from, to)`: day files with blocks written in a range. +- `devlog.managedCanvas(key, { title })`: a canvas the extension keeps; + `addBlock` gains `kind` and free `date` there, and `editBlock` works on any + block there. +- `ui.openPage(viewId)`. +- `permissions.send`; `destinations.list()`, `preview(to, sheet)`, + `send(to, sheet)`; types `DestinationInfo`, `SheetToSend`. +- Canvas fields may say `"inherited": false`. +- Views send `key` messages for shortcuts they did not use. +- Types: `ActivityRecord`, `ExtensionDayBlocks`. + +## 1.6 + +- **Node types**: `contributes.nodeTypes`; `ExtensionCanvas.type`. +- `devlog.createCanvas`, `updateCanvas`, `editBlock`, `promote`. +- `devlog.onBlockAdded(cb)`. +- Commands: a `CommandContext` argument (`source`, `canvasId`, `date`, + `blockId`); manifest `keybinding`, `menus` (`canvas`, `block`, `tray`), + `nodeType`, `post`, `tag`. +- `ui.pick`, `ui.open`, `ui.highlight`. +- `app.setTrayLabel`, `app.keepRunning`. +- `activity.idleAfter(minutes)`. +- `provide.activity(fn)` and `TimeEvent`. +- Views: `canvasHeader` placement, `nodeType` on views, the `context` + message and `ViewContext`. + +## 1.5 + +- `contributes.views` with placements `page`, `statusbar`, `popover`. +- `views.handle`, `views.post`. +- The view message protocol (`@devlog/extension-api/view`) and `@devlog/ui`. + +## 1.4 + +- `devlog.todos({ doneSince })` and `ExtensionTodo`. +- `addBlock` options `parentId` + `date` (inside a block) and `todo`. + +## 1.3 + +- `contributes.destinations`, `destinations.register(id, { preview, send })`. +- Types `DestinationSheet`, `DestinationEntry`, `DestinationLine`, + `SendResult`, `Destination`. + +## 1.2 + +- `permissions.unrestricted` and `ctx.packageDir`. + +## 1.1 + +- `ctx.machine`. +- `provide.focus(fn)` and `FocusEvent`. + +## 1.0 + +- `ctx.id`, `ctx.apiVersion`. +- `devlog.canvases`, `field`, `days`, `blocks`, `search`, `addBlock`. +- `settings.get` / `onChange`; `secrets.get` / `set` / `delete`. +- `files.repo` / `files.local`. +- `activity.on`. +- `ui.notify`, `ui.confirm`. +- `commands.register`. +- Manifest: `canvasFields`, `settings`, `secrets`, `commands`, `check`, + `permissions.read` / `write` / `network`, `appendOnly`. + +## Changing the API (for Devlog contributors) + +1. Add the member to `packages/extension-api/src/index.ts` with a comment + naming the new version, and to `APP_METHODS` in `protocol.ts` if the + extension calls the app. +2. Implement it in the host process (`src/main/extensions/hostProcess.ts`) + and the manager (`src/main/extensions/manager.ts`), filtered by the grant. +3. Mirror it in the test harness (`packages/extension-api/src/testing.ts`). +4. Bump `API_VERSION` and `EXTENSION_API_VERSION` together, and the version + in `docs/EXTENSIONS.md` and `packages/extension-api/README.md`. +5. Cover it in `tests/extensions.test.ts` through the real host (the + `probe` or `shaper` fixture). +6. Update this wiki: the [API reference](Extension-API-Reference), the + [types](Extension-Types) and this page. diff --git a/wiki/Activity-and-Time-Data.md b/wiki/Activity-and-Time-Data.md new file mode 100644 index 0000000..9dadef7 --- /dev/null +++ b/wiki/Activity-and-Time-Data.md @@ -0,0 +1,140 @@ +# Activity and time data + +Devlog keeps no clock of its own. The machine's state (locked, idle, asleep) +is the app's; **time** and **window focus** come from extensions. This page +covers the four pieces of API around that: + +| API | Direction | Since | +|---|---|---| +| `ctx.activity.on(cb)` | app → extension: pause and resume notices | 1.0 | +| `ctx.activity.idleAfter(minutes)` | extension → app: when to count the machine idle | 1.6 | +| `ctx.provide.activity(fn)` | extension → app views: time-tracking events | 1.6 | +| `ctx.provide.focus(fn)` | extension → app views: window focus events | 1.1 | +| `ctx.devlog.activity(from, to)` | app → extension: everything recorded, merged | 1.7 | + +The design is in +[`docs/DESIGN.md` → Time](https://github.com/sdeken/devlog/blob/main/docs/DESIGN.md#time-devlog-time-and-the-activity-log) +and [`docs/TIME-EXTENSION.md`](https://github.com/sdeken/devlog/blob/main/docs/TIME-EXTENSION.md). + +## Machine state: pause and resume + +```js +ctx.activity.on((n) => { + if (n.type === 'pause') clock.pause(n.reason, n.t) // 'locked' | 'idle' | 'suspended' + if (n.type === 'resume') clock.resume(n.t) +}) +``` + +- The app watches Electron's `powerMonitor` (lock/unlock, suspend/resume) + and polls the system idle state every 15 seconds. +- Pauses are tracked **per reason**: waking from sleep clears only + "suspended", input after idle clears only "idle", and unlocking clears + everything. `resume` is sent when no reason is left. +- **Idle detection is opt-in.** Call `ctx.activity.idleAfter(minutes)`; the + app uses the shortest time any extension asks for (0 means never; at most + 1440). Without any request, idleness never pauses. + +```js +const idle = () => Number(ctx.settings.get('idle_minutes') ?? '10') +ctx.activity.idleAfter(idle()) +ctx.settings.onChange(() => ctx.activity.idleAfter(idle())) +``` + +## Providing time: `provide.activity` + +An extension that tracks time gives the app its events, and the app's +Timeline, Weekly review, Summary and Timesheet are drawn from them. + +```js +ctx.provide.activity(async (fromDate, toDate) => { + // Every machine's events between two local dates, inclusive. + const out = [] + for (const date of datesBetween(fromDate, toDate)) { + for (const f of await ctx.files.repo.list()) { + if (!f.path.endsWith(`${date}.jsonl`)) continue + const text = (await ctx.files.repo.readText(f.path)) ?? '' + for (const line of text.split('\n')) if (line) out.push(JSON.parse(line)) + } + } + return out // TimeEvent[] +}) +``` + +`TimeEvent`: + +| `type` | Fields | Meaning | +|---|---|---| +| `start` | `canvasId?` | The clock started with the app (and the task it restored). | +| `task` | `canvasId` (or `null`), `blockId?` | The active canvas changed; `null` stops it. | +| `stop` | — | The clock stopped with the app. | +| `heartbeat` | — | Still running. | + +Every event carries `t` (ISO) and `machine` (`ctx.machine` where it was +recorded). Events of other types are ignored; so are events without a valid +`t`. + +How the app uses them: + +- It **replays** your events together with its own record of locks, idle and + sleep into task segments (task → next task, stop or pause; resumed on + unpause), per machine, then flattens overlaps between machines (the later + start wins), so time is never counted twice. +- **Heartbeats are the liveness signal.** A gap of more than two heartbeats + is read as "the app was not running", so a crash cannot book a weekend. + devlog-time writes one every five minutes, and none while paused. +- While **any** extension provides time, the app also writes lock/idle/sleep + and git events to the devlog's `activity//…` log. Without one, it + records nothing about time. +- Explicit durations (`[2h]` on a block) and user corrections (`exclude` + events) are applied on top. + +Keep per-machine logs in per-machine files +(`ctx.files.repo`, `${ctx.machine}/YYYY/MM/YYYY-MM-DD.jsonl`) and declare +them `appendOnly`; machines then never conflict in git. + +Also relevant for a time tracker: + +- `ctx.ui.highlight(canvasId)`: mark the running task in the sidebar; + commits from a linked repository then land on it when it sits inside the + linked canvas. +- `ctx.app.keepRunning(true)`: time accrues only while Devlog runs, so close + to the tray instead of quitting. +- `ctx.app.setTrayLabel(label)`: show the running task in the tray. +- `ctx.devlog.onBlockAdded(cb)`: devlog-time starts a task when the user + posts on it. + +## Providing focus: `provide.focus` + +```js +ctx.provide.focus(async (fromDate, toDate) => readFocusLog(fromDate, toDate)) // FocusEvent[] +``` + +`FocusEvent` is `{ t, app, title, machine }`: the window that came to the +front. The app cleans the stream for its views (drops shell windows such as +the task switcher, folds flips shorter than a few seconds, merges repeats) +but you should record it raw. `app` is cut to 200 characters and `title` to +1000. + +Reading the foreground window needs a platform helper, which the sandbox +forbids, so a focus provider normally runs **unrestricted**. devlog-focus is +the complete example (see [Built-in Extensions](Built-in-Extensions)). + +## Reading it back: `devlog.activity` (1.7) + +```js +const records = await ctx.devlog.activity('2026-09-21', '2026-09-27') +const tracked = records.filter((r) => r.type === 'task') +``` + +Returns the app's own log merged with what providers return (time and +focus), as `ActivityRecord`s. Filtered by the grant: + +- needs a read grant (`No read access` otherwise); +- events on canvases it may not read show `canvasId: null` (git events on + them are dropped); +- `focus` events only with read access to the whole devlog; +- at most 400 days per call. + +Providers have 20 seconds to answer and at most 500 000 events per call are +used. A provider that throws is logged and skipped; the views show what the +others gave. diff --git a/wiki/Architecture.md b/wiki/Architecture.md new file mode 100644 index 0000000..fb1943b --- /dev/null +++ b/wiki/Architecture.md @@ -0,0 +1,149 @@ +# Architecture + +Devlog is an Electron app over a git repository. The one rule that shapes +everything: **only `@devlog/core` touches the repository's files**. The +renderer never touches the file system, and extensions reach the devlog only +through the app. + +The long-form rationale is +[`docs/DESIGN.md`](https://github.com/sdeken/devlog/blob/main/docs/DESIGN.md). + +## Processes + +``` +┌──────────── renderer (sandboxed, no Node) ────────────┐ +│ React UI │ +│ Composer = TipTap (ProseMirror) + @tiptap/markdown │ +│ Feed = marked + DOMPurify │ +│ Extension views: