jackherd 1.15.1 → 1.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/README.md +22 -19
  3. package/dist/{chunk-V4HOLGFP.js → chunk-4LUFR7RC.js} +19 -1
  4. package/dist/config.d.ts +47 -2
  5. package/dist/config.js +65 -4
  6. package/dist/plan-format.js +3 -19
  7. package/herdr-plugin/herdr-plugin.toml +1 -1
  8. package/install.sh +49 -4
  9. package/kit/hooks/hooks/merge-guard/decision.ts +17 -2
  10. package/kit/workflow/skills/follow/SKILL.md +18 -10
  11. package/kit/workflow/skills/orchestrate/SKILL.md +9 -6
  12. package/package.json +2 -1
  13. package/recipes/README.md +3 -2
  14. package/recipes/google-calendar.md +30 -0
  15. package/recipes/sources/google-calendar-today.ts +32 -0
  16. package/recipes/sources/google-calendar.ts +368 -0
  17. package/recipes/sources.md +15 -3
  18. package/schemas/jackherd.schema.json +12 -0
  19. package/schemas/sources.schema.json +141 -0
  20. package/src/check/skill-budget-defaults.ts +11 -4
  21. package/src/cli.ts +1 -0
  22. package/src/config/json-schema.ts +7 -3
  23. package/src/config/location.ts +7 -0
  24. package/src/config/personal-sources.ts +83 -0
  25. package/src/config/schema.ts +29 -0
  26. package/src/plans/herd-claim.ts +9 -31
  27. package/src/plans/layout.ts +135 -18
  28. package/src/plans/live.ts +54 -7
  29. package/src/plans/row-actions.ts +17 -10
  30. package/src/plans/settings.ts +20 -7
  31. package/src/plans/snapshot.ts +34 -22
  32. package/src/plans/source-rows.ts +35 -2
  33. package/src/secrets/command.ts +163 -0
  34. package/src/secrets/environment.ts +45 -0
  35. package/src/secrets/hidden-input.ts +51 -0
  36. package/src/secrets/listing.ts +63 -0
  37. package/src/secrets/options.ts +45 -0
  38. package/src/secrets/store.ts +74 -0
  39. package/src/shared/process-info.ts +89 -0
  40. package/src/sync/command-context.ts +26 -4
  41. package/src/sync/render.ts +9 -4
  42. package/src/watch/command.ts +44 -12
  43. package/src/watch/lane-report.ts +2 -1
  44. package/src/watch/plan-badges-read.ts +28 -10
  45. package/src/watch/source-dry-run-command.ts +23 -12
  46. package/src/watch/source-dry-run.ts +20 -3
  47. package/src/watch/source-reload.ts +100 -0
  48. package/src/watch/source-run.ts +63 -7
  49. package/src/watch/source-tickets.ts +15 -0
  50. package/src/watch/sources.ts +21 -0
  51. package/src/watch/texts.ts +1 -1
  52. package/src/watch/watch-events.ts +9 -0
  53. package/src/watch/watched-source.ts +39 -0
  54. package/src/watch/watcher-lock.ts +8 -1
@@ -1,36 +1,41 @@
1
1
  ---
2
2
  name: follow
3
- description: 'Have the watcher report changes of something outside GitHub (a ticket, an issue, an error, a status page): pick a recipe, check the tool and its login read only, dry-run the source, write it to `watch.sources` after approval. Use when the user asks to follow or be told about such an item, e.g. "/follow PROJ-123".'
3
+ description: 'Have the watcher report changes of something outside GitHub (a ticket, an issue, an error, a status page): pick a recipe, check the tool and its login read only, dry-run the source, write it to the watcher''s sources (personal or repository) after approval. Use when the user asks to follow or be told about such an item, e.g. "/follow PROJ-123".'
4
4
  argument-hint: 'What to follow, e.g. a ticket key, an issue or a query'
5
5
  ---
6
6
 
7
7
  If `.claude/project/follow.md` exists, read it first. It extends this skill for this project and wins where the two disagree.
8
8
 
9
- You add one entry to `watch.sources`, so that `jh watch` reports a new or changed entry of an external service as `=== source <name> …`. You run only reading commands; the one file you write is the project's `.jackherd/config.json` in the main checkout, and only after the user approved the entry. Read the recipe index `recipes/README.md` of the installed package first (`"$(dirname "$(readlink -f "$(command -v jh)")")/../recipes/README.md"`), then only the recipe file it names for the chosen service; do not copy a recipe from memory. Where the recipe leaves a field open, the index's section **Rules every recipe follows** decides, and `recipes/sources.md` beside it describes every field and limit of a source.
9
+ You add one entry to the watcher's sources, so that `jh watch` reports a new or changed entry of an external service as `=== source <name> …`. You run only reading commands; the one file you write is the entry's file (step 4: the user's `~/.jackherd/sources.json` or the project's `.jackherd/config.json` in the main checkout), and only after the user approved the entry. Read the recipe index `recipes/README.md` of the installed package first (`"$(dirname "$(readlink -f "$(command -v jh)")")/../recipes/README.md"`), then only the recipe file it names for the chosen service; do not copy a recipe from memory. Where the recipe leaves a field open, the index's section **Rules every recipe follows** decides, and `recipes/sources.md` beside it describes every field and limit of a source.
10
10
 
11
11
  ## 1. Ask
12
12
 
13
13
  `AskUserQuestion` in {{kit.responseLanguage}}, only for what the argument and the conversation leave open, each with the recommended option first; at most four questions per call, so more open points go into a second call:
14
14
 
15
+ - **Where it lives:** a personal source (`~/.jackherd/sources.json`: runs in every repository of the user, the keychain can hold its secrets; recommended when a personal secret is attached, otherwise second) or a repository source (`watch.sources` of the project: runs for everyone who starts the watcher there, and its `secrets` read only the watcher's environment, never the keychain, so it needs a token from the environment or none).
15
16
  - **Service:** the services the recipe index lists, and "another command that prints JSON".
16
17
  - **What to follow:** the item the argument names (its comments, recommended) or a query over several items.
17
18
  - **Lane:** the lane the reports name, from the board's lanes table, or none.
18
19
  - **Ticket:** whether the entries are tickets (`ticket: true`, recommended for an issue tracker or a project board), which the herd shows as a badge beside the plan of their lane: the lane above, or the lane whose name, board row or plan names the ticket's id. A source of comments or messages is no ticket; it shows in the herd's `Sources` block instead (a click opens the entry at `map.url`). The path of the status you find yourself in step 3.
19
- - **Tone:** for a non-ticket source, the colour of its `Sources` mark while its report is new (`tone`: amber, teal, grey, red, green, blue, magenta); recommended: no `tone`.- **What reports:** a change of what the user reads (title, body, the latest comment; recommended), or a change of named fields such as a status (`reportOn` with those fields, mapped in `map`).
20
+ - **Tone:** for a non-ticket source, the colour of its `Sources` mark while its report is new (`tone`: amber, teal, grey, red, green, blue, magenta); recommended: no `tone`.
21
+ - **Show:** for a non-ticket source whose entries are the day's content (a calendar), `show: "entries"` opens them in a block of their own under "Your turn"; otherwise none.
22
+ - **What reports:** a change of what the user reads (title, body, the latest comment; recommended), or a change of named fields such as a status (`reportOn` with those fields, mapped in `map`).
20
23
  - **Own changes:** ignore the user's own account on that service (`ignoreAuthors`, needs an author in the map), or report everything.
21
24
 
22
25
  ## 2. Check, read only
23
26
 
24
27
  - The tool: `command -v <tool>` for the recipe's program (`curl`, the service's CLI, and `bun` for a recipe with a script).
25
- - The login: the tool's own status command where it has one. For a token in an environment variable only whether it is set: `test -n "$<VARIABLE>" && echo set || echo missing`. Never print, echo or pass on a token's value, and never read a file that holds one.
28
+ - The login: the tool's own status command where it has one. For a token in an environment variable only whether it is set: `test -n "$<VARIABLE>" && echo set || echo missing`. Never print, echo or pass on a token's value, never ask for it, and never read a file that holds one.
26
29
 
27
- Anything missing goes to the user as the command to run themselves (`! <command>`); wait until they did, then check again.
30
+ Anything missing goes to the user as the command to run themselves (`! <command>`; a keychain secret: below, not with `!`); wait until they did, then check again.
31
+
32
+ **A secret** (a token the script needs) is named in the entry's `secrets` (`["NAME"]`) and never passes through you (the rule above). A repository source does not read the keychain: check its environment variable as above. A personal source's secret goes into the keychain in step 4, after the entry is written: `jh secret list` knows only names an entry already lists.
28
33
 
29
34
  ## 3. Fill and dry-run
30
35
 
31
36
  Before filling, read the help of the installed CLI version for every command the recipe calls (`acli jira workitem search --help` and `workitem view --help`, `gh issue list --help`) and confirm that the fields and options the entry needs exist there. Read the provider's web documentation only when the help leaves it open or no recipe fits. A recipe is a starting point; the installed version decides.
32
37
 
33
- Fill the recipe with the item or query and the answers from step 1: a `name` of letters, digits, `.`, `_` and `-` that says what is followed, `lane`, `ignoreAuthors`, `ticket` and `tone` where chosen, and a short `label` (e.g. `Error log · Checkout`; it prefixes the source's `Waiting for you` rows). For a ticket source, find the path of the status in the output (from the CLI's help, or the dry run with `--raw`) and set it as `map.status`; the entry you show for approval in step 4 carries it, and without a status in the output the entry has none and the badge shows only the key. A recipe that needs a script (a token from the environment, or one call per item) gets TypeScript run by Bun: write it, from the recipe's script under `recipes/sources/`, to the scratchpad first as `<name>.ts`, never into the repository before step 4, and let the entry's `command` be `["bun", "scripts/sources/<name>.ts"]`. Script and direct command follow the index's **Rules every recipe follows**.
38
+ Fill the recipe with the item or query and the answers from step 1: a `name` of letters, digits, `.`, `_` and `-` that says what is followed, `lane`, `ignoreAuthors`, `ticket` and `tone` where chosen, and a short `label` (e.g. `Error log · Checkout`; it prefixes the source's `Waiting for you` rows). For a ticket source, find the path of the status in the output (from the CLI's help, or the dry run with `--raw`) and set it as `map.status`; the entry you show for approval in step 4 carries it, and without a status in the output the entry has none and the badge shows only the key. A recipe that needs a script (a token from the environment, or one call per item) gets TypeScript run by Bun: write it, from the recipe's script under `recipes/sources/`, to the scratchpad first as `<name>.ts`, never into the repository before step 4, and let the entry's `command` be `["bun", "scripts/sources/<name>.ts"]` (a personal source runs in `~/.jackherd/`: `["bun", "sources/<name>.ts"]`). Script and direct command follow the index's **Rules every recipe follows**.
34
39
 
35
40
  Write the entry as one JSON object to a draft file in the scratchpad and run, from the main checkout:
36
41
 
@@ -38,12 +43,15 @@ Write the entry as one JSON object to a draft file in the scratchpad and run, fr
38
43
  jh watch source <draft.json> --dry-run
39
44
  ```
40
45
 
41
- It runs the command once and shows the entries and the report the watcher would print, without touching the watcher's state. Exit code 1 means the run failed or a `map` field is empty in every entry (`map.<field>: in … entries empty (<path>)`): never write such an entry, correct the path or drop the field. When the run fails or the entries look wrong, run it again with `--raw` to see the command's output, correct `items` or a path of `map`, and try again; after two failed corrections, show the user the reason and the output's first lines and ask how to go on. Show the user the dry run's list and report, not the raw output.
46
+ It runs the command once and shows the entries and the report the watcher would print, without touching the watcher's state. A draft runs as a repository source: in the main checkout, without the keychain. A personal entry with `secrets` or a script under `sources/` cannot pass there; skip its draft run, show the entry and the recipe's sample output, and dry-run it by name in step 4. Exit code 1 means the run failed or a `map` field is empty in every entry (`map.<field>: in … entries empty (<path>)`): never write such an entry, correct the path or drop the field. When the run fails or the entries look wrong, run it again with `--raw` to see the command's output, correct `items` or a path of `map`, and try again; after two failed corrections, show the user the reason and the output's first lines and ask how to go on. Show the user the dry run's list and report, not the raw output.
42
47
 
43
48
  ## 4. Write after approval
44
49
 
45
- Ask through `AskUserQuestion`: write the entry (recommended), change it, or drop it. Say in the question that the source runs for everyone who starts the watcher in this repository, and that the watcher cannot keep a command from writing, so the command only reads.
50
+ Ask through `AskUserQuestion`: write the entry (recommended), change it, or drop it. Say in the question which file it goes to and that the watcher cannot keep a command from writing, so the command only reads; for a repository source add that it runs for everyone who starts the watcher in this repository.
51
+
52
+ On approval write exactly one of these files, and a script from step 3 under the path the entry names:
46
53
 
47
- On approval add the entry to `watch.sources` in `.jackherd/config.json` of the main checkout (create the array if missing), and a script from step 3 under the path the entry names. Then run `jh config`, which fails on an invalid config, and `jh watch source <name> --dry-run` once more against the written entry.
54
+ - **Personal:** add the entry to `sources` in `~/.jackherd/sources.json`; create the file as `{ "$schema": "<installed package>/schemas/sources.schema.json", "sources": [] }` if missing (the package directory is the one the recipe path above resolves to, minus `/recipes/README.md`). The script goes to `~/.jackherd/sources/<name>.ts`. A repository source of the same `name` wins over it, so pick a name the project does not use. For each name in `secrets`, ask the user to run `jh secret set <NAME>` in a terminal of their own (a separate pane; it asks hidden and refuses without a terminal), wait, then check only `jh secret list`: `set` is fine, `missing` means they still have to, `set (environment)` means the watcher's environment must carry it too, `secret store unavailable` means it can come only from that environment. Then run `jh watch source <name> --dry-run`; it prints `origin: personal`. A failure here is corrected in the written entry as in step 3.
55
+ - **Repository:** add the entry to `watch.sources` in `.jackherd/config.json` of the main checkout (create the array if missing). Then run `jh config`, which fails on an invalid config, and `jh watch source <name> --dry-run` once more.
48
56
 
49
- End with one line: the watcher takes the source over at its next start, so a running watcher (the coordinator's stream) has to be started again. The new file and the changed config are part of the repository and follow its commit rules.
57
+ End with one line: a running watcher and the herd take the source over at their next cycle (about a minute); a file that is invalid then keeps the previous sources and prints `sources not reloaded`. A repository entry and its script are part of the repository and follow its commit rules; the personal file is not.
@@ -84,6 +84,8 @@ otherwise it runs in a fitting lane, as its next phase or as a subagent there (`
84
84
  table are met and step 1 finds nothing. One line to the user afterwards; commit, push, PR and merge
85
85
  stay approval-bound (§ 5, § 6).
86
86
 
87
+ **A pull request of the lane that only waits for its approval or its merge holds the commit, not the next phase.** While only the approval waits there is no commit yet: the next phase goes on in the same worktree, and the commit stages only that PR's files. A round or phase the plan starts "after the PR" begins at once, on a branch over the PR's branch, and after the squash merge moves onto the base branch with `git rebase --onto origin/{{kit.baseBranch}} <old PR head>` (§ 6). Waiting stays for a PR of another lane that shares a write path, for a migration and for a release.
88
+
87
89
  When the user wants a plan's lanes merged without a question for a while: § 5, **Standing approval**.
88
90
 
89
91
  0. **Plan commit as the base**, when § 0 found the board or a plan missing on
@@ -250,7 +252,7 @@ On wake-up:
250
252
  | `blocked` | Read the screen (`--source detection`, or zoom the pane when cut off). A question is vetted (below, **Settled questions stay in the lane**): one the plan or a rule settles you answer in the lane, an open decision goes to the user via `AskUserQuestion`. You never hand the lane's pane to the user. A question dialog in a lane (not a permission dialog) is a gap in the lane prompt (§ 1): send it the text-block rule and the answer by prompt. A permission dialog for a step on the list in § 5 you confirm, after checking the cursor sits on the one-time option; anything else goes to the user. Dim text in the input line is a suggestion, not input (`--format ansi` shows `\e[2m`); in a multi-select dialog `space` toggles an option, `enter` moves on. The watcher prints a duty line for each blocked lane; it is done when the lane has its answer or the user has the question |
251
253
  | `working` with `questions: <n>` | The lane changed the questions of its report while its subagents run. Read `report <path>` and close each new question as the `done` / `idle` row does (`jh settled`, or `AskUserQuestion` with the answer sent on by `herdr agent prompt`). No diff check and no next phase: the lane keeps working |
252
254
  | `main checkout on <branch>, not …` | § 0, **The main checkout stays on `{{kit.baseBranch}}`** |
253
- | `done` / `idle` | Read the report the watcher shows (`report <path>` and its first lines, or the screen); read further only where that is not enough. Check the diff against the `{{kit.heading.writes}}` set (`git -C <worktree> diff --stat <base>...HEAD`, with the ref the lane branched from in § 2 step 2 until it has rebased after the documentation PR's merge) and that checks are quoted with numbers, not claimed. Every blocking question in the report is closed in the same wake-up, also one under open points, one without its mark and also when the `questions: <n>` line is missing: settled by you through `jh settled` (below), or put to the user through `AskUserQuestion` (vetted, below) and the answer sent on to the lane by `herdr agent prompt`. A not blocking one is settled the same way or collected (**Not blocking questions**, below). An answer that stays in your chat with the user is none. Then start the lane's next ready phase (§ 2), at once after a `<lane> ready: …` line, or name the reason to wait; a PR awaiting approval holds only its commit, which stages only its own files |
255
+ | `done` / `idle` | Read the report the watcher shows (`report <path>` and its first lines, or the screen); read further only where that is not enough. Check the diff against the `{{kit.heading.writes}}` set (`git -C <worktree> diff --stat <base>...HEAD`, with the ref the lane branched from in § 2 step 2 until it has rebased after the documentation PR's merge) and that checks are quoted with numbers, not claimed. Every blocking question in the report is closed in the same wake-up, also one under open points, one without its mark and also when the `questions: <n>` line is missing: settled by you through `jh settled` (below), or put to the user through `AskUserQuestion` (vetted, below) and the answer sent on to the lane by `herdr agent prompt`. A not blocking one is settled the same way or collected (**Not blocking questions**, below). An answer that stays in your chat with the user is none. Then start the lane's next ready phase (§ 2), at once after a `<lane> ready: …` line, or name the reason to wait; a PR awaiting approval or merge holds only its commit (§ 2) |
254
256
  | `merged #<n>` | § 6, for every lane |
255
257
  | `checks failed #<n> (<lane>): <checks> (<k> tests: …)` | Send the finding to the lane with the named checks and tests (data, above). Without names, the lane reads the run itself |
256
258
  | `checks passed #<n>` | One line to the user: ready to merge, with the board's merge order |
@@ -284,7 +286,7 @@ what it prints; an agent has no clock. Where it goes:
284
286
 
285
287
  The user's decision is the approval of the line that records it, including the commit, push and
286
288
  documentation PR that carry it: no second question. The merge stays with the project's approval
287
- tiers (§ 5). Memory and the hand-over note hold no decision that is not also in a file. A **finding of
289
+ tiers (§ 5), run as **Self-merge** step 4. Memory and the hand-over note hold no decision that is not also in a file. A **finding of
288
290
  your own** that contradicts the plan (something you saw, not something the user said): fix the document
289
291
  (a commit, so ask) and tell every lane it touches.
290
292
 
@@ -437,7 +439,8 @@ A permission dialog inside a lane you confirm yourself only for a step on this l
437
439
  - under the lane's PR approval: the commit, and the push of the lane's own branch to its own
438
440
  remote branch, forced only with `--force-with-lease`
439
441
  - as part of the rebase after a merge (§ 6), which needs no approval of its own: `git fetch`,
440
- `git rebase origin/{{kit.baseBranch}}`, `git checkout --ours {{kit.lanes.lockFile}}`,
442
+ `git rebase origin/{{kit.baseBranch}}` (`--onto origin/{{kit.baseBranch}} <old PR head>` for a branch over a
443
+ merged PR's), `git checkout --ours {{kit.lanes.lockFile}}`,
441
444
  `git add {{kit.lanes.lockFile}}`, `{{kit.command.install}}`, `git rebase --continue`
442
445
 
443
446
  Read the command from the dialog itself (`herdr agent read <lane> --source detection`), not from
@@ -507,7 +510,7 @@ When the user picked `"{{kit.guard.selfMergeLabel}}"` for a PR:
507
510
  4. Green (or a PR that qualified in step 2): `gh pr merge <n> --squash --delete-branch --match-head-commit <sha>`,
508
511
  with the plain PR number, the full `headRefOid` from `gh pr view <n> --json headRefOid` read right before, and
509
512
  without `-R`, `--repo` or `--admin`. The merge guard lets exactly this command through for the approved branch
510
- with green checks, for `guard.selfMerge.approvalMinutes` after the answer. Red, or checks that never finish: do not
513
+ with green checks, for `guard.selfMerge.approvalMinutes` after the answer. Run it as a Bash call of its own, without a pipe, `;` or `&&`, and read the result afterwards with `gh pr view <n>`: the guard checks a merge against approval and checks only when it is the sole command of the call, and asks otherwise. Red, or checks that never finish: do not
511
514
  merge, report to the user with the failing check and the lane's next step.
512
515
  5. When the guard asks anyway (the approval lapsed, the branch differs, the command carries an
513
516
  option it does not cover), the dialog is the user's. Do not confirm it for them and do not look
@@ -545,7 +548,7 @@ For a PR the user approved (step 2) whose lane works on a plan under a standing
545
548
  1. A PR behind the base branch is rebased first (§ 6, under the PR's approval), so its checks run
546
549
  against the current base.
547
550
  2. Turn a draft ready as in **Self-merge** step 1, then wait for the checks as in its step 3.
548
- 3. Green: the same command as **Self-merge** step 4, with the `<sha>` read right before. The merge
551
+ 3. Green: the same command as **Self-merge** step 4, run the same way, with the `<sha>` read right before. The merge
549
552
  guard lets it through only before the approval's end, with every check green, GitHub able to merge
550
553
  it and no workflow, migration, env file or file of the guards in it; otherwise it asks, and that dialog is the user's
551
554
  (**Self-merge** step 5).
@@ -563,7 +566,7 @@ For a PR the user approved (step 2) whose lane works on a plan under a standing
563
566
  No overlap, no run; an overlap runs the checks of the touched package roots; whenever in doubt, a lock-file
564
567
  conflict or a change to the root `package.json` among them, all of them run. A `{{kit.lanes.lockFile}}` conflict is never resolved by hand: take the base branch's
565
568
  (`git checkout --ours {{kit.lanes.lockFile}}` during a rebase), run `{{kit.command.install}}`, continue.
566
- - Merge order is the board's. A lane ready early waits; say so.
569
+ - Merge order is the board's. A PR ready early waits for its turn to merge, its lane goes on (§ 2); say so.
567
570
  - **The documentation PR of § 2 step 0 merges first**, before any lane PR that started from it. After its
568
571
  merge, the copies in the main checkout and its branch are cleaned up as in
569
572
  [`references/plan-commit.md`](references/plan-commit.md).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "jackherd",
3
- "version": "1.15.1",
3
+ "version": "1.16.0",
4
4
  "keywords": [
5
5
  "claude-code",
6
6
  "coding-agents",
@@ -52,6 +52,7 @@
52
52
  "!assets/icons/build.ts",
53
53
  "!assets/branding/outline.ts",
54
54
  "recipes",
55
+ "!recipes/**/*.test.ts",
55
56
  "README.md",
56
57
  "INSTALL.md",
57
58
  "CHANGELOG.md",
package/recipes/README.md CHANGED
@@ -10,13 +10,14 @@ Each recipe is one `watch.sources` entry for the jackherd watcher: put in your k
10
10
  | GitHub issues | [github-issues.md](github-issues.md) | `gh auth login` | none |
11
11
  | GitHub Projects, as tickets | [github-projects.md](github-projects.md) | `gh auth refresh -s project` | none |
12
12
  | Sentry | [sentry.md](sentry.md) | `SENTRY_AUTH_TOKEN` | [sources/sentry.ts](sources/sentry.ts) |
13
+ | Google Calendar, today's appointments | [google-calendar.md](google-calendar.md) | secret iCal address in `GOOGLE_CALENDAR_ICS_URL` | [sources/google-calendar-today.ts](sources/google-calendar-today.ts) |
13
14
  | A status page | [status-page.md](status-page.md) | none, public API | none |
14
15
 
15
16
  ## Rules every recipe follows
16
17
 
17
- - **A token never goes into an argument:** an argument shows in the process list. Where a tool keeps its own login, it is used; where a token sits in an environment variable, a small TypeScript script run by Bun (which runs wherever `jh` does) puts it from `process.env` straight into the request header.
18
+ - **A token never goes into an argument:** an argument shows in the process list. Where a tool keeps its own login, it is used; where a token sits in an environment variable, a small TypeScript script run by Bun (which runs wherever `jh` does) puts it from `process.env` straight into the request header. The token reaches the script through the entry's `secrets` (the keychain, for a personal source only; see [sources.md](sources.md)) or through the watcher's environment; either way the script reads `process.env`.
18
19
  - **A script prints one JSON value on stdout and nothing else.**
19
20
  - **A script fails with an exit code**, never with an empty answer the watcher would take for a source with nothing new, and it prints neither the token nor the body of a failed answer. A 200 that carries an `errors` list, as GraphQL answers, is a failure too. Every request and every tool call is bounded with `AbortSignal.timeout(20_000)`, well under the 30 seconds a source may run.
20
- - **The scripts are files of their own** under [`sources/`](sources/). Copy one to `scripts/sources/<name>.ts` in your repository, the path the entry's `command` names. The closing `export {}` makes the file a module, so the type check accepts top-level `await` and the names do not clash between scripts.
21
+ - **The scripts are files of their own** under [`sources/`](sources/). Copy one to `scripts/sources/<name>.ts` in your repository (a personal source: `~/.jackherd/sources/<name>.ts`), the path the entry's `command` names. The closing `export {}` makes the file a module, so the type check accepts top-level `await` and the names do not clash between scripts.
21
22
  - **A recipe that needs no script** (`gh …`, `curl` on a public page, `curl --netrc`) stays a direct command.
22
23
  - **`bun` as the first word, never a path to it.** The watcher starts a source without a shell and reads `bun` to mean the Bun that runs the watcher itself, so the entry needs no absolute path, which would differ from machine to machine, even when Bun is not on the `PATH` (installed in `~/.bun/bin` only, say).
@@ -0,0 +1,30 @@
1
+ # Google Calendar, today's appointments
2
+
3
+ Your day, from the calendars you already keep in Google Calendar, opened in the herd under "Your turn". No Google login and no API project: Google gives each calendar a secret address in the iCalendar format, and that address is the credential.
4
+
5
+ **Secret address:** in Google Calendar, open the settings of a calendar, section "Integrate calendar", and copy "Secret address in iCal format". Anyone who has the address can read the calendar, so it never goes into the entry, into a repository or into an argument. Store it in the keychain (the command asks hidden and needs a terminal):
6
+
7
+ ```
8
+ jh secret set GOOGLE_CALENDAR_ICS_URL
9
+ jh secret set GOOGLE_CALENDAR_ICS_URL_2 # a second calendar, and so on
10
+ ```
11
+
12
+ **Script:** the day's appointments are a personal matter, so this is a personal source. Copy both [sources/google-calendar.ts](sources/google-calendar.ts) (the iCalendar reader) and [sources/google-calendar-today.ts](sources/google-calendar-today.ts) to `~/.jackherd/sources/`. The script reads the addresses from `GOOGLE_CALENDAR_ICS_URL`, `GOOGLE_CALENDAR_ICS_URL_2`, … and shows the day in the time zone of the machine. It expands daily, weekly, monthly and yearly series with `INTERVAL`, `UNTIL`, `COUNT`, `BYDAY`, `BYMONTHDAY` and `BYMONTH`, honours moved and cancelled occurrences, and skips a series it cannot expand. One calendar that cannot be read fails the whole run, without printing its address; an appointment with a date that cannot be read is skipped on its own.
13
+
14
+ **Entry:** in `~/.jackherd/sources.json`, under `sources`. List in `secrets` exactly the names you stored; a name that is found nowhere fails the run.
15
+
16
+ ```json
17
+ {
18
+ "name": "calendar",
19
+ "label": "Today",
20
+ "every": 15,
21
+ "command": ["bun", "sources/google-calendar-today.ts"],
22
+ "secrets": ["GOOGLE_CALENDAR_ICS_URL"],
23
+ "show": "entries",
24
+ "map": { "id": ".id", "updatedAt": ".updatedAt", "title": ".title", "url": ".url" }
25
+ }
26
+ ```
27
+
28
+ **Dry run:** a draft outside `~/.jackherd/sources.json` runs without the keychain. Once the entry is in the file and the secrets are stored, `jh watch source calendar --dry-run` runs it by name, with the keychain, and lists today's appointments.
29
+
30
+ **Limits:** one entry per occurrence, so a moved or renamed appointment reports as a change and a new day brings its appointments in as new. Google refreshes the iCal feed with a delay of some minutes up to hours. The secret address stays valid until you reset it in the calendar settings; reset it if it leaks.
@@ -0,0 +1,32 @@
1
+ // Copy to ~/.jackherd/sources/google-calendar-today.ts together with google-calendar.ts; the recipe
2
+ // recipes/google-calendar.md says what to fill in. Today's appointments of the calendars whose secret
3
+ // iCal addresses sit in GOOGLE_CALENDAR_ICS_URL, GOOGLE_CALENDAR_ICS_URL_2, … (the entry's `secrets`).
4
+ // One entry per occurrence, so a moved or renamed appointment reports as a change and a new day brings
5
+ // its appointments in as new. One calendar that cannot be read fails the whole run.
6
+ import { addressesOf, civilOf, occurrencesOn, parseIcs, toItems } from './google-calendar'
7
+
8
+ const zone = Intl.DateTimeFormat().resolvedOptions().timeZone
9
+
10
+ const addresses = addressesOf(process.env)
11
+ if (addresses.length === 0) process.exit(1)
12
+
13
+ // The address is the credential: nothing from a failed request (error text, URL) may reach stderr.
14
+ const readCalendar = async (address: string): Promise<string | undefined> => {
15
+ try {
16
+ const answer = await fetch(address, { signal: AbortSignal.timeout(20_000) })
17
+ const text = answer.status === 200 ? await answer.text() : ''
18
+ return text.includes('BEGIN:VCALENDAR') ? text : undefined
19
+ } catch {
20
+ return undefined
21
+ }
22
+ }
23
+
24
+ const answers = await Promise.all(addresses.map(readCalendar))
25
+ const texts = answers.filter((text) => text !== undefined)
26
+ if (texts.length < answers.length) process.exit(1)
27
+
28
+ const today = civilOf(Date.now(), zone)
29
+ const events = texts.flatMap((text) => parseIcs(text, zone))
30
+ console.log(JSON.stringify(toItems(occurrencesOn(events, today, zone), today, zone)))
31
+
32
+ export {}
@@ -0,0 +1,368 @@
1
+ // The iCalendar reader of the Google Calendar recipe (recipes/google-calendar.md). Copy it next to
2
+ // google-calendar-today.ts; the zone every function reads a wall-clock time in is an argument.
3
+ export type Civil = { year: number; month: number; day: number; hour: number; minute: number; second: number }
4
+ export type IcsDate = { civil: Civil; allDay: boolean; zone: string }
5
+ export const FREQUENCY = { daily: 'DAILY', weekly: 'WEEKLY', monthly: 'MONTHLY', yearly: 'YEARLY' } as const
6
+ export type Frequency = (typeof FREQUENCY)[keyof typeof FREQUENCY]
7
+ export const isFrequency = (value: string): value is Frequency => (Object.values(FREQUENCY) as string[]).includes(value)
8
+ export const EVENT_STATUS = { cancelled: 'CANCELLED' } as const
9
+
10
+ export type Rule = {
11
+ freq: Frequency | undefined
12
+ interval: number
13
+ until?: IcsDate
14
+ count?: number
15
+ byDay: { ordinal: number; weekday: number }[]
16
+ byMonthDay: number[]
17
+ byMonth: number[]
18
+ unsupported: boolean
19
+ }
20
+ export type IcsEvent = {
21
+ uid: string
22
+ summary: string
23
+ location: string
24
+ status: string
25
+ lastModified?: IcsDate
26
+ start: IcsDate
27
+ end?: IcsDate
28
+ rule?: Rule
29
+ exDates: string[]
30
+ recurrenceId?: IcsDate
31
+ }
32
+ export type Occurrence = { event: IcsEvent; key: string; startMs: number; endMs: number }
33
+ export type CalendarItem = {
34
+ id: string
35
+ title: string
36
+ url: string
37
+ updatedAt: string
38
+ }
39
+
40
+ const DAY_MS = 24 * 60 * 60 * 1000
41
+ const WEEKDAYS = ['SU', 'MO', 'TU', 'WE', 'TH', 'FR', 'SA']
42
+ const UNSUPPORTED_PARTS = ['BYSETPOS', 'BYWEEKNO', 'BYYEARDAY', 'BYHOUR', 'BYMINUTE', 'BYSECOND']
43
+ const PROPERTY_LINE = /^([A-Za-z-]+)((?:;[^:;=]+=(?:"[^"]*"|[^:;]*))*):(.*)$/
44
+ const UNFOLD = /\r?\n[ \t]/g
45
+ const UTC = 'UTC'
46
+ const ADDRESS_VARIABLE = /^GOOGLE_CALENDAR_ICS_URL(?:_(\d+))?$/
47
+
48
+ /** The calendar addresses in the environment, GOOGLE_CALENDAR_ICS_URL first, then `_2`, `_3`, … */
49
+ export const addressesOf = (environment: Record<string, string | undefined>): string[] =>
50
+ Object.entries(environment)
51
+ .flatMap(([name, value]) => {
52
+ const match = ADDRESS_VARIABLE.exec(name)
53
+ return match && value ? [{ order: Number(match[1] ?? 1), value }] : []
54
+ })
55
+ .sort((a, b) => a.order - b.order)
56
+ .map(({ value }) => value)
57
+
58
+ const formatters = new Map<string, Intl.DateTimeFormat>()
59
+
60
+ const formatterFor = (zone: string): Intl.DateTimeFormat => {
61
+ const cached = formatters.get(zone)
62
+ if (cached) return cached
63
+ const formatter = new Intl.DateTimeFormat('en-US', {
64
+ timeZone: zone,
65
+ hourCycle: 'h23',
66
+ year: 'numeric',
67
+ month: 'numeric',
68
+ day: 'numeric',
69
+ hour: 'numeric',
70
+ minute: 'numeric',
71
+ second: 'numeric',
72
+ })
73
+ formatters.set(zone, formatter)
74
+ return formatter
75
+ }
76
+
77
+ export const civilOf = (ms: number, zone: string): Civil => {
78
+ const parts = Object.fromEntries(formatterFor(zone).formatToParts(new Date(ms)).map((part) => [part.type, part.value]))
79
+ return {
80
+ year: Number(parts.year),
81
+ month: Number(parts.month),
82
+ day: Number(parts.day),
83
+ hour: Number(parts.hour),
84
+ minute: Number(parts.minute),
85
+ second: Number(parts.second),
86
+ }
87
+ }
88
+
89
+ const civilMs = (civil: Civil): number =>
90
+ Date.UTC(civil.year, civil.month - 1, civil.day, civil.hour, civil.minute, civil.second)
91
+
92
+ const offsetAt = (ms: number, zone: string): number => civilMs(civilOf(ms, zone)) - Math.floor(ms / 1000) * 1000
93
+
94
+ /** The instant a wall-clock time in `zone` names; in a DST gap it lands one hour later. */
95
+ export const instantOf = (civil: Civil, zone: string): number => {
96
+ if (zone === UTC) return civilMs(civil)
97
+ const guess = civilMs(civil)
98
+ const first = guess - offsetAt(guess, zone)
99
+ return guess - offsetAt(first, zone)
100
+ }
101
+
102
+ const addDays = (civil: Civil, days: number): Civil => {
103
+ const shifted = new Date(civilMs(civil) + days * DAY_MS)
104
+ return {
105
+ ...civil,
106
+ year: shifted.getUTCFullYear(),
107
+ month: shifted.getUTCMonth() + 1,
108
+ day: shifted.getUTCDate(),
109
+ }
110
+ }
111
+
112
+ const dayNumber = (civil: Civil): number => Math.floor(Date.UTC(civil.year, civil.month - 1, civil.day) / DAY_MS)
113
+ const weekdayOf = (civil: Civil): number => new Date(Date.UTC(civil.year, civil.month - 1, civil.day)).getUTCDay()
114
+ const daysInMonth = (civil: Civil): number => new Date(Date.UTC(civil.year, civil.month, 0)).getUTCDate()
115
+ const dateKey = (civil: Civil): string =>
116
+ `${civil.year}${String(civil.month).padStart(2, '0')}${String(civil.day).padStart(2, '0')}`
117
+
118
+ const unescapeText = (value: string): string =>
119
+ value.replace(/\\([\\,;nN])/g, (_, character: string) => (character.toLowerCase() === 'n' ? '\n' : character))
120
+
121
+ const parseParameters = (raw: string): Record<string, string> =>
122
+ Object.fromEntries(
123
+ raw
124
+ .split(';')
125
+ .filter(Boolean)
126
+ .map((pair) => {
127
+ const [name, ...value] = pair.split('=')
128
+ return [name.toUpperCase(), value.join('=').replace(/^"|"$/g, '')]
129
+ }),
130
+ )
131
+
132
+ const parseIcsDate =(value: string, parameters: Record<string, string>, floatingZone: string): IcsDate => {
133
+ const match = /^(\d{4})(\d{2})(\d{2})(?:T(\d{2})(\d{2})(\d{2})(Z)?)?$/.exec(value.trim())
134
+ if (!match) throw new Error('unreadable date')
135
+ const [, year, month, day, hour, minute, second, utc] = match
136
+ const allDay = hour === undefined
137
+ return {
138
+ civil: {
139
+ year: Number(year),
140
+ month: Number(month),
141
+ day: Number(day),
142
+ hour: Number(hour ?? 0),
143
+ minute: Number(minute ?? 0),
144
+ second: Number(second ?? 0),
145
+ },
146
+ allDay,
147
+ zone: utc ? UTC : (parameters.TZID ?? floatingZone),
148
+ }
149
+ }
150
+
151
+ const parseWeekday = (token: string): { ordinal: number; weekday: number } => {
152
+ const match = /^([+-]?\d+)?([A-Z]{2})$/.exec(token)
153
+ return { ordinal: Number(match?.[1] ?? 0), weekday: WEEKDAYS.indexOf(match?.[2] ?? '') }
154
+ }
155
+
156
+ const numbers = (value?: string): number[] => (value ? value.split(',').map(Number) : [])
157
+
158
+ export const parseRule = (value: string, floatingZone: string): Rule => {
159
+ const parts = Object.fromEntries(value.split(';').map((pair) => pair.split('=') as [string, string]))
160
+ return {
161
+ freq: isFrequency(parts.FREQ ?? '') ? (parts.FREQ as Frequency) : undefined,
162
+ interval: Number(parts.INTERVAL ?? 1),
163
+ until: parts.UNTIL ? parseIcsDate(parts.UNTIL, {}, floatingZone) : undefined,
164
+ count: parts.COUNT ? Number(parts.COUNT) : undefined,
165
+ byDay: parts.BYDAY ? parts.BYDAY.split(',').map(parseWeekday) : [],
166
+ byMonthDay: numbers(parts.BYMONTHDAY),
167
+ byMonth: numbers(parts.BYMONTH),
168
+ unsupported: !isFrequency(parts.FREQ ?? '') || UNSUPPORTED_PARTS.some((part) => part in parts),
169
+ }
170
+ }
171
+
172
+ const occurrenceKey = (date: IcsDate): string =>
173
+ date.allDay ? dateKey(date.civil) : new Date(instantOf(date.civil, date.zone)).toISOString()
174
+
175
+ type Property = { name: string; parameters: Record<string, string>; value: string }
176
+
177
+ const readProperties = (block: string[]): Property[] =>
178
+ block.flatMap((line) => {
179
+ const match = PROPERTY_LINE.exec(line)
180
+ return match ? [{ name: match[1].toUpperCase(), parameters: parseParameters(match[2]), value: match[3] }] : []
181
+ })
182
+
183
+ const buildEvent = (properties: Property[], floatingZone: string): IcsEvent | undefined => {
184
+ const first = (name: string) => properties.find((property) => property.name === name)
185
+ const date = (name: string) => {
186
+ const property = first(name)
187
+ return property ? parseIcsDate(property.value, property.parameters, floatingZone) : undefined
188
+ }
189
+ const start = date('DTSTART')
190
+ const uid = first('UID')?.value
191
+ if (!start || !uid) return undefined
192
+ const rule = first('RRULE')
193
+ return {
194
+ uid,
195
+ summary: unescapeText(first('SUMMARY')?.value ?? ''),
196
+ location: unescapeText(first('LOCATION')?.value ?? ''),
197
+ status: (first('STATUS')?.value ?? '').toUpperCase(),
198
+ lastModified: date('LAST-MODIFIED'),
199
+ start,
200
+ end: date('DTEND'),
201
+ rule: rule ? parseRule(rule.value, floatingZone) : undefined,
202
+ exDates: properties
203
+ .filter((property) => property.name === 'EXDATE')
204
+ .flatMap((property) =>
205
+ property.value.split(',').map((value) => occurrenceKey(parseIcsDate(value, property.parameters, floatingZone))),
206
+ ),
207
+ recurrenceId: date('RECURRENCE-ID'),
208
+ }
209
+ }
210
+
211
+ const toEvent = (properties: Property[], floatingZone: string): IcsEvent | undefined => {
212
+ try {
213
+ return buildEvent(properties, floatingZone)
214
+ } catch {
215
+ return undefined
216
+ }
217
+ }
218
+
219
+ /**
220
+ * The VEVENTs of an iCalendar text. A floating time (no TZID, no Z) is read in `floatingZone`;
221
+ * a block without UID or DTSTART, or with a date it cannot read, is dropped on its own and
222
+ * leaves the other events alone.
223
+ */
224
+ export const parseIcs = (text: string, floatingZone: string): IcsEvent[] => {
225
+ const events: IcsEvent[] = []
226
+ let block: string[] | undefined
227
+ for (const line of text.replace(UNFOLD, '').split(/\r?\n/)) {
228
+ if (line === 'BEGIN:VEVENT') block = []
229
+ else if (line === 'END:VEVENT' && block) {
230
+ const event = toEvent(readProperties(block), floatingZone)
231
+ if (event) events.push(event)
232
+ block = undefined
233
+ } else block?.push(line)
234
+ }
235
+ return events
236
+ }
237
+
238
+ const monthsBetween = (from: Civil, to: Civil): number => (to.year - from.year) * 12 + (to.month - from.month)
239
+
240
+ const matchesByDay = (rule: Rule, candidate: Civil): boolean =>
241
+ rule.byDay.some(({ ordinal, weekday }) => {
242
+ if (weekday !== weekdayOf(candidate)) return false
243
+ if (ordinal === 0) return true
244
+ const fromStart = Math.floor((candidate.day - 1) / 7) + 1
245
+ const fromEnd = -(Math.floor((daysInMonth(candidate) - candidate.day) / 7) + 1)
246
+ return ordinal === fromStart || ordinal === fromEnd
247
+ })
248
+
249
+ const matchesMonthDay = (rule: Rule, candidate: Civil): boolean =>
250
+ rule.byMonthDay.some((day) => (day > 0 ? day : daysInMonth(candidate) + day + 1) === candidate.day)
251
+
252
+ const matchesMonthly = (rule: Rule, start: Civil, candidate: Civil): boolean => {
253
+ if (rule.byMonthDay.length > 0) return matchesMonthDay(rule, candidate)
254
+ if (rule.byDay.length > 0) return matchesByDay(rule, candidate)
255
+ return candidate.day === start.day
256
+ }
257
+
258
+ /** Whether a rule with its DTSTART on `start` falls on the civil day `candidate` (on or after `start`). */
259
+ export const ruleMatches = (rule: Rule, start: Civil, candidate: Civil): boolean => {
260
+ if (rule.byMonth.length > 0 && !rule.byMonth.includes(candidate.month)) return false
261
+ const days = dayNumber(candidate) - dayNumber(start)
262
+ if (rule.freq === FREQUENCY.daily) return days % rule.interval === 0
263
+ if (rule.freq === FREQUENCY.weekly) {
264
+ const mondayOf = (civil: Civil) => dayNumber(civil) - ((weekdayOf(civil) + 6) % 7)
265
+ const weeks = (mondayOf(candidate) - mondayOf(start)) / 7
266
+ const weekdays = rule.byDay.length > 0 ? rule.byDay.map(({ weekday }) => weekday) : [weekdayOf(start)]
267
+ return weeks % rule.interval === 0 && weekdays.includes(weekdayOf(candidate))
268
+ }
269
+ if (rule.freq === FREQUENCY.monthly) {
270
+ return monthsBetween(start, candidate) % rule.interval === 0 && matchesMonthly(rule, start, candidate)
271
+ }
272
+ if (rule.freq === FREQUENCY.yearly) {
273
+ const month = rule.byMonth.length > 0 ? candidate.month : start.month
274
+ return (candidate.year - start.year) % rule.interval === 0 && candidate.month === month && candidate.day === start.day
275
+ }
276
+ return false
277
+ }
278
+
279
+ const durationMs = (event: IcsEvent): number => {
280
+ if (!event.end) return event.start.allDay ? DAY_MS : 0
281
+ if (event.start.allDay) return (dayNumber(event.end.civil) - dayNumber(event.start.civil)) * DAY_MS
282
+ return instantOf(event.end.civil, event.end.zone) - instantOf(event.start.civil, event.start.zone)
283
+ }
284
+
285
+ const startMsOf = (date: IcsDate, viewZone: string): number =>
286
+ date.allDay ? instantOf(date.civil, viewZone) : instantOf(date.civil, date.zone)
287
+
288
+ const isBeyondUntil = (rule: Rule, candidate: IcsDate, viewZone: string): boolean => {
289
+ if (!rule.until) return false
290
+ if (rule.until.allDay || candidate.allDay) return dayNumber(candidate.civil) > dayNumber(rule.until.civil)
291
+ return startMsOf(candidate, viewZone) > startMsOf(rule.until, viewZone)
292
+ }
293
+
294
+ const expandRule = (event: IcsEvent, rule: Rule, lastDay: Civil, viewZone: string): IcsDate[] => {
295
+ const dates: IcsDate[] = []
296
+ let matched = 0
297
+ for (let civil = event.start.civil; dayNumber(civil) <= dayNumber(lastDay); civil = addDays(civil, 1)) {
298
+ if (!ruleMatches(rule, event.start.civil, civil)) continue
299
+ const candidate = { ...event.start, civil }
300
+ if (isBeyondUntil(rule, candidate, viewZone)) break
301
+ matched += 1
302
+ if (rule.count !== undefined && matched > rule.count) break
303
+ dates.push(candidate)
304
+ }
305
+ return dates
306
+ }
307
+
308
+ /**
309
+ * Every occurrence that overlaps the civil day `today` in `viewZone`. Recurring events are expanded
310
+ * day by day from their DTSTART (FREQ DAILY, WEEKLY, MONTHLY and YEARLY with INTERVAL, UNTIL, COUNT,
311
+ * BYDAY, BYMONTHDAY and BYMONTH); a rule beyond that is skipped. EXDATE removes an occurrence, an
312
+ * event with RECURRENCE-ID replaces it, and a cancelled one is dropped.
313
+ */
314
+ export const occurrencesOn = (events: IcsEvent[], today: Civil, viewZone: string): Occurrence[] => {
315
+ const dayStart = instantOf({ ...today, hour: 0, minute: 0, second: 0 }, viewZone)
316
+ const dayEnd = instantOf({ ...addDays(today, 1), hour: 0, minute: 0, second: 0 }, viewZone)
317
+ const overridden = new Set(
318
+ events.flatMap((event) => (event.recurrenceId ? [`${event.uid}@${occurrenceKey(event.recurrenceId)}`] : [])),
319
+ )
320
+ const occurrences: Occurrence[] = []
321
+ for (const event of events) {
322
+ if (event.status === EVENT_STATUS.cancelled) continue
323
+ if (event.rule?.unsupported) continue
324
+ const starts = event.rule && !event.recurrenceId ? expandRule(event, event.rule, today, viewZone) : [event.start]
325
+ for (const start of starts) {
326
+ const key = occurrenceKey(event.recurrenceId ?? start)
327
+ if (!event.recurrenceId && (event.exDates.includes(key) || overridden.has(`${event.uid}@${key}`))) continue
328
+ const startMs = startMsOf(start, viewZone)
329
+ const endMs = startMs + durationMs(event)
330
+ const overlaps = startMs < dayEnd && (endMs > dayStart || (endMs === startMs && startMs >= dayStart))
331
+ if (overlaps) occurrences.push({ event, key, startMs, endMs })
332
+ }
333
+ }
334
+ return occurrences.sort((a, b) => a.startMs - b.startMs)
335
+ }
336
+
337
+ const clock = (ms: number, zone: string): string => {
338
+ const civil = civilOf(ms, zone)
339
+ return `${String(civil.hour).padStart(2, '0')}:${String(civil.minute).padStart(2, '0')}`
340
+ }
341
+
342
+ const whenOf = (occurrence: Occurrence, zone: string): string => {
343
+ if (occurrence.event.start.allDay) return 'all day'
344
+ if (occurrence.endMs === occurrence.startMs) return clock(occurrence.startMs, zone)
345
+ return `${clock(occurrence.startMs, zone)}–${clock(occurrence.endMs, zone)}`
346
+ }
347
+
348
+ /** A short stable id per occurrence: the report line prints it, and a Google UID runs to 60 characters. */
349
+ export const occurrenceId = (uid: string, key: string): string =>
350
+ new Bun.CryptoHasher('sha1').update(`${uid}@${key}`).digest('hex').slice(0, 12)
351
+
352
+ /** Google Calendar's day view of `today`, a short link the watcher keeps. */
353
+ const dayUrl =(today: Civil): string =>
354
+ `https://calendar.google.com/calendar/r/day/${today.year}/${today.month}/${today.day}`
355
+
356
+ export const toItems = (occurrences: Occurrence[], today: Civil, zone: string): CalendarItem[] =>
357
+ occurrences.map((occurrence) => {
358
+ const { event } = occurrence
359
+ const what = [event.summary || '(no title)', event.location].filter(Boolean).join(' · ')
360
+ return {
361
+ id: occurrenceId(event.uid, occurrence.key),
362
+ title: `${whenOf(occurrence, zone)} ${what}`,
363
+ url: dayUrl(today),
364
+ updatedAt: event.lastModified
365
+ ? new Date(instantOf(event.lastModified.civil, event.lastModified.zone)).toISOString()
366
+ : '',
367
+ }
368
+ })