@plinth-music/cli 0.6.0 → 0.7.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.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,31 @@
2
2
 
3
3
  Notable changes to `@plinth-music/cli`. Grouped by what a user notices, not by PR.
4
4
 
5
+ ## 0.7.0 — 2026-08-23
6
+
7
+ One PR and one docs commit since `0.6.0` (`v0.6.0..71c775c`). The mirror gains a
8
+ calendar the agent can read without opening one.
9
+
10
+ ### Each artist's calendar is projected into the mirror
11
+
12
+ `plinth start` now runs a second loop on its own 15-minute timer
13
+ (`PLINTH_CALENDAR_INTERVAL_MS`, one run at boot), writing `views/calendar/` into the
14
+ mirror: a file per mapped artist plus an `upcoming.md` across all of them.
15
+ Deliberately not the 15-second pull loop — one tick fans out to one Google call per
16
+ mapped calendar, which would breach the server route's 20/min limit inside a minute.
17
+
18
+ `/plinth:start` gains a step that reads one of those files. It still does not open a
19
+ calendar, and the generated text says so.
20
+
21
+ **An empty cache and a clear diary are not allowed to look alike.** `events: []` means
22
+ exactly one thing and is refused on any row that did not actually fetch; a failed
23
+ refresh keeps every body and rewrites only the header, so a stale view says it is
24
+ stale rather than saying nothing is on; an artist with no mapped calendar is named in
25
+ `upcoming.md` rather than silently absent.
26
+
27
+ **Requires the server's `GET /api/sync/calendar`** (`plinth` #257), live in
28
+ production. A 0.6.0 client is unaffected: it never calls it.
29
+
5
30
  ## 0.6.0 — 2026-08-20
6
31
 
7
32
  Eleven PRs since `0.5.0` (`v0.5.0..af8c7df`). A session in a mirror now opens and
package/README.md CHANGED
@@ -24,7 +24,7 @@ plinth start # run the daemon: continuous pull + watch + pu
24
24
 
25
25
  - `plinth login --workspace <slug>` — issue a personal access token in the browser, paste it back, store it in the OS keychain (macOS Keychain / Linux Secret Service / Windows Credential Manager).
26
26
  - `plinth sync [--workspace <slug>]` — one-shot pull into `~/Plinth/<workspace>/`. Also generates the workspace-root `CLAUDE.md` (a thin TOC + entity model + skill pointers), an empty `CLAUDE.local.md` stub on first sync, and the two session-boundary commands (below), and silently refreshes `CLAUDE.md` on a schema-version change. Defaults to the active workspace if `--workspace` is omitted.
27
- - `plinth start` — run the long-running sync daemon: pull the latest, then watch the mirror for local edits and push them. Logs to `~/.plinth/daemon.log`.
27
+ - `plinth start` — run the long-running sync daemon: pull the latest, then watch the mirror for local edits and push them. It also projects each mapped artist's Google calendar into `views/calendar/` in the mirror — a file per artist plus an `upcoming.md` — on its own 15-minute timer (`PLINTH_CALENDAR_INTERVAL_MS`), with one run at boot. That is deliberately not the 15-second pull cadence: one tick makes a Google call per mapped calendar, which would breach the server route's rate limit inside a minute. A refresh that fails keeps the last known bodies and rewrites only the header, so a stale view says it is stale rather than reading as an empty diary. Logs to `~/.plinth/daemon.log`.
28
28
  - `plinth status` — local sync state: daemon liveness, last sync and per-type counts, and any destructive batches being held. Warns when a source install's `dist/` is behind its checkout.
29
29
  - `plinth confirm` — review and release destructive batches the daemon has quarantined (apply, or `--discard`).
30
30
  - `plinth refresh-context [--workspace <slug>]` — force-regenerate the workspace-root `CLAUDE.md` **and the two generated commands** from the current Plinth schema + workspace. The user-customisable block between the `<!-- BEGIN: user-customisable -->` / `<!-- END -->` markers is always preserved verbatim in every generated file; `CLAUDE.local.md` is never touched.
@@ -1,5 +1,5 @@
1
1
  {
2
- "sha": "8ae75780594f0715a00b4ee07af7d49ec055c3fd",
2
+ "sha": "c8ca82b2d16c0ed618813268f475e63ba0c4d26d",
3
3
  "dirty": false,
4
- "built_at": "2026-08-20T17:35:39.314Z"
4
+ "built_at": "2026-08-23T13:01:01.515Z"
5
5
  }
package/dist/cli.js CHANGED
@@ -9193,7 +9193,7 @@ async function runMain(cmd, opts = {}) {
9193
9193
  // package.json
9194
9194
  var package_default = {
9195
9195
  name: "@plinth-music/cli",
9196
- version: "0.6.0",
9196
+ version: "0.7.0",
9197
9197
  description: "Plinth capstone sync daemon and CLI",
9198
9198
  type: "module",
9199
9199
  private: false,
@@ -25237,6 +25237,79 @@ function deleteExtractedText(workspaceSlug, relPath) {
25237
25237
  throw err;
25238
25238
  }
25239
25239
  }
25240
+ var CALENDAR_VIEW_DIR = "calendar";
25241
+ var CALENDAR_UPCOMING_NAME = "upcoming";
25242
+ var CALENDAR_INTERNAL_NAME = "_internal";
25243
+ var CALENDAR_NAME_RE = /^[a-z0-9][a-z0-9-]*$/;
25244
+ function calendarViewRelPath(name) {
25245
+ return `${VIEWS_SUBTREE}/${CALENDAR_VIEW_DIR}/${name}.md`;
25246
+ }
25247
+ function calendarViewPath(workspaceSlug, name) {
25248
+ if (name !== CALENDAR_INTERNAL_NAME && !CALENDAR_NAME_RE.test(name)) {
25249
+ throw new MirrorPathError(`Invalid calendar view name: ${JSON.stringify(name)}`);
25250
+ }
25251
+ if (name.length > 200) {
25252
+ throw new MirrorPathError(`Calendar view name too long: ${name.length}`);
25253
+ }
25254
+ const ws = workspaceMirrorPath(workspaceSlug);
25255
+ const posix = calendarViewRelPath(name);
25256
+ const native = sep2 === "/" ? posix : posix.split("/").join(sep2);
25257
+ const target = resolve(join2(ws, native));
25258
+ const prefix = join2(ws, VIEWS_SUBTREE, CALENDAR_VIEW_DIR) + sep2;
25259
+ if (!target.startsWith(prefix)) {
25260
+ throw new MirrorPathError(`Resolved calendar-view path escapes the views/calendar subtree`);
25261
+ }
25262
+ return target;
25263
+ }
25264
+ function writeCalendarView(workspaceSlug, name, content) {
25265
+ const target = calendarViewPath(workspaceSlug, name);
25266
+ const tmp = `${target}.${process.pid}.${Date.now()}.tmp`;
25267
+ mkdirSync(dirname(target), { recursive: true, mode: 448 });
25268
+ writeFileSync(tmp, content, { mode: 384 });
25269
+ try {
25270
+ renameSync(tmp, target);
25271
+ } catch (err) {
25272
+ try {
25273
+ unlinkSync(tmp);
25274
+ } catch {}
25275
+ throw err;
25276
+ }
25277
+ }
25278
+ function readCalendarView(workspaceSlug, name) {
25279
+ try {
25280
+ return readFileSync(calendarViewPath(workspaceSlug, name), "utf8");
25281
+ } catch (err) {
25282
+ if (err?.code === "ENOENT")
25283
+ return null;
25284
+ throw err;
25285
+ }
25286
+ }
25287
+ function deleteCalendarView(workspaceSlug, name) {
25288
+ const target = calendarViewPath(workspaceSlug, name);
25289
+ try {
25290
+ unlinkSync(target);
25291
+ return true;
25292
+ } catch (err) {
25293
+ if (err?.code === "ENOENT")
25294
+ return false;
25295
+ throw err;
25296
+ }
25297
+ }
25298
+ function listCalendarViews(workspaceSlug) {
25299
+ let dir;
25300
+ try {
25301
+ dir = join2(workspaceMirrorPath(workspaceSlug), VIEWS_SUBTREE, CALENDAR_VIEW_DIR);
25302
+ } catch {
25303
+ return [];
25304
+ }
25305
+ let entries;
25306
+ try {
25307
+ entries = readdirSync(dir, { withFileTypes: true });
25308
+ } catch {
25309
+ return [];
25310
+ }
25311
+ return entries.filter((e2) => e2.isFile() && e2.name.endsWith(".md")).map((e2) => e2.name.slice(0, -3)).sort();
25312
+ }
25240
25313
  function contextFilePath(workspaceSlug, relativePath) {
25241
25314
  const ws = workspaceMirrorPath(workspaceSlug);
25242
25315
  const resolved = resolve(join2(ws, relativePath));
@@ -26837,7 +26910,7 @@ function buildGroundingBlock(today, workspaceRules = null, workspaceMemory = nul
26837
26910
  ``,
26838
26911
  `Write through the canonical surface. In this cockpit the file tree is your edit ` + `surface, so reach for it before the most direct tool. Change prose (document ` + `bodies, free-form notes, anything textual) by editing the mirror markdown ` + `file; the daemon syncs it up. An artist's master context is the clearest case: ` + `it lives in the body of that artist's \`artists/<slug>/_artist.md\` mirror file, ` + `the file the web context pane renders, and one of only two files under ` + `\`artists/<slug>/\` the daemon syncs both ways (\`memory.md\` is the other), ` + `so change it by editing that body, never by creating ` + `a separate document or context entity (e.g. \`create_document\`) the pane won't ` + `render. One carve-out inside that file: a block labelled as rendered from ` + `Plinth is regenerated on every sync and your edits to it are discarded, so ` + `write those through the tool the block names. Change structured fields (task ` + `status, priority, assignees, due dates, a project's category) through the ` + `Plinth MCP (\`mcp__plinth__*\`) or the task file's frontmatter, which is ` + `row-canonical but still written through the validated API. Make a bulk or ` + `destructive change (a mass delete, a gutting overwrite) by editing the file ` + `path, so the daemon's confirm-gate can quarantine it for \`plinth confirm\`. ` + `Never write the database directly, which rules out raw SQL and ` + `\`execute_sql\`, \`apply_migration\` and \`psql\`: those bypass the daemon, the ` + `mirror, the confirm-gate, canonicality, and API validation.`,
26839
26912
  ``,
26840
- `Dates belong on the calendar, not in \`key_dates\`. The date surface for an artist ` + `is their mapped Google Calendar; the \`key_dates\` table is retired. Never create ` + `or update a key date: do not call \`create_key_date\` or \`update_key_date\` (they ` + `write to the retired table and are being removed from the tool surface). Reading ` + `dates is fine (\`list_key_dates\`, \`list_artist_calendars\`).`,
26913
+ `Dates belong on the calendar, not in \`key_dates\`. The date surface for an artist ` + `is their mapped Google Calendar; the \`key_dates\` table is retired. Never create ` + `or update a key date: do not call \`create_key_date\` or \`update_key_date\` (they ` + `write to the retired table and are being removed from the tool surface). Reading ` + `dates is fine, and this mirror already holds them: the daemon projects every ` + `mapped calendar into \`views/calendar/upcoming.md\` and ` + `\`views/calendar/<slug>.md\`, so read those before reaching for a tool ` + `(\`list_artist_calendars\`, \`list_key_dates\`). They are derived and overwritten, ` + `so they are current only as of the \`generated_at\` in their header - quote that ` + `stamp, never edit them, and check the status line, because a calendar Plinth ` + `could not reach is unknown rather than empty.`,
26841
26914
  ``,
26842
26915
  `Read the calendar, and hand a date over rather than writing it yourself. An ` + `artist's dates live on their mapped Google Calendar and reading them is yours: ` + `resolve the calendar through \`mcp__plinth__list_artist_calendars\` and use its ` + `\`calendar_id\` field, not the mapping row's \`id\`, because the default ` + `\`primary\` is almost never theirs. Writing it is not yours. When a date needs ` + `adding or correcting (a show, a hold, a release, a deadline), say plainly what ` + `the change is, say that it goes in through Plinth's calendar view, and say that ` + `a person makes that change rather than you. Say the reason too, because a date ` + `left looking handled is worse than one openly outstanding: a calendar write ` + `goes out to everyone invited the moment it lands, and it cannot be recalled. ` + `This is not a puzzle to route around. Do not reach for a shell command line to ` + `do it, the Google Workspace one included, and do not go looking for another ` + `tool that writes a calendar. Those tools are denied to you wherever Plinth has ` + `configured this workspace, and where a deny has not reached, the rule is the ` + `same and you are the only thing holding it.`,
26843
26916
  ``,
@@ -29237,7 +29310,7 @@ function withUserBlock(lines) {
29237
29310
  function buildStartCommandMd() {
29238
29311
  return withUserBlock([
29239
29312
  "---",
29240
- "description: Open a Plinth session in this workspace. Reads both memory files, pulls what is unread, gives a three-line brief, then asks what you want to work on.",
29313
+ "description: Open a Plinth session in this workspace. Reads both memory files, pulls what is unread, reads the projected calendar, gives a three-line brief, then asks what you want to work on.",
29241
29314
  "---",
29242
29315
  "",
29243
29316
  OWNERSHIP_NOTE,
@@ -29260,23 +29333,37 @@ function buildStartCommandMd() {
29260
29333
  "",
29261
29334
  "Pull **unread notifications** from the Plinth MCP (`list_notifications`). If the Plinth MCP is not connected, say so in one line and carry on from the injected indexes rather than failing.",
29262
29335
  "",
29263
- "**That one call is the whole budget, and the list below is exhaustive rather than indicative.** Do not sweep tasks, do not list projects, do not open a calendar, do not read an artist folder, and do not re-read the memory files. If a fact is not in the two injected indexes or in the notifications, it does not belong in this brief.",
29336
+ "**That one call plus the single file read in Step 3 are the whole budget, and the list below is exhaustive rather than indicative.** Do not sweep tasks, do not list projects, do not open a calendar, do not read an artist folder, and do not re-read the memory files. If a fact is not in the two injected indexes, in the notifications, or in that one file, it does not belong in this brief.",
29337
+ "",
29338
+ "## Step 3: what is coming up",
29339
+ "",
29340
+ "Read **one file**: `views/calendar/upcoming.md` at the mirror root. It is Plinth's projection of every artist's calendar, written into the mirror by the daemon.",
29341
+ "",
29342
+ "**This is a FILE READ, not a calendar call, and the rule in Step 2 stands unchanged: you still never open a calendar tool.** The file is here so that you do not have to.",
29264
29343
  "",
29265
- "## Step 3: brief, in three lines",
29344
+ '**Brief it as of its own `generated_at` stamp, and say the stamp.** A projection is a cache. It is exactly as current as the header says it is and no more, so "nothing on this week" is only ever true as of that moment.',
29345
+ "",
29346
+ "**Read its calendar-status list before you read the dates, and escalate anything that was not read.** A calendar Plinth could not reach is UNKNOWN, not empty, and the file says which. Briefing an unread calendar as a clear diary is the one failure this file exists to prevent - if a line says NOT READ, say so out loud in the brief.",
29347
+ "",
29348
+ "If the file carries `fetch_state: stale`, the last refresh failed: say how old the dates are rather than reporting them as current. If the file is absent altogether, say the projection has not run yet and carry on without it.",
29349
+ "",
29350
+ "**Dates are read here and changed by a person, in Plinth's calendar view.** If something needs moving, say what the change is and hand it over.",
29351
+ "",
29352
+ "## Step 4: brief, in three lines",
29266
29353
  "",
29267
29354
  "Then stop reading and report. **Three lines, and not a summary of what you read:**",
29268
29355
  "",
29269
- "1. Today, and anything with a clock on it that the injected indexes or the notifications **have already named**. Do not go looking for more.",
29356
+ "1. Today, and anything with a clock on it that the injected indexes, the notifications or the calendar file **have already named** - with the calendar's own stamp when you use it. Do not go looking for more.",
29270
29357
  "2. The unread count, and the one or two that actually matter.",
29271
29358
  "3. Anything blocked or waiting on someone.",
29272
29359
  "",
29273
29360
  'Then ask: **"What do you want to work on?"** If the user already said so in the same message, skip the question and load that context now.',
29274
29361
  "",
29275
- "## Step 4: offer the sweep, do not run it",
29362
+ "## Step 5: offer the sweep, do not run it",
29276
29363
  "",
29277
29364
  `A full roster sweep, every artist's overdue and upcoming work plus calendars and project status, is useful and it is **not** part of opening a session. **Offer it in one clause ("want the roster sweep?") and stop there.** Reporting a sweep you ran anyway is the failure this step exists to prevent: it is the same cost the user did not ask for, with the offer bolted on afterwards.`,
29278
29365
  "",
29279
- "**The budget is the one call in Step 2, and it is a ceiling rather than a target.** Every new session pays this command before the user has asked for anything, so a second call needs a reason you would defend out loud. An opener that takes half a minute gets turned off, and an opener nobody runs banks nothing at the other end.",
29366
+ "**The budget is the one call in Step 2 and the one file read in Step 3, and it is a ceiling rather than a target.** Every new session pays this command before the user has asked for anything, so a second call needs a reason you would defend out loud. An opener that takes half a minute gets turned off, and an opener nobody runs banks nothing at the other end.",
29280
29367
  "",
29281
29368
  "## Loading area context",
29282
29369
  "",
@@ -30609,6 +30696,17 @@ function pullNoticeLine(kind) {
30609
30696
  const token = kind === "auth" ? "PULL_PAUSED" : "PULL_DEGRADED";
30610
30697
  return `[${token} reason=${kind}]`;
30611
30698
  }
30699
+ function calendarProjectedLine(f3) {
30700
+ return `[CALENDAR_PROJECTED written=${f3.written} pruned=${f3.pruned} ` + `calendars=${f3.calendars} not_read=${f3.notRead}]`;
30701
+ }
30702
+ function calendarFailedLine(f3) {
30703
+ const serverPart = f3.serverMessage ? ` server_msg="${f3.serverMessage}"` : "";
30704
+ return `[CALENDAR_FAILED status=${f3.status} reason=${f3.reason} staled=${f3.staled}${serverPart}]`;
30705
+ }
30706
+ function calendarNoticeLine(kind) {
30707
+ const token = kind === "auth" ? "CALENDAR_PAUSED" : kind === "unsupported" ? "CALENDAR_UNSUPPORTED" : "CALENDAR_DEGRADED";
30708
+ return `[${token} reason=${kind}]`;
30709
+ }
30612
30710
 
30613
30711
  // src/lib/rejected.ts
30614
30712
  import { relative as relative3 } from "node:path";
@@ -35363,6 +35461,753 @@ class PullLoop {
35363
35461
  }
35364
35462
  }
35365
35463
 
35464
+ // src/lib/calendar-loop.ts
35465
+ var DEFAULT_CALENDAR_INTERVAL_MS = 900000;
35466
+ var CALENDAR_BACKOFF_CAP_MS = 3600000;
35467
+ var CALENDAR_AUTH_RETRY_MS = 300000;
35468
+ function calendarIntervalMs() {
35469
+ const raw = process.env.PLINTH_CALENDAR_INTERVAL_MS;
35470
+ if (raw) {
35471
+ const n2 = Number(raw);
35472
+ if (Number.isFinite(n2) && n2 > 0)
35473
+ return n2;
35474
+ }
35475
+ return DEFAULT_CALENDAR_INTERVAL_MS;
35476
+ }
35477
+
35478
+ class CalendarLoop {
35479
+ runOnce;
35480
+ onProjected;
35481
+ notice;
35482
+ loginHint;
35483
+ intervalMs;
35484
+ capMs;
35485
+ authRetryMs;
35486
+ setTimer;
35487
+ clearTimer;
35488
+ timer = null;
35489
+ inFlight = false;
35490
+ stopped = false;
35491
+ failures = 0;
35492
+ authAnnounced = false;
35493
+ degradedAnnounced = false;
35494
+ constructor(deps) {
35495
+ this.runOnce = deps.runOnce;
35496
+ this.onProjected = deps.onProjected;
35497
+ this.notice = deps.notice;
35498
+ this.loginHint = deps.loginHint;
35499
+ this.intervalMs = deps.intervalMs ?? calendarIntervalMs();
35500
+ this.capMs = deps.capMs ?? CALENDAR_BACKOFF_CAP_MS;
35501
+ this.authRetryMs = deps.authRetryMs ?? CALENDAR_AUTH_RETRY_MS;
35502
+ this.setTimer = deps.setTimer ?? ((fn, ms) => setTimeout(fn, ms));
35503
+ this.clearTimer = deps.clearTimer ?? ((t2) => clearTimeout(t2));
35504
+ }
35505
+ startNow() {
35506
+ this.tick();
35507
+ }
35508
+ start() {
35509
+ this.schedule(this.intervalMs);
35510
+ }
35511
+ stop() {
35512
+ this.stopped = true;
35513
+ if (this.timer !== null) {
35514
+ this.clearTimer(this.timer);
35515
+ this.timer = null;
35516
+ }
35517
+ }
35518
+ schedule(ms) {
35519
+ if (this.stopped)
35520
+ return;
35521
+ this.timer = this.setTimer(() => {
35522
+ this.tick();
35523
+ }, ms);
35524
+ }
35525
+ async tick() {
35526
+ if (this.stopped || this.inFlight)
35527
+ return;
35528
+ this.inFlight = true;
35529
+ let nextDelay;
35530
+ try {
35531
+ const result = await this.runOnce();
35532
+ nextDelay = result.ok ? this.onSuccess(result) : this.onFailure(result.failure);
35533
+ } catch (err) {
35534
+ const reason = err instanceof Error ? err.message : "unknown error";
35535
+ nextDelay = this.onFailure({ kind: "transient", reason });
35536
+ } finally {
35537
+ this.inFlight = false;
35538
+ }
35539
+ if (nextDelay !== null)
35540
+ this.schedule(nextDelay);
35541
+ }
35542
+ onSuccess(result) {
35543
+ this.failures = 0;
35544
+ this.authAnnounced = false;
35545
+ this.degradedAnnounced = false;
35546
+ if (result.written > 0 || result.pruned > 0) {
35547
+ this.onProjected(result.written, result.pruned);
35548
+ }
35549
+ return this.intervalMs;
35550
+ }
35551
+ onFailure(failure) {
35552
+ if (failure.kind === "unsupported") {
35553
+ this.stop();
35554
+ this.notice("unsupported", `Calendar projection is off: this Plinth server has no calendar route. ` + `Nothing under views/calendar/ will be written or updated. Restart the ` + `daemon once the server is up to date.`);
35555
+ return null;
35556
+ }
35557
+ if (failure.kind === "auth") {
35558
+ if (!this.authAnnounced) {
35559
+ this.authAnnounced = true;
35560
+ this.notice("auth", `Calendar refresh paused - the server rejected the token. Run ` + `\`${this.loginHint}\`, then RESTART the daemon: the token is read once ` + `at startup, so a re-login is not picked up until it does. Files ` + `under views/calendar/ keep their last good read and say they are stale.`);
35561
+ }
35562
+ return this.authRetryMs;
35563
+ }
35564
+ this.failures += 1;
35565
+ let delay;
35566
+ if (failure.kind === "rate_limit" && failure.retryAfterMs !== undefined) {
35567
+ delay = Math.min(Math.max(failure.retryAfterMs, 1000), this.capMs);
35568
+ } else {
35569
+ delay = Math.min(this.intervalMs * 2 ** (this.failures - 1), this.capMs);
35570
+ }
35571
+ if (delay >= this.capMs && !this.degradedAnnounced) {
35572
+ this.degradedAnnounced = true;
35573
+ const mins = Math.round(this.capMs / 60000);
35574
+ this.notice("degraded", `Calendar refresh degraded - repeated failures, retrying about every ` + `${mins}m. Files under views/calendar/ still show the last good read ` + `and say that they are stale.`);
35575
+ }
35576
+ return delay;
35577
+ }
35578
+ }
35579
+ function classifyCalendarFailure(err) {
35580
+ if (err instanceof ApiError) {
35581
+ if (err.status === 404)
35582
+ return { kind: "unsupported", reason: err.message };
35583
+ if (err.status === 401 || err.status === 403) {
35584
+ return { kind: "auth", reason: err.message };
35585
+ }
35586
+ if (err.status === 429) {
35587
+ return {
35588
+ kind: "rate_limit",
35589
+ reason: err.message,
35590
+ retryAfterMs: err.retryAfterMs
35591
+ };
35592
+ }
35593
+ return { kind: "transient", reason: err.message };
35594
+ }
35595
+ return {
35596
+ kind: "transient",
35597
+ reason: err instanceof Error ? err.message : "unknown error"
35598
+ };
35599
+ }
35600
+
35601
+ // src/lib/calendar/api.ts
35602
+ class CalendarContractError extends ApiError {
35603
+ constructor(status, message) {
35604
+ super(status, message);
35605
+ this.name = "CalendarContractError";
35606
+ }
35607
+ }
35608
+ var CalendarEventSchema = exports_external.object({
35609
+ id: exports_external.string(),
35610
+ title: exports_external.string(),
35611
+ start: exports_external.string().min(1),
35612
+ end: exports_external.string().min(1),
35613
+ all_day: exports_external.boolean(),
35614
+ location: exports_external.string().nullish().transform((v2) => v2 ?? null),
35615
+ booking_status: exports_external.enum(["confirmed", "hold"]),
35616
+ url: exports_external.string()
35617
+ });
35618
+ var CALENDAR_STATUSES = [
35619
+ "fetched",
35620
+ "no-token",
35621
+ "token-expired",
35622
+ "google-error"
35623
+ ];
35624
+ var CalendarRowSchema = exports_external.object({
35625
+ artist_id: exports_external.string().nullable(),
35626
+ artist_slug: exports_external.string().nullable(),
35627
+ artist_name: exports_external.string().nullable(),
35628
+ calendar_id: exports_external.string(),
35629
+ calendar_name: exports_external.string(),
35630
+ status: exports_external.enum(CALENDAR_STATUSES),
35631
+ events: exports_external.array(CalendarEventSchema).nullable(),
35632
+ error: exports_external.object({ upstream_status: exports_external.number(), message: exports_external.string() }).nullish().transform((v2) => v2 ?? null),
35633
+ truncated: exports_external.boolean().optional().default(false)
35634
+ });
35635
+ var CalendarResponseSchema = exports_external.object({
35636
+ generated_at: exports_external.string().min(1),
35637
+ window: exports_external.object({
35638
+ time_min: exports_external.string().min(1),
35639
+ time_max: exports_external.string().min(1)
35640
+ }),
35641
+ artist_count: exports_external.number().int().nonnegative(),
35642
+ mapped_artist_count: exports_external.number().int().nonnegative(),
35643
+ calendars: exports_external.array(CalendarRowSchema)
35644
+ }).superRefine((body, ctx) => {
35645
+ for (const [i2, row] of body.calendars.entries()) {
35646
+ const isFetched = row.status === "fetched";
35647
+ if (isFetched && row.events === null) {
35648
+ ctx.addIssue({
35649
+ code: exports_external.ZodIssueCode.custom,
35650
+ path: ["calendars", i2, "events"],
35651
+ message: `status "fetched" must carry an events array, not null`
35652
+ });
35653
+ }
35654
+ if (!isFetched && row.events !== null) {
35655
+ ctx.addIssue({
35656
+ code: exports_external.ZodIssueCode.custom,
35657
+ path: ["calendars", i2, "events"],
35658
+ message: `status "${row.status}" must carry events: null, not an array`
35659
+ });
35660
+ }
35661
+ }
35662
+ });
35663
+ async function fetchCalendar(args) {
35664
+ const { baseUrl, pat, fetchImpl = fetch } = args;
35665
+ const url2 = `${baseUrl.replace(/\/$/, "")}/api/sync/calendar`;
35666
+ let res;
35667
+ try {
35668
+ res = await fetchImpl(url2, {
35669
+ method: "GET",
35670
+ headers: {
35671
+ Authorization: `Bearer ${pat}`,
35672
+ Accept: "application/json"
35673
+ }
35674
+ });
35675
+ } catch (err) {
35676
+ const msg = err instanceof Error ? err.message : "network error";
35677
+ throw new ApiError(0, `Could not reach ${url2}: ${msg}`);
35678
+ }
35679
+ if (res.status === 401) {
35680
+ throw new ApiError(401, "Token was rejected by the server. Run `plinth login --workspace <slug>` to refresh.");
35681
+ }
35682
+ if (res.status === 404) {
35683
+ throw new ApiError(404, "This Plinth server has no /api/sync/calendar route.");
35684
+ }
35685
+ if (res.status === 429) {
35686
+ throw new ApiError(429, "Rate limited by the server. Wait a few seconds and retry.", parseRetryAfterMs(res.headers.get("retry-after")));
35687
+ }
35688
+ if (!res.ok) {
35689
+ throw new ApiError(res.status, `sync calendar failed with HTTP ${res.status}`);
35690
+ }
35691
+ let body;
35692
+ try {
35693
+ body = await res.json();
35694
+ } catch {
35695
+ throw new CalendarContractError(res.status, "sync calendar response was not JSON");
35696
+ }
35697
+ const parsed = CalendarResponseSchema.safeParse(body);
35698
+ if (!parsed.success) {
35699
+ const detail = parsed.error.issues.slice(0, 3).map((i2) => `${i2.path.join(".") || "(root)"}: ${i2.message}`).join("; ");
35700
+ throw new CalendarContractError(res.status, `sync calendar response did not match the contract: ${detail}`);
35701
+ }
35702
+ return parsed.data;
35703
+ }
35704
+
35705
+ // src/lib/calendar/render.ts
35706
+ var CALENDAR_BODY_MARKER = "<!-- PLINTH:CALENDAR-BODY -->";
35707
+ var OWNERSHIP_NOTE2 = [
35708
+ "<!-- Generated by Plinth from the calendars mapped in this workspace. This file",
35709
+ " is DERIVED: one-way, overwritten on every refresh, and never uploaded.",
35710
+ " Your edits here are lost. Dates are changed in Plinth's calendar view, by a",
35711
+ " person - not from this file and not by an agent. -->"
35712
+ ].join(`
35713
+ `);
35714
+ var DEFAULT_USER_LABEL = "the Plinth account this mirror syncs with";
35715
+ var WEEKDAYS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
35716
+ var MONTHS = [
35717
+ "Jan",
35718
+ "Feb",
35719
+ "Mar",
35720
+ "Apr",
35721
+ "May",
35722
+ "Jun",
35723
+ "Jul",
35724
+ "Aug",
35725
+ "Sep",
35726
+ "Oct",
35727
+ "Nov",
35728
+ "Dec"
35729
+ ];
35730
+ var DAY_MS = 86400000;
35731
+ var FULL_BAND_DAYS = 14;
35732
+ function yamlString(value) {
35733
+ return JSON.stringify(value);
35734
+ }
35735
+ function oneLine(value, maxLength = 200) {
35736
+ const flattened = value.replace(/[\u0000-\u001F\u007F]+/g, " ").replace(/<!--/g, "<! --").replace(/\s+/g, " ").trim();
35737
+ if (flattened.length === 0)
35738
+ return "(untitled)";
35739
+ if (flattened.length <= maxLength)
35740
+ return flattened;
35741
+ return `${flattened.slice(0, maxLength - 3)}...`;
35742
+ }
35743
+ function dateParts(value) {
35744
+ const m2 = /^(\d{4})-(\d{2})-(\d{2})/.exec(value);
35745
+ if (!m2)
35746
+ return null;
35747
+ return { y: Number(m2[1]), m: Number(m2[2]), d: Number(m2[3]) };
35748
+ }
35749
+ function timeParts(value) {
35750
+ const m2 = /T(\d{2}):(\d{2})(?::\d{2}(?:\.\d+)?)?(Z|[+-]\d{2}:\d{2})?$/.exec(value);
35751
+ if (!m2)
35752
+ return null;
35753
+ const rawOffset = m2[3];
35754
+ const offset = rawOffset === undefined ? "" : rawOffset === "Z" ? "UTC" : rawOffset;
35755
+ return { hh: m2[1], mi: m2[2], offset };
35756
+ }
35757
+ function utcMs(parts) {
35758
+ return Date.UTC(parts.y, parts.m - 1, parts.d);
35759
+ }
35760
+ function shiftDays(parts, days) {
35761
+ const d2 = new Date(utcMs(parts) + days * DAY_MS);
35762
+ return { y: d2.getUTCFullYear(), m: d2.getUTCMonth() + 1, d: d2.getUTCDate() };
35763
+ }
35764
+ function formatDay(parts, referenceYear) {
35765
+ const weekday = WEEKDAYS[new Date(utcMs(parts)).getUTCDay()] ?? "";
35766
+ const month = MONTHS[parts.m - 1] ?? "";
35767
+ const year = parts.y === referenceYear ? "" : ` ${parts.y}`;
35768
+ return `${weekday} ${parts.d} ${month}${year}`;
35769
+ }
35770
+ function sortInstant(event) {
35771
+ if (!event.all_day) {
35772
+ const ms = Date.parse(event.start);
35773
+ if (!Number.isNaN(ms))
35774
+ return ms;
35775
+ }
35776
+ const parts = dateParts(event.start);
35777
+ if (parts)
35778
+ return utcMs(parts);
35779
+ return Number.MAX_SAFE_INTEGER;
35780
+ }
35781
+ function describeWhen(event, referenceYear) {
35782
+ const start = dateParts(event.start);
35783
+ if (!start)
35784
+ return oneLine(event.start, 60);
35785
+ if (event.all_day) {
35786
+ const end2 = dateParts(event.end);
35787
+ const last = end2 ? shiftDays(end2, -1) : start;
35788
+ const single = !end2 || utcMs(last) <= utcMs(start);
35789
+ return single ? `${formatDay(start, referenceYear)} (all day)` : `${formatDay(start, referenceYear)} to ${formatDay(last, referenceYear)} (all day)`;
35790
+ }
35791
+ const startTime = timeParts(event.start);
35792
+ const end = dateParts(event.end);
35793
+ const endTime = timeParts(event.end);
35794
+ if (!startTime)
35795
+ return formatDay(start, referenceYear);
35796
+ const suffix = startTime.offset ? ` ${startTime.offset}` : "";
35797
+ if (!end || !endTime) {
35798
+ return `${formatDay(start, referenceYear)}, ${startTime.hh}:${startTime.mi}${suffix}`;
35799
+ }
35800
+ if (utcMs(end) === utcMs(start)) {
35801
+ return `${formatDay(start, referenceYear)}, ${startTime.hh}:${startTime.mi}-${endTime.hh}:${endTime.mi}${suffix}`;
35802
+ }
35803
+ const endSuffix = endTime.offset ? ` ${endTime.offset}` : "";
35804
+ return `${formatDay(start, referenceYear)} ${startTime.hh}:${startTime.mi}${suffix}` + ` to ${formatDay(end, referenceYear)} ${endTime.hh}:${endTime.mi}${endSuffix}`;
35805
+ }
35806
+ function fullLine(event, referenceYear, artistPrefix, includeUrl) {
35807
+ const parts = [
35808
+ `**${describeWhen(event, referenceYear)}**`,
35809
+ artistPrefix,
35810
+ `${oneLine(event.title)} (${event.booking_status})`
35811
+ ].filter((p) => p.length > 0);
35812
+ if (event.location)
35813
+ parts.push(oneLine(event.location, 80));
35814
+ if (includeUrl && event.url)
35815
+ parts.push(oneLine(event.url, 300));
35816
+ return `- ${parts.join(" - ")}`;
35817
+ }
35818
+ function compactLine(event, referenceYear, artistPrefix) {
35819
+ const parts = [
35820
+ describeWhen(event, referenceYear),
35821
+ artistPrefix,
35822
+ `${oneLine(event.title)} (${event.booking_status})`
35823
+ ].filter((p) => p.length > 0);
35824
+ return `- ${parts.join(" - ")}`;
35825
+ }
35826
+ function humanStamp(iso) {
35827
+ const ms = Date.parse(iso);
35828
+ if (Number.isNaN(ms))
35829
+ return iso;
35830
+ const d2 = new Date(ms);
35831
+ const day = formatDay({ y: d2.getUTCFullYear(), m: d2.getUTCMonth() + 1, d: d2.getUTCDate() }, -1);
35832
+ const hh = String(d2.getUTCHours()).padStart(2, "0");
35833
+ const mi = String(d2.getUTCMinutes()).padStart(2, "0");
35834
+ return `${day}, ${hh}:${mi} UTC`;
35835
+ }
35836
+ function windowLine(timeMin, timeMax) {
35837
+ const from = dateParts(timeMin);
35838
+ const to = dateParts(timeMax);
35839
+ if (!from || !to)
35840
+ return `${timeMin} to ${timeMax}`;
35841
+ return `${formatDay(from, -1)} to ${formatDay(to, -1)}`;
35842
+ }
35843
+ function statusLine(args) {
35844
+ const { status, eventCount, error: error52, truncated, stamp, userLabel } = args;
35845
+ switch (status) {
35846
+ case "fetched": {
35847
+ if (eventCount === 0) {
35848
+ return `**Read fine as of ${stamp}. Nothing in this window.** The diary really ` + `is clear across the dates above. This is a successful read of an empty ` + `calendar, not a failed read - if it had failed, this line would say so.`;
35849
+ }
35850
+ const truncatedNote = truncated ? " **This list is INCOMPLETE**: Google had more entries than one page and the rest were not collected." : "";
35851
+ return `**Read fine as of ${stamp}.** ${eventCount} ` + `${eventCount === 1 ? "entry" : "entries"} in the window.${truncatedNote}`;
35852
+ }
35853
+ case "no-token":
35854
+ return `**NOT READ - Google is not connected for ${userLabel}.** Nothing is listed ` + `below because Plinth could not look, NOT because the diary is clear. ` + `Connect Google in Plinth to fill this in, and until then treat this ` + `calendar as unknown rather than empty.`;
35855
+ case "token-expired":
35856
+ return `**NOT READ - the Google connection for ${userLabel} has expired.** Plinth ` + `still holds a connection but could not refresh it, so nothing below is ` + `current. Reconnect Google in Plinth. Treat this calendar as unknown ` + `rather than empty.`;
35857
+ case "google-error":
35858
+ return `**NOT READ - Google refused the request` + `${error52 ? ` (HTTP ${error52.upstream_status})` : ""}.** ` + `${error52 ? oneLine(error52.message, 300) : "No detail was returned."} ` + `A calendar that was unshared or deleted in Google reads exactly like this. ` + `Nothing below is current; treat this calendar as unknown rather than empty.`;
35859
+ }
35860
+ }
35861
+ function shortStatus(row) {
35862
+ switch (row.status) {
35863
+ case "fetched": {
35864
+ const n2 = row.events?.length ?? 0;
35865
+ if (n2 === 0)
35866
+ return "read fine, nothing in this window";
35867
+ return `read fine, ${n2} ${n2 === 1 ? "entry" : "entries"}${row.truncated ? " (INCOMPLETE - more than one page)" : ""}`;
35868
+ }
35869
+ case "no-token":
35870
+ return "NOT READ - Google is not connected; unknown, not empty";
35871
+ case "token-expired":
35872
+ return "NOT READ - the Google connection has expired; unknown, not empty";
35873
+ case "google-error":
35874
+ return `NOT READ - Google refused` + `${row.error ? ` (HTTP ${row.error.upstream_status}): ${oneLine(row.error.message, 120)}` : ""}` + `; unknown, not empty`;
35875
+ }
35876
+ }
35877
+ function rowFileName(row, internalName) {
35878
+ return row.artist_slug ?? internalName;
35879
+ }
35880
+ function rowDisplayName(row) {
35881
+ if (row.artist_name)
35882
+ return oneLine(row.artist_name, 80);
35883
+ return `${oneLine(row.calendar_name, 80)} (internal)`;
35884
+ }
35885
+ function splitBands(events, generatedAt) {
35886
+ const base2 = Date.parse(generatedAt);
35887
+ const boundary = Number.isNaN(base2) ? Number.MAX_SAFE_INTEGER : base2 + FULL_BAND_DAYS * DAY_MS;
35888
+ const full = [];
35889
+ const rest = [];
35890
+ for (const e2 of events) {
35891
+ (sortInstant(e2) < boundary ? full : rest).push(e2);
35892
+ }
35893
+ return { full, rest };
35894
+ }
35895
+ function renderArtistCalendar(args) {
35896
+ const { row, generatedAt, window: window2 } = args;
35897
+ const userLabel = args.userLabel ?? DEFAULT_USER_LABEL;
35898
+ const events = row.events ?? [];
35899
+ const stamp = humanStamp(generatedAt);
35900
+ const referenceYear = new Date(Date.parse(generatedAt) || 0).getUTCFullYear();
35901
+ const title = rowDisplayName(row);
35902
+ const header = [
35903
+ "---",
35904
+ "plinth_generated: true",
35905
+ "view: calendar",
35906
+ `artist_slug: ${row.artist_slug === null ? "null" : yamlString(row.artist_slug)}`,
35907
+ `artist_name: ${row.artist_name === null ? "null" : yamlString(row.artist_name)}`,
35908
+ `calendar_name: ${yamlString(row.calendar_name)}`,
35909
+ `generated_at: ${yamlString(generatedAt)}`,
35910
+ `window_start: ${yamlString(window2.time_min)}`,
35911
+ `window_end: ${yamlString(window2.time_max)}`,
35912
+ `status: ${row.status}`,
35913
+ "fetch_state: fresh",
35914
+ `entry_count: ${events.length}`,
35915
+ `truncated: ${row.truncated}`,
35916
+ "---",
35917
+ "",
35918
+ OWNERSHIP_NOTE2,
35919
+ "",
35920
+ `# ${title} - calendar`,
35921
+ ""
35922
+ ].join(`
35923
+ `);
35924
+ const body = [
35925
+ "",
35926
+ statusLine({
35927
+ status: row.status,
35928
+ eventCount: events.length,
35929
+ error: row.error,
35930
+ truncated: row.truncated,
35931
+ stamp,
35932
+ userLabel
35933
+ }),
35934
+ "",
35935
+ `**Window:** ${windowLine(window2.time_min, window2.time_max)}.`,
35936
+ "",
35937
+ "**Dates are read here and changed in Plinth's calendar view, by a person.** If something below needs moving, say what the change is and hand it over.",
35938
+ "",
35939
+ renderBands(events, generatedAt, referenceYear, "")
35940
+ ].join(`
35941
+ `);
35942
+ return `${header}${CALENDAR_BODY_MARKER}
35943
+ ${body}`;
35944
+ }
35945
+ function renderBands(events, generatedAt, referenceYear, artistPrefix) {
35946
+ if (events.length === 0)
35947
+ return "";
35948
+ const { full, rest } = splitBands(events, generatedAt);
35949
+ const out = [""];
35950
+ out.push(`## Next ${FULL_BAND_DAYS} days`, "");
35951
+ if (full.length === 0) {
35952
+ out.push(`_Nothing in the next ${FULL_BAND_DAYS} days._`);
35953
+ } else {
35954
+ for (const e2 of full)
35955
+ out.push(fullLine(e2, referenceYear, artistPrefix, true));
35956
+ }
35957
+ out.push("", "## Rest of the window", "");
35958
+ if (rest.length === 0) {
35959
+ out.push("_Nothing further in the window._");
35960
+ } else {
35961
+ for (const e2 of rest)
35962
+ out.push(compactLine(e2, referenceYear, artistPrefix));
35963
+ }
35964
+ out.push("");
35965
+ return out.join(`
35966
+ `);
35967
+ }
35968
+ function renderUpcoming(args) {
35969
+ const {
35970
+ rows,
35971
+ unmappedArtists,
35972
+ generatedAt,
35973
+ window: window2,
35974
+ artistCount,
35975
+ mappedArtistCount,
35976
+ internalName
35977
+ } = args;
35978
+ const stamp = humanStamp(generatedAt);
35979
+ const referenceYear = new Date(Date.parse(generatedAt) || 0).getUTCFullYear();
35980
+ const fetched = rows.filter((r3) => r3.status === "fetched");
35981
+ const failed = rows.filter((r3) => r3.status !== "fetched");
35982
+ const truncatedAnywhere = rows.some((r3) => r3.truncated);
35983
+ const merged = fetched.flatMap((row) => (row.events ?? []).map((event) => ({ event, row }))).sort((a2, b2) => sortInstant(a2.event) - sortInstant(b2.event));
35984
+ const { full, rest } = splitBands(merged.map((m2) => m2.event), generatedAt);
35985
+ const fullSet = new Set(full);
35986
+ const restSet = new Set(rest);
35987
+ const header = [
35988
+ "---",
35989
+ "plinth_generated: true",
35990
+ "view: calendar",
35991
+ "scope: workspace",
35992
+ `generated_at: ${yamlString(generatedAt)}`,
35993
+ `window_start: ${yamlString(window2.time_min)}`,
35994
+ `window_end: ${yamlString(window2.time_max)}`,
35995
+ `artist_count: ${artistCount}`,
35996
+ `mapped_artist_count: ${mappedArtistCount}`,
35997
+ `calendars_read: ${fetched.length}`,
35998
+ `calendars_not_read: ${failed.length}`,
35999
+ "fetch_state: fresh",
36000
+ `truncated: ${truncatedAnywhere}`,
36001
+ "---",
36002
+ "",
36003
+ OWNERSHIP_NOTE2,
36004
+ "",
36005
+ "# Upcoming - every calendar in this workspace",
36006
+ ""
36007
+ ].join(`
36008
+ `);
36009
+ const preamble = [
36010
+ "",
36011
+ `**As of ${stamp}.** Window ${windowLine(window2.time_min, window2.time_max)}.`,
36012
+ "",
36013
+ failed.length === 0 ? `**All ${fetched.length} mapped ${fetched.length === 1 ? "calendar was" : "calendars were"} read.**` : `**${fetched.length} of ${rows.length} mapped ${rows.length === 1 ? "calendar" : "calendars"} read; ${failed.length} could NOT be read.**` + ` Anything on an unread calendar is missing from the lists below - unknown, not absent.`,
36014
+ "",
36015
+ `${artistCount} ${artistCount === 1 ? "artist" : "artists"} in the roster; ` + `${mappedArtistCount} ${mappedArtistCount === 1 ? "has" : "have"} a calendar mapped in Plinth.`,
36016
+ "",
36017
+ "**Dates are read here and changed in Plinth's calendar view, by a person.** If something below needs moving, say what the change is and hand it over.",
36018
+ "",
36019
+ "## Calendar status",
36020
+ "",
36021
+ ...rows.map((row) => `- **${rowDisplayName(row)}**` + `${row.artist_slug ? ` (\`${oneLine(row.artist_slug, 80)}\`)` : ""}` + ` - ${shortStatus(row)} - \`views/calendar/${rowFileName(row, internalName)}.md\``),
36022
+ ...unmappedArtists.map((a2) => `- **${oneLine(a2.name, 80)}** (\`${oneLine(a2.slug, 80)}\`) - NO CALENDAR MAPPED in Plinth, so there is nothing to read and no file for them. Map one in Plinth if they should have dates.`),
36023
+ ""
36024
+ ].join(`
36025
+ `);
36026
+ const body = [preamble];
36027
+ body.push(`## Next ${FULL_BAND_DAYS} days`, "", "_Links are in the per-artist file named beside each calendar above._", "");
36028
+ const fullMerged = merged.filter((m2) => fullSet.has(m2.event));
36029
+ if (fullMerged.length === 0) {
36030
+ body.push(fetched.length === 0 ? `_No calendar could be read, so nothing can be listed for the next ${FULL_BAND_DAYS} days._` : `_Nothing in the next ${FULL_BAND_DAYS} days on any calendar that was read._`);
36031
+ } else {
36032
+ for (const { event, row } of fullMerged) {
36033
+ body.push(fullLine(event, referenceYear, `**${rowDisplayName(row)}**`, false));
36034
+ }
36035
+ }
36036
+ body.push("", "## Rest of the window", "");
36037
+ const restMerged = merged.filter((m2) => restSet.has(m2.event));
36038
+ if (restMerged.length === 0) {
36039
+ body.push(fetched.length === 0 ? "_No calendar could be read, so nothing can be listed._" : "_Nothing further in the window on any calendar that was read._");
36040
+ } else {
36041
+ for (const { event, row } of restMerged) {
36042
+ body.push(compactLine(event, referenceYear, `**${rowDisplayName(row)}**`));
36043
+ }
36044
+ }
36045
+ body.push("");
36046
+ return `${header}${CALENDAR_BODY_MARKER}
36047
+ ${body.join(`
36048
+ `)}`;
36049
+ }
36050
+ function splitCalendarFile(content) {
36051
+ const at = content.indexOf(CALENDAR_BODY_MARKER);
36052
+ if (at === -1)
36053
+ return null;
36054
+ return {
36055
+ header: content.slice(0, at),
36056
+ body: content.slice(at + CALENDAR_BODY_MARKER.length)
36057
+ };
36058
+ }
36059
+ function headerField(header, key) {
36060
+ const re = new RegExp(`^${key}: (.*)$`, "m");
36061
+ const m2 = re.exec(header);
36062
+ if (!m2)
36063
+ return null;
36064
+ const raw = m2[1].trim();
36065
+ if (raw === "null")
36066
+ return null;
36067
+ if (raw.startsWith('"')) {
36068
+ try {
36069
+ return JSON.parse(raw);
36070
+ } catch {
36071
+ return raw;
36072
+ }
36073
+ }
36074
+ return raw;
36075
+ }
36076
+ function renderStaleFile(existing, args) {
36077
+ const split = splitCalendarFile(existing);
36078
+ if (!split)
36079
+ return null;
36080
+ const previousGeneratedAt = headerField(split.header, "generated_at");
36081
+ const title = /^# (.+)$/m.exec(split.header)?.[1]?.trim() ?? "Calendar";
36082
+ const fmMatch = /^---\n([\s\S]*?)\n---\n/.exec(split.header);
36083
+ const carried = (fmMatch?.[1] ?? "").split(`
36084
+ `).filter((line) => !/^(fetch_state|last_refresh_attempt|last_refresh_error):/.test(line));
36085
+ const stamp = previousGeneratedAt ? humanStamp(previousGeneratedAt) : "an earlier refresh";
36086
+ const header = [
36087
+ "---",
36088
+ ...carried,
36089
+ "fetch_state: stale",
36090
+ `last_refresh_attempt: ${yamlString(args.attemptedAt)}`,
36091
+ `last_refresh_error: ${yamlString(oneLine(args.reason, 300))}`,
36092
+ "---",
36093
+ "",
36094
+ OWNERSHIP_NOTE2,
36095
+ "",
36096
+ `# ${title}`,
36097
+ "",
36098
+ `**STALE - the last refresh FAILED and nothing below was updated.** Attempted ` + `${humanStamp(args.attemptedAt)} and it did not succeed: ` + `${oneLine(args.reason, 300)}`,
36099
+ "",
36100
+ `**What is below was last read at ${stamp}** and has not moved since. Anything ` + `changed in Google after that is not here, and something cancelled after that ` + `is still listed. Say the age out loud rather than briefing it as current.`,
36101
+ ""
36102
+ ].join(`
36103
+ `);
36104
+ return `${header}${CALENDAR_BODY_MARKER}${split.body}`;
36105
+ }
36106
+
36107
+ // src/lib/calendar/project.ts
36108
+ function writeIfChanged(workspaceSlug, name, content) {
36109
+ if (readCalendarView(workspaceSlug, name) === content)
36110
+ return false;
36111
+ writeCalendarView(workspaceSlug, name, content);
36112
+ return true;
36113
+ }
36114
+ function projectCalendar(args) {
36115
+ const { workspaceSlug, response, roster, userLabel } = args;
36116
+ const { generated_at: generatedAt, window: window2 } = response;
36117
+ let written = 0;
36118
+ let unchanged = 0;
36119
+ let collided = 0;
36120
+ const seen = new Set;
36121
+ const rows = [];
36122
+ for (const row of response.calendars) {
36123
+ const name = rowFileName(row, CALENDAR_INTERNAL_NAME);
36124
+ if (seen.has(name) || name === CALENDAR_UPCOMING_NAME) {
36125
+ collided += 1;
36126
+ continue;
36127
+ }
36128
+ seen.add(name);
36129
+ rows.push(row);
36130
+ const content = renderArtistCalendar({
36131
+ row,
36132
+ generatedAt,
36133
+ window: window2,
36134
+ userLabel
36135
+ });
36136
+ if (writeIfChanged(workspaceSlug, name, content))
36137
+ written += 1;
36138
+ else
36139
+ unchanged += 1;
36140
+ }
36141
+ const mapped = new Set(response.calendars.map((r3) => r3.artist_slug).filter((s2) => s2 !== null));
36142
+ const unmappedArtists = roster.filter((a2) => !mapped.has(a2.slug) && a2.status !== "archived").map((a2) => ({ slug: a2.slug, name: a2.name }));
36143
+ const upcoming = renderUpcoming({
36144
+ rows,
36145
+ unmappedArtists,
36146
+ generatedAt,
36147
+ window: window2,
36148
+ artistCount: response.artist_count,
36149
+ mappedArtistCount: response.mapped_artist_count,
36150
+ internalName: CALENDAR_INTERNAL_NAME,
36151
+ userLabel
36152
+ });
36153
+ if (writeIfChanged(workspaceSlug, CALENDAR_UPCOMING_NAME, upcoming))
36154
+ written += 1;
36155
+ else
36156
+ unchanged += 1;
36157
+ const expected = new Set([...seen, CALENDAR_UPCOMING_NAME]);
36158
+ let pruned = 0;
36159
+ for (const name of listCalendarViews(workspaceSlug)) {
36160
+ if (expected.has(name))
36161
+ continue;
36162
+ if (!ownsFile(workspaceSlug, name))
36163
+ continue;
36164
+ try {
36165
+ if (deleteCalendarView(workspaceSlug, name))
36166
+ pruned += 1;
36167
+ } catch {}
36168
+ }
36169
+ return { written, unchanged, pruned, collided };
36170
+ }
36171
+ function ownsFile(workspaceSlug, name) {
36172
+ try {
36173
+ const existing = readCalendarView(workspaceSlug, name);
36174
+ return existing !== null && existing.includes(CALENDAR_BODY_MARKER);
36175
+ } catch {
36176
+ return false;
36177
+ }
36178
+ }
36179
+ function markCalendarStale(args) {
36180
+ const { workspaceSlug, attemptedAt, reason } = args;
36181
+ let rewritten = 0;
36182
+ let unchanged = 0;
36183
+ let skipped = 0;
36184
+ for (const name of listCalendarViews(workspaceSlug)) {
36185
+ let existing;
36186
+ try {
36187
+ existing = readCalendarView(workspaceSlug, name);
36188
+ } catch {
36189
+ skipped += 1;
36190
+ continue;
36191
+ }
36192
+ if (existing === null)
36193
+ continue;
36194
+ const staled = renderStaleFile(existing, { attemptedAt, reason });
36195
+ if (staled === null) {
36196
+ skipped += 1;
36197
+ continue;
36198
+ }
36199
+ try {
36200
+ if (writeIfChanged(workspaceSlug, name, staled))
36201
+ rewritten += 1;
36202
+ else
36203
+ unchanged += 1;
36204
+ } catch {
36205
+ skipped += 1;
36206
+ }
36207
+ }
36208
+ return { rewritten, unchanged, skipped };
36209
+ }
36210
+
35366
36211
  // src/lib/pull-suppression.ts
35367
36212
  var PULL_SUPPRESS_TTL_MS = 1e4;
35368
36213
 
@@ -36474,7 +37319,7 @@ import { existsSync as existsSync5, readFileSync as readFileSync18, statSync as
36474
37319
  // src/lib/files/extracted-text.ts
36475
37320
  import { createHash as createHash3 } from "node:crypto";
36476
37321
  var DERIVED_TEXT_VERSION = 2;
36477
- function yamlString(value) {
37322
+ function yamlString2(value) {
36478
37323
  return JSON.stringify(value);
36479
37324
  }
36480
37325
  var PARTIAL_WARNING = [
@@ -36513,8 +37358,8 @@ function renderExtractedText(doc2) {
36513
37358
  const frontmatter = [
36514
37359
  "---",
36515
37360
  "plinth_generated: true",
36516
- `source_file: ${doc2.bytesInMirror ? yamlString(`files/${doc2.relPath}`) : "null"}`,
36517
- `source_uri: ${doc2.sourceUri === undefined ? "unknown" : doc2.sourceUri === null ? "null" : yamlString(doc2.sourceUri)}`,
37361
+ `source_file: ${doc2.bytesInMirror ? yamlString2(`files/${doc2.relPath}`) : "null"}`,
37362
+ `source_uri: ${doc2.sourceUri === undefined ? "unknown" : doc2.sourceUri === null ? "null" : yamlString2(doc2.sourceUri)}`,
36518
37363
  `content_quality: ${doc2.quality ?? "unchecked"}`,
36519
37364
  `body_withheld: ${withheld}`,
36520
37365
  `extracted_chars: ${withheld ? 0 : doc2.text.length}`,
@@ -38327,9 +39172,76 @@ async function runStart(workspaceSlug, deps = {}) {
38327
39172
  loginHint: `plinth login --workspace ${slug}`
38328
39173
  });
38329
39174
  pullLoop.start();
39175
+ let contractMismatchAnnounced = false;
39176
+ let collisionAnnounced = false;
39177
+ const calendarLoop = new CalendarLoop({
39178
+ runOnce: async () => {
39179
+ const attemptedAt = (deps.now?.() ?? new Date).toISOString();
39180
+ try {
39181
+ const response = await fetchCalendar({
39182
+ baseUrl: entry2.api_base_url,
39183
+ pat,
39184
+ fetchImpl
39185
+ });
39186
+ const result = projectCalendar({
39187
+ workspaceSlug: slug,
39188
+ response,
39189
+ roster: readRoster(slug)
39190
+ });
39191
+ const notRead = response.calendars.filter((c3) => c3.status !== "fetched").length;
39192
+ daemonLog.log(calendarProjectedLine({
39193
+ written: result.written,
39194
+ pruned: result.pruned,
39195
+ calendars: response.calendars.length,
39196
+ notRead
39197
+ }));
39198
+ if (result.collided > 0 && !collisionAnnounced) {
39199
+ collisionAnnounced = true;
39200
+ spinner.warn(`${result.collided} calendar row${result.collided === 1 ? "" : "s"} ` + `shared a file name with another and ${result.collided === 1 ? "was" : "were"} skipped.`);
39201
+ spinner.update(`Watching ${slug} · ${watchingSummary(state)}`);
39202
+ }
39203
+ contractMismatchAnnounced = false;
39204
+ return { ok: true, written: result.written, pruned: result.pruned };
39205
+ } catch (err) {
39206
+ const failure = classifyCalendarFailure(err);
39207
+ let staled = 0;
39208
+ try {
39209
+ staled = markCalendarStale({
39210
+ workspaceSlug: slug,
39211
+ attemptedAt,
39212
+ reason: failure.reason
39213
+ }).rewritten;
39214
+ } catch (staleErr) {
39215
+ out.log(`[CALENDAR_STALE_FAILED ${categorizeError(staleErr).reason}]`);
39216
+ }
39217
+ daemonLog.log(calendarFailedLine({ ...categorizeError(err), staled }));
39218
+ if (err instanceof CalendarContractError && !contractMismatchAnnounced) {
39219
+ contractMismatchAnnounced = true;
39220
+ spinner.warn(`Calendar refresh: the server's response did not match the contract ` + `this daemon was built against. ${err.message}`);
39221
+ spinner.update(`Watching ${slug} · ${watchingSummary(state)}`);
39222
+ }
39223
+ return { ok: false, failure };
39224
+ }
39225
+ },
39226
+ onProjected: (written, pruned) => {
39227
+ const parts = [`${written} calendar file${written === 1 ? "" : "s"}`];
39228
+ if (pruned > 0)
39229
+ parts.push(`${pruned} pruned`);
39230
+ spinner.success(`↻ ${parts.join(", ")}`);
39231
+ spinner.update(`Watching ${slug} · ${watchingSummary(state)}`);
39232
+ },
39233
+ notice: (kind, message) => {
39234
+ daemonLog.log(calendarNoticeLine(kind));
39235
+ spinner.warn(message);
39236
+ spinner.update(`Watching ${slug} · ${watchingSummary(state)}`);
39237
+ },
39238
+ loginHint: `plinth login --workspace ${slug}`
39239
+ });
39240
+ calendarLoop.startNow();
38330
39241
  const shutdown = (signal) => {
38331
39242
  (async () => {
38332
39243
  pullLoop.stop();
39244
+ calendarLoop.stop();
38333
39245
  spinner.warn(`Received ${signal}, draining pending pushes…`);
38334
39246
  try {
38335
39247
  await Promise.race([
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@plinth-music/cli",
3
- "version": "0.6.0",
3
+ "version": "0.7.0",
4
4
  "description": "Plinth capstone sync daemon and CLI",
5
5
  "type": "module",
6
6
  "private": false,