circal-mcp 0.1.0 → 0.2.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/README.md +28 -10
- package/dist/circal-mcp.mjs +222 -16
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
# circal-mcp
|
|
2
2
|
|
|
3
3
|
A stdio [MCP](https://modelcontextprotocol.io) server that reads and writes
|
|
4
|
-
circal's own mirror file — the `.json` a browser tab
|
|
5
|
-
through the File System Access API
|
|
6
|
-
`src/lib/mirrorLink.ts`
|
|
4
|
+
circal's own mirror file — the `.json` a browser tab or the macOS app keeps
|
|
5
|
+
in sync on disk, through the File System Access API on the web and the
|
|
6
|
+
shell's own bridge in the app (see `src/lib/mirror.ts`, `src/lib/mirrorLink.ts`
|
|
7
|
+
and `mac/main.swift`'s `MirrorFile`). It imports circal's own domain layer
|
|
7
8
|
(`src/lib/events.ts`, `recur.ts`, `quickadd.ts`, `mutate.ts`, `backup.ts`, …),
|
|
8
9
|
so an agent and the app can never disagree about what a legal event, an
|
|
9
10
|
occurrence, or a recurrence rule is.
|
|
@@ -18,10 +19,11 @@ again, the same as any second writer would.
|
|
|
18
19
|
|
|
19
20
|
## Quick start
|
|
20
21
|
|
|
21
|
-
A `FileSystemFileHandle` (what
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
22
|
+
A `FileSystemFileHandle` (what a browser hands circal when the mirror is turned on there) never
|
|
23
|
+
exposes the full path it came from: that is deliberate browser privacy design, so circal cannot print
|
|
24
|
+
it for you. The macOS app does not have this problem — it mints and adopts a mirror at a fixed path
|
|
25
|
+
on first launch, no gesture required — so `--find` checks that path first, by name, then falls back
|
|
26
|
+
to the browser case and searches the places a mirror file usually lands.
|
|
25
27
|
|
|
26
28
|
```sh
|
|
27
29
|
npx -y circal-mcp --find
|
|
@@ -32,6 +34,22 @@ zone, revision) plus a ready-to-paste config block. Paste that block into
|
|
|
32
34
|
your client below, swap the path if it picked up the wrong file, then
|
|
33
35
|
restart the client.
|
|
34
36
|
|
|
37
|
+
> **Use a locally built bundle, not `npx circal-mcp`.** The published
|
|
38
|
+
> `circal-mcp@0.1.0` predates several fields in `src/lib` (`free`,
|
|
39
|
+
> `remindLead`, `organizer` and the guest pair on an event; `backupNudge`,
|
|
40
|
+
> `backupInterval`, `deviceZone`, `stampedZones` in settings). Every write
|
|
41
|
+
> round-trips the whole document through its reader, so it silently drops
|
|
42
|
+
> those nine keys — measured, same fixture, two bundles. A current server
|
|
43
|
+
> refuses to write a file whose format is newer than its own, so this cannot
|
|
44
|
+
> happen unnoticed any more, but the published bundle is still stale.
|
|
45
|
+
>
|
|
46
|
+
> ```sh
|
|
47
|
+
> pnpm build:mcp # -> mcp/dist/circal-mcp.mjs
|
|
48
|
+
> ```
|
|
49
|
+
>
|
|
50
|
+
> Then use `node /absolute/path/to/circal/mcp/dist/circal-mcp.mjs` wherever
|
|
51
|
+
> the examples below say `npx -y circal-mcp`.
|
|
52
|
+
|
|
35
53
|
### Claude Desktop
|
|
36
54
|
|
|
37
55
|
Add to `claude_desktop_config.json`:
|
|
@@ -81,9 +99,9 @@ Add to `.cursor/mcp.json` (project) or `~/.cursor/mcp.json` (global):
|
|
|
81
99
|
}
|
|
82
100
|
```
|
|
83
101
|
|
|
84
|
-
No mirror file yet:
|
|
85
|
-
|
|
86
|
-
environment variable list.
|
|
102
|
+
No mirror file yet: on the Mac app there is almost always one already (`--find` should have caught
|
|
103
|
+
it); in a browser, turn the mirror on from circal's Settings panel, then run `--find` again. Run
|
|
104
|
+
`npx -y circal-mcp --help` for the full flag and environment variable list.
|
|
87
105
|
|
|
88
106
|
## From source (contributors)
|
|
89
107
|
|
package/dist/circal-mcp.mjs
CHANGED
|
@@ -1460,7 +1460,7 @@ import { serveStdio } from "@modelcontextprotocol/server/stdio";
|
|
|
1460
1460
|
|
|
1461
1461
|
// mcp/file.ts
|
|
1462
1462
|
import { randomBytes } from "node:crypto";
|
|
1463
|
-
import { readFile, rename,
|
|
1463
|
+
import { open, readFile, rename, rm } from "node:fs/promises";
|
|
1464
1464
|
import { dirname, join } from "node:path";
|
|
1465
1465
|
|
|
1466
1466
|
// src/lib/time.ts
|
|
@@ -2162,6 +2162,78 @@ function fuzzyScore(text, query) {
|
|
|
2162
2162
|
|
|
2163
2163
|
// src/lib/events.ts
|
|
2164
2164
|
var ICON_MAX = 16;
|
|
2165
|
+
var ICON_NAME_MAX = 48;
|
|
2166
|
+
function isIconName(icon) {
|
|
2167
|
+
return /^[a-z0-9-]+$/.test(icon);
|
|
2168
|
+
}
|
|
2169
|
+
function firstGrapheme(value) {
|
|
2170
|
+
const text = value.trim();
|
|
2171
|
+
if (!text) return "";
|
|
2172
|
+
if (typeof Intl.Segmenter === "function") {
|
|
2173
|
+
const [first] = new Intl.Segmenter(void 0, { granularity: "grapheme" }).segment(text);
|
|
2174
|
+
return first ? first.segment.slice(0, ICON_MAX) : "";
|
|
2175
|
+
}
|
|
2176
|
+
return [...text][0]?.slice(0, ICON_MAX) ?? "";
|
|
2177
|
+
}
|
|
2178
|
+
function readMark(value) {
|
|
2179
|
+
if (typeof value !== "string") return "";
|
|
2180
|
+
const mark = value.trim();
|
|
2181
|
+
if (mark === "") return "";
|
|
2182
|
+
if (isIconName(mark)) return mark.length <= ICON_NAME_MAX ? mark : "";
|
|
2183
|
+
return firstGrapheme(mark);
|
|
2184
|
+
}
|
|
2185
|
+
var REMIND_INHERIT = -1;
|
|
2186
|
+
var REMIND_SILENT = -2;
|
|
2187
|
+
var REMIND_LEAD_MAX = 40320;
|
|
2188
|
+
function readRemindLead(value) {
|
|
2189
|
+
if (value === REMIND_SILENT) return REMIND_SILENT;
|
|
2190
|
+
if (typeof value !== "number" || !Number.isInteger(value)) return REMIND_INHERIT;
|
|
2191
|
+
if (value < 0 || value > REMIND_LEAD_MAX) return REMIND_INHERIT;
|
|
2192
|
+
return value;
|
|
2193
|
+
}
|
|
2194
|
+
var PARTSTATS = ["needs-action", "accepted", "declined", "tentative"];
|
|
2195
|
+
var ATTENDEE_MAX = 32;
|
|
2196
|
+
var GUEST_NAME_MAX = 64;
|
|
2197
|
+
var GUEST_ADDRESS_MAX = 254;
|
|
2198
|
+
var ATTENDEE_COUNT_MAX = 9999;
|
|
2199
|
+
function readText(value, max) {
|
|
2200
|
+
return typeof value === "string" ? value.trim().slice(0, max) : "";
|
|
2201
|
+
}
|
|
2202
|
+
function readPartstat(value) {
|
|
2203
|
+
if (typeof value !== "string") return "needs-action";
|
|
2204
|
+
const lower = value.trim().toLowerCase();
|
|
2205
|
+
return PARTSTATS.includes(lower) ? lower : "needs-action";
|
|
2206
|
+
}
|
|
2207
|
+
function readPerson(value) {
|
|
2208
|
+
if (value === null || typeof value !== "object") return null;
|
|
2209
|
+
const record = value;
|
|
2210
|
+
const address = readText(record.address, GUEST_ADDRESS_MAX);
|
|
2211
|
+
if (!address) return null;
|
|
2212
|
+
return { name: readText(record.name, GUEST_NAME_MAX), address };
|
|
2213
|
+
}
|
|
2214
|
+
function readGuests(list, count) {
|
|
2215
|
+
const seen = /* @__PURE__ */ new Set();
|
|
2216
|
+
const people = [];
|
|
2217
|
+
if (Array.isArray(list)) {
|
|
2218
|
+
for (const entry of list) {
|
|
2219
|
+
const person = readPerson(entry);
|
|
2220
|
+
if (!person) continue;
|
|
2221
|
+
const key = person.address.toLowerCase();
|
|
2222
|
+
if (seen.has(key)) continue;
|
|
2223
|
+
seen.add(key);
|
|
2224
|
+
people.push({
|
|
2225
|
+
...person,
|
|
2226
|
+
status: readPartstat(entry.status)
|
|
2227
|
+
});
|
|
2228
|
+
}
|
|
2229
|
+
}
|
|
2230
|
+
const attendees = people.slice(0, ATTENDEE_MAX);
|
|
2231
|
+
const claimed = typeof count === "number" && Number.isInteger(count) && count > 0 ? count : people.length;
|
|
2232
|
+
return {
|
|
2233
|
+
attendees,
|
|
2234
|
+
attendeeCount: Math.min(Math.max(claimed, people.length), ATTENDEE_COUNT_MAX)
|
|
2235
|
+
};
|
|
2236
|
+
}
|
|
2165
2237
|
function overridesIn(events) {
|
|
2166
2238
|
const out = /* @__PURE__ */ new Map();
|
|
2167
2239
|
for (const event of events) {
|
|
@@ -2249,7 +2321,7 @@ function toggleDoneKey(keys, at) {
|
|
|
2249
2321
|
}
|
|
2250
2322
|
var DEFAULT_WAKING = { from: 8 * 60, to: 22 * 60 };
|
|
2251
2323
|
function freeGaps(occurrences, minMinutes, from, to) {
|
|
2252
|
-
const busy = occurrences.filter((o) => !o.event.allDay).map((o) => ({ start: Math.max(o.startMin, from), end: Math.min(o.endMin, to) })).filter((b) => b.end > b.start).sort((a, b) => a.start - b.start);
|
|
2324
|
+
const busy = occurrences.filter((o) => !o.event.allDay && !o.event.free).map((o) => ({ start: Math.max(o.startMin, from), end: Math.min(o.endMin, to) })).filter((b) => b.end > b.start).sort((a, b) => a.start - b.start);
|
|
2253
2325
|
const gaps = [];
|
|
2254
2326
|
let cursor = from;
|
|
2255
2327
|
for (const block of busy) {
|
|
@@ -3098,6 +3170,13 @@ var DEFAULT_SETTINGS = {
|
|
|
3098
3170
|
// a day here, not half of one, so an hour has exactly one place on it and
|
|
3099
3171
|
// `13` is where `13` is. AM/PM is a preference, and it is one click away.
|
|
3100
3172
|
clock: "24h",
|
|
3173
|
+
// Unknown until the first launch writes it, and written silently there: a
|
|
3174
|
+
// browser with nothing stored has no "before", and the question this field
|
|
3175
|
+
// exists to ask is only ever about a change.
|
|
3176
|
+
deviceZone: "",
|
|
3177
|
+
// Nothing stamped, which is every browser that has never travelled and
|
|
3178
|
+
// every one that answered "move".
|
|
3179
|
+
stampedZones: [],
|
|
3101
3180
|
secondZone: "",
|
|
3102
3181
|
showZoneName: true,
|
|
3103
3182
|
showZoneOffset: true,
|
|
@@ -3178,6 +3257,14 @@ var DEFAULT_SETTINGS = {
|
|
|
3178
3257
|
// Ten minutes. Long enough to walk somewhere, short enough that the thing
|
|
3179
3258
|
// is still the next thing when it lands.
|
|
3180
3259
|
remindLead: 10,
|
|
3260
|
+
// On. This is the one interruption in the app that defends against losing
|
|
3261
|
+
// everything rather than missing one thing, and the browsers it is aimed at
|
|
3262
|
+
// have no other warning — so unlike `remind` it earns the aggressive default.
|
|
3263
|
+
backupNudge: true,
|
|
3264
|
+
// Thirty days. A month is long enough that a working calendar has changed
|
|
3265
|
+
// enough to be worth saving again, short enough that a lost month is a loss
|
|
3266
|
+
// and not a catastrophe.
|
|
3267
|
+
backupInterval: 30,
|
|
3181
3268
|
// An hour, which is what `DEFAULT_DURATION` has always been and what a
|
|
3182
3269
|
// VEVENT with no DTEND is read as. This setting exists to be *changed* — a
|
|
3183
3270
|
// day of thirty-minute meetings is a real working life — not to have a new
|
|
@@ -3231,7 +3318,19 @@ function draftEvent(day, startMin, endMin, calendarId, repeat = "never") {
|
|
|
3231
3318
|
color: "",
|
|
3232
3319
|
icon: "",
|
|
3233
3320
|
task: false,
|
|
3234
|
-
doneKeys: []
|
|
3321
|
+
doneKeys: [],
|
|
3322
|
+
// Busy: a gesture that named a time meant that time to be spoken for.
|
|
3323
|
+
free: false,
|
|
3324
|
+
// Inheriting, which is the only answer a gesture gives: the drag named a
|
|
3325
|
+
// time, not how much warning it wants about it.
|
|
3326
|
+
remindLead: REMIND_INHERIT,
|
|
3327
|
+
// Nobody, and no way to make it anybody: circal has no channel to send an
|
|
3328
|
+
// invitation on, so a draft has no guest list and the editor offers no
|
|
3329
|
+
// control that would make one. These fields only ever arrive from a file
|
|
3330
|
+
// somebody else wrote.
|
|
3331
|
+
organizer: null,
|
|
3332
|
+
attendees: [],
|
|
3333
|
+
attendeeCount: 0
|
|
3235
3334
|
};
|
|
3236
3335
|
}
|
|
3237
3336
|
|
|
@@ -3245,6 +3344,17 @@ function readSettings(value) {
|
|
|
3245
3344
|
// version that has ever had the setting.
|
|
3246
3345
|
clock: value.clock === "12h" || value.clock === "24h" ? value.clock : DEFAULT_SETTINGS.clock,
|
|
3247
3346
|
secondZone: typeof value.secondZone === "string" ? value.secondZone : "",
|
|
3347
|
+
// The zone the browser that wrote this backup was in. Carried rather than
|
|
3348
|
+
// dropped, and deliberately not replaced with the reader's own: a Berlin
|
|
3349
|
+
// calendar restored in New York is exactly the journey the travel notice
|
|
3350
|
+
// exists to ask about, and the events in the file are floating. `sane()`
|
|
3351
|
+
// refuses a zone `Intl` cannot resolve; absent reads as a first run, which
|
|
3352
|
+
// asks nothing.
|
|
3353
|
+
deviceZone: typeof value.deviceZone === "string" ? value.deviceZone : "",
|
|
3354
|
+
// The zones that browser's "keep" answer stamped, so the row that offers
|
|
3355
|
+
// to take them off survives a restore with the events it is about. Strings
|
|
3356
|
+
// only here; `sane()` is what refuses one `Intl` cannot resolve.
|
|
3357
|
+
stampedZones: Array.isArray(value.stampedZones) ? value.stampedZones.filter((zone) => typeof zone === "string") : [],
|
|
3248
3358
|
// The dial switches read absent-as-on: a backup written before they
|
|
3249
3359
|
// existed came from a dial that was showing all of them.
|
|
3250
3360
|
showZoneName: value.showZoneName !== false,
|
|
@@ -3309,6 +3419,12 @@ function readSettings(value) {
|
|
|
3309
3419
|
moon: value.moon === true || typeof value.moonPhase === "string" && value.moonPhase !== "off",
|
|
3310
3420
|
remind: value.remind === true,
|
|
3311
3421
|
remindLead: typeof value.remindLead === "number" ? value.remindLead : DEFAULT_SETTINGS.remindLead,
|
|
3422
|
+
// Absent-as-on, like the dial switches: a backup written before the nudge
|
|
3423
|
+
// existed came from a build that could not have turned this data-safety
|
|
3424
|
+
// warning off, so it reads as the default rather than as a choice.
|
|
3425
|
+
backupNudge: value.backupNudge !== false,
|
|
3426
|
+
// Types only: `sane()` pins an interval off the list to the default.
|
|
3427
|
+
backupInterval: typeof value.backupInterval === "number" ? value.backupInterval : DEFAULT_SETTINGS.backupInterval,
|
|
3312
3428
|
// What a new event starts as. `sane()` clamps the length; the calendar is
|
|
3313
3429
|
// an id and is deliberately not checked against the backup's own
|
|
3314
3430
|
// calendars — `defaultCalendarId` resolves it at the moment it is used,
|
|
@@ -3363,12 +3479,33 @@ function readEvent(value) {
|
|
|
3363
3479
|
seriesId: typeof value.seriesId === "string" ? value.seriesId : "",
|
|
3364
3480
|
seriesKey: typeof value.seriesKey === "string" ? value.seriesKey : "",
|
|
3365
3481
|
color: typeof value.color === "string" ? value.color : "",
|
|
3366
|
-
|
|
3482
|
+
// What a legal mark is lives in `events.ts`, next to the two vocabularies
|
|
3483
|
+
// it has to tell apart. This used to slice at one shared cap, which turned
|
|
3484
|
+
// ten of the 247 shipped names into strings matching no path.
|
|
3485
|
+
icon: readMark(value.icon),
|
|
3367
3486
|
task,
|
|
3368
3487
|
// The same instant-key check EXDATE gets, and the same reason: a key this
|
|
3369
3488
|
// does not spell matches no occurrence, so it is a row of dead weight that
|
|
3370
3489
|
// survives every future export.
|
|
3371
|
-
doneKeys: readDoneKeys(value.doneKeys)
|
|
3490
|
+
doneKeys: readDoneKeys(value.doneKeys),
|
|
3491
|
+
// Anything other than a literal `true` is busy, which is what an event
|
|
3492
|
+
// exported before this field existed was. A truthy string in a
|
|
3493
|
+
// hand-edited blob must not quietly take a meeting off the day's total.
|
|
3494
|
+
free: value.free === true,
|
|
3495
|
+
// What a legal lead is lives in `remind.ts`, beside the three states it
|
|
3496
|
+
// has to distinguish: an unreadable one comes back inheriting, which is
|
|
3497
|
+
// what an event exported before this field existed already did.
|
|
3498
|
+
remindLead: readRemindLead(value.remindLead),
|
|
3499
|
+
// The one field on this model that arrives from a stranger with no natural
|
|
3500
|
+
// length, so this is where the caps are enforced rather than where they are
|
|
3501
|
+
// chosen: `readGuests` in `events.ts` owns the numbers, because a
|
|
3502
|
+
// subscription's ATTENDEE lines never pass through this file and the two
|
|
3503
|
+
// paths must not disagree about how big a guest list may be. The count
|
|
3504
|
+
// comes through so a restore prints the same "and 40 others" the import
|
|
3505
|
+
// did — clamped up to the list it labels, since a blob understating it
|
|
3506
|
+
// would print a negative remainder.
|
|
3507
|
+
organizer: readPerson(value.organizer),
|
|
3508
|
+
...readGuests(value.attendees, value.attendeeCount)
|
|
3372
3509
|
};
|
|
3373
3510
|
}
|
|
3374
3511
|
function readCalendar(value) {
|
|
@@ -3380,11 +3517,12 @@ function readCalendar(value) {
|
|
|
3380
3517
|
id,
|
|
3381
3518
|
name: typeof name === "string" && name ? name : id,
|
|
3382
3519
|
color: typeof color === "string" && color ? color : "#8ecbff",
|
|
3383
|
-
//
|
|
3384
|
-
// renders as the dot rather than as nothing, so an unknown
|
|
3385
|
-
// keeping — a backup restored into an older build and back
|
|
3386
|
-
// not lose the mark — but an unbounded string
|
|
3387
|
-
|
|
3520
|
+
// Same validator as an event's, same reason. An icon name this build does
|
|
3521
|
+
// not carry still renders as the dot rather than as nothing, so an unknown
|
|
3522
|
+
// one is worth keeping — a backup restored into an older build and back
|
|
3523
|
+
// again should not lose the mark — but an unbounded string is a paste, and
|
|
3524
|
+
// an over-long *name* is now dropped rather than cut into junk.
|
|
3525
|
+
icon: readMark(icon),
|
|
3388
3526
|
visible: value.visible !== false,
|
|
3389
3527
|
...feedUrl ? { feed: { url: feedUrl, fetchedAt: 0, error: "" } } : {}
|
|
3390
3528
|
};
|
|
@@ -3447,7 +3585,11 @@ function readMirrorFile(text) {
|
|
|
3447
3585
|
// A plain `.json` backup has no zone. Absent means "written by something
|
|
3448
3586
|
// that did not say", which the reader treats as agreeing with it — the
|
|
3449
3587
|
// alternative is refusing to open every backup ever exported.
|
|
3450
|
-
zone: typeof envelope.zone === "string" ? envelope.zone : ""
|
|
3588
|
+
zone: typeof envelope.zone === "string" ? envelope.zone : "",
|
|
3589
|
+
// Absent reads as this build's own, for the reason `zone` does: a plain
|
|
3590
|
+
// `.json` backup handed to the mirror is a case the shared format exists
|
|
3591
|
+
// to allow, and refusing every export ever taken would be the wrong trade.
|
|
3592
|
+
version: typeof envelope.version === "number" && Number.isFinite(envelope.version) ? envelope.version : MIRROR_VERSION
|
|
3451
3593
|
};
|
|
3452
3594
|
}
|
|
3453
3595
|
|
|
@@ -3474,9 +3616,30 @@ async function readMirror(path2) {
|
|
|
3474
3616
|
return contents;
|
|
3475
3617
|
}
|
|
3476
3618
|
async function atomicWrite(path2, body) {
|
|
3477
|
-
const
|
|
3478
|
-
|
|
3479
|
-
|
|
3619
|
+
const dir = dirname(path2);
|
|
3620
|
+
const tmp = join(dir, `.circal-mcp-${randomBytes(6).toString("hex")}.tmp`);
|
|
3621
|
+
try {
|
|
3622
|
+
const file = await open(tmp, "w");
|
|
3623
|
+
try {
|
|
3624
|
+
await file.writeFile(body, "utf8");
|
|
3625
|
+
await file.sync();
|
|
3626
|
+
} finally {
|
|
3627
|
+
await file.close();
|
|
3628
|
+
}
|
|
3629
|
+
await rename(tmp, path2);
|
|
3630
|
+
} catch (error) {
|
|
3631
|
+
await rm(tmp, { force: true });
|
|
3632
|
+
throw error;
|
|
3633
|
+
}
|
|
3634
|
+
try {
|
|
3635
|
+
const entry = await open(dir, "r");
|
|
3636
|
+
try {
|
|
3637
|
+
await entry.sync();
|
|
3638
|
+
} finally {
|
|
3639
|
+
await entry.close();
|
|
3640
|
+
}
|
|
3641
|
+
} catch {
|
|
3642
|
+
}
|
|
3480
3643
|
}
|
|
3481
3644
|
function zoneWarning(contents) {
|
|
3482
3645
|
const here = localZone();
|
|
@@ -3491,6 +3654,12 @@ function assertZoneAllowsMutation(contents, env) {
|
|
|
3491
3654
|
`Refusing to write: the file's zone (${contents.zone}) does not match this server's (${here}) \u2014 a write here could land on the wrong day there. Set CIRCAL_ALLOW_ZONE_MISMATCH=1 to write anyway.`
|
|
3492
3655
|
);
|
|
3493
3656
|
}
|
|
3657
|
+
function assertVersionAllowsMutation(contents) {
|
|
3658
|
+
if (contents.version <= MIRROR_VERSION) return;
|
|
3659
|
+
throw new RefusalError(
|
|
3660
|
+
`Refusing to write: the file's format is version ${contents.version} and this server understands ${MIRROR_VERSION}. Writing it would drop every field this build has no name for. Rebuild circal-mcp from the matching source (pnpm build:mcp) and retry.`
|
|
3661
|
+
);
|
|
3662
|
+
}
|
|
3494
3663
|
async function loadForRead(ctx2) {
|
|
3495
3664
|
const contents = await readMirror(ctx2.path);
|
|
3496
3665
|
return { contents, warning: zoneWarning(contents) };
|
|
@@ -3498,6 +3667,7 @@ async function loadForRead(ctx2) {
|
|
|
3498
3667
|
async function casWriteOnce(ctx2, mutate) {
|
|
3499
3668
|
for (let attempt = 0; attempt < 2; attempt++) {
|
|
3500
3669
|
const before = await readMirror(ctx2.path);
|
|
3670
|
+
assertVersionAllowsMutation(before);
|
|
3501
3671
|
assertZoneAllowsMutation(before, ctx2.env);
|
|
3502
3672
|
const { result, summary } = mutate(before);
|
|
3503
3673
|
const after = await readMirror(ctx2.path);
|
|
@@ -3542,6 +3712,9 @@ function roots(home) {
|
|
|
3542
3712
|
join2(home, "Sync")
|
|
3543
3713
|
];
|
|
3544
3714
|
}
|
|
3715
|
+
function appDefault(home) {
|
|
3716
|
+
return join2(home, "Library", "Application Support", "circal", "calendar.json");
|
|
3717
|
+
}
|
|
3545
3718
|
async function jsonFilesUnder(root) {
|
|
3546
3719
|
const found = [];
|
|
3547
3720
|
const top = await listDir(root);
|
|
@@ -3581,6 +3754,7 @@ async function asMirror(path2) {
|
|
|
3581
3754
|
async function runFind(home = homedir()) {
|
|
3582
3755
|
const searchRoots = roots(home);
|
|
3583
3756
|
const candidates = /* @__PURE__ */ new Set();
|
|
3757
|
+
candidates.add(appDefault(home));
|
|
3584
3758
|
for (const root of searchRoots) {
|
|
3585
3759
|
for (const path2 of await jsonFilesUnder(root)) candidates.add(path2);
|
|
3586
3760
|
}
|
|
@@ -3592,6 +3766,7 @@ async function runFind(home = homedir()) {
|
|
|
3592
3766
|
if (hits.length === 0) {
|
|
3593
3767
|
console.log("No circal mirror file found.\n");
|
|
3594
3768
|
console.log("Searched these locations (their own files, and one level of subdirectories):");
|
|
3769
|
+
console.log(` ${appDefault(home)}`);
|
|
3595
3770
|
for (const root of searchRoots) console.log(` ${root}`);
|
|
3596
3771
|
console.log(
|
|
3597
3772
|
"\nTurn the mirror on from circal's Settings panel to create a file, then run this again."
|
|
@@ -3836,7 +4011,20 @@ function buildEventRecord(input, calendars, settings) {
|
|
|
3836
4011
|
color: input.color ?? "",
|
|
3837
4012
|
icon: input.icon ?? "",
|
|
3838
4013
|
task: span.task,
|
|
3839
|
-
doneKeys: []
|
|
4014
|
+
doneKeys: [],
|
|
4015
|
+
// Busy. There is no tool argument for this yet, and an agent asked to put
|
|
4016
|
+
// an hour in the diary means the hour to be spoken for.
|
|
4017
|
+
free: false,
|
|
4018
|
+
// Inheriting, for the same reason: no tool argument names a lead, and an
|
|
4019
|
+
// event created with one of its own would be an agent quietly deciding
|
|
4020
|
+
// how much warning its user wants.
|
|
4021
|
+
remindLead: REMIND_INHERIT,
|
|
4022
|
+
// Nobody, for the reason there is no tool argument and never will be: an
|
|
4023
|
+
// agent has no channel to invite anyone on either, so a guest list it
|
|
4024
|
+
// assembled would be a list of people who were never asked.
|
|
4025
|
+
organizer: null,
|
|
4026
|
+
attendees: [],
|
|
4027
|
+
attendeeCount: 0
|
|
3840
4028
|
});
|
|
3841
4029
|
}
|
|
3842
4030
|
function applyPatchToEvent(base, input, calendars, settings, anchorMs = base.start) {
|
|
@@ -3858,7 +4046,25 @@ function applyPatchToEvent(base, input, calendars, settings, anchorMs = base.sta
|
|
|
3858
4046
|
color: input.color ?? base.color,
|
|
3859
4047
|
icon: input.icon ?? base.icon,
|
|
3860
4048
|
task: span.task,
|
|
3861
|
-
doneKeys: base.doneKeys
|
|
4049
|
+
doneKeys: base.doneKeys,
|
|
4050
|
+
// Carried, like every other field a patch does not name: `validate` reads
|
|
4051
|
+
// the draft through `backup.readEvent`, which defaults an absent `free`
|
|
4052
|
+
// to busy — so leaving it out would quietly rebook a free event the
|
|
4053
|
+
// moment its title was edited.
|
|
4054
|
+
free: base.free,
|
|
4055
|
+
// Carried for exactly the reason above, and this is the field where it
|
|
4056
|
+
// costs most: an event the user silenced would start speaking again the
|
|
4057
|
+
// moment an assistant fixed a typo in its title, because `readEvent`
|
|
4058
|
+
// reads an absent lead as inheriting.
|
|
4059
|
+
remindLead: base.remindLead,
|
|
4060
|
+
// Carried, and this is the field where dropping it would be least
|
|
4061
|
+
// recoverable: the guest list is a record of a message somebody else sent,
|
|
4062
|
+
// and `readEvent` reads an absent one as no invitation at all — so an
|
|
4063
|
+
// assistant fixing a typo in the title would erase the only copy of who
|
|
4064
|
+
// had asked and who was coming.
|
|
4065
|
+
organizer: base.organizer,
|
|
4066
|
+
attendees: base.attendees,
|
|
4067
|
+
attendeeCount: base.attendeeCount
|
|
3862
4068
|
});
|
|
3863
4069
|
}
|
|
3864
4070
|
function calendarName(calendars, id) {
|