circal-mcp 0.2.1 → 0.4.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 (3) hide show
  1. package/README.md +86 -28
  2. package/dist/circal-mcp.mjs +2122 -823
  3. package/package.json +9 -7
@@ -456,9 +456,9 @@ var require_react_production = __commonJS({
456
456
  exports.useState = function(initialState) {
457
457
  return ReactSharedInternals.H.useState(initialState);
458
458
  };
459
- exports.useSyncExternalStore = function(subscribe, getSnapshot, getServerSnapshot) {
459
+ exports.useSyncExternalStore = function(subscribe2, getSnapshot, getServerSnapshot) {
460
460
  return ReactSharedInternals.H.useSyncExternalStore(
461
- subscribe,
461
+ subscribe2,
462
462
  getSnapshot,
463
463
  getServerSnapshot
464
464
  );
@@ -1426,9 +1426,9 @@ var require_react_development = __commonJS({
1426
1426
  exports.useState = function(initialState) {
1427
1427
  return resolveDispatcher().useState(initialState);
1428
1428
  };
1429
- exports.useSyncExternalStore = function(subscribe, getSnapshot, getServerSnapshot) {
1429
+ exports.useSyncExternalStore = function(subscribe2, getSnapshot, getServerSnapshot) {
1430
1430
  return resolveDispatcher().useSyncExternalStore(
1431
- subscribe,
1431
+ subscribe2,
1432
1432
  getSnapshot,
1433
1433
  getServerSnapshot
1434
1434
  );
@@ -1458,11 +1458,100 @@ var require_react = __commonJS({
1458
1458
  import { McpServer } from "@modelcontextprotocol/server";
1459
1459
  import { serveStdio } from "@modelcontextprotocol/server/stdio";
1460
1460
 
1461
- // mcp/file.ts
1462
- import { randomBytes } from "node:crypto";
1463
- import { open, readFile, rename, rm, stat } from "node:fs/promises";
1464
- import { dirname, join } from "node:path";
1465
- import { setTimeout as sleep } from "node:timers/promises";
1461
+ // mcp/cloud.ts
1462
+ import { chmod, mkdir, readFile as readFile2, rm as rm2, writeFile } from "node:fs/promises";
1463
+ import { homedir } from "node:os";
1464
+ import { join as join2 } from "node:path";
1465
+ import { createInterface } from "node:readline";
1466
+ import { Writable } from "node:stream";
1467
+
1468
+ // src/lib/base64.ts
1469
+ function bytesToBase64Url(bytes) {
1470
+ let binary = "";
1471
+ for (const byte of bytes) binary += String.fromCharCode(byte);
1472
+ return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
1473
+ }
1474
+ function base64UrlToBytes(value) {
1475
+ if (!/^[A-Za-z0-9_-]+$/.test(value)) return null;
1476
+ try {
1477
+ const binary = atob(value.replace(/-/g, "+").replace(/_/g, "/"));
1478
+ const bytes = new Uint8Array(binary.length);
1479
+ for (let i = 0; i < binary.length; i += 1) bytes[i] = binary.charCodeAt(i);
1480
+ return bytes;
1481
+ } catch {
1482
+ return null;
1483
+ }
1484
+ }
1485
+
1486
+ // src/lib/cloudLink.ts
1487
+ var import_react3 = __toESM(require_react(), 1);
1488
+
1489
+ // src/lib/notify.ts
1490
+ var import_react = __toESM(require_react(), 1);
1491
+
1492
+ // src/lib/fuzzy.ts
1493
+ var MATCH = 10;
1494
+ var RUN = 9;
1495
+ var WORD_START = 12;
1496
+ var LEAD = 6;
1497
+ var GAP = 1;
1498
+ var GAP_CAP = 12;
1499
+ var LENGTH = 0.2;
1500
+ var TYPO = 40;
1501
+ var TYPO_MIN = 4;
1502
+ var WORD_BREAK = /[\s\-_/:.,(–—]/;
1503
+ function scan(text, query, start = 0) {
1504
+ let score = 0;
1505
+ let at = start;
1506
+ let previous = -2;
1507
+ let run = 0;
1508
+ for (const character of query) {
1509
+ if (character === " ") continue;
1510
+ const found = text.indexOf(character, at);
1511
+ if (found === -1) return null;
1512
+ score += MATCH;
1513
+ if (found === previous + 1) score += RUN * ++run;
1514
+ else {
1515
+ run = 0;
1516
+ score -= Math.min(found - at, GAP_CAP) * GAP;
1517
+ }
1518
+ if (found === 0) score += WORD_START + LEAD;
1519
+ else if (WORD_BREAK.test(text[found - 1])) score += WORD_START;
1520
+ previous = found;
1521
+ at = found + 1;
1522
+ }
1523
+ return score - text.length * LENGTH;
1524
+ }
1525
+ function bestScan(text, query) {
1526
+ const first = query.replace(/\s+/g, "")[0];
1527
+ if (!first) return null;
1528
+ let best = null;
1529
+ let tried = false;
1530
+ for (let i = text.indexOf(first); i !== -1; i = text.indexOf(first, i + 1)) {
1531
+ if (tried && i !== 0 && !WORD_BREAK.test(text[i - 1])) continue;
1532
+ tried = true;
1533
+ const score = scan(text, query, i);
1534
+ if (score !== null && (best === null || score > best)) best = score;
1535
+ }
1536
+ return best;
1537
+ }
1538
+ function fuzzyScore(text, query) {
1539
+ const wanted = query.trim().toLowerCase();
1540
+ if (!wanted) return 0;
1541
+ if (!text) return null;
1542
+ const haystack = text.toLowerCase();
1543
+ const clean = bestScan(haystack, wanted);
1544
+ if (clean !== null) return clean;
1545
+ const letters = wanted.replace(/\s+/g, "");
1546
+ if (letters.length < TYPO_MIN) return null;
1547
+ let best = null;
1548
+ for (let i = 0; i < wanted.length; i++) {
1549
+ if (wanted[i] === " ") continue;
1550
+ const score = bestScan(haystack, wanted.slice(0, i) + wanted.slice(i + 1));
1551
+ if (score !== null && (best === null || score > best)) best = score;
1552
+ }
1553
+ return best === null ? null : best - TYPO;
1554
+ }
1466
1555
 
1467
1556
  // src/lib/time.ts
1468
1557
  var MINUTES_PER_DAY = 1440;
@@ -2097,70 +2186,6 @@ function splitRule(rule, seedMs, at) {
2097
2186
  };
2098
2187
  }
2099
2188
 
2100
- // src/lib/fuzzy.ts
2101
- var MATCH = 10;
2102
- var RUN = 9;
2103
- var WORD_START = 12;
2104
- var LEAD = 6;
2105
- var GAP = 1;
2106
- var GAP_CAP = 12;
2107
- var LENGTH = 0.2;
2108
- var TYPO = 40;
2109
- var TYPO_MIN = 4;
2110
- var WORD_BREAK = /[\s\-_/:.,(–—]/;
2111
- function scan(text, query, start = 0) {
2112
- let score = 0;
2113
- let at = start;
2114
- let previous = -2;
2115
- let run = 0;
2116
- for (const character of query) {
2117
- if (character === " ") continue;
2118
- const found = text.indexOf(character, at);
2119
- if (found === -1) return null;
2120
- score += MATCH;
2121
- if (found === previous + 1) score += RUN * ++run;
2122
- else {
2123
- run = 0;
2124
- score -= Math.min(found - at, GAP_CAP) * GAP;
2125
- }
2126
- if (found === 0) score += WORD_START + LEAD;
2127
- else if (WORD_BREAK.test(text[found - 1])) score += WORD_START;
2128
- previous = found;
2129
- at = found + 1;
2130
- }
2131
- return score - text.length * LENGTH;
2132
- }
2133
- function bestScan(text, query) {
2134
- const first = query.replace(/\s+/g, "")[0];
2135
- if (!first) return null;
2136
- let best = null;
2137
- let tried = false;
2138
- for (let i = text.indexOf(first); i !== -1; i = text.indexOf(first, i + 1)) {
2139
- if (tried && i !== 0 && !WORD_BREAK.test(text[i - 1])) continue;
2140
- tried = true;
2141
- const score = scan(text, query, i);
2142
- if (score !== null && (best === null || score > best)) best = score;
2143
- }
2144
- return best;
2145
- }
2146
- function fuzzyScore(text, query) {
2147
- const wanted = query.trim().toLowerCase();
2148
- if (!wanted) return 0;
2149
- if (!text) return null;
2150
- const haystack = text.toLowerCase();
2151
- const clean = bestScan(haystack, wanted);
2152
- if (clean !== null) return clean;
2153
- const letters = wanted.replace(/\s+/g, "");
2154
- if (letters.length < TYPO_MIN) return null;
2155
- let best = null;
2156
- for (let i = 0; i < wanted.length; i++) {
2157
- if (wanted[i] === " ") continue;
2158
- const score = bestScan(haystack, wanted.slice(0, i) + wanted.slice(i + 1));
2159
- if (score !== null && (best === null || score > best)) best = score;
2160
- }
2161
- return best === null ? null : best - TYPO;
2162
- }
2163
-
2164
2189
  // src/lib/events.ts
2165
2190
  var ICON_MAX = 16;
2166
2191
  var ICON_NAME_MAX = 48;
@@ -2235,6 +2260,25 @@ function readGuests(list, count) {
2235
2260
  attendeeCount: Math.min(Math.max(claimed, people.length), ATTENDEE_COUNT_MAX)
2236
2261
  };
2237
2262
  }
2263
+ function readStamp(value) {
2264
+ if (typeof value !== "number" || !Number.isFinite(value) || value < 0) return 0;
2265
+ return Math.floor(value);
2266
+ }
2267
+ var GRAVE_TTL = 90 * 864e5;
2268
+ var GRAVE_MAX = 5e3;
2269
+ function readGraves(value) {
2270
+ if (!Array.isArray(value)) return [];
2271
+ const latest = /* @__PURE__ */ new Map();
2272
+ for (const entry of value) {
2273
+ if (entry === null || typeof entry !== "object") continue;
2274
+ const { id, at } = entry;
2275
+ if (typeof id !== "string" || !id) continue;
2276
+ const when = readStamp(at);
2277
+ if (when === 0) continue;
2278
+ latest.set(id, Math.max(latest.get(id) ?? 0, when));
2279
+ }
2280
+ return [...latest].map(([id, at]) => ({ id, at })).sort((a, b) => b.at - a.at).slice(0, GRAVE_MAX);
2281
+ }
2238
2282
  function overridesIn(events) {
2239
2283
  const out = /* @__PURE__ */ new Map();
2240
2284
  for (const event of events) {
@@ -2288,10 +2332,9 @@ function occurrencesOn(events, calendars, day) {
2288
2332
  continuesAfter,
2289
2333
  dayIndex: daysBetween(firstDay, dayStart) + 1,
2290
2334
  dayCount: daysBetween(firstDay, lastDay) + 1,
2291
- // A tick is per occurrence, so it is looked up per occurrence. The
2292
- // list is empty on everything that is not a task, which is almost
2293
- // every event in the store.
2294
- done: event.doneKeys.length > 0 && event.doneKeys.includes(instantKey(start)),
2335
+ // An event is never done — the field exists for a task's occurrences,
2336
+ // resolved in `taskOccurrences`.
2337
+ done: false,
2295
2338
  lane: 0
2296
2339
  });
2297
2340
  }
@@ -2303,22 +2346,99 @@ function packLanes(occurrences, pad2 = 6) {
2303
2346
  const laneEnds = [];
2304
2347
  return occurrences.map((occ) => {
2305
2348
  if (occ.event.allDay) return occ;
2306
- if (occ.event.task) return occ;
2349
+ if (occ.task && occ.startMin === occ.endMin) return occ;
2307
2350
  let lane = laneEnds.findIndex((end) => end <= occ.startMin + pad2);
2308
2351
  if (lane === -1) lane = laneEnds.length;
2309
2352
  laneEnds[lane] = occ.endMin;
2310
2353
  return { ...occ, lane };
2311
2354
  });
2312
2355
  }
2313
- function dayAgenda(events, calendars, day) {
2314
- const all = occurrencesOn(events, calendars, day);
2356
+ function dayAgenda(events, calendars, day, tasks = []) {
2357
+ const all = [...occurrencesOn(events, calendars, day), ...taskOccurrences(tasks, calendars, day)];
2358
+ all.sort((a, b) => a.start.getTime() - b.start.getTime() || a.end.getTime() - b.end.getTime());
2315
2359
  const allDay = all.filter((o) => o.event.allDay);
2316
2360
  const timed = packLanes(all.filter((o) => !o.event.allDay));
2317
2361
  return { timed, allDay, all: [...allDay, ...timed] };
2318
2362
  }
2319
- function toggleDoneKey(keys, at) {
2320
- const key = instantKey(at);
2321
- return keys.includes(key) ? keys.filter((k) => k !== key) : [...keys, key];
2363
+ function taskEvent(task, start, end) {
2364
+ return {
2365
+ id: task.id,
2366
+ title: task.title,
2367
+ calendarId: task.calendarId,
2368
+ start,
2369
+ end,
2370
+ allDay: false,
2371
+ tzid: "",
2372
+ location: "",
2373
+ url: "",
2374
+ notes: task.notes,
2375
+ ...NEVER,
2376
+ seriesId: "",
2377
+ seriesKey: "",
2378
+ color: task.color,
2379
+ icon: task.icon,
2380
+ free: false,
2381
+ remindLead: REMIND_INHERIT,
2382
+ organizer: null,
2383
+ attendees: [],
2384
+ attendeeCount: 0,
2385
+ updatedAt: 0
2386
+ };
2387
+ }
2388
+ function taskOccurrences(tasks, calendars, day) {
2389
+ const hidden = new Set(calendars.filter((c) => !c.visible).map((c) => c.id));
2390
+ const dayStart = startOfDay(day);
2391
+ const dayEnd = addDays(dayStart, 1);
2392
+ const out = [];
2393
+ for (const task of tasks) {
2394
+ if (hidden.has(task.calendarId)) continue;
2395
+ let planned = false;
2396
+ for (const plan of task.plans) {
2397
+ if (plan.end <= dayStart.getTime() || plan.start >= dayEnd.getTime()) continue;
2398
+ planned = true;
2399
+ const start = new Date(plan.start);
2400
+ const end = new Date(plan.end);
2401
+ const continuesBefore = start < dayStart;
2402
+ const continuesAfter = end > dayEnd;
2403
+ const firstDay = startOfDay(start);
2404
+ const lastDay = startOfDay(new Date(Math.max(plan.start, plan.end - 1)));
2405
+ out.push({
2406
+ key: `${task.id}@${instantKey(start)}`,
2407
+ event: taskEvent(task, plan.start, plan.end),
2408
+ task,
2409
+ start,
2410
+ end,
2411
+ startMin: continuesBefore ? 0 : minutesOfDay(start),
2412
+ endMin: end >= dayEnd ? MINUTES_PER_DAY : minutesOfDay(end),
2413
+ continuesBefore,
2414
+ continuesAfter,
2415
+ dayIndex: daysBetween(firstDay, dayStart) + 1,
2416
+ dayCount: daysBetween(firstDay, lastDay) + 1,
2417
+ done: task.done,
2418
+ lane: 0
2419
+ });
2420
+ }
2421
+ if (planned || task.dueAllDay) continue;
2422
+ if (task.due < dayStart.getTime() || task.due >= dayEnd.getTime()) continue;
2423
+ const due = new Date(task.due);
2424
+ out.push({
2425
+ key: `${task.id}@${instantKey(due)}`,
2426
+ event: taskEvent(task, task.due, task.due),
2427
+ task,
2428
+ start: due,
2429
+ end: due,
2430
+ startMin: minutesOfDay(due),
2431
+ endMin: minutesOfDay(due),
2432
+ continuesBefore: false,
2433
+ continuesAfter: false,
2434
+ dayIndex: 1,
2435
+ dayCount: 1,
2436
+ done: task.done,
2437
+ lane: 0
2438
+ });
2439
+ }
2440
+ out.sort((a, b) => a.start.getTime() - b.start.getTime() || a.end.getTime() - b.end.getTime());
2441
+ return out;
2322
2442
  }
2323
2443
  var DEFAULT_WAKING = { from: 8 * 60, to: 22 * 60 };
2324
2444
  function freeGaps(occurrences, minMinutes, from, to) {
@@ -2342,13 +2462,15 @@ function dayLoad(occurrences, waking) {
2342
2462
  }
2343
2463
  var FIELD_PENALTY = { location: 30, notes: 45 };
2344
2464
  var penalise = (score, cost) => score === null ? null : score - cost;
2345
- function scoreEvent(event, query) {
2346
- const scores = [
2347
- fuzzyScore(event.title, query),
2348
- penalise(fuzzyScore(event.location, query), FIELD_PENALTY.location),
2349
- penalise(fuzzyScore(event.notes, query), FIELD_PENALTY.notes)
2350
- ].filter((score) => score !== null);
2351
- return scores.length > 0 ? Math.max(...scores) : null;
2465
+ function scoreEventBy(event, query) {
2466
+ let best = null;
2467
+ const consider = (field, score) => {
2468
+ if (score !== null && (best === null || score > best.score)) best = { field, score };
2469
+ };
2470
+ consider("title", fuzzyScore(event.title, query));
2471
+ consider("location", penalise(fuzzyScore(event.location, query), FIELD_PENALTY.location));
2472
+ consider("notes", penalise(fuzzyScore(event.notes, query), FIELD_PENALTY.notes));
2473
+ return best;
2352
2474
  }
2353
2475
  var PALETTE = [
2354
2476
  "#7ce3d8",
@@ -2377,505 +2499,110 @@ function spanLabel(occ) {
2377
2499
  return occ.dayCount > 1 ? `Day ${occ.dayIndex}/${occ.dayCount}` : null;
2378
2500
  }
2379
2501
 
2380
- // src/lib/ics.ts
2381
- var encoder = new TextEncoder();
2382
- function fnv1a(source) {
2383
- let h = 2166136261;
2384
- for (let i = 0; i < source.length; i++) {
2385
- h ^= source.charCodeAt(i);
2386
- h = Math.imul(h, 16777619) >>> 0;
2502
+ // src/lib/store.ts
2503
+ var import_react2 = __toESM(require_react(), 1);
2504
+
2505
+ // src/lib/tasks.ts
2506
+ var ESTIMATE_MAX = 24 * 60;
2507
+ var PLANS_MAX = 32;
2508
+ function readEstimate(value) {
2509
+ if (typeof value !== "number" || !Number.isFinite(value)) return null;
2510
+ return Math.min(ESTIMATE_MAX, Math.max(0, Math.round(value)));
2511
+ }
2512
+ function readPriority(value) {
2513
+ return value === 1 || value === 2 ? value : 0;
2514
+ }
2515
+ function readPlans(value) {
2516
+ if (!Array.isArray(value)) return [];
2517
+ const plans = [];
2518
+ for (const entry of value) {
2519
+ if (typeof entry !== "object" || entry === null) continue;
2520
+ const { start, end } = entry;
2521
+ if (typeof start !== "number" || typeof end !== "number") continue;
2522
+ if (!Number.isFinite(start) || !Number.isFinite(end)) continue;
2523
+ if (end <= start) continue;
2524
+ plans.push({ start, end });
2525
+ if (plans.length === PLANS_MAX) break;
2387
2526
  }
2388
- return h.toString(36);
2527
+ return plans.sort((a, b) => a.start - b.start || a.end - b.end);
2528
+ }
2529
+ var DUE_SOON_DAYS = 7;
2530
+ function taskState(task, now) {
2531
+ if (task.done) return "done";
2532
+ const today = startOfDay(now);
2533
+ if (task.dueAllDay) {
2534
+ const dueDay = startOfDay(new Date(task.due));
2535
+ if (dueDay < today) return "overdue";
2536
+ return dueDay < addDays(today, DUE_SOON_DAYS + 1) ? "due-soon" : "later";
2537
+ }
2538
+ if (task.due <= now.getTime()) return "overdue";
2539
+ return startOfDay(new Date(task.due)) < addDays(today, DUE_SOON_DAYS + 1) ? "due-soon" : "later";
2540
+ }
2541
+ var NOTES_PENALTY = 45;
2542
+ function scoreTaskBy(task, query) {
2543
+ const title = fuzzyScore(task.title, query);
2544
+ const notes = fuzzyScore(task.notes, query);
2545
+ const penalised = notes === null ? null : notes - NOTES_PENALTY;
2546
+ let best = title === null ? null : { field: "title", score: title };
2547
+ if (penalised !== null && (best === null || penalised > best.score)) {
2548
+ best = { field: "notes", score: penalised };
2549
+ }
2550
+ return best;
2551
+ }
2552
+ function compareTasks(a, b) {
2553
+ return a.due - b.due || b.priority - a.priority || a.title.localeCompare(b.title);
2389
2554
  }
2390
2555
 
2391
- // src/lib/places.ts
2392
- var COORDS = {
2393
- "Africa/Abidjan": [5.32, -4.03],
2394
- "Africa/Accra": [5.55, -0.22],
2395
- "Africa/Addis_Ababa": [9.03, 38.7],
2396
- "Africa/Algiers": [36.78, 3.05],
2397
- "Africa/Asmara": [15.33, 38.88],
2398
- "Africa/Bamako": [12.65, -8],
2399
- "Africa/Bangui": [4.37, 18.58],
2400
- "Africa/Banjul": [13.47, -16.65],
2401
- "Africa/Bissau": [11.85, -15.58],
2402
- "Africa/Blantyre": [-15.78, 35],
2403
- "Africa/Brazzaville": [-4.27, 15.28],
2404
- "Africa/Bujumbura": [-3.38, 29.37],
2405
- "Africa/Cairo": [30.05, 31.25],
2406
- "Africa/Casablanca": [33.65, -7.58],
2407
- "Africa/Ceuta": [35.88, -5.32],
2408
- "Africa/Conakry": [9.52, -13.72],
2409
- "Africa/Dakar": [14.67, -17.43],
2410
- "Africa/Dar_es_Salaam": [-6.8, 39.28],
2411
- "Africa/Djibouti": [11.6, 43.15],
2412
- "Africa/Douala": [4.05, 9.7],
2413
- "Africa/El_Aaiun": [27.15, -13.2],
2414
- "Africa/Freetown": [8.5, -13.25],
2415
- "Africa/Gaborone": [-24.65, 25.92],
2416
- "Africa/Harare": [-17.83, 31.05],
2417
- "Africa/Johannesburg": [-26.25, 28],
2418
- "Africa/Juba": [4.85, 31.62],
2419
- "Africa/Kampala": [0.32, 32.42],
2420
- "Africa/Khartoum": [15.6, 32.53],
2421
- "Africa/Kigali": [-1.95, 30.07],
2422
- "Africa/Kinshasa": [-4.3, 15.3],
2423
- "Africa/Lagos": [6.45, 3.4],
2424
- "Africa/Libreville": [0.38, 9.45],
2425
- "Africa/Lome": [6.13, 1.22],
2426
- "Africa/Luanda": [-8.8, 13.23],
2427
- "Africa/Lubumbashi": [-11.67, 27.47],
2428
- "Africa/Lusaka": [-15.42, 28.28],
2429
- "Africa/Malabo": [3.75, 8.78],
2430
- "Africa/Maputo": [-25.97, 32.58],
2431
- "Africa/Maseru": [-29.47, 27.5],
2432
- "Africa/Mbabane": [-26.3, 31.1],
2433
- "Africa/Mogadishu": [2.07, 45.37],
2434
- "Africa/Monrovia": [6.3, -10.78],
2435
- "Africa/Nairobi": [-1.28, 36.82],
2436
- "Africa/Ndjamena": [12.12, 15.05],
2437
- "Africa/Niamey": [13.52, 2.12],
2438
- "Africa/Nouakchott": [18.1, -15.95],
2439
- "Africa/Ouagadougou": [12.37, -1.52],
2440
- "Africa/Porto-Novo": [6.48, 2.62],
2441
- "Africa/Sao_Tome": [0.33, 6.73],
2442
- "Africa/Tripoli": [32.9, 13.18],
2443
- "Africa/Tunis": [36.8, 10.18],
2444
- "Africa/Windhoek": [-22.57, 17.1],
2445
- "America/Adak": [51.88, -176.66],
2446
- "America/Anchorage": [61.22, -149.9],
2447
- "America/Anguilla": [18.2, -63.07],
2448
- "America/Antigua": [17.05, -61.8],
2449
- "America/Araguaina": [-7.2, -48.2],
2450
- "America/Argentina/Buenos_Aires": [-34.6, -58.45],
2451
- "America/Argentina/Catamarca": [-28.47, -65.78],
2452
- "America/Argentina/Cordoba": [-31.4, -64.18],
2453
- "America/Argentina/Jujuy": [-24.18, -65.3],
2454
- "America/Argentina/La_Rioja": [-29.43, -66.85],
2455
- "America/Argentina/Mendoza": [-32.88, -68.82],
2456
- "America/Argentina/Rio_Gallegos": [-51.63, -69.22],
2457
- "America/Argentina/Salta": [-24.78, -65.42],
2458
- "America/Argentina/San_Juan": [-31.53, -68.52],
2459
- "America/Argentina/San_Luis": [-33.32, -66.35],
2460
- "America/Argentina/Tucuman": [-26.82, -65.22],
2461
- "America/Argentina/Ushuaia": [-54.8, -68.3],
2462
- "America/Aruba": [12.5, -69.97],
2463
- "America/Asuncion": [-25.27, -57.67],
2464
- "America/Atikokan": [48.76, -91.62],
2465
- "America/Bahia": [-12.98, -38.52],
2466
- "America/Bahia_Banderas": [20.8, -105.25],
2467
- "America/Barbados": [13.1, -59.62],
2468
- "America/Belem": [-1.45, -48.48],
2469
- "America/Belize": [17.5, -88.2],
2470
- "America/Blanc-Sablon": [51.42, -57.12],
2471
- "America/Boa_Vista": [2.82, -60.67],
2472
- "America/Bogota": [4.6, -74.08],
2473
- "America/Boise": [43.61, -116.2],
2474
- "America/Cambridge_Bay": [69.11, -105.05],
2475
- "America/Campo_Grande": [-20.45, -54.62],
2476
- "America/Cancun": [21.08, -86.77],
2477
- "America/Caracas": [10.5, -66.93],
2478
- "America/Cayenne": [4.93, -52.33],
2479
- "America/Cayman": [19.3, -81.38],
2480
- "America/Chicago": [41.85, -87.65],
2481
- "America/Chihuahua": [28.63, -106.08],
2482
- "America/Ciudad_Juarez": [31.73, -106.48],
2483
- "America/Costa_Rica": [9.93, -84.08],
2484
- "America/Coyhaique": [-45.57, -72.07],
2485
- "America/Creston": [49.1, -116.52],
2486
- "America/Cuiaba": [-15.58, -56.08],
2487
- "America/Curacao": [12.18, -69],
2488
- "America/Danmarkshavn": [76.77, -18.67],
2489
- "America/Dawson": [64.07, -139.42],
2490
- "America/Dawson_Creek": [55.77, -120.23],
2491
- "America/Denver": [39.74, -104.98],
2492
- "America/Detroit": [42.33, -83.05],
2493
- "America/Dominica": [15.3, -61.4],
2494
- "America/Edmonton": [53.55, -113.47],
2495
- "America/Eirunepe": [-6.67, -69.87],
2496
- "America/El_Salvador": [13.7, -89.2],
2497
- "America/Fort_Nelson": [58.8, -122.7],
2498
- "America/Fortaleza": [-3.72, -38.5],
2499
- "America/Glace_Bay": [46.2, -59.95],
2500
- "America/Goose_Bay": [53.33, -60.42],
2501
- "America/Grand_Turk": [21.47, -71.13],
2502
- "America/Grenada": [12.05, -61.75],
2503
- "America/Guadeloupe": [16.23, -61.53],
2504
- "America/Guatemala": [14.63, -90.52],
2505
- "America/Guayaquil": [-2.17, -79.83],
2506
- "America/Guyana": [6.8, -58.17],
2507
- "America/Halifax": [44.65, -63.6],
2508
- "America/Havana": [23.13, -82.37],
2509
- "America/Hermosillo": [29.07, -110.97],
2510
- "America/Indiana/Indianapolis": [39.77, -86.16],
2511
- "America/Indiana/Knox": [41.3, -86.62],
2512
- "America/Indiana/Marengo": [38.38, -86.34],
2513
- "America/Indiana/Petersburg": [38.49, -87.28],
2514
- "America/Indiana/Tell_City": [37.95, -86.76],
2515
- "America/Indiana/Vevay": [38.75, -85.07],
2516
- "America/Indiana/Vincennes": [38.68, -87.53],
2517
- "America/Indiana/Winamac": [41.05, -86.6],
2518
- "America/Inuvik": [68.35, -133.72],
2519
- "America/Iqaluit": [63.73, -68.47],
2520
- "America/Jamaica": [17.97, -76.79],
2521
- "America/Juneau": [58.3, -134.42],
2522
- "America/Kentucky/Louisville": [38.25, -85.76],
2523
- "America/Kentucky/Monticello": [36.83, -84.85],
2524
- "America/Kralendijk": [12.15, -68.28],
2525
- "America/La_Paz": [-16.5, -68.15],
2526
- "America/Lima": [-12.05, -77.05],
2527
- "America/Los_Angeles": [34.05, -118.24],
2528
- "America/Lower_Princes": [18.05, -63.05],
2529
- "America/Maceio": [-9.67, -35.72],
2530
- "America/Managua": [12.15, -86.28],
2531
- "America/Manaus": [-3.13, -60.02],
2532
- "America/Marigot": [18.07, -63.08],
2533
- "America/Martinique": [14.6, -61.08],
2534
- "America/Matamoros": [25.83, -97.5],
2535
- "America/Mazatlan": [23.22, -106.42],
2536
- "America/Menominee": [45.11, -87.61],
2537
- "America/Merida": [20.97, -89.62],
2538
- "America/Metlakatla": [55.13, -131.58],
2539
- "America/Mexico_City": [19.4, -99.15],
2540
- "America/Miquelon": [47.05, -56.33],
2541
- "America/Moncton": [46.1, -64.78],
2542
- "America/Monterrey": [25.67, -100.32],
2543
- "America/Montevideo": [-34.91, -56.21],
2544
- "America/Montserrat": [16.72, -62.22],
2545
- "America/Nassau": [25.08, -77.35],
2546
- "America/New_York": [40.71, -74.01],
2547
- "America/Nome": [64.5, -165.41],
2548
- "America/Noronha": [-3.85, -32.42],
2549
- "America/North_Dakota/Beulah": [47.26, -101.78],
2550
- "America/North_Dakota/Center": [47.12, -101.3],
2551
- "America/North_Dakota/New_Salem": [46.84, -101.41],
2552
- "America/Nuuk": [64.18, -51.73],
2553
- "America/Ojinaga": [29.57, -104.42],
2554
- "America/Panama": [8.97, -79.53],
2555
- "America/Paramaribo": [5.83, -55.17],
2556
- "America/Phoenix": [33.45, -112.07],
2557
- "America/Port-au-Prince": [18.53, -72.33],
2558
- "America/Port_of_Spain": [10.65, -61.52],
2559
- "America/Porto_Velho": [-8.77, -63.9],
2560
- "America/Puerto_Rico": [18.47, -66.11],
2561
- "America/Punta_Arenas": [-53.15, -70.92],
2562
- "America/Rankin_Inlet": [62.82, -92.08],
2563
- "America/Recife": [-8.05, -34.9],
2564
- "America/Regina": [50.4, -104.65],
2565
- "America/Resolute": [74.7, -94.83],
2566
- "America/Rio_Branco": [-9.97, -67.8],
2567
- "America/Santarem": [-2.43, -54.87],
2568
- "America/Santiago": [-33.45, -70.67],
2569
- "America/Santo_Domingo": [18.47, -69.9],
2570
- "America/Sao_Paulo": [-23.53, -46.62],
2571
- "America/Scoresbysund": [70.48, -21.97],
2572
- "America/Sitka": [57.18, -135.3],
2573
- "America/St_Barthelemy": [17.88, -62.85],
2574
- "America/St_Johns": [47.57, -52.72],
2575
- "America/St_Kitts": [17.3, -62.72],
2576
- "America/St_Lucia": [14.02, -61],
2577
- "America/St_Thomas": [18.35, -64.93],
2578
- "America/St_Vincent": [13.15, -61.23],
2579
- "America/Swift_Current": [50.28, -107.83],
2580
- "America/Tegucigalpa": [14.1, -87.22],
2581
- "America/Thule": [76.57, -68.78],
2582
- "America/Tijuana": [32.53, -117.02],
2583
- "America/Toronto": [43.65, -79.38],
2584
- "America/Tortola": [18.45, -64.62],
2585
- "America/Vancouver": [49.27, -123.12],
2586
- "America/Whitehorse": [60.72, -135.05],
2587
- "America/Winnipeg": [49.88, -97.15],
2588
- "America/Yakutat": [59.55, -139.73],
2589
- "Antarctica/Casey": [-66.28, 110.52],
2590
- "Antarctica/Davis": [-68.58, 77.97],
2591
- "Antarctica/DumontDUrville": [-66.67, 140.02],
2592
- "Antarctica/Macquarie": [-54.5, 158.95],
2593
- "Antarctica/Mawson": [-67.6, 62.88],
2594
- "Antarctica/McMurdo": [-77.83, 166.6],
2595
- "Antarctica/Palmer": [-64.8, -64.1],
2596
- "Antarctica/Rothera": [-67.57, -68.13],
2597
- "Antarctica/Syowa": [-69.01, 39.59],
2598
- "Antarctica/Troll": [-72.01, 2.53],
2599
- "Antarctica/Vostok": [-78.4, 106.9],
2600
- "Arctic/Longyearbyen": [78, 16],
2601
- "Asia/Aden": [12.75, 45.2],
2602
- "Asia/Almaty": [43.25, 76.95],
2603
- "Asia/Amman": [31.95, 35.93],
2604
- "Asia/Anadyr": [64.75, 177.48],
2605
- "Asia/Aqtau": [44.52, 50.27],
2606
- "Asia/Aqtobe": [50.28, 57.17],
2607
- "Asia/Ashgabat": [37.95, 58.38],
2608
- "Asia/Atyrau": [47.12, 51.93],
2609
- "Asia/Baghdad": [33.35, 44.42],
2610
- "Asia/Bahrain": [26.38, 50.58],
2611
- "Asia/Baku": [40.38, 49.85],
2612
- "Asia/Bangkok": [13.75, 100.52],
2613
- "Asia/Barnaul": [53.37, 83.75],
2614
- "Asia/Beirut": [33.88, 35.5],
2615
- "Asia/Bishkek": [42.9, 74.6],
2616
- "Asia/Brunei": [4.93, 114.92],
2617
- "Asia/Chita": [52.05, 113.47],
2618
- "Asia/Colombo": [6.93, 79.85],
2619
- "Asia/Damascus": [33.5, 36.3],
2620
- "Asia/Dhaka": [23.72, 90.42],
2621
- "Asia/Dili": [-8.55, 125.58],
2622
- "Asia/Dubai": [25.3, 55.3],
2623
- "Asia/Dushanbe": [38.58, 68.8],
2624
- "Asia/Famagusta": [35.12, 33.95],
2625
- "Asia/Gaza": [31.5, 34.47],
2626
- "Asia/Hebron": [31.53, 35.09],
2627
- "Asia/Ho_Chi_Minh": [10.75, 106.67],
2628
- "Asia/Hong_Kong": [22.28, 114.15],
2629
- "Asia/Hovd": [48.02, 91.65],
2630
- "Asia/Irkutsk": [52.27, 104.33],
2631
- "Asia/Jakarta": [-6.17, 106.8],
2632
- "Asia/Jayapura": [-2.53, 140.7],
2633
- "Asia/Jerusalem": [31.78, 35.22],
2634
- "Asia/Kabul": [34.52, 69.2],
2635
- "Asia/Kamchatka": [53.02, 158.65],
2636
- "Asia/Karachi": [24.87, 67.05],
2637
- "Asia/Kathmandu": [27.72, 85.32],
2638
- "Asia/Khandyga": [62.66, 135.55],
2639
- "Asia/Kolkata": [22.53, 88.37],
2640
- "Asia/Krasnoyarsk": [56.02, 92.83],
2641
- "Asia/Kuala_Lumpur": [3.17, 101.7],
2642
- "Asia/Kuching": [1.55, 110.33],
2643
- "Asia/Kuwait": [29.33, 47.98],
2644
- "Asia/Macau": [22.2, 113.54],
2645
- "Asia/Magadan": [59.57, 150.8],
2646
- "Asia/Makassar": [-5.12, 119.4],
2647
- "Asia/Manila": [14.59, 120.97],
2648
- "Asia/Muscat": [23.6, 58.58],
2649
- "Asia/Nicosia": [35.17, 33.37],
2650
- "Asia/Novokuznetsk": [53.75, 87.12],
2651
- "Asia/Novosibirsk": [55.03, 82.92],
2652
- "Asia/Omsk": [55, 73.4],
2653
- "Asia/Oral": [51.22, 51.35],
2654
- "Asia/Phnom_Penh": [11.55, 104.92],
2655
- "Asia/Pontianak": [-0.03, 109.33],
2656
- "Asia/Pyongyang": [39.02, 125.75],
2657
- "Asia/Qatar": [25.28, 51.53],
2658
- "Asia/Qostanay": [53.2, 63.62],
2659
- "Asia/Qyzylorda": [44.8, 65.47],
2660
- "Asia/Riyadh": [24.63, 46.72],
2661
- "Asia/Sakhalin": [46.97, 142.7],
2662
- "Asia/Samarkand": [39.67, 66.8],
2663
- "Asia/Seoul": [37.55, 126.97],
2664
- "Asia/Shanghai": [31.23, 121.47],
2665
- "Asia/Singapore": [1.28, 103.85],
2666
- "Asia/Srednekolymsk": [67.47, 153.72],
2667
- "Asia/Taipei": [25.05, 121.5],
2668
- "Asia/Tashkent": [41.33, 69.3],
2669
- "Asia/Tbilisi": [41.72, 44.82],
2670
- "Asia/Tehran": [35.67, 51.43],
2671
- "Asia/Thimphu": [27.47, 89.65],
2672
- "Asia/Tokyo": [35.65, 139.74],
2673
- "Asia/Tomsk": [56.5, 84.97],
2674
- "Asia/Ulaanbaatar": [47.92, 106.88],
2675
- "Asia/Urumqi": [43.8, 87.58],
2676
- "Asia/Ust-Nera": [64.56, 143.23],
2677
- "Asia/Vientiane": [17.97, 102.6],
2678
- "Asia/Vladivostok": [43.17, 131.93],
2679
- "Asia/Yakutsk": [62, 129.67],
2680
- "Asia/Yangon": [16.78, 96.17],
2681
- "Asia/Yekaterinburg": [56.85, 60.6],
2682
- "Asia/Yerevan": [40.18, 44.5],
2683
- "Atlantic/Azores": [37.73, -25.67],
2684
- "Atlantic/Bermuda": [32.28, -64.77],
2685
- "Atlantic/Canary": [28.1, -15.4],
2686
- "Atlantic/Cape_Verde": [14.92, -23.52],
2687
- "Atlantic/Faroe": [62.02, -6.77],
2688
- "Atlantic/Madeira": [32.63, -16.9],
2689
- "Atlantic/Reykjavik": [64.15, -21.85],
2690
- "Atlantic/South_Georgia": [-54.27, -36.53],
2691
- "Atlantic/St_Helena": [-15.92, -5.7],
2692
- "Atlantic/Stanley": [-51.7, -57.85],
2693
- "Australia/Adelaide": [-34.92, 138.58],
2694
- "Australia/Brisbane": [-27.47, 153.03],
2695
- "Australia/Broken_Hill": [-31.95, 141.45],
2696
- "Australia/Darwin": [-12.47, 130.83],
2697
- "Australia/Eucla": [-31.72, 128.87],
2698
- "Australia/Hobart": [-42.88, 147.32],
2699
- "Australia/Lindeman": [-20.27, 149],
2700
- "Australia/Lord_Howe": [-31.55, 159.08],
2701
- "Australia/Melbourne": [-37.82, 144.97],
2702
- "Australia/Perth": [-31.95, 115.85],
2703
- "Australia/Sydney": [-33.87, 151.22],
2704
- "Europe/Amsterdam": [52.37, 4.9],
2705
- "Europe/Andorra": [42.5, 1.52],
2706
- "Europe/Astrakhan": [46.35, 48.05],
2707
- "Europe/Athens": [37.97, 23.72],
2708
- "Europe/Belgrade": [44.83, 20.5],
2709
- "Europe/Berlin": [52.5, 13.37],
2710
- "Europe/Bratislava": [48.15, 17.12],
2711
- "Europe/Brussels": [50.83, 4.33],
2712
- "Europe/Bucharest": [44.43, 26.1],
2713
- "Europe/Budapest": [47.5, 19.08],
2714
- "Europe/Busingen": [47.7, 8.68],
2715
- "Europe/Chisinau": [47, 28.83],
2716
- "Europe/Copenhagen": [55.67, 12.58],
2717
- "Europe/Dublin": [53.33, -6.25],
2718
- "Europe/Gibraltar": [36.13, -5.35],
2719
- "Europe/Guernsey": [49.45, -2.54],
2720
- "Europe/Helsinki": [60.17, 24.97],
2721
- "Europe/Isle_of_Man": [54.15, -4.47],
2722
- "Europe/Istanbul": [41.02, 28.97],
2723
- "Europe/Jersey": [49.18, -2.11],
2724
- "Europe/Kaliningrad": [54.72, 20.5],
2725
- "Europe/Kirov": [58.6, 49.65],
2726
- "Europe/Kyiv": [50.43, 30.52],
2727
- "Europe/Lisbon": [38.72, -9.13],
2728
- "Europe/Ljubljana": [46.05, 14.52],
2729
- "Europe/London": [51.51, -0.13],
2730
- "Europe/Luxembourg": [49.6, 6.15],
2731
- "Europe/Madrid": [40.4, -3.68],
2732
- "Europe/Malta": [35.9, 14.52],
2733
- "Europe/Mariehamn": [60.1, 19.95],
2734
- "Europe/Minsk": [53.9, 27.57],
2735
- "Europe/Monaco": [43.7, 7.38],
2736
- "Europe/Moscow": [55.76, 37.62],
2737
- "Europe/Oslo": [59.92, 10.75],
2738
- "Europe/Paris": [48.87, 2.33],
2739
- "Europe/Podgorica": [42.43, 19.27],
2740
- "Europe/Prague": [50.08, 14.43],
2741
- "Europe/Riga": [56.95, 24.1],
2742
- "Europe/Rome": [41.9, 12.48],
2743
- "Europe/Samara": [53.2, 50.15],
2744
- "Europe/San_Marino": [43.92, 12.47],
2745
- "Europe/Sarajevo": [43.87, 18.42],
2746
- "Europe/Saratov": [51.57, 46.03],
2747
- "Europe/Simferopol": [44.95, 34.1],
2748
- "Europe/Skopje": [41.98, 21.43],
2749
- "Europe/Sofia": [42.68, 23.32],
2750
- "Europe/Stockholm": [59.33, 18.05],
2751
- "Europe/Tallinn": [59.42, 24.75],
2752
- "Europe/Tirane": [41.33, 19.83],
2753
- "Europe/Ulyanovsk": [54.33, 48.4],
2754
- "Europe/Vaduz": [47.15, 9.52],
2755
- "Europe/Vatican": [41.9, 12.45],
2756
- "Europe/Vienna": [48.22, 16.33],
2757
- "Europe/Vilnius": [54.68, 25.32],
2758
- "Europe/Volgograd": [48.73, 44.42],
2759
- "Europe/Warsaw": [52.25, 21],
2760
- "Europe/Zagreb": [45.8, 15.97],
2761
- "Europe/Zurich": [47.38, 8.53],
2762
- "Indian/Antananarivo": [-18.92, 47.52],
2763
- "Indian/Chagos": [-7.33, 72.42],
2764
- "Indian/Christmas": [-10.42, 105.72],
2765
- "Indian/Cocos": [-12.17, 96.92],
2766
- "Indian/Comoro": [-11.68, 43.27],
2767
- "Indian/Kerguelen": [-49.35, 70.22],
2768
- "Indian/Mahe": [-4.67, 55.47],
2769
- "Indian/Maldives": [4.17, 73.5],
2770
- "Indian/Mauritius": [-20.17, 57.5],
2771
- "Indian/Mayotte": [-12.78, 45.23],
2772
- "Indian/Reunion": [-20.87, 55.47],
2773
- "Pacific/Apia": [-13.83, -171.73],
2774
- "Pacific/Auckland": [-36.87, 174.77],
2775
- "Pacific/Bougainville": [-6.22, 155.57],
2776
- "Pacific/Chatham": [-43.95, -176.55],
2777
- "Pacific/Chuuk": [7.42, 151.78],
2778
- "Pacific/Easter": [-27.15, -109.43],
2779
- "Pacific/Efate": [-17.67, 168.42],
2780
- "Pacific/Fakaofo": [-9.37, -171.23],
2781
- "Pacific/Fiji": [-18.13, 178.42],
2782
- "Pacific/Funafuti": [-8.52, 179.22],
2783
- "Pacific/Galapagos": [-0.9, -89.6],
2784
- "Pacific/Gambier": [-23.13, -134.95],
2785
- "Pacific/Guadalcanal": [-9.53, 160.2],
2786
- "Pacific/Guam": [13.47, 144.75],
2787
- "Pacific/Honolulu": [21.31, -157.86],
2788
- "Pacific/Kanton": [-2.78, -171.72],
2789
- "Pacific/Kiritimati": [1.87, -157.33],
2790
- "Pacific/Kosrae": [5.32, 162.98],
2791
- "Pacific/Kwajalein": [9.08, 167.33],
2792
- "Pacific/Majuro": [7.15, 171.2],
2793
- "Pacific/Marquesas": [-9, -139.5],
2794
- "Pacific/Midway": [28.22, -177.37],
2795
- "Pacific/Nauru": [-0.52, 166.92],
2796
- "Pacific/Niue": [-19.02, -169.92],
2797
- "Pacific/Norfolk": [-29.05, 167.97],
2798
- "Pacific/Noumea": [-22.27, 166.45],
2799
- "Pacific/Pago_Pago": [-14.27, -170.7],
2800
- "Pacific/Palau": [7.33, 134.48],
2801
- "Pacific/Pitcairn": [-25.07, -130.08],
2802
- "Pacific/Pohnpei": [6.97, 158.22],
2803
- "Pacific/Port_Moresby": [-9.5, 147.17],
2804
- "Pacific/Rarotonga": [-21.23, -159.77],
2805
- "Pacific/Saipan": [15.2, 145.75],
2806
- "Pacific/Tahiti": [-17.53, -149.57],
2807
- "Pacific/Tarawa": [1.42, 173],
2808
- "Pacific/Tongatapu": [-21.13, -175.2],
2809
- "Pacific/Wake": [19.28, 166.62],
2810
- "Pacific/Wallis": [-13.3, -176.17]
2811
- };
2812
- var ALIASES = {
2813
- "Africa/Asmera": "Africa/Asmara",
2814
- "America/Buenos_Aires": "America/Argentina/Buenos_Aires",
2815
- "America/Catamarca": "America/Argentina/Catamarca",
2816
- "America/Coral_Harbour": "America/Atikokan",
2817
- "America/Cordoba": "America/Argentina/Cordoba",
2818
- "America/Godthab": "America/Nuuk",
2819
- "America/Indianapolis": "America/Indiana/Indianapolis",
2820
- "America/Jujuy": "America/Argentina/Jujuy",
2821
- "America/Louisville": "America/Kentucky/Louisville",
2822
- "America/Mendoza": "America/Argentina/Mendoza",
2823
- "Asia/Calcutta": "Asia/Kolkata",
2824
- "Asia/Katmandu": "Asia/Kathmandu",
2825
- "Asia/Rangoon": "Asia/Yangon",
2826
- "Asia/Saigon": "Asia/Ho_Chi_Minh",
2827
- "Atlantic/Faeroe": "Atlantic/Faroe",
2828
- "Europe/Kiev": "Europe/Kyiv",
2829
- "Pacific/Enderbury": "Pacific/Kanton",
2830
- "Pacific/Ponape": "Pacific/Pohnpei",
2831
- "Pacific/Truk": "Pacific/Chuuk"
2832
- };
2833
- function placeForZone(zone) {
2834
- const coords = COORDS[zone] ?? COORDS[ALIASES[zone]];
2835
- if (!coords) return null;
2836
- return { zone, lat: coords[0], lon: coords[1], label: zoneLabel(zone) };
2837
- }
2838
- var PLACE_OPTIONS = Object.keys(COORDS).map((zone) => ({ value: zone, label: zoneLabel(zone) })).sort((a, b) => a.label.localeCompare(b.label));
2839
-
2840
- // src/lib/store.ts
2841
- var import_react2 = __toESM(require_react(), 1);
2842
-
2843
- // src/lib/notify.ts
2844
- var import_react = __toESM(require_react(), 1);
2845
-
2846
2556
  // src/lib/mutate.ts
2847
- function applyUpdate(events, id, patch) {
2848
- return events.map((e) => e.id === id ? { ...e, ...patch } : e);
2849
- }
2850
- function applyToggleDone(events, id, at) {
2851
- return events.map((e) => e.id === id ? { ...e, doneKeys: toggleDoneKey(e.doneKeys, at) } : e);
2557
+ function applyUpdate(events, id, patch, now) {
2558
+ return events.map((e) => e.id === id ? { ...e, ...patch, updatedAt: now } : e);
2852
2559
  }
2853
- function applyRemove(events, id) {
2854
- return events.filter((e) => e.id !== id && e.seriesId !== id);
2560
+ function applyRemove(events, id, now) {
2561
+ const buried = events.filter((e) => e.id === id || e.seriesId === id);
2562
+ return {
2563
+ events: events.filter((e) => e.id !== id && e.seriesId !== id),
2564
+ graves: buried.map((e) => ({ id: e.id, at: now }))
2565
+ };
2855
2566
  }
2856
- function applySplit(events, id, at, tail, mintId) {
2567
+ function applySplit(events, id, at, tail, mintId, now) {
2857
2568
  const original = events.find((e) => e.id === id);
2858
2569
  if (!original) return { events, created: null };
2859
2570
  const split = splitRule(original, original.start, at);
2860
- const created = { ...tail, ...split.tail, seriesId: "", seriesKey: "", id: mintId() };
2571
+ const created = {
2572
+ ...tail,
2573
+ ...split.tail,
2574
+ seriesId: "",
2575
+ seriesKey: "",
2576
+ id: mintId(),
2577
+ updatedAt: now
2578
+ };
2861
2579
  const seed = new Date(original.start);
2862
2580
  const detached = (e) => e.seriesId === id && (keyToInstant(e.seriesKey, seed) ?? -Infinity) >= at;
2863
2581
  return {
2864
2582
  events: [
2583
+ // The head, the tail and every re-pointed override are the three things
2584
+ // this touches, so they are the three it stamps. An override before the
2585
+ // cut is handed back as the same object and keeps the stamp it had.
2865
2586
  ...events.map(
2866
- (e) => e.id === id ? { ...e, ...split.head } : detached(e) ? { ...e, seriesId: created.id } : e
2587
+ (e) => e.id === id ? { ...e, ...split.head, updatedAt: now } : detached(e) ? { ...e, seriesId: created.id, updatedAt: now } : e
2867
2588
  ),
2868
2589
  created
2869
2590
  ],
2870
2591
  created
2871
2592
  };
2872
2593
  }
2873
- function applyTruncate(events, id, at) {
2594
+ function applyTruncate(events, id, at, now) {
2874
2595
  const original = events.find((e) => e.id === id);
2875
- if (!original) return events;
2596
+ if (!original) return { events, graves: [] };
2876
2597
  const split = splitRule(original, original.start, at);
2877
2598
  const seed = new Date(original.start);
2878
- return events.filter((e) => !(e.seriesId === id && (keyToInstant(e.seriesKey, seed) ?? -Infinity) >= at)).map((e) => e.id === id ? { ...e, ...split.head } : e);
2599
+ const past = (e) => e.seriesId === id && (keyToInstant(e.seriesKey, seed) ?? -Infinity) >= at;
2600
+ return {
2601
+ events: events.filter((e) => !past(e)).map((e) => e.id === id ? { ...e, ...split.head, updatedAt: now } : e),
2602
+ // Only what is past the cut: the head survives the truncation, so a grave
2603
+ // for it would delete the series a merge is meant to keep.
2604
+ graves: events.filter(past).map((e) => ({ id: e.id, at: now }))
2605
+ };
2879
2606
  }
2880
2607
 
2881
2608
  // src/lib/quickadd.ts
@@ -2906,6 +2633,16 @@ function parseDate(raw, reference) {
2906
2633
  const delta = (weekdayIndex - base.getDay() + 7) % 7 || 7;
2907
2634
  return addDays(base, delta);
2908
2635
  }
2636
+ const lastWeekday = /^last\s+([a-z]+)$/.exec(query);
2637
+ if (lastWeekday) {
2638
+ const idx = WEEKDAYS_LOWER.findIndex(
2639
+ (w) => w === lastWeekday[1] || w.slice(0, 3) === lastWeekday[1]
2640
+ );
2641
+ if (idx !== -1) {
2642
+ const delta = (base.getDay() - idx + 7) % 7 || 7;
2643
+ return addDays(base, -delta);
2644
+ }
2645
+ }
2909
2646
  const iso = /^(\d{4})-(\d{2})-(\d{2})$/.exec(query);
2910
2647
  if (iso) {
2911
2648
  const year = Number(iso[1]);
@@ -2919,11 +2656,23 @@ function parseDate(raw, reference) {
2919
2656
  const resolveMonthDay = (monthStr, domStr) => {
2920
2657
  const monthIndex = MONTHS_LOWER.findIndex((m) => m === monthStr || m.slice(0, 3) === monthStr);
2921
2658
  const dom = Number(domStr);
2922
- if (monthIndex === -1 || dom < 1 || dom > daysInMonth(reference.getFullYear(), monthIndex))
2923
- return null;
2659
+ if (monthIndex === -1 || dom < 1) return null;
2924
2660
  const year = reference.getFullYear();
2925
- const candidate = new Date(year, monthIndex, dom);
2926
- return candidate.getTime() < base.getTime() ? new Date(year + 1, monthIndex, dom) : candidate;
2661
+ let best = null;
2662
+ for (const y of [year - 1, year, year + 1]) {
2663
+ if (dom > daysInMonth(y, monthIndex)) continue;
2664
+ const candidate = new Date(y, monthIndex, dom);
2665
+ if (best === null) {
2666
+ best = candidate;
2667
+ continue;
2668
+ }
2669
+ const kept = Math.abs(best.getTime() - base.getTime());
2670
+ const offered = Math.abs(candidate.getTime() - base.getTime());
2671
+ if (offered < kept || offered === kept && candidate.getTime() > base.getTime()) {
2672
+ best = candidate;
2673
+ }
2674
+ }
2675
+ return best;
2927
2676
  };
2928
2677
  const monthDay = /^([a-z]+)\s+(\d{1,2})$/.exec(query);
2929
2678
  if (monthDay) {
@@ -3164,9 +2913,474 @@ function parseQuickAdd(input, reference, now = /* @__PURE__ */ new Date(), fallb
3164
2913
  repeat
3165
2914
  };
3166
2915
  }
2916
+ var EDIT_FILLER = /* @__PURE__ */ new Set([...FILLER, "to", "by"]);
2917
+
2918
+ // src/lib/remind.ts
2919
+ function remindLabel(minutes) {
2920
+ return minutes === 0 ? "As it starts" : `${formatDuration(minutes)} before`;
2921
+ }
2922
+
2923
+ // src/lib/theme.ts
2924
+ var THEMES = ["midnight", "ember", "paper"];
2925
+ var DEFAULT_THEME = "midnight";
2926
+ function isThemeName(value) {
2927
+ return typeof value === "string" && THEMES.includes(value);
2928
+ }
2929
+
2930
+ // src/lib/places.ts
2931
+ var COORDS = {
2932
+ "Africa/Abidjan": [5.32, -4.03],
2933
+ "Africa/Accra": [5.55, -0.22],
2934
+ "Africa/Addis_Ababa": [9.03, 38.7],
2935
+ "Africa/Algiers": [36.78, 3.05],
2936
+ "Africa/Asmara": [15.33, 38.88],
2937
+ "Africa/Bamako": [12.65, -8],
2938
+ "Africa/Bangui": [4.37, 18.58],
2939
+ "Africa/Banjul": [13.47, -16.65],
2940
+ "Africa/Bissau": [11.85, -15.58],
2941
+ "Africa/Blantyre": [-15.78, 35],
2942
+ "Africa/Brazzaville": [-4.27, 15.28],
2943
+ "Africa/Bujumbura": [-3.38, 29.37],
2944
+ "Africa/Cairo": [30.05, 31.25],
2945
+ "Africa/Casablanca": [33.65, -7.58],
2946
+ "Africa/Ceuta": [35.88, -5.32],
2947
+ "Africa/Conakry": [9.52, -13.72],
2948
+ "Africa/Dakar": [14.67, -17.43],
2949
+ "Africa/Dar_es_Salaam": [-6.8, 39.28],
2950
+ "Africa/Djibouti": [11.6, 43.15],
2951
+ "Africa/Douala": [4.05, 9.7],
2952
+ "Africa/El_Aaiun": [27.15, -13.2],
2953
+ "Africa/Freetown": [8.5, -13.25],
2954
+ "Africa/Gaborone": [-24.65, 25.92],
2955
+ "Africa/Harare": [-17.83, 31.05],
2956
+ "Africa/Johannesburg": [-26.25, 28],
2957
+ "Africa/Juba": [4.85, 31.62],
2958
+ "Africa/Kampala": [0.32, 32.42],
2959
+ "Africa/Khartoum": [15.6, 32.53],
2960
+ "Africa/Kigali": [-1.95, 30.07],
2961
+ "Africa/Kinshasa": [-4.3, 15.3],
2962
+ "Africa/Lagos": [6.45, 3.4],
2963
+ "Africa/Libreville": [0.38, 9.45],
2964
+ "Africa/Lome": [6.13, 1.22],
2965
+ "Africa/Luanda": [-8.8, 13.23],
2966
+ "Africa/Lubumbashi": [-11.67, 27.47],
2967
+ "Africa/Lusaka": [-15.42, 28.28],
2968
+ "Africa/Malabo": [3.75, 8.78],
2969
+ "Africa/Maputo": [-25.97, 32.58],
2970
+ "Africa/Maseru": [-29.47, 27.5],
2971
+ "Africa/Mbabane": [-26.3, 31.1],
2972
+ "Africa/Mogadishu": [2.07, 45.37],
2973
+ "Africa/Monrovia": [6.3, -10.78],
2974
+ "Africa/Nairobi": [-1.28, 36.82],
2975
+ "Africa/Ndjamena": [12.12, 15.05],
2976
+ "Africa/Niamey": [13.52, 2.12],
2977
+ "Africa/Nouakchott": [18.1, -15.95],
2978
+ "Africa/Ouagadougou": [12.37, -1.52],
2979
+ "Africa/Porto-Novo": [6.48, 2.62],
2980
+ "Africa/Sao_Tome": [0.33, 6.73],
2981
+ "Africa/Tripoli": [32.9, 13.18],
2982
+ "Africa/Tunis": [36.8, 10.18],
2983
+ "Africa/Windhoek": [-22.57, 17.1],
2984
+ "America/Adak": [51.88, -176.66],
2985
+ "America/Anchorage": [61.22, -149.9],
2986
+ "America/Anguilla": [18.2, -63.07],
2987
+ "America/Antigua": [17.05, -61.8],
2988
+ "America/Araguaina": [-7.2, -48.2],
2989
+ "America/Argentina/Buenos_Aires": [-34.6, -58.45],
2990
+ "America/Argentina/Catamarca": [-28.47, -65.78],
2991
+ "America/Argentina/Cordoba": [-31.4, -64.18],
2992
+ "America/Argentina/Jujuy": [-24.18, -65.3],
2993
+ "America/Argentina/La_Rioja": [-29.43, -66.85],
2994
+ "America/Argentina/Mendoza": [-32.88, -68.82],
2995
+ "America/Argentina/Rio_Gallegos": [-51.63, -69.22],
2996
+ "America/Argentina/Salta": [-24.78, -65.42],
2997
+ "America/Argentina/San_Juan": [-31.53, -68.52],
2998
+ "America/Argentina/San_Luis": [-33.32, -66.35],
2999
+ "America/Argentina/Tucuman": [-26.82, -65.22],
3000
+ "America/Argentina/Ushuaia": [-54.8, -68.3],
3001
+ "America/Aruba": [12.5, -69.97],
3002
+ "America/Asuncion": [-25.27, -57.67],
3003
+ "America/Atikokan": [48.76, -91.62],
3004
+ "America/Bahia": [-12.98, -38.52],
3005
+ "America/Bahia_Banderas": [20.8, -105.25],
3006
+ "America/Barbados": [13.1, -59.62],
3007
+ "America/Belem": [-1.45, -48.48],
3008
+ "America/Belize": [17.5, -88.2],
3009
+ "America/Blanc-Sablon": [51.42, -57.12],
3010
+ "America/Boa_Vista": [2.82, -60.67],
3011
+ "America/Bogota": [4.6, -74.08],
3012
+ "America/Boise": [43.61, -116.2],
3013
+ "America/Cambridge_Bay": [69.11, -105.05],
3014
+ "America/Campo_Grande": [-20.45, -54.62],
3015
+ "America/Cancun": [21.08, -86.77],
3016
+ "America/Caracas": [10.5, -66.93],
3017
+ "America/Cayenne": [4.93, -52.33],
3018
+ "America/Cayman": [19.3, -81.38],
3019
+ "America/Chicago": [41.85, -87.65],
3020
+ "America/Chihuahua": [28.63, -106.08],
3021
+ "America/Ciudad_Juarez": [31.73, -106.48],
3022
+ "America/Costa_Rica": [9.93, -84.08],
3023
+ "America/Coyhaique": [-45.57, -72.07],
3024
+ "America/Creston": [49.1, -116.52],
3025
+ "America/Cuiaba": [-15.58, -56.08],
3026
+ "America/Curacao": [12.18, -69],
3027
+ "America/Danmarkshavn": [76.77, -18.67],
3028
+ "America/Dawson": [64.07, -139.42],
3029
+ "America/Dawson_Creek": [55.77, -120.23],
3030
+ "America/Denver": [39.74, -104.98],
3031
+ "America/Detroit": [42.33, -83.05],
3032
+ "America/Dominica": [15.3, -61.4],
3033
+ "America/Edmonton": [53.55, -113.47],
3034
+ "America/Eirunepe": [-6.67, -69.87],
3035
+ "America/El_Salvador": [13.7, -89.2],
3036
+ "America/Fort_Nelson": [58.8, -122.7],
3037
+ "America/Fortaleza": [-3.72, -38.5],
3038
+ "America/Glace_Bay": [46.2, -59.95],
3039
+ "America/Goose_Bay": [53.33, -60.42],
3040
+ "America/Grand_Turk": [21.47, -71.13],
3041
+ "America/Grenada": [12.05, -61.75],
3042
+ "America/Guadeloupe": [16.23, -61.53],
3043
+ "America/Guatemala": [14.63, -90.52],
3044
+ "America/Guayaquil": [-2.17, -79.83],
3045
+ "America/Guyana": [6.8, -58.17],
3046
+ "America/Halifax": [44.65, -63.6],
3047
+ "America/Havana": [23.13, -82.37],
3048
+ "America/Hermosillo": [29.07, -110.97],
3049
+ "America/Indiana/Indianapolis": [39.77, -86.16],
3050
+ "America/Indiana/Knox": [41.3, -86.62],
3051
+ "America/Indiana/Marengo": [38.38, -86.34],
3052
+ "America/Indiana/Petersburg": [38.49, -87.28],
3053
+ "America/Indiana/Tell_City": [37.95, -86.76],
3054
+ "America/Indiana/Vevay": [38.75, -85.07],
3055
+ "America/Indiana/Vincennes": [38.68, -87.53],
3056
+ "America/Indiana/Winamac": [41.05, -86.6],
3057
+ "America/Inuvik": [68.35, -133.72],
3058
+ "America/Iqaluit": [63.73, -68.47],
3059
+ "America/Jamaica": [17.97, -76.79],
3060
+ "America/Juneau": [58.3, -134.42],
3061
+ "America/Kentucky/Louisville": [38.25, -85.76],
3062
+ "America/Kentucky/Monticello": [36.83, -84.85],
3063
+ "America/Kralendijk": [12.15, -68.28],
3064
+ "America/La_Paz": [-16.5, -68.15],
3065
+ "America/Lima": [-12.05, -77.05],
3066
+ "America/Los_Angeles": [34.05, -118.24],
3067
+ "America/Lower_Princes": [18.05, -63.05],
3068
+ "America/Maceio": [-9.67, -35.72],
3069
+ "America/Managua": [12.15, -86.28],
3070
+ "America/Manaus": [-3.13, -60.02],
3071
+ "America/Marigot": [18.07, -63.08],
3072
+ "America/Martinique": [14.6, -61.08],
3073
+ "America/Matamoros": [25.83, -97.5],
3074
+ "America/Mazatlan": [23.22, -106.42],
3075
+ "America/Menominee": [45.11, -87.61],
3076
+ "America/Merida": [20.97, -89.62],
3077
+ "America/Metlakatla": [55.13, -131.58],
3078
+ "America/Mexico_City": [19.4, -99.15],
3079
+ "America/Miquelon": [47.05, -56.33],
3080
+ "America/Moncton": [46.1, -64.78],
3081
+ "America/Monterrey": [25.67, -100.32],
3082
+ "America/Montevideo": [-34.91, -56.21],
3083
+ "America/Montserrat": [16.72, -62.22],
3084
+ "America/Nassau": [25.08, -77.35],
3085
+ "America/New_York": [40.71, -74.01],
3086
+ "America/Nome": [64.5, -165.41],
3087
+ "America/Noronha": [-3.85, -32.42],
3088
+ "America/North_Dakota/Beulah": [47.26, -101.78],
3089
+ "America/North_Dakota/Center": [47.12, -101.3],
3090
+ "America/North_Dakota/New_Salem": [46.84, -101.41],
3091
+ "America/Nuuk": [64.18, -51.73],
3092
+ "America/Ojinaga": [29.57, -104.42],
3093
+ "America/Panama": [8.97, -79.53],
3094
+ "America/Paramaribo": [5.83, -55.17],
3095
+ "America/Phoenix": [33.45, -112.07],
3096
+ "America/Port-au-Prince": [18.53, -72.33],
3097
+ "America/Port_of_Spain": [10.65, -61.52],
3098
+ "America/Porto_Velho": [-8.77, -63.9],
3099
+ "America/Puerto_Rico": [18.47, -66.11],
3100
+ "America/Punta_Arenas": [-53.15, -70.92],
3101
+ "America/Rankin_Inlet": [62.82, -92.08],
3102
+ "America/Recife": [-8.05, -34.9],
3103
+ "America/Regina": [50.4, -104.65],
3104
+ "America/Resolute": [74.7, -94.83],
3105
+ "America/Rio_Branco": [-9.97, -67.8],
3106
+ "America/Santarem": [-2.43, -54.87],
3107
+ "America/Santiago": [-33.45, -70.67],
3108
+ "America/Santo_Domingo": [18.47, -69.9],
3109
+ "America/Sao_Paulo": [-23.53, -46.62],
3110
+ "America/Scoresbysund": [70.48, -21.97],
3111
+ "America/Sitka": [57.18, -135.3],
3112
+ "America/St_Barthelemy": [17.88, -62.85],
3113
+ "America/St_Johns": [47.57, -52.72],
3114
+ "America/St_Kitts": [17.3, -62.72],
3115
+ "America/St_Lucia": [14.02, -61],
3116
+ "America/St_Thomas": [18.35, -64.93],
3117
+ "America/St_Vincent": [13.15, -61.23],
3118
+ "America/Swift_Current": [50.28, -107.83],
3119
+ "America/Tegucigalpa": [14.1, -87.22],
3120
+ "America/Thule": [76.57, -68.78],
3121
+ "America/Tijuana": [32.53, -117.02],
3122
+ "America/Toronto": [43.65, -79.38],
3123
+ "America/Tortola": [18.45, -64.62],
3124
+ "America/Vancouver": [49.27, -123.12],
3125
+ "America/Whitehorse": [60.72, -135.05],
3126
+ "America/Winnipeg": [49.88, -97.15],
3127
+ "America/Yakutat": [59.55, -139.73],
3128
+ "Antarctica/Casey": [-66.28, 110.52],
3129
+ "Antarctica/Davis": [-68.58, 77.97],
3130
+ "Antarctica/DumontDUrville": [-66.67, 140.02],
3131
+ "Antarctica/Macquarie": [-54.5, 158.95],
3132
+ "Antarctica/Mawson": [-67.6, 62.88],
3133
+ "Antarctica/McMurdo": [-77.83, 166.6],
3134
+ "Antarctica/Palmer": [-64.8, -64.1],
3135
+ "Antarctica/Rothera": [-67.57, -68.13],
3136
+ "Antarctica/Syowa": [-69.01, 39.59],
3137
+ "Antarctica/Troll": [-72.01, 2.53],
3138
+ "Antarctica/Vostok": [-78.4, 106.9],
3139
+ "Arctic/Longyearbyen": [78, 16],
3140
+ "Asia/Aden": [12.75, 45.2],
3141
+ "Asia/Almaty": [43.25, 76.95],
3142
+ "Asia/Amman": [31.95, 35.93],
3143
+ "Asia/Anadyr": [64.75, 177.48],
3144
+ "Asia/Aqtau": [44.52, 50.27],
3145
+ "Asia/Aqtobe": [50.28, 57.17],
3146
+ "Asia/Ashgabat": [37.95, 58.38],
3147
+ "Asia/Atyrau": [47.12, 51.93],
3148
+ "Asia/Baghdad": [33.35, 44.42],
3149
+ "Asia/Bahrain": [26.38, 50.58],
3150
+ "Asia/Baku": [40.38, 49.85],
3151
+ "Asia/Bangkok": [13.75, 100.52],
3152
+ "Asia/Barnaul": [53.37, 83.75],
3153
+ "Asia/Beirut": [33.88, 35.5],
3154
+ "Asia/Bishkek": [42.9, 74.6],
3155
+ "Asia/Brunei": [4.93, 114.92],
3156
+ "Asia/Chita": [52.05, 113.47],
3157
+ "Asia/Colombo": [6.93, 79.85],
3158
+ "Asia/Damascus": [33.5, 36.3],
3159
+ "Asia/Dhaka": [23.72, 90.42],
3160
+ "Asia/Dili": [-8.55, 125.58],
3161
+ "Asia/Dubai": [25.3, 55.3],
3162
+ "Asia/Dushanbe": [38.58, 68.8],
3163
+ "Asia/Famagusta": [35.12, 33.95],
3164
+ "Asia/Gaza": [31.5, 34.47],
3165
+ "Asia/Hebron": [31.53, 35.09],
3166
+ "Asia/Ho_Chi_Minh": [10.75, 106.67],
3167
+ "Asia/Hong_Kong": [22.28, 114.15],
3168
+ "Asia/Hovd": [48.02, 91.65],
3169
+ "Asia/Irkutsk": [52.27, 104.33],
3170
+ "Asia/Jakarta": [-6.17, 106.8],
3171
+ "Asia/Jayapura": [-2.53, 140.7],
3172
+ "Asia/Jerusalem": [31.78, 35.22],
3173
+ "Asia/Kabul": [34.52, 69.2],
3174
+ "Asia/Kamchatka": [53.02, 158.65],
3175
+ "Asia/Karachi": [24.87, 67.05],
3176
+ "Asia/Kathmandu": [27.72, 85.32],
3177
+ "Asia/Khandyga": [62.66, 135.55],
3178
+ "Asia/Kolkata": [22.53, 88.37],
3179
+ "Asia/Krasnoyarsk": [56.02, 92.83],
3180
+ "Asia/Kuala_Lumpur": [3.17, 101.7],
3181
+ "Asia/Kuching": [1.55, 110.33],
3182
+ "Asia/Kuwait": [29.33, 47.98],
3183
+ "Asia/Macau": [22.2, 113.54],
3184
+ "Asia/Magadan": [59.57, 150.8],
3185
+ "Asia/Makassar": [-5.12, 119.4],
3186
+ "Asia/Manila": [14.59, 120.97],
3187
+ "Asia/Muscat": [23.6, 58.58],
3188
+ "Asia/Nicosia": [35.17, 33.37],
3189
+ "Asia/Novokuznetsk": [53.75, 87.12],
3190
+ "Asia/Novosibirsk": [55.03, 82.92],
3191
+ "Asia/Omsk": [55, 73.4],
3192
+ "Asia/Oral": [51.22, 51.35],
3193
+ "Asia/Phnom_Penh": [11.55, 104.92],
3194
+ "Asia/Pontianak": [-0.03, 109.33],
3195
+ "Asia/Pyongyang": [39.02, 125.75],
3196
+ "Asia/Qatar": [25.28, 51.53],
3197
+ "Asia/Qostanay": [53.2, 63.62],
3198
+ "Asia/Qyzylorda": [44.8, 65.47],
3199
+ "Asia/Riyadh": [24.63, 46.72],
3200
+ "Asia/Sakhalin": [46.97, 142.7],
3201
+ "Asia/Samarkand": [39.67, 66.8],
3202
+ "Asia/Seoul": [37.55, 126.97],
3203
+ "Asia/Shanghai": [31.23, 121.47],
3204
+ "Asia/Singapore": [1.28, 103.85],
3205
+ "Asia/Srednekolymsk": [67.47, 153.72],
3206
+ "Asia/Taipei": [25.05, 121.5],
3207
+ "Asia/Tashkent": [41.33, 69.3],
3208
+ "Asia/Tbilisi": [41.72, 44.82],
3209
+ "Asia/Tehran": [35.67, 51.43],
3210
+ "Asia/Thimphu": [27.47, 89.65],
3211
+ "Asia/Tokyo": [35.65, 139.74],
3212
+ "Asia/Tomsk": [56.5, 84.97],
3213
+ "Asia/Ulaanbaatar": [47.92, 106.88],
3214
+ "Asia/Urumqi": [43.8, 87.58],
3215
+ "Asia/Ust-Nera": [64.56, 143.23],
3216
+ "Asia/Vientiane": [17.97, 102.6],
3217
+ "Asia/Vladivostok": [43.17, 131.93],
3218
+ "Asia/Yakutsk": [62, 129.67],
3219
+ "Asia/Yangon": [16.78, 96.17],
3220
+ "Asia/Yekaterinburg": [56.85, 60.6],
3221
+ "Asia/Yerevan": [40.18, 44.5],
3222
+ "Atlantic/Azores": [37.73, -25.67],
3223
+ "Atlantic/Bermuda": [32.28, -64.77],
3224
+ "Atlantic/Canary": [28.1, -15.4],
3225
+ "Atlantic/Cape_Verde": [14.92, -23.52],
3226
+ "Atlantic/Faroe": [62.02, -6.77],
3227
+ "Atlantic/Madeira": [32.63, -16.9],
3228
+ "Atlantic/Reykjavik": [64.15, -21.85],
3229
+ "Atlantic/South_Georgia": [-54.27, -36.53],
3230
+ "Atlantic/St_Helena": [-15.92, -5.7],
3231
+ "Atlantic/Stanley": [-51.7, -57.85],
3232
+ "Australia/Adelaide": [-34.92, 138.58],
3233
+ "Australia/Brisbane": [-27.47, 153.03],
3234
+ "Australia/Broken_Hill": [-31.95, 141.45],
3235
+ "Australia/Darwin": [-12.47, 130.83],
3236
+ "Australia/Eucla": [-31.72, 128.87],
3237
+ "Australia/Hobart": [-42.88, 147.32],
3238
+ "Australia/Lindeman": [-20.27, 149],
3239
+ "Australia/Lord_Howe": [-31.55, 159.08],
3240
+ "Australia/Melbourne": [-37.82, 144.97],
3241
+ "Australia/Perth": [-31.95, 115.85],
3242
+ "Australia/Sydney": [-33.87, 151.22],
3243
+ "Europe/Amsterdam": [52.37, 4.9],
3244
+ "Europe/Andorra": [42.5, 1.52],
3245
+ "Europe/Astrakhan": [46.35, 48.05],
3246
+ "Europe/Athens": [37.97, 23.72],
3247
+ "Europe/Belgrade": [44.83, 20.5],
3248
+ "Europe/Berlin": [52.5, 13.37],
3249
+ "Europe/Bratislava": [48.15, 17.12],
3250
+ "Europe/Brussels": [50.83, 4.33],
3251
+ "Europe/Bucharest": [44.43, 26.1],
3252
+ "Europe/Budapest": [47.5, 19.08],
3253
+ "Europe/Busingen": [47.7, 8.68],
3254
+ "Europe/Chisinau": [47, 28.83],
3255
+ "Europe/Copenhagen": [55.67, 12.58],
3256
+ "Europe/Dublin": [53.33, -6.25],
3257
+ "Europe/Gibraltar": [36.13, -5.35],
3258
+ "Europe/Guernsey": [49.45, -2.54],
3259
+ "Europe/Helsinki": [60.17, 24.97],
3260
+ "Europe/Isle_of_Man": [54.15, -4.47],
3261
+ "Europe/Istanbul": [41.02, 28.97],
3262
+ "Europe/Jersey": [49.18, -2.11],
3263
+ "Europe/Kaliningrad": [54.72, 20.5],
3264
+ "Europe/Kirov": [58.6, 49.65],
3265
+ "Europe/Kyiv": [50.43, 30.52],
3266
+ "Europe/Lisbon": [38.72, -9.13],
3267
+ "Europe/Ljubljana": [46.05, 14.52],
3268
+ "Europe/London": [51.51, -0.13],
3269
+ "Europe/Luxembourg": [49.6, 6.15],
3270
+ "Europe/Madrid": [40.4, -3.68],
3271
+ "Europe/Malta": [35.9, 14.52],
3272
+ "Europe/Mariehamn": [60.1, 19.95],
3273
+ "Europe/Minsk": [53.9, 27.57],
3274
+ "Europe/Monaco": [43.7, 7.38],
3275
+ "Europe/Moscow": [55.76, 37.62],
3276
+ "Europe/Oslo": [59.92, 10.75],
3277
+ "Europe/Paris": [48.87, 2.33],
3278
+ "Europe/Podgorica": [42.43, 19.27],
3279
+ "Europe/Prague": [50.08, 14.43],
3280
+ "Europe/Riga": [56.95, 24.1],
3281
+ "Europe/Rome": [41.9, 12.48],
3282
+ "Europe/Samara": [53.2, 50.15],
3283
+ "Europe/San_Marino": [43.92, 12.47],
3284
+ "Europe/Sarajevo": [43.87, 18.42],
3285
+ "Europe/Saratov": [51.57, 46.03],
3286
+ "Europe/Simferopol": [44.95, 34.1],
3287
+ "Europe/Skopje": [41.98, 21.43],
3288
+ "Europe/Sofia": [42.68, 23.32],
3289
+ "Europe/Stockholm": [59.33, 18.05],
3290
+ "Europe/Tallinn": [59.42, 24.75],
3291
+ "Europe/Tirane": [41.33, 19.83],
3292
+ "Europe/Ulyanovsk": [54.33, 48.4],
3293
+ "Europe/Vaduz": [47.15, 9.52],
3294
+ "Europe/Vatican": [41.9, 12.45],
3295
+ "Europe/Vienna": [48.22, 16.33],
3296
+ "Europe/Vilnius": [54.68, 25.32],
3297
+ "Europe/Volgograd": [48.73, 44.42],
3298
+ "Europe/Warsaw": [52.25, 21],
3299
+ "Europe/Zagreb": [45.8, 15.97],
3300
+ "Europe/Zurich": [47.38, 8.53],
3301
+ "Indian/Antananarivo": [-18.92, 47.52],
3302
+ "Indian/Chagos": [-7.33, 72.42],
3303
+ "Indian/Christmas": [-10.42, 105.72],
3304
+ "Indian/Cocos": [-12.17, 96.92],
3305
+ "Indian/Comoro": [-11.68, 43.27],
3306
+ "Indian/Kerguelen": [-49.35, 70.22],
3307
+ "Indian/Mahe": [-4.67, 55.47],
3308
+ "Indian/Maldives": [4.17, 73.5],
3309
+ "Indian/Mauritius": [-20.17, 57.5],
3310
+ "Indian/Mayotte": [-12.78, 45.23],
3311
+ "Indian/Reunion": [-20.87, 55.47],
3312
+ "Pacific/Apia": [-13.83, -171.73],
3313
+ "Pacific/Auckland": [-36.87, 174.77],
3314
+ "Pacific/Bougainville": [-6.22, 155.57],
3315
+ "Pacific/Chatham": [-43.95, -176.55],
3316
+ "Pacific/Chuuk": [7.42, 151.78],
3317
+ "Pacific/Easter": [-27.15, -109.43],
3318
+ "Pacific/Efate": [-17.67, 168.42],
3319
+ "Pacific/Fakaofo": [-9.37, -171.23],
3320
+ "Pacific/Fiji": [-18.13, 178.42],
3321
+ "Pacific/Funafuti": [-8.52, 179.22],
3322
+ "Pacific/Galapagos": [-0.9, -89.6],
3323
+ "Pacific/Gambier": [-23.13, -134.95],
3324
+ "Pacific/Guadalcanal": [-9.53, 160.2],
3325
+ "Pacific/Guam": [13.47, 144.75],
3326
+ "Pacific/Honolulu": [21.31, -157.86],
3327
+ "Pacific/Kanton": [-2.78, -171.72],
3328
+ "Pacific/Kiritimati": [1.87, -157.33],
3329
+ "Pacific/Kosrae": [5.32, 162.98],
3330
+ "Pacific/Kwajalein": [9.08, 167.33],
3331
+ "Pacific/Majuro": [7.15, 171.2],
3332
+ "Pacific/Marquesas": [-9, -139.5],
3333
+ "Pacific/Midway": [28.22, -177.37],
3334
+ "Pacific/Nauru": [-0.52, 166.92],
3335
+ "Pacific/Niue": [-19.02, -169.92],
3336
+ "Pacific/Norfolk": [-29.05, 167.97],
3337
+ "Pacific/Noumea": [-22.27, 166.45],
3338
+ "Pacific/Pago_Pago": [-14.27, -170.7],
3339
+ "Pacific/Palau": [7.33, 134.48],
3340
+ "Pacific/Pitcairn": [-25.07, -130.08],
3341
+ "Pacific/Pohnpei": [6.97, 158.22],
3342
+ "Pacific/Port_Moresby": [-9.5, 147.17],
3343
+ "Pacific/Rarotonga": [-21.23, -159.77],
3344
+ "Pacific/Saipan": [15.2, 145.75],
3345
+ "Pacific/Tahiti": [-17.53, -149.57],
3346
+ "Pacific/Tarawa": [1.42, 173],
3347
+ "Pacific/Tongatapu": [-21.13, -175.2],
3348
+ "Pacific/Wake": [19.28, 166.62],
3349
+ "Pacific/Wallis": [-13.3, -176.17]
3350
+ };
3351
+ var ALIASES = {
3352
+ "Africa/Asmera": "Africa/Asmara",
3353
+ "America/Buenos_Aires": "America/Argentina/Buenos_Aires",
3354
+ "America/Catamarca": "America/Argentina/Catamarca",
3355
+ "America/Coral_Harbour": "America/Atikokan",
3356
+ "America/Cordoba": "America/Argentina/Cordoba",
3357
+ "America/Godthab": "America/Nuuk",
3358
+ "America/Indianapolis": "America/Indiana/Indianapolis",
3359
+ "America/Jujuy": "America/Argentina/Jujuy",
3360
+ "America/Louisville": "America/Kentucky/Louisville",
3361
+ "America/Mendoza": "America/Argentina/Mendoza",
3362
+ "Asia/Calcutta": "Asia/Kolkata",
3363
+ "Asia/Katmandu": "Asia/Kathmandu",
3364
+ "Asia/Rangoon": "Asia/Yangon",
3365
+ "Asia/Saigon": "Asia/Ho_Chi_Minh",
3366
+ "Atlantic/Faeroe": "Atlantic/Faroe",
3367
+ "Europe/Kiev": "Europe/Kyiv",
3368
+ "Pacific/Enderbury": "Pacific/Kanton",
3369
+ "Pacific/Ponape": "Pacific/Pohnpei",
3370
+ "Pacific/Truk": "Pacific/Chuuk"
3371
+ };
3372
+ function placeForZone(zone) {
3373
+ const coords = COORDS[zone] ?? COORDS[ALIASES[zone]];
3374
+ if (!coords) return null;
3375
+ return { zone, lat: coords[0], lon: coords[1], label: zoneLabel(zone) };
3376
+ }
3377
+ var PLACE_OPTIONS = Object.keys(COORDS).map((zone) => ({ value: zone, label: zoneLabel(zone) })).sort((a, b) => a.label.localeCompare(b.label));
3167
3378
 
3168
3379
  // src/lib/store.ts
3169
3380
  var DEFAULT_SETTINGS = {
3381
+ // Midnight is not "a preset that happens to be first": it is the app every
3382
+ // existing user has, so it is the default and the other two are opt-in.
3383
+ theme: DEFAULT_THEME,
3170
3384
  // The 0–23 face. It is the one that matches the instrument: a revolution is
3171
3385
  // a day here, not half of one, so an hour has exactly one place on it and
3172
3386
  // `13` is where `13` is. AM/PM is a preference, and it is one click away.
@@ -3283,12 +3497,39 @@ var DEFAULT_SETTINGS = {
3283
3497
  // `DEFAULT_WAKING`'s own pair: this setting exists to replace the constant,
3284
3498
  // not to disagree with it on day one.
3285
3499
  wakingFrom: DEFAULT_WAKING.from,
3286
- wakingTo: DEFAULT_WAKING.to
3500
+ wakingTo: DEFAULT_WAKING.to,
3501
+ // Opt-in: writes to the calendar nobody asked for are the one thing an
3502
+ // instrument must never surprise anybody with.
3503
+ autoSchedule: false
3287
3504
  };
3288
3505
  var DEFAULT_CALENDARS = [
3289
- { id: "personal", name: "Personal", color: PALETTE[0], icon: "house", visible: true },
3290
- { id: "work", name: "Work", color: PALETTE[3], icon: "briefcase", visible: true },
3291
- { id: "family", name: "Family", color: PALETTE[5], icon: "users", visible: true }
3506
+ {
3507
+ id: "personal",
3508
+ name: "Personal",
3509
+ color: PALETTE[0],
3510
+ icon: "house",
3511
+ visible: true,
3512
+ remindLead: REMIND_INHERIT,
3513
+ updatedAt: 0
3514
+ },
3515
+ {
3516
+ id: "work",
3517
+ name: "Work",
3518
+ color: PALETTE[3],
3519
+ icon: "briefcase",
3520
+ visible: true,
3521
+ remindLead: REMIND_INHERIT,
3522
+ updatedAt: 0
3523
+ },
3524
+ {
3525
+ id: "family",
3526
+ name: "Family",
3527
+ color: PALETTE[5],
3528
+ icon: "users",
3529
+ visible: true,
3530
+ remindLead: REMIND_INHERIT,
3531
+ updatedAt: 0
3532
+ }
3292
3533
  ];
3293
3534
  function newId() {
3294
3535
  return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
@@ -3318,8 +3559,6 @@ function draftEvent(day, startMin, endMin, calendarId, repeat = "never") {
3318
3559
  seriesKey: "",
3319
3560
  color: "",
3320
3561
  icon: "",
3321
- task: false,
3322
- doneKeys: [],
3323
3562
  // Busy: a gesture that named a time meant that time to be spoken for.
3324
3563
  free: false,
3325
3564
  // Inheriting, which is the only answer a gesture gives: the drag named a
@@ -3331,10 +3570,26 @@ function draftEvent(day, startMin, endMin, calendarId, repeat = "never") {
3331
3570
  // somebody else wrote.
3332
3571
  organizer: null,
3333
3572
  attendees: [],
3334
- attendeeCount: 0
3573
+ attendeeCount: 0,
3574
+ // A draft has never been saved, so it has never been changed. `create` and
3575
+ // `update` are where a record gets its stamp; a `0` reaching storage would
3576
+ // be an event that predates stamping, which is the conservative loser in
3577
+ // every merge rather than a lie.
3578
+ updatedAt: 0
3335
3579
  };
3336
3580
  }
3337
3581
 
3582
+ // src/lib/ics.ts
3583
+ var encoder = new TextEncoder();
3584
+ function fnv1a(source) {
3585
+ let h = 2166136261;
3586
+ for (let i = 0; i < source.length; i++) {
3587
+ h ^= source.charCodeAt(i);
3588
+ h = Math.imul(h, 16777619) >>> 0;
3589
+ }
3590
+ return h.toString(36);
3591
+ }
3592
+
3338
3593
  // src/lib/backup.ts
3339
3594
  function readSettings(value) {
3340
3595
  if (!isRecord(value)) return DEFAULT_SETTINGS;
@@ -3344,6 +3599,10 @@ function readSettings(value) {
3344
3599
  // there are exactly two spellings and both are written out by every
3345
3600
  // version that has ever had the setting.
3346
3601
  clock: value.clock === "12h" || value.clock === "24h" ? value.clock : DEFAULT_SETTINGS.clock,
3602
+ // A preset this build does not carry resets rather than half-applies:
3603
+ // `sane()` would pin it anyway, but a restored calendar should not spend
3604
+ // one commit looking broken first.
3605
+ theme: isThemeName(value.theme) ? value.theme : DEFAULT_SETTINGS.theme,
3347
3606
  secondZone: typeof value.secondZone === "string" ? value.secondZone : "",
3348
3607
  // The zone the browser that wrote this backup was in. Carried rather than
3349
3608
  // dropped, and deliberately not replaced with the reader's own: a Berlin
@@ -3437,13 +3696,12 @@ function readSettings(value) {
3437
3696
  // waking window to the defaults, same as every other bounded field here.
3438
3697
  weekStart: value.weekStart === 0 || value.weekStart === 1 || value.weekStart === 6 ? value.weekStart : DEFAULT_SETTINGS.weekStart,
3439
3698
  wakingFrom: typeof value.wakingFrom === "number" ? value.wakingFrom : DEFAULT_SETTINGS.wakingFrom,
3440
- wakingTo: typeof value.wakingTo === "number" ? value.wakingTo : DEFAULT_SETTINGS.wakingTo
3699
+ wakingTo: typeof value.wakingTo === "number" ? value.wakingTo : DEFAULT_SETTINGS.wakingTo,
3700
+ // Off unless the backup says so — a restore must not start writing to
3701
+ // the calendar because the field was absent.
3702
+ autoSchedule: value.autoSchedule === true
3441
3703
  };
3442
3704
  }
3443
- var INSTANT_KEY = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}$/;
3444
- function readDoneKeys(value) {
3445
- return Array.isArray(value) ? [...new Set(value.filter((k) => typeof k === "string" && INSTANT_KEY.test(k)))] : [];
3446
- }
3447
3705
  function derivedId(value, start, end) {
3448
3706
  const text = (key) => typeof value[key] === "string" ? value[key] : "";
3449
3707
  return `${start}-${fnv1a([start, end, text("title"), text("location"), text("seriesKey")].join("|"))}`;
@@ -3453,8 +3711,7 @@ function readEvent(value) {
3453
3711
  const { id, title, calendarId, start, end } = value;
3454
3712
  if (typeof start !== "number" || typeof end !== "number") return null;
3455
3713
  if (!Number.isFinite(start) || !Number.isFinite(end)) return null;
3456
- const task = value.task === true;
3457
- if (task ? end < start : end <= start) return null;
3714
+ if (end <= start) return null;
3458
3715
  return {
3459
3716
  // Derived from the record's own fields, never minted: `Math.random()` gave
3460
3717
  // a different id every read, so the same backup imported twice was two
@@ -3484,11 +3741,6 @@ function readEvent(value) {
3484
3741
  // it has to tell apart. This used to slice at one shared cap, which turned
3485
3742
  // ten of the 247 shipped names into strings matching no path.
3486
3743
  icon: readMark(value.icon),
3487
- task,
3488
- // The same instant-key check EXDATE gets, and the same reason: a key this
3489
- // does not spell matches no occurrence, so it is a row of dead weight that
3490
- // survives every future export.
3491
- doneKeys: readDoneKeys(value.doneKeys),
3492
3744
  // Anything other than a literal `true` is busy, which is what an event
3493
3745
  // exported before this field existed was. A truthy string in a
3494
3746
  // hand-edited blob must not quietly take a meeting off the day's total.
@@ -3506,7 +3758,12 @@ function readEvent(value) {
3506
3758
  // did — clamped up to the list it labels, since a blob understating it
3507
3759
  // would print a negative remainder.
3508
3760
  organizer: readPerson(value.organizer),
3509
- ...readGuests(value.attendees, value.attendeeCount)
3761
+ ...readGuests(value.attendees, value.attendeeCount),
3762
+ // What a legal stamp is lives in `events.ts`, beside the graves it is
3763
+ // compared against. `0` for anything unreadable and for every backup taken
3764
+ // before stamping existed, which is what tells a merge that a document is
3765
+ // not one it can merge.
3766
+ updatedAt: readStamp(value.updatedAt)
3510
3767
  };
3511
3768
  }
3512
3769
  function readCalendar(value) {
@@ -3524,14 +3781,44 @@ function readCalendar(value) {
3524
3781
  // again should not lose the mark — but an unbounded string is a paste, and
3525
3782
  // an over-long *name* is now dropped rather than cut into junk.
3526
3783
  icon: readMark(icon),
3784
+ // Same validator as an event's lead, same reason: the middle level of the
3785
+ // inherit chain arrives from the same untrusted JSON the top one does, and
3786
+ // a second opinion about what a legal lead is would be `readRecurrence`'s
3787
+ // documented mistake. Absent reads as inheriting, which is what every
3788
+ // calendar written before this field existed already did.
3789
+ remindLead: readRemindLead(value.remindLead),
3527
3790
  visible: value.visible !== false,
3791
+ updatedAt: readStamp(value.updatedAt),
3528
3792
  ...feedUrl ? { feed: { url: feedUrl, fetchedAt: 0, error: "" } } : {}
3529
3793
  };
3530
3794
  }
3795
+ function readTask(value) {
3796
+ if (!isRecord(value)) return null;
3797
+ const { id, title, due } = value;
3798
+ if (typeof id !== "string" || !id) return null;
3799
+ if (typeof due !== "number" || !Number.isFinite(due)) return null;
3800
+ return {
3801
+ id,
3802
+ title: typeof title === "string" ? title : "Untitled",
3803
+ notes: typeof value.notes === "string" ? value.notes : "",
3804
+ calendarId: typeof value.calendarId === "string" ? value.calendarId : "",
3805
+ // The mark and the colour take an event's own validators and defaults.
3806
+ icon: readMark(value.icon),
3807
+ color: typeof value.color === "string" ? value.color : "",
3808
+ due,
3809
+ dueAllDay: value.dueAllDay === true,
3810
+ estimate: readEstimate(value.estimate),
3811
+ priority: readPriority(value.priority),
3812
+ plans: readPlans(value.plans),
3813
+ done: value.done === true,
3814
+ updatedAt: readStamp(value.updatedAt)
3815
+ };
3816
+ }
3531
3817
  function readBackup(parsed) {
3532
3818
  if (!isRecord(parsed) || parsed.app !== "circal") return { ok: false, reason: "not-circal" };
3533
3819
  const events = (Array.isArray(parsed.events) ? parsed.events : []).map(readEvent).filter((e) => e !== null);
3534
3820
  const calendars = (Array.isArray(parsed.calendars) ? parsed.calendars : []).map(readCalendar).filter((c) => c !== null);
3821
+ const tasks = (Array.isArray(parsed.tasks) ? parsed.tasks : []).map(readTask).filter((t) => t !== null);
3535
3822
  if (calendars.length === 0) return { ok: false, reason: "no-calendars" };
3536
3823
  const known = new Set(calendars.map((c) => c.id));
3537
3824
  const fallback = calendars[0].id;
@@ -3543,12 +3830,19 @@ function readBackup(parsed) {
3543
3830
  return e.seriesId && !seriesIds.has(e.seriesId) ? { ...parented, seriesId: "", seriesKey: "" } : parented;
3544
3831
  }),
3545
3832
  calendars,
3546
- settings: readSettings(parsed.settings)
3833
+ // A task's calendar is parked the same way an event's is, same reason.
3834
+ tasks: tasks.map((t) => known.has(t.calendarId) ? t : { ...t, calendarId: fallback }),
3835
+ settings: readSettings(parsed.settings),
3836
+ // Not through `readSettings`: this is bookkeeping about the object rather
3837
+ // than a field of it, and a reader that clamped it into `Settings` would
3838
+ // have to teach every one of the thirty-eight about a stamp.
3839
+ settingsAt: readStamp(parsed.settingsAt),
3840
+ graves: readGraves(parsed.graves)
3547
3841
  };
3548
3842
  }
3549
3843
 
3550
3844
  // src/lib/mirror.ts
3551
- var MIRROR_VERSION = 1;
3845
+ var MIRROR_VERSION = 3;
3552
3846
  function settled(calendars) {
3553
3847
  return calendars.map(
3554
3848
  (c) => c.feed ? { ...c, feed: { ...c.feed, fetchedAt: 0, error: "" } } : c
@@ -3562,8 +3856,11 @@ function serialiseMirror(s, rev, zone = localZone()) {
3562
3856
  zone,
3563
3857
  rev,
3564
3858
  events: s.events,
3859
+ tasks: s.tasks,
3565
3860
  calendars: settled(s.calendars),
3566
- settings: s.settings
3861
+ settings: s.settings,
3862
+ settingsAt: s.settingsAt,
3863
+ graves: s.graves
3567
3864
  };
3568
3865
  return `${JSON.stringify(file, null, 2)}
3569
3866
  `;
@@ -3580,8 +3877,14 @@ function readMirrorFile(text) {
3580
3877
  const envelope = parsed;
3581
3878
  return {
3582
3879
  events: read.events,
3880
+ tasks: read.tasks,
3583
3881
  calendars: read.calendars,
3584
3882
  settings: read.settings,
3883
+ // Both through `readBackup`, which is the boundary that already assumes
3884
+ // hostile input. A file that carries neither reads as a document that
3885
+ // predates stamping, and `mirrorMerges` declines to merge it.
3886
+ settingsAt: read.settingsAt,
3887
+ graves: read.graves,
3585
3888
  rev: typeof envelope.rev === "number" && Number.isFinite(envelope.rev) ? envelope.rev : 0,
3586
3889
  // A plain `.json` backup has no zone. Absent means "written by something
3587
3890
  // that did not say", which the reader treats as agreeing with it — the
@@ -3594,30 +3897,128 @@ function readMirrorFile(text) {
3594
3897
  };
3595
3898
  }
3596
3899
 
3900
+ // src/lib/vault.ts
3901
+ var VAULT_VERSION = 1;
3902
+ var PBKDF2_ITERATIONS = 6e5;
3903
+ var VERSION_BYTES = 1;
3904
+ var SALT_BYTES = 16;
3905
+ var IV_BYTES = 12;
3906
+ var TAG_BYTES = 16;
3907
+ var HEADER_BYTES = VERSION_BYTES + SALT_BYTES + IV_BYTES;
3908
+ var MIN_BYTES = HEADER_BYTES + TAG_BYTES;
3909
+ var utf8 = new TextEncoder();
3910
+ var utf8Reader = new TextDecoder();
3911
+ var WrongPassphrase = class extends Error {
3912
+ };
3913
+ var UnreadablePayload = class extends Error {
3914
+ };
3915
+ function parse(payload) {
3916
+ const bytes = base64UrlToBytes(payload);
3917
+ if (!bytes || bytes.length < MIN_BYTES) return null;
3918
+ if (bytes[0] < 1 || bytes[0] > VAULT_VERSION) return null;
3919
+ return {
3920
+ salt: bytes.subarray(VERSION_BYTES, VERSION_BYTES + SALT_BYTES),
3921
+ iv: bytes.subarray(VERSION_BYTES + SALT_BYTES, HEADER_BYTES),
3922
+ body: bytes.subarray(HEADER_BYTES)
3923
+ };
3924
+ }
3925
+ function saltOf(payload) {
3926
+ const envelope = parse(payload);
3927
+ return envelope ? new Uint8Array(envelope.salt) : null;
3928
+ }
3929
+ async function deriveBits(passphrase, salt) {
3930
+ const material = await crypto.subtle.importKey("raw", utf8.encode(passphrase), "PBKDF2", false, [
3931
+ "deriveBits"
3932
+ ]);
3933
+ const bits = await crypto.subtle.deriveBits(
3934
+ { name: "PBKDF2", salt, iterations: PBKDF2_ITERATIONS, hash: "SHA-256" },
3935
+ material,
3936
+ 256
3937
+ );
3938
+ return new Uint8Array(bits);
3939
+ }
3940
+ async function restoreKeyed(raw, salt) {
3941
+ const key = await crypto.subtle.importKey("raw", raw, { name: "AES-GCM" }, false, [
3942
+ "encrypt",
3943
+ "decrypt"
3944
+ ]);
3945
+ return { key, salt };
3946
+ }
3947
+ function vaultOf({ key, salt }) {
3948
+ return {
3949
+ async seal(plaintext) {
3950
+ const iv = crypto.getRandomValues(new Uint8Array(IV_BYTES));
3951
+ const cipher = new Uint8Array(
3952
+ await crypto.subtle.encrypt({ name: "AES-GCM", iv }, key, utf8.encode(plaintext))
3953
+ );
3954
+ const out = new Uint8Array(HEADER_BYTES + cipher.length);
3955
+ out[0] = VAULT_VERSION;
3956
+ out.set(salt, VERSION_BYTES);
3957
+ out.set(iv, VERSION_BYTES + SALT_BYTES);
3958
+ out.set(cipher, HEADER_BYTES);
3959
+ return bytesToBase64Url(out);
3960
+ },
3961
+ async open(payload) {
3962
+ const envelope = parse(payload);
3963
+ if (!envelope) throw new UnreadablePayload("not a vault payload this build can read");
3964
+ let plain;
3965
+ try {
3966
+ plain = await crypto.subtle.decrypt(
3967
+ { name: "AES-GCM", iv: envelope.iv },
3968
+ key,
3969
+ envelope.body
3970
+ );
3971
+ } catch {
3972
+ throw new WrongPassphrase("passphrase does not open this payload");
3973
+ }
3974
+ return utf8Reader.decode(plain);
3975
+ }
3976
+ };
3977
+ }
3978
+
3979
+ // src/lib/cloudLink.ts
3980
+ function readCloudRow(value) {
3981
+ if (typeof value !== "object" || value === null) return null;
3982
+ const row = value;
3983
+ if (typeof row.payload !== "string" || row.payload === "") return null;
3984
+ return {
3985
+ payload: row.payload,
3986
+ rev: typeof row.rev === "number" && Number.isFinite(row.rev) ? row.rev : 0,
3987
+ zone: typeof row.zone === "string" ? row.zone : "",
3988
+ version: typeof row.version === "number" && Number.isFinite(row.version) ? row.version : MIRROR_VERSION
3989
+ };
3990
+ }
3991
+ var LOCKED = "That passphrase does not open the cloud copy. Nothing was written \u2014 this device's calendar is untouched.";
3992
+ var writing = Promise.resolve();
3993
+
3597
3994
  // mcp/file.ts
3995
+ import { randomBytes } from "node:crypto";
3996
+ import { open, readFile, rename, rm, stat } from "node:fs/promises";
3997
+ import { dirname, join } from "node:path";
3998
+ import { setTimeout as sleep } from "node:timers/promises";
3598
3999
  var RefusalError = class extends Error {
3599
4000
  };
3600
4001
  function resolveFilePath(argv2, env) {
3601
4002
  const flagIndex = argv2.indexOf("--file");
3602
- const flagValue = flagIndex >= 0 ? argv2[flagIndex + 1] : void 0;
3603
- const path2 = flagValue || env.CIRCAL_FILE;
3604
- if (!path2) {
4003
+ const flagValue2 = flagIndex >= 0 ? argv2[flagIndex + 1] : void 0;
4004
+ const path = flagValue2 || env.CIRCAL_FILE;
4005
+ if (!path) {
3605
4006
  throw new Error(
3606
4007
  "circal-mcp needs a mirror file to read and write. Set the CIRCAL_FILE environment variable or pass --file <path> on the command line. Neither was given."
3607
4008
  );
3608
4009
  }
3609
- return path2;
4010
+ return path;
3610
4011
  }
3611
- async function readMirror(path2) {
3612
- const text = await readFile(path2, "utf8");
4012
+ async function readMirror(path) {
4013
+ const text = await readFile(path, "utf8");
3613
4014
  const contents = readMirrorFile(text);
3614
4015
  if (!contents) {
3615
- throw new Error(`${path2} is not a circal mirror file (or a circal .json backup).`);
4016
+ throw new Error(`${path} is not a circal mirror file (or a circal .json backup).`);
3616
4017
  }
3617
4018
  return contents;
3618
4019
  }
3619
- async function atomicWrite(path2, body) {
3620
- const dir = dirname(path2);
4020
+ async function atomicWrite(path, body) {
4021
+ const dir = dirname(path);
3621
4022
  const tmp = join(dir, `.circal-mcp-${randomBytes(6).toString("hex")}.tmp`);
3622
4023
  try {
3623
4024
  const file = await open(tmp, "w");
@@ -3627,7 +4028,7 @@ async function atomicWrite(path2, body) {
3627
4028
  } finally {
3628
4029
  await file.close();
3629
4030
  }
3630
- await rename(tmp, path2);
4031
+ await rename(tmp, path);
3631
4032
  } catch (error) {
3632
4033
  await rm(tmp, { force: true });
3633
4034
  throw error;
@@ -3661,15 +4062,22 @@ function assertVersionAllowsMutation(contents) {
3661
4062
  `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.`
3662
4063
  );
3663
4064
  }
3664
- async function loadForRead(ctx2) {
3665
- const contents = await readMirror(ctx2.path);
4065
+ async function loadForRead(ctx) {
4066
+ const contents = await readMirror(ctx.path);
3666
4067
  return { contents, warning: zoneWarning(contents) };
3667
4068
  }
4069
+ function fileStore(ctx) {
4070
+ return {
4071
+ transport: `file ${ctx.path}`,
4072
+ load: () => loadForRead(ctx),
4073
+ write: (mutate) => casWrite(ctx, mutate)
4074
+ };
4075
+ }
3668
4076
  var LOCK_STALE_MS = 1e4;
3669
4077
  var LOCK_WAIT_MS = 2e3;
3670
4078
  var LOCK_POLL_MS = 100;
3671
- async function acquireLock(path2) {
3672
- const lock = `${path2}.lock`;
4079
+ async function acquireLock(path) {
4080
+ const lock = `${path}.lock`;
3673
4081
  const deadline = Date.now() + LOCK_WAIT_MS;
3674
4082
  for (; ; ) {
3675
4083
  try {
@@ -3700,15 +4108,15 @@ async function acquireLock(path2) {
3700
4108
  await sleep(LOCK_POLL_MS);
3701
4109
  }
3702
4110
  }
3703
- async function casWriteOnce(ctx2, mutate) {
3704
- const lock = await acquireLock(ctx2.path);
4111
+ async function casWriteOnce(ctx, mutate) {
4112
+ const lock = await acquireLock(ctx.path);
3705
4113
  try {
3706
4114
  for (let attempt = 0; attempt < 2; attempt++) {
3707
- const before = await readMirror(ctx2.path);
4115
+ const before = await readMirror(ctx.path);
3708
4116
  assertVersionAllowsMutation(before);
3709
- assertZoneAllowsMutation(before, ctx2.env);
4117
+ assertZoneAllowsMutation(before, ctx.env);
3710
4118
  const { result, summary } = mutate(before);
3711
- const after = await readMirror(ctx2.path);
4119
+ const after = await readMirror(ctx.path);
3712
4120
  if (after.rev !== before.rev) {
3713
4121
  if (attempt === 1) {
3714
4122
  throw new Error(
@@ -3717,54 +4125,431 @@ async function casWriteOnce(ctx2, mutate) {
3717
4125
  }
3718
4126
  continue;
3719
4127
  }
3720
- await atomicWrite(
3721
- ctx2.path,
3722
- serialiseMirror(result, after.rev + 1, after.zone || localZone())
3723
- );
3724
- return summary;
4128
+ await atomicWrite(
4129
+ ctx.path,
4130
+ serialiseMirror(result, after.rev + 1, after.zone || localZone())
4131
+ );
4132
+ return summary;
4133
+ }
4134
+ throw new Error("unreachable");
4135
+ } finally {
4136
+ await rm(lock, { force: true });
4137
+ }
4138
+ }
4139
+ var writeQueue = Promise.resolve();
4140
+ function casWrite(ctx, mutate) {
4141
+ const run = writeQueue.then(() => casWriteOnce(ctx, mutate));
4142
+ writeQueue = run.then(
4143
+ () => void 0,
4144
+ () => void 0
4145
+ );
4146
+ return run;
4147
+ }
4148
+
4149
+ // mcp/cloud.ts
4150
+ function credentialsPath(env = process.env) {
4151
+ if (env.CIRCAL_CLOUD_CREDENTIALS) return env.CIRCAL_CLOUD_CREDENTIALS;
4152
+ const home = homedir();
4153
+ if (process.platform === "darwin")
4154
+ return join2(home, "Library", "Application Support", "circal", "cloud.json");
4155
+ if (process.platform === "win32")
4156
+ return join2(env.APPDATA || join2(home, "AppData", "Roaming"), "circal", "cloud.json");
4157
+ return join2(env.XDG_CONFIG_HOME || join2(home, ".config"), "circal", "cloud.json");
4158
+ }
4159
+ async function readCredentials(path) {
4160
+ let text;
4161
+ try {
4162
+ text = await readFile2(path, "utf8");
4163
+ } catch {
4164
+ return null;
4165
+ }
4166
+ let parsed;
4167
+ try {
4168
+ parsed = JSON.parse(text);
4169
+ } catch {
4170
+ return null;
4171
+ }
4172
+ if (typeof parsed !== "object" || parsed === null) return null;
4173
+ const record = parsed;
4174
+ const strings = [
4175
+ "url",
4176
+ "key",
4177
+ "email",
4178
+ "user",
4179
+ "salt",
4180
+ "aesKey",
4181
+ "accessToken",
4182
+ "refreshToken"
4183
+ ];
4184
+ for (const field of strings) if (typeof record[field] !== "string" || !record[field]) return null;
4185
+ if (typeof record.expiresAt !== "number") return null;
4186
+ return record;
4187
+ }
4188
+ async function writeCredentials(path, creds) {
4189
+ await mkdir(join2(path, ".."), { recursive: true });
4190
+ await writeFile(path, JSON.stringify(creds, null, 2) + "\n", { encoding: "utf8", mode: 384 });
4191
+ await chmod(path, 384);
4192
+ }
4193
+ function readSession(value) {
4194
+ if (typeof value !== "object" || value === null) return null;
4195
+ const body = value;
4196
+ const user = body.user;
4197
+ if (typeof body.access_token !== "string" || typeof body.refresh_token !== "string") return null;
4198
+ if (typeof user?.id !== "string") return null;
4199
+ const expiresAt = typeof body.expires_at === "number" ? body.expires_at : Math.floor(Date.now() / 1e3) + (typeof body.expires_in === "number" ? body.expires_in : 3600);
4200
+ return {
4201
+ accessToken: body.access_token,
4202
+ refreshToken: body.refresh_token,
4203
+ expiresAt,
4204
+ user: user.id,
4205
+ email: typeof user.email === "string" ? user.email : ""
4206
+ };
4207
+ }
4208
+ async function apiError(response, doing) {
4209
+ let detail = "";
4210
+ try {
4211
+ const body = await response.json();
4212
+ detail = String(body.msg ?? body.message ?? body.error_description ?? body.error ?? "");
4213
+ } catch {
4214
+ }
4215
+ return new Error(`${doing} failed (${response.status})${detail ? `: ${detail}` : ""}`);
4216
+ }
4217
+ async function authRequest(url, key, path, body, doing) {
4218
+ const response = await fetch(`${url}/auth/v1/${path}`, {
4219
+ method: "POST",
4220
+ headers: { apikey: key, "Content-Type": "application/json" },
4221
+ body: JSON.stringify(body)
4222
+ });
4223
+ if (!response.ok) throw await apiError(response, doing);
4224
+ return response.json();
4225
+ }
4226
+ async function requestCode(url, key, email) {
4227
+ await authRequest(url, key, "otp", { email, create_user: true }, "requesting a sign-in code");
4228
+ }
4229
+ async function verifyCode(url, key, email, token) {
4230
+ const body = await authRequest(
4231
+ url,
4232
+ key,
4233
+ "verify",
4234
+ { type: "email", email, token },
4235
+ "verifying the code"
4236
+ );
4237
+ const session = readSession(body);
4238
+ if (!session) throw new Error("the sign-in answer did not carry a session");
4239
+ return session;
4240
+ }
4241
+ async function refreshSession(url, key, refreshToken) {
4242
+ const body = await authRequest(
4243
+ url,
4244
+ key,
4245
+ "token?grant_type=refresh_token",
4246
+ { refresh_token: refreshToken },
4247
+ "refreshing the session"
4248
+ );
4249
+ const session = readSession(body);
4250
+ if (!session) throw new Error("the refresh answer did not carry a session");
4251
+ return session;
4252
+ }
4253
+ var EXPIRY_SLACK_S = 60;
4254
+ var CloudClient = class {
4255
+ constructor(creds, path) {
4256
+ this.creds = creds;
4257
+ this.path = path;
4258
+ }
4259
+ creds;
4260
+ path;
4261
+ get email() {
4262
+ return this.creds.email;
4263
+ }
4264
+ /**
4265
+ * A live access token, refreshed when near expiry. GoTrue *rotates*
4266
+ * refresh tokens, so a successful refresh is persisted immediately —
4267
+ * losing the rotation means the next launch's token is already spent
4268
+ * and the user is sent back through `login` for no crime of theirs.
4269
+ * The persistence itself is best-effort, `rememberRev`'s reason: the
4270
+ * refresh has already succeeded, and failing the user's actual request
4271
+ * over bookkeeping is the wrong way round.
4272
+ */
4273
+ async token() {
4274
+ if (this.creds.expiresAt - EXPIRY_SLACK_S > Date.now() / 1e3) return this.creds.accessToken;
4275
+ const session = await refreshSession(this.creds.url, this.creds.key, this.creds.refreshToken);
4276
+ this.creds = {
4277
+ ...this.creds,
4278
+ accessToken: session.accessToken,
4279
+ refreshToken: session.refreshToken,
4280
+ expiresAt: session.expiresAt
4281
+ };
4282
+ try {
4283
+ await writeCredentials(this.path, this.creds);
4284
+ } catch {
4285
+ }
4286
+ return this.creds.accessToken;
4287
+ }
4288
+ async rest(path, init) {
4289
+ const token = await this.token();
4290
+ return fetch(`${this.creds.url}/rest/v1/${path}`, {
4291
+ ...init,
4292
+ headers: {
4293
+ apikey: this.creds.key,
4294
+ Authorization: `Bearer ${token}`,
4295
+ "Content-Type": "application/json",
4296
+ ...init.headers ?? {}
4297
+ }
4298
+ });
4299
+ }
4300
+ /** The row, or `null` when the account has none yet. RLS scopes the select
4301
+ * to the signed-in user's own row, so there is no filter to get wrong. */
4302
+ async fetchRow() {
4303
+ const response = await this.rest("documents?select=rev,version,zone,payload", {
4304
+ method: "GET"
4305
+ });
4306
+ if (!response.ok) throw await apiError(response, "reading the cloud copy");
4307
+ const rows = await response.json();
4308
+ return readCloudRow(rows[0]);
4309
+ }
4310
+ /**
4311
+ * The compare-and-swap: `update … where rev = expected`, and the row count
4312
+ * answers whether we won. `Prefer: return=representation` is not decoration
4313
+ * — without it PostgREST returns nothing to count (the browser's `replace`
4314
+ * makes the same point with `.select()`). No insert branch on purpose: this
4315
+ * client never creates the row, so "no row" and "somebody moved it" can
4316
+ * share the miss without ambiguity — the caller re-reads either way.
4317
+ */
4318
+ async casReplace(expected, next, zone, payload) {
4319
+ const response = await this.rest(`documents?rev=eq.${expected}`, {
4320
+ method: "PATCH",
4321
+ headers: { Prefer: "return=representation" },
4322
+ body: JSON.stringify({ rev: next, version: MIRROR_VERSION, zone, payload })
4323
+ });
4324
+ if (!response.ok) throw await apiError(response, "writing the cloud copy");
4325
+ const rows = await response.json();
4326
+ return rows.length === 1;
4327
+ }
4328
+ };
4329
+ var NO_ROW = "No cloud copy exists for this account yet. Open circal in a browser, sign in and turn cloud sync on (Settings \u2192 Cloud copy), then retry.";
4330
+ var RESEALED_HERE = "The cloud copy was re-sealed under a different passphrase since this machine signed in, so the key it kept no longer opens it. Nothing was written. Run `circal-mcp login` again.";
4331
+ var UNOPENABLE = "The stored key does not open the cloud copy, though the copy claims its salt. Nothing was written. Run `circal-mcp login` again; if this repeats, the payload may be damaged.";
4332
+ function cloudStore(ctx) {
4333
+ const client = new CloudClient(ctx.credentials, ctx.path);
4334
+ const storedSalt = base64UrlToBytes(ctx.credentials.salt);
4335
+ const storedKey = base64UrlToBytes(ctx.credentials.aesKey);
4336
+ if (!storedSalt || !storedKey) {
4337
+ throw new Error(`${ctx.path} is damaged \u2014 run \`circal-mcp login\` to rewrite it.`);
4338
+ }
4339
+ const salt = storedSalt;
4340
+ const vaultReady = restoreKeyed(storedKey, salt).then(vaultOf);
4341
+ async function openRow(row) {
4342
+ const rowSalt = saltOf(row.payload);
4343
+ if (!rowSalt || rowSalt.length !== salt.length || !rowSalt.every((b, i) => b === salt[i])) {
4344
+ throw new RefusalError(RESEALED_HERE);
4345
+ }
4346
+ let text;
4347
+ try {
4348
+ text = await (await vaultReady).open(row.payload);
4349
+ } catch (error) {
4350
+ if (error instanceof WrongPassphrase) throw new RefusalError(UNOPENABLE);
4351
+ if (error instanceof UnreadablePayload)
4352
+ throw new RefusalError(
4353
+ "The cloud copy's payload is not a vault this build can read. Nothing was written."
4354
+ );
4355
+ throw error;
4356
+ }
4357
+ const contents = readMirrorFile(text);
4358
+ if (!contents) {
4359
+ throw new Error("the cloud payload decrypted, but not into a circal document");
4360
+ }
4361
+ return { ...contents, rev: row.rev, zone: row.zone || contents.zone };
4362
+ }
4363
+ async function loadOnce() {
4364
+ const row = await client.fetchRow();
4365
+ if (!row) throw new RefusalError(NO_ROW);
4366
+ const contents = await openRow(row);
4367
+ return { contents, warning: zoneWarning(contents) };
4368
+ }
4369
+ async function writeOnce(mutate) {
4370
+ let firstRev = null;
4371
+ for (let attempt = 0; attempt < 2; attempt++) {
4372
+ const row = await client.fetchRow();
4373
+ if (!row) throw new RefusalError(NO_ROW);
4374
+ const contents = await openRow(row);
4375
+ assertVersionAllowsMutation(contents);
4376
+ assertZoneAllowsMutation(contents, ctx.env);
4377
+ const { result, summary } = mutate(contents);
4378
+ const zone = row.zone || localZone();
4379
+ const next = row.rev + 1;
4380
+ const payload = await (await vaultReady).seal(serialiseMirror(result, next, zone));
4381
+ if (await client.casReplace(row.rev, next, zone, payload)) return summary;
4382
+ if (firstRev === null) firstRev = row.rev;
4383
+ else {
4384
+ throw new Error(
4385
+ `The cloud copy moved under this write: read at rev ${firstRev}, lost the swap again at rev ${row.rev}. Something else is writing it \u2014 try again.`
4386
+ );
4387
+ }
3725
4388
  }
3726
4389
  throw new Error("unreachable");
3727
- } finally {
3728
- await rm(lock, { force: true });
3729
4390
  }
4391
+ let writeQueue2 = Promise.resolve();
4392
+ return {
4393
+ transport: `cloud (${ctx.credentials.email || "signed in"})`,
4394
+ load: loadOnce,
4395
+ write(mutate) {
4396
+ const run = writeQueue2.then(() => writeOnce(mutate));
4397
+ writeQueue2 = run.then(
4398
+ () => void 0,
4399
+ () => void 0
4400
+ );
4401
+ return run;
4402
+ }
4403
+ };
3730
4404
  }
3731
- var writeQueue = Promise.resolve();
3732
- function casWrite(ctx2, mutate) {
3733
- const run = writeQueue.then(() => casWriteOnce(ctx2, mutate));
3734
- writeQueue = run.then(
3735
- () => void 0,
3736
- () => void 0
4405
+ function ask(question, hidden = false) {
4406
+ const muted = { current: false };
4407
+ const output = new Writable({
4408
+ write(chunk, _enc, done) {
4409
+ if (!muted.current) process.stdout.write(chunk);
4410
+ done();
4411
+ }
4412
+ });
4413
+ const rl = createInterface({ input: process.stdin, output, terminal: true });
4414
+ const { promise, resolve } = Promise.withResolvers();
4415
+ rl.question(question, (answer) => {
4416
+ muted.current = false;
4417
+ if (hidden) process.stdout.write("\n");
4418
+ rl.close();
4419
+ resolve(answer.trim());
4420
+ });
4421
+ muted.current = hidden;
4422
+ return promise;
4423
+ }
4424
+ function flagValue(argv2, flag) {
4425
+ const index = argv2.indexOf(flag);
4426
+ return index >= 0 ? argv2[index + 1] : void 0;
4427
+ }
4428
+ var DEFAULT_SITE = "https://circal.day";
4429
+ function readProject(value) {
4430
+ if (typeof value !== "object" || value === null) return null;
4431
+ const body = value;
4432
+ if (typeof body.url !== "string" || !body.url) return null;
4433
+ if (typeof body.key !== "string" || !body.key) return null;
4434
+ return { url: body.url.replace(/\/+$/, ""), key: body.key };
4435
+ }
4436
+ async function resolveProject(argv2, env) {
4437
+ const url = (flagValue(argv2, "--url") || env.PUBLIC_SUPABASE_URL || "").replace(/\/+$/, "");
4438
+ const key = flagValue(argv2, "--key") || env.PUBLIC_SUPABASE_PUBLISHABLE_KEY || "";
4439
+ if (url && key) return { url, key };
4440
+ if (url || key) {
4441
+ throw new Error(
4442
+ "circal-mcp login was given half a project: --url and --key (or the two PUBLIC_SUPABASE_* variables) travel together."
4443
+ );
4444
+ }
4445
+ const site = (flagValue(argv2, "--site") || DEFAULT_SITE).replace(/\/+$/, "");
4446
+ let response;
4447
+ try {
4448
+ response = await fetch(`${site}/mcp.json`);
4449
+ } catch (error) {
4450
+ throw new Error(`could not reach ${site} to ask which project it uses`, { cause: error });
4451
+ }
4452
+ if (!response.ok) {
4453
+ throw new Error(
4454
+ `${site} did not answer /mcp.json (${response.status}) \u2014 an older deployment. Pass --url and --key instead (the publishable sb_publishable_\u2026 pair the site is built with).`
4455
+ );
4456
+ }
4457
+ const project = readProject(await response.json().catch(() => null));
4458
+ if (!project) {
4459
+ throw new Error(
4460
+ `${site} does not offer a cloud copy (its /mcp.json names no project). Pass --url and --key if you run your own.`
4461
+ );
4462
+ }
4463
+ return project;
4464
+ }
4465
+ async function runLogin(argv2, env) {
4466
+ const { url, key } = await resolveProject(argv2, env);
4467
+ const email = await ask("Email: ");
4468
+ if (!email) throw new Error("an email address is required");
4469
+ await requestCode(url, key, email);
4470
+ const code = await ask("Sign-in code (from the email): ");
4471
+ const session = await verifyCode(url, key, email, code);
4472
+ const client = new CloudClient(
4473
+ {
4474
+ url,
4475
+ key,
4476
+ email,
4477
+ user: session.user,
4478
+ salt: "",
4479
+ aesKey: "",
4480
+ accessToken: session.accessToken,
4481
+ refreshToken: session.refreshToken,
4482
+ expiresAt: session.expiresAt
4483
+ },
4484
+ credentialsPath(env)
3737
4485
  );
3738
- return run;
4486
+ const row = await client.fetchRow();
4487
+ if (!row) throw new Error(NO_ROW);
4488
+ const foundSalt = saltOf(row.payload);
4489
+ if (!foundSalt) throw new Error("the cloud copy's payload is not a vault this build can read");
4490
+ const salt = new Uint8Array(foundSalt);
4491
+ const passphrase = await ask("Passphrase: ", true);
4492
+ const rawKey = await deriveBits(passphrase, salt);
4493
+ try {
4494
+ await vaultOf(await restoreKeyed(rawKey, salt)).open(row.payload);
4495
+ } catch (error) {
4496
+ if (error instanceof WrongPassphrase) throw new Error(LOCKED, { cause: error });
4497
+ throw error;
4498
+ }
4499
+ const path = credentialsPath(env);
4500
+ await writeCredentials(path, {
4501
+ url,
4502
+ key,
4503
+ email: session.email || email,
4504
+ user: session.user,
4505
+ salt: bytesToBase64Url(salt),
4506
+ aesKey: bytesToBase64Url(rawKey),
4507
+ accessToken: session.accessToken,
4508
+ refreshToken: session.refreshToken,
4509
+ expiresAt: session.expiresAt
4510
+ });
4511
+ console.log(`
4512
+ Signed in as ${session.email || email}; the cloud copy opens.`);
4513
+ console.log(`Credentials written to ${path} (0600 \u2014 the derived key, never the passphrase).`);
4514
+ console.log(`
4515
+ Serve it with:
4516
+
4517
+ circal-mcp --cloud
4518
+ `);
4519
+ }
4520
+ async function runLogout(env) {
4521
+ const path = credentialsPath(env);
4522
+ await rm2(path, { force: true });
4523
+ console.log(`Removed ${path}. This machine can no longer open the cloud copy.`);
3739
4524
  }
3740
4525
 
3741
4526
  // mcp/find.ts
3742
- import { readFile as readFile2, readdir, stat as stat2 } from "node:fs/promises";
3743
- import { homedir } from "node:os";
3744
- import { join as join2 } from "node:path";
4527
+ import { readFile as readFile3, readdir, stat as stat2 } from "node:fs/promises";
4528
+ import { homedir as homedir2 } from "node:os";
4529
+ import { join as join3 } from "node:path";
3745
4530
  var SKIP_DIR_NAMES = { node_modules: true, Library: true, ".git": true };
3746
4531
  var MAX_FILE_BYTES = 32 * 1024 * 1024;
3747
4532
  function roots(home) {
3748
4533
  return [
3749
4534
  home,
3750
- join2(home, "Documents"),
3751
- join2(home, "Desktop"),
3752
- join2(home, "Downloads"),
3753
- join2(home, "Library", "Mobile Documents", "com~apple~CloudDocs"),
4535
+ join3(home, "Documents"),
4536
+ join3(home, "Desktop"),
4537
+ join3(home, "Downloads"),
4538
+ join3(home, "Library", "Mobile Documents", "com~apple~CloudDocs"),
3754
4539
  // iCloud Drive (Documents)
3755
- join2(home, "Dropbox"),
3756
- join2(home, "Sync")
4540
+ join3(home, "Dropbox"),
4541
+ join3(home, "Sync")
3757
4542
  ];
3758
4543
  }
3759
4544
  function appDefault(home) {
3760
- return join2(home, "Library", "Application Support", "circal", "calendar.json");
4545
+ return join3(home, "Library", "Application Support", "circal", "calendar.json");
3761
4546
  }
3762
4547
  async function jsonFilesUnder(root) {
3763
4548
  const found = [];
3764
4549
  const top = await listDir(root);
3765
4550
  for (const entry of top) {
3766
4551
  if (entry.name.startsWith(".")) continue;
3767
- const full = join2(root, entry.name);
4552
+ const full = join3(root, entry.name);
3768
4553
  if (entry.isFile()) {
3769
4554
  if (entry.name.toLowerCase().endsWith(".json")) found.push(full);
3770
4555
  continue;
@@ -3772,39 +4557,39 @@ async function jsonFilesUnder(root) {
3772
4557
  if (!entry.isDirectory() || SKIP_DIR_NAMES[entry.name]) continue;
3773
4558
  for (const sub of await listDir(full)) {
3774
4559
  if (sub.name.startsWith(".") || !sub.isFile()) continue;
3775
- if (sub.name.toLowerCase().endsWith(".json")) found.push(join2(full, sub.name));
4560
+ if (sub.name.toLowerCase().endsWith(".json")) found.push(join3(full, sub.name));
3776
4561
  }
3777
4562
  }
3778
4563
  return found;
3779
4564
  }
3780
- async function listDir(path2) {
4565
+ async function listDir(path) {
3781
4566
  try {
3782
- return await readdir(path2, { withFileTypes: true });
4567
+ return await readdir(path, { withFileTypes: true });
3783
4568
  } catch {
3784
4569
  return [];
3785
4570
  }
3786
4571
  }
3787
- async function asMirror(path2) {
4572
+ async function asMirror(path) {
3788
4573
  try {
3789
- const info = await stat2(path2);
4574
+ const info = await stat2(path);
3790
4575
  if (!info.isFile() || info.size > MAX_FILE_BYTES) return null;
3791
- const text = await readFile2(path2, "utf8");
4576
+ const text = await readFile3(path, "utf8");
3792
4577
  const contents = readMirrorFile(text);
3793
- return contents ? { path: path2, contents } : null;
4578
+ return contents ? { path, contents } : null;
3794
4579
  } catch {
3795
4580
  return null;
3796
4581
  }
3797
4582
  }
3798
- async function runFind(home = homedir()) {
4583
+ async function runFind(home = homedir2()) {
3799
4584
  const searchRoots = roots(home);
3800
4585
  const candidates = /* @__PURE__ */ new Set();
3801
4586
  candidates.add(appDefault(home));
3802
4587
  for (const root of searchRoots) {
3803
- for (const path2 of await jsonFilesUnder(root)) candidates.add(path2);
4588
+ for (const path of await jsonFilesUnder(root)) candidates.add(path);
3804
4589
  }
3805
4590
  const hits = [];
3806
- for (const path2 of [...candidates].sort()) {
3807
- const hit = await asMirror(path2);
4591
+ for (const path of [...candidates].sort()) {
4592
+ const hit = await asMirror(path);
3808
4593
  if (hit) hits.push(hit);
3809
4594
  }
3810
4595
  if (hits.length === 0) {
@@ -3939,22 +4724,47 @@ function requireScope(event, scope) {
3939
4724
  }
3940
4725
  return scope;
3941
4726
  }
3942
- function resolveCalendar(calendarId, calendars, settings) {
3943
- if (calendarId) {
3944
- const found2 = calendars.find((c) => c.id === calendarId);
3945
- if (!found2) throw new RefusalError(`no calendar with id "${calendarId}"`);
3946
- if (found2.feed) {
3947
- throw new RefusalError(
3948
- `"${found2.name}" is a subscription \u2014 its events are pulled from a feed, never written to`
3949
- );
3950
- }
3951
- return found2;
4727
+ function findCalendar(ref, calendars) {
4728
+ const byId = calendars.find((c) => c.id === ref);
4729
+ if (byId) return byId;
4730
+ const needle = ref.trim().toLowerCase();
4731
+ const matches = needle ? calendars.filter((c) => c.name.toLowerCase().startsWith(needle)) : [];
4732
+ if (matches.length === 1) return matches[0];
4733
+ if (matches.length > 1) {
4734
+ throw new RefusalError(
4735
+ `"${ref}" names ${matches.length} calendars (${matches.map((c) => c.name).join(", ")}) \u2014 use an id from list_calendars`
4736
+ );
4737
+ }
4738
+ throw new RefusalError(`no calendar with id or name "${ref}"`);
4739
+ }
4740
+ function requireWritable(calendar) {
4741
+ if (calendar.feed) {
4742
+ throw new RefusalError(
4743
+ `"${calendar.name}" is a subscription \u2014 its events are pulled from a feed, never written to`
4744
+ );
3952
4745
  }
4746
+ return calendar;
4747
+ }
4748
+ function resolveCalendar(calendarId, calendars, settings, pin) {
4749
+ if (calendarId) return requireWritable(findCalendar(calendarId, calendars));
4750
+ if (pin) return requireWritable(findCalendar(pin, calendars));
3953
4751
  const id = defaultCalendarId(calendars, settings.defaultCalendar);
3954
4752
  const found = calendars.find((c) => c.id === id);
3955
4753
  if (!found) throw new RefusalError("there is no calendar to put this on \u2014 create one first");
3956
4754
  return found;
3957
4755
  }
4756
+ function defaultedCalendar(calendarId, pin) {
4757
+ return calendarId === void 0 && !pin;
4758
+ }
4759
+ function scopedToCalendar(contents, ref) {
4760
+ if (!ref) return contents;
4761
+ const calendar = findCalendar(ref, contents.calendars);
4762
+ return {
4763
+ ...contents,
4764
+ events: contents.events.filter((e) => e.calendarId === calendar.id),
4765
+ tasks: contents.tasks.filter((t) => t.calendarId === calendar.id)
4766
+ };
4767
+ }
3958
4768
  function refuseIfFeed(event, calendars) {
3959
4769
  const calendar = calendars.find((c) => c.id === event.calendarId);
3960
4770
  if (calendar?.feed) {
@@ -3963,49 +4773,70 @@ function refuseIfFeed(event, calendars) {
3963
4773
  );
3964
4774
  }
3965
4775
  }
3966
- function buildRecurrence(input) {
4776
+ function buildRecurrence(input, zone = "") {
3967
4777
  if (!input) return { ...NEVER };
3968
- const until = input.until ? addDays(fromDayKey(input.until), 1).getTime() - 1 : 0;
4778
+ const boundary = input.until ? addDays(fromDayKey(input.until), 1) : null;
4779
+ const until = boundary ? (zone ? instantOfWall(boundary, zone) : boundary.getTime()) - 1 : 0;
3969
4780
  return readRecurrence({ ...input, until, exdates: [], rdates: [] });
3970
4781
  }
3971
- function resolveSpanForCreate(input, day, defaultDuration) {
3972
- if (input.task === true) {
3973
- if (input.start === void 0) throw new RefusalError('a task needs "start" (HH:MM)');
3974
- const at = atMinutes(day, parseTime(input.start, "start")).getTime();
3975
- return { start: at, end: at, allDay: false, task: true };
4782
+ function spanInstant(day, minutes, zone) {
4783
+ return zone ? instantOfWall(atMinutes(day, minutes), zone) : atMinutes(day, minutes).getTime();
4784
+ }
4785
+ function zoneFrom(value) {
4786
+ if (value === "") return "";
4787
+ if (!isZone(value)) {
4788
+ throw new RefusalError(
4789
+ `"${value}" is not an IANA time zone this runtime knows \u2014 try "Asia/Seoul"`
4790
+ );
4791
+ }
4792
+ return value;
4793
+ }
4794
+ function leadFrom(value) {
4795
+ if (value === "inherit") return REMIND_INHERIT;
4796
+ if (value === "silent") return REMIND_SILENT;
4797
+ if (readRemindLead(value) !== value) {
4798
+ throw new RefusalError(
4799
+ `"remindLead" must be whole minutes, 0 to ${REMIND_LEAD_MAX} \u2014 got ${value}`
4800
+ );
3976
4801
  }
4802
+ return value;
4803
+ }
4804
+ function resolveSpanForCreate(input, day, defaultDuration, zone) {
3977
4805
  const endIsDay = input.end !== void 0 && DATE_KEY_RE.test(input.end);
3978
4806
  const noTimeGiven = input.start === void 0 && (input.end === void 0 || endIsDay) && input.duration === void 0;
3979
4807
  const allDay = input.allDay === true || input.allDay === void 0 && noTimeGiven;
3980
4808
  if (allDay) {
4809
+ if (zone) {
4810
+ throw new RefusalError(
4811
+ 'an all-day event carries no clock to zone \u2014 drop "zone" or give a timed "start"'
4812
+ );
4813
+ }
3981
4814
  const days = input.end ? Math.max(1, daysBetween(day, fromDayKey(input.end)) + 1) : 1;
3982
4815
  return {
3983
4816
  start: startOfDay(day).getTime(),
3984
4817
  end: addDays(startOfDay(day), days).getTime(),
3985
- allDay: true,
3986
- task: false
4818
+ allDay: true
3987
4819
  };
3988
4820
  }
3989
4821
  if (input.start === void 0) throw new RefusalError('a timed event needs "start" (HH:MM)');
3990
4822
  const startMin = parseTime(input.start, "start");
3991
4823
  const endMin = input.end !== void 0 ? parseTime(input.end, "end") : startMin + (input.duration ?? defaultDuration);
3992
4824
  return {
3993
- start: atMinutes(day, startMin).getTime(),
3994
- end: atMinutes(day, endMin).getTime(),
3995
- allDay: false,
3996
- task: false
4825
+ start: spanInstant(day, startMin, zone),
4826
+ end: spanInstant(day, endMin, zone),
4827
+ allDay: false
3997
4828
  };
3998
4829
  }
3999
- function resolveSpanForPatch(input, base, anchorMs) {
4000
- const day = input.date ? parseDay(input.date) : startOfDay(new Date(anchorMs));
4001
- const task = input.task ?? base.task;
4002
- if (task) {
4003
- const startMin2 = input.start !== void 0 ? parseTime(input.start, "start") : minutesOfDay(new Date(anchorMs));
4004
- const at = atMinutes(day, startMin2).getTime();
4005
- return { start: at, end: at, allDay: false, task: true };
4006
- }
4830
+ function resolveSpanForPatch(input, base, anchorMs, zone) {
4831
+ const anchorWall = base.tzid ? wallClockIn(anchorMs, base.tzid) : new Date(anchorMs);
4832
+ const day = input.date ? parseDay(input.date) : startOfDay(anchorWall);
4007
4833
  const allDay = input.allDay ?? base.allDay;
4008
4834
  if (allDay) {
4835
+ if (input.zone) {
4836
+ throw new RefusalError(
4837
+ 'an all-day event carries no clock to zone \u2014 drop "zone" or give a timed "start"'
4838
+ );
4839
+ }
4009
4840
  const baseDays = Math.max(
4010
4841
  1,
4011
4842
  daysBetween(startOfDay(new Date(base.start)), startOfDay(new Date(base.end)))
@@ -4014,94 +4845,97 @@ function resolveSpanForPatch(input, base, anchorMs) {
4014
4845
  return {
4015
4846
  start: startOfDay(day).getTime(),
4016
4847
  end: addDays(startOfDay(day), days).getTime(),
4017
- allDay: true,
4018
- task: false
4848
+ allDay: true
4019
4849
  };
4020
4850
  }
4021
4851
  const baseDurationMin = Math.max(1, Math.round((base.end - base.start) / 6e4));
4022
- const startMin = input.start !== void 0 ? parseTime(input.start, "start") : minutesOfDay(new Date(anchorMs));
4852
+ const startMin = input.start !== void 0 ? parseTime(input.start, "start") : minutesOfDay(anchorWall);
4023
4853
  const endMin = input.end !== void 0 ? parseTime(input.end, "end") : startMin + (input.duration ?? baseDurationMin);
4024
4854
  return {
4025
- start: atMinutes(day, startMin).getTime(),
4026
- end: atMinutes(day, endMin).getTime(),
4027
- allDay: false,
4028
- task: false
4855
+ start: spanInstant(day, startMin, zone),
4856
+ end: spanInstant(day, endMin, zone),
4857
+ allDay: false
4029
4858
  };
4030
4859
  }
4031
4860
  function validate(draft) {
4032
4861
  const validated = readEvent({ ...draft, id: "pending" });
4033
4862
  if (!validated)
4034
- throw new RefusalError("the assembled event failed validation (check start, end and task)");
4863
+ throw new RefusalError("the assembled event failed validation (check start and end)");
4035
4864
  const { id: _drop, ...rest } = validated;
4036
4865
  return rest;
4037
4866
  }
4038
- function buildEventRecord(input, calendars, settings) {
4867
+ function buildEventRecord(input, calendars, settings, pin) {
4039
4868
  if (!input.date) throw new RefusalError('an event needs "date" (YYYY-MM-DD)');
4040
4869
  const day = parseDay(input.date);
4041
- const calendar = resolveCalendar(input.calendarId, calendars, settings);
4042
- const span = resolveSpanForCreate(input, day, settings.defaultDuration);
4870
+ const calendar = resolveCalendar(input.calendarId, calendars, settings, pin);
4871
+ const zone = input.zone ? zoneFrom(input.zone) : "";
4872
+ const span = resolveSpanForCreate(input, day, settings.defaultDuration, zone);
4043
4873
  return validate({
4044
4874
  title: input.title ?? "",
4045
4875
  calendarId: calendar.id,
4046
4876
  start: span.start,
4047
4877
  end: span.end,
4048
4878
  allDay: span.allDay,
4049
- tzid: "",
4879
+ // The zone the times were spelled in, kept so every surface can print
4880
+ // both clocks — and so a rule walks that zone's own wall calendar.
4881
+ tzid: zone,
4050
4882
  location: input.location ?? "",
4051
4883
  url: input.url ?? "",
4052
4884
  notes: input.notes ?? "",
4053
- ...buildRecurrence(input.recurrence),
4885
+ ...buildRecurrence(input.recurrence, zone),
4054
4886
  seriesId: "",
4055
4887
  seriesKey: "",
4056
4888
  color: input.color ?? "",
4057
4889
  icon: input.icon ?? "",
4058
- task: span.task,
4059
- doneKeys: [],
4060
4890
  // Busy. There is no tool argument for this yet, and an agent asked to put
4061
4891
  // an hour in the diary means the hour to be spoken for.
4062
4892
  free: false,
4063
- // Inheriting, for the same reason: no tool argument names a lead, and an
4064
- // event created with one of its own would be an agent quietly deciding
4065
- // how much warning its user wants.
4066
- remindLead: REMIND_INHERIT,
4893
+ // Inheriting unless the caller named a lead of its own — an event created
4894
+ // with one it was not asked for would be an agent quietly deciding how
4895
+ // much warning its user wants.
4896
+ remindLead: input.remindLead !== void 0 ? leadFrom(input.remindLead) : REMIND_INHERIT,
4067
4897
  // Nobody, for the reason there is no tool argument and never will be: an
4068
4898
  // agent has no channel to invite anyone on either, so a guest list it
4069
4899
  // assembled would be a list of people who were never asked.
4070
4900
  organizer: null,
4071
4901
  attendees: [],
4072
- attendeeCount: 0
4902
+ attendeeCount: 0,
4903
+ // The stamp is the caller's: `performCreateEvent` and the quick-add path
4904
+ // are where this record starts existing, the same rule `draftEvent`
4905
+ // follows in the app.
4906
+ updatedAt: 0
4073
4907
  });
4074
4908
  }
4075
4909
  function applyPatchToEvent(base, input, calendars, settings, anchorMs = base.start) {
4076
4910
  const calendar = input.calendarId !== void 0 ? resolveCalendar(input.calendarId, calendars, settings) : calendars.find((c) => c.id === base.calendarId) ?? resolveCalendar(void 0, calendars, settings);
4077
- const span = resolveSpanForPatch(input, base, anchorMs);
4911
+ const zone = input.zone !== void 0 ? zoneFrom(input.zone) : base.tzid;
4912
+ const span = resolveSpanForPatch(input, base, anchorMs, zone);
4078
4913
  return validate({
4079
4914
  title: input.title ?? base.title,
4080
4915
  calendarId: calendar.id,
4081
4916
  start: span.start,
4082
4917
  end: span.end,
4083
4918
  allDay: span.allDay,
4084
- tzid: base.tzid,
4919
+ // The patch's zone, else the base's — cleared with the clock when the
4920
+ // patch flips the event all-day, since an all-day event is never zoned.
4921
+ tzid: span.allDay ? "" : zone,
4085
4922
  location: input.location ?? base.location,
4086
4923
  url: input.url ?? base.url,
4087
4924
  notes: input.notes ?? base.notes,
4088
- ...input.recurrence ? buildRecurrence(input.recurrence) : recurrenceOf(base),
4925
+ ...input.recurrence ? buildRecurrence(input.recurrence, zone) : recurrenceOf(base),
4089
4926
  seriesId: base.seriesId,
4090
4927
  seriesKey: base.seriesKey,
4091
4928
  color: input.color ?? base.color,
4092
4929
  icon: input.icon ?? base.icon,
4093
- task: span.task,
4094
- doneKeys: base.doneKeys,
4095
4930
  // Carried, like every other field a patch does not name: `validate` reads
4096
4931
  // the draft through `backup.readEvent`, which defaults an absent `free`
4097
4932
  // to busy — so leaving it out would quietly rebook a free event the
4098
4933
  // moment its title was edited.
4099
4934
  free: base.free,
4100
- // Carried for exactly the reason above, and this is the field where it
4101
- // costs most: an event the user silenced would start speaking again the
4102
- // moment an assistant fixed a typo in its title, because `readEvent`
4103
- // reads an absent lead as inheriting.
4104
- remindLead: base.remindLead,
4935
+ // Carried unless the patch names one — `readEvent` reads an absent lead
4936
+ // as inheriting, so leaving it out would un-silence an event the user
4937
+ // silenced the moment an assistant fixed a typo in its title.
4938
+ remindLead: input.remindLead !== void 0 ? leadFrom(input.remindLead) : base.remindLead,
4105
4939
  // Carried, and this is the field where dropping it would be least
4106
4940
  // recoverable: the guest list is a record of a message somebody else sent,
4107
4941
  // and `readEvent` reads an absent one as no invitation at all — so an
@@ -4109,25 +4943,73 @@ function applyPatchToEvent(base, input, calendars, settings, anchorMs = base.sta
4109
4943
  // had asked and who was coming.
4110
4944
  organizer: base.organizer,
4111
4945
  attendees: base.attendees,
4112
- attendeeCount: base.attendeeCount
4946
+ attendeeCount: base.attendeeCount,
4947
+ // Carried, and overwritten by the reducer that commits the patch: it is
4948
+ // the base's stamp until this edit lands, never a fresh record's zero.
4949
+ updatedAt: base.updatedAt
4113
4950
  });
4114
4951
  }
4952
+ function carried(contents) {
4953
+ return {
4954
+ events: contents.events,
4955
+ calendars: contents.calendars,
4956
+ tasks: contents.tasks,
4957
+ settings: contents.settings,
4958
+ settingsAt: contents.settingsAt,
4959
+ graves: contents.graves
4960
+ };
4961
+ }
4962
+ var ID_SHAPE_RE = /^([0-9a-z]+-[0-9a-z]{6}|x-.+)$/;
4963
+ function notFound(contents, id, kind) {
4964
+ const grave = contents.graves.find((g) => g.id === id);
4965
+ if (grave) {
4966
+ return new RefusalError(
4967
+ `no ${kind} with id "${id}" \u2014 deleted ${new Date(grave.at).toISOString()} (a grave for it is in the document, so this writer or another one removed it)`
4968
+ );
4969
+ }
4970
+ if (ID_SHAPE_RE.test(id)) {
4971
+ return new RefusalError(
4972
+ `no ${kind} with id "${id}", and no grave for it either \u2014 the id has this app's shape, so another writer may have dropped or rewritten it; re-read with list_day or search`
4973
+ );
4974
+ }
4975
+ return new RefusalError(`no ${kind} with id "${id}"`);
4976
+ }
4115
4977
  function calendarName(calendars, id) {
4116
4978
  return calendars.find((c) => c.id === id)?.name ?? "\u2014";
4117
4979
  }
4980
+ function zoneSuffix(event, clock) {
4981
+ if (!event.tzid || event.allDay) return "";
4982
+ const start = formatTimeShort(wallClockIn(event.start, event.tzid), clock);
4983
+ const end = formatTimeShort(wallClockIn(event.end, event.tzid), clock);
4984
+ return ` (${start}\u2013${end} ${event.tzid})`;
4985
+ }
4118
4986
  function eventSummaryLine(event, calendars, clock) {
4119
- const when = event.task ? `${dayKey(new Date(event.start))} ${formatTimeShort(new Date(event.start), clock)}` : event.allDay ? `${dayKey(new Date(event.start))} \u2013 ${dayKey(addDays(new Date(event.end), -1))} (all day)` : `${dayKey(new Date(event.start))} ${formatTimeShort(new Date(event.start), clock)}\u2013${formatTimeShort(new Date(event.end), clock)}`;
4987
+ const when = event.allDay ? `${dayKey(new Date(event.start))} \u2013 ${dayKey(addDays(new Date(event.end), -1))} (all day)` : `${dayKey(new Date(event.start))} ${formatTimeShort(new Date(event.start), clock)}\u2013${formatTimeShort(new Date(event.end), clock)}${zoneSuffix(event, clock)}`;
4120
4988
  return `${event.title || "Untitled"} \u2014 [${calendarName(calendars, event.calendarId)}] \u2014 ${when} \u2014 id=${event.id}`;
4121
4989
  }
4990
+ function taskSummaryLine(task, calendars, clock) {
4991
+ const when = task.dueAllDay ? `due ${dayKey(new Date(task.due))}` : `due ${dayKey(new Date(task.due))} ${formatTimeShort(new Date(task.due), clock)}`;
4992
+ const bits = [
4993
+ `${task.title || "Untitled"} (task)`,
4994
+ `[${calendarName(calendars, task.calendarId)}]`,
4995
+ when,
4996
+ task.done ? "done" : "open",
4997
+ ...task.estimate !== null ? [`~${formatDuration(task.estimate)}`] : [],
4998
+ ...task.priority > 0 ? [task.priority === 2 ? "urgent" : "high"] : [],
4999
+ `id=${task.id}`
5000
+ ];
5001
+ return bits.join(" \u2014 ");
5002
+ }
4122
5003
  function occurrenceLine(occ, calendars, clock) {
4123
- const when = occ.event.allDay ? "all day" : occ.event.task ? `${occ.start.toISOString()} (${formatTimeShort(occ.start, clock)})` : `${occ.start.toISOString()} (${formatTimeShort(occ.start, clock)}) \u2013 ${occ.end.toISOString()} (${formatTimeShort(occ.end, clock)})`;
5004
+ const pip = occ.task && occ.start.getTime() === occ.end.getTime();
5005
+ const when = occ.event.allDay ? "all day" : pip ? `${occ.start.toISOString()} (${formatTimeShort(occ.start, clock)}) due` : `${occ.start.toISOString()} (${formatTimeShort(occ.start, clock)}) \u2013 ${occ.end.toISOString()} (${formatTimeShort(occ.end, clock)})`;
4124
5006
  const span = spanLabel(occ);
4125
5007
  const bits = [
4126
5008
  occ.event.title || "Untitled",
4127
5009
  `[${calendarName(calendars, occ.event.calendarId)}]`,
4128
5010
  when,
4129
5011
  ...span ? [span] : [],
4130
- ...occ.event.task ? [occ.done ? "done" : "open"] : [],
5012
+ ...occ.task ? [occ.done ? "done" : "open"] : [],
4131
5013
  `id=${occ.event.id}`,
4132
5014
  `occurrence=${dayKey(occ.start)}`
4133
5015
  ];
@@ -4136,7 +5018,7 @@ function occurrenceLine(occ, calendars, clock) {
4136
5018
  function listDay(contents, date) {
4137
5019
  assertDateKey(date);
4138
5020
  const day = fromDayKey(date);
4139
- const agenda = dayAgenda(contents.events, contents.calendars, day);
5021
+ const agenda = dayAgenda(contents.events, contents.calendars, day, contents.tasks);
4140
5022
  const clock = contents.settings.clock;
4141
5023
  return [
4142
5024
  `${date} \u2014 ${agenda.all.length} occurrence${agenda.all.length === 1 ? "" : "s"}`,
@@ -4166,7 +5048,10 @@ function listRange(contents, from, to) {
4166
5048
  let truncated = false;
4167
5049
  for (let i = 0; i < span && !truncated; i++) {
4168
5050
  const day = addDays(fromDay, i);
4169
- const occs = occurrencesOn(contents.events, contents.calendars, day);
5051
+ const occs = [
5052
+ ...occurrencesOn(contents.events, contents.calendars, day),
5053
+ ...taskOccurrences(contents.tasks, contents.calendars, day)
5054
+ ].sort((a, b) => a.start.getTime() - b.start.getTime() || a.end.getTime() - b.end.getTime());
4170
5055
  if (occs.length === 0) continue;
4171
5056
  lines.push(`${dayKey(day)}:`);
4172
5057
  for (const occ of occs) {
@@ -4183,16 +5068,29 @@ function listRange(contents, from, to) {
4183
5068
  }
4184
5069
  function searchEvents(contents, query, limit = 20) {
4185
5070
  const clock = contents.settings.clock;
4186
- const scored = contents.events.map((event) => ({ event, score: scoreEvent(event, query) })).filter((s) => s.score !== null).sort((a, b) => b.score - a.score).slice(0, Math.max(0, limit));
5071
+ const cap = Math.max(0, limit);
5072
+ const scored = contents.events.map((event) => ({
5073
+ line: eventSummaryLine(event, contents.calendars, clock),
5074
+ hit: scoreEventBy(event, query)
5075
+ })).concat(
5076
+ contents.tasks.map((task) => ({
5077
+ line: taskSummaryLine(task, contents.calendars, clock),
5078
+ hit: scoreTaskBy(task, query)
5079
+ }))
5080
+ ).filter((s) => s.hit !== null).sort((a, b) => b.hit.score - a.hit.score).slice(0, cap);
4187
5081
  return [
4188
5082
  `"${query}" \u2014 ${scored.length} match${scored.length === 1 ? "" : "es"}`,
4189
- ...scored.length ? scored.map(({ event }) => eventSummaryLine(event, contents.calendars, clock)) : ["(none)"]
5083
+ // A hit outside the title says so: a notes match on a long note is
5084
+ // otherwise indistinguishable from what the record is called.
5085
+ ...scored.length ? scored.map(
5086
+ ({ line, hit }) => hit.field === "title" ? line : `${line} \u2014 matched ${hit.field}`
5087
+ ) : ["(none)"]
4190
5088
  ].join("\n");
4191
5089
  }
4192
5090
  function freeSlots(contents, date, minutes, from, to) {
4193
5091
  assertDateKey(date);
4194
5092
  const day = fromDayKey(date);
4195
- const occs = occurrencesOn(contents.events, contents.calendars, day);
5093
+ const occs = dayAgenda(contents.events, contents.calendars, day, contents.tasks).all;
4196
5094
  const fromMin = from !== void 0 ? parseTime(from, "from") : contents.settings.wakingFrom ?? DEFAULT_WAKING.from;
4197
5095
  const toMin = to !== void 0 ? parseTime(to, "to") : contents.settings.wakingTo ?? DEFAULT_WAKING.to;
4198
5096
  const clock = contents.settings.clock;
@@ -4213,24 +5111,56 @@ function listCalendars(contents) {
4213
5111
  }
4214
5112
  function describeEvent(contents, id) {
4215
5113
  const event = contents.events.find((e) => e.id === id);
4216
- if (!event) throw new RefusalError(`no event with id "${id}"`);
5114
+ if (!event) {
5115
+ const task = contents.tasks.find((t) => t.id === id);
5116
+ if (task) return describeTask(contents, task);
5117
+ throw notFound(contents, id, "event");
5118
+ }
4217
5119
  const calendar = contents.calendars.find((c) => c.id === event.calendarId);
4218
5120
  const clock = contents.settings.clock;
4219
5121
  return [
4220
5122
  `${event.title || "Untitled"} (id=${event.id})`,
4221
5123
  `Calendar: ${calendarName(contents.calendars, event.calendarId)}${calendar?.feed ? " (subscription, read-only)" : ""}`,
4222
- event.task ? `Task at ${dayKey(new Date(event.start))} ${formatTimeShort(new Date(event.start), clock)}` : event.allDay ? `All day, ${dayKey(new Date(event.start))} to ${dayKey(addDays(new Date(event.end), -1))}` : `${dayKey(new Date(event.start))} ${formatTimeShort(new Date(event.start), clock)}\u2013${formatTimeShort(new Date(event.end), clock)}`,
5124
+ event.allDay ? `All day, ${dayKey(new Date(event.start))} to ${dayKey(addDays(new Date(event.end), -1))}` : `${dayKey(new Date(event.start))} ${formatTimeShort(new Date(event.start), clock)}\u2013${formatTimeShort(new Date(event.end), clock)}`,
5125
+ // Both clocks for a zoned event — the times above are local, and the
5126
+ // hours the deadline was written in are the ones its owner reasons in.
5127
+ ...event.tzid && !event.allDay ? [
5128
+ `Zone: ${event.tzid} \u2014 ${formatTimeShort(wallClockIn(event.start, event.tzid), clock)}\u2013${formatTimeShort(wallClockIn(event.end, event.tzid), clock)} there`
5129
+ ] : [],
4223
5130
  `Repeats: ${repeats(event) ? describeRule(event) : "never"}`,
5131
+ ...event.remindLead !== REMIND_INHERIT ? [`Remind: ${event.remindLead === REMIND_SILENT ? "silent" : remindLabel(event.remindLead)}`] : [],
4224
5132
  ...event.location ? [`Location: ${event.location}`] : [],
4225
5133
  ...event.url ? [`URL: ${event.url}`] : [],
4226
5134
  ...event.notes ? [`Notes: ${event.notes}`] : [],
4227
- ...event.seriesId ? [`Overrides one occurrence of id=${event.seriesId} (${event.seriesKey})`] : [],
4228
- ...event.task ? [
4229
- `Done: ${event.doneKeys.length} occurrence${event.doneKeys.length === 1 ? "" : "s"} ticked`
4230
- ] : []
5135
+ ...event.seriesId ? [`Overrides one occurrence of id=${event.seriesId} (${event.seriesKey})`] : []
5136
+ ].join("\n");
5137
+ }
5138
+ function describeTask(contents, task) {
5139
+ const clock = contents.settings.clock;
5140
+ return [
5141
+ `${task.title || "Untitled"} (task, id=${task.id})`,
5142
+ `Calendar: ${calendarName(contents.calendars, task.calendarId)}`,
5143
+ task.dueAllDay ? `Due ${dayKey(new Date(task.due))}` : `Due ${dayKey(new Date(task.due))} ${formatTimeShort(new Date(task.due), clock)}`,
5144
+ `Status: ${task.done ? "done" : "open"}`,
5145
+ ...task.estimate !== null ? [`Estimate: ${formatDuration(task.estimate)}`] : [],
5146
+ ...task.priority > 0 ? [`Priority: ${task.priority === 2 ? "urgent" : "high"}`] : [],
5147
+ ...task.plans.length ? task.plans.map(
5148
+ (p) => `Planned: ${dayKey(new Date(p.start))} ${formatTimeShort(new Date(p.start), clock)}\u2013${formatTimeShort(new Date(p.end), clock)}`
5149
+ ) : [],
5150
+ ...task.notes ? [`Notes: ${task.notes}`] : []
5151
+ ].join("\n");
5152
+ }
5153
+ function syncStatus(contents, transport) {
5154
+ return [
5155
+ `Document: rev ${contents.rev}, format version ${contents.version}, zone ${contents.zone || "(unrecorded)"}`,
5156
+ `Transport: ${transport}`,
5157
+ `Counts: ${contents.events.length} events, ${contents.tasks.length} tasks, ${contents.calendars.length} calendars, ${contents.graves.length} graves`
4231
5158
  ].join("\n");
4232
5159
  }
4233
- function performQuickAdd(contents, text, date) {
5160
+ function defaultedSuffix(defaulted) {
5161
+ return defaulted ? " \u2014 calendar defaulted, not named" : "";
5162
+ }
5163
+ function performQuickAdd(contents, text, date, pin) {
4234
5164
  if (!text.trim()) throw new RefusalError('"text" is empty');
4235
5165
  const reference = date ? parseDay(date) : startOfDay(/* @__PURE__ */ new Date());
4236
5166
  const parsed = parseQuickAdd(text, reference, /* @__PURE__ */ new Date(), contents.settings.defaultDuration);
@@ -4239,46 +5169,200 @@ function performQuickAdd(contents, text, date) {
4239
5169
  `could not read "${text}" as an event \u2014 name something beyond a bare title, e.g. "lunch tomorrow 1pm 45m" or "gym every monday 7am"`
4240
5170
  );
4241
5171
  }
4242
- const calendar = resolveCalendar(void 0, contents.calendars, contents.settings);
5172
+ const calendar = resolveCalendar(void 0, contents.calendars, contents.settings, pin);
4243
5173
  const record = validate({
4244
5174
  ...draftEvent(parsed.day, parsed.startMin, parsed.endMin, calendar.id),
4245
5175
  title: parsed.title,
4246
5176
  ...parsed.repeat
4247
5177
  });
4248
- const event = { ...record, id: newId() };
5178
+ const event = { ...record, id: newId(), updatedAt: Date.now() };
4249
5179
  return {
4250
- mutated: {
4251
- events: [...contents.events, event],
4252
- calendars: contents.calendars,
4253
- settings: contents.settings
4254
- },
4255
- message: `Created ${eventSummaryLine(event, contents.calendars, contents.settings.clock)}`
5180
+ mutated: { ...carried(contents), events: [...contents.events, event] },
5181
+ message: `Created ${eventSummaryLine(event, contents.calendars, contents.settings.clock)}` + defaultedSuffix(defaultedCalendar(void 0, pin))
5182
+ };
5183
+ }
5184
+ var KEYED_ID_PREFIX = "x-";
5185
+ function keyedId(key) {
5186
+ return `${KEYED_ID_PREFIX}${key}`;
5187
+ }
5188
+ function performCreateEvent(contents, input, key, pin) {
5189
+ const suffix = defaultedSuffix(defaultedCalendar(input.calendarId, pin));
5190
+ if (key) {
5191
+ const id = keyedId(key);
5192
+ const existing = contents.events.find((e) => e.id === id);
5193
+ if (existing) {
5194
+ refuseIfFeed(existing, contents.calendars);
5195
+ const record3 = applyPatchToEvent(existing, input, contents.calendars, contents.settings);
5196
+ const events = applyUpdate(contents.events, id, record3, Date.now());
5197
+ const updated = events.find((e) => e.id === id);
5198
+ return {
5199
+ mutated: { ...carried(contents), events },
5200
+ message: `Updated (by key "${key}") ` + eventSummaryLine(updated, contents.calendars, contents.settings.clock) + suffix
5201
+ };
5202
+ }
5203
+ const record2 = buildEventRecord(input, contents.calendars, contents.settings, pin);
5204
+ const event2 = { ...record2, id, updatedAt: Date.now() };
5205
+ return {
5206
+ mutated: { ...carried(contents), events: [...contents.events, event2] },
5207
+ message: `Created ${eventSummaryLine(event2, contents.calendars, contents.settings.clock)}` + suffix
5208
+ };
5209
+ }
5210
+ const record = buildEventRecord(input, contents.calendars, contents.settings, pin);
5211
+ const event = { ...record, id: newId(), updatedAt: Date.now() };
5212
+ return {
5213
+ mutated: { ...carried(contents), events: [...contents.events, event] },
5214
+ message: `Created ${eventSummaryLine(event, contents.calendars, contents.settings.clock)}` + suffix
5215
+ };
5216
+ }
5217
+ var CREATE_BATCH_MAX = 100;
5218
+ function performCreateEvents(contents, inputs, pin) {
5219
+ if (inputs.length === 0) throw new RefusalError('"events" is empty');
5220
+ if (inputs.length > CREATE_BATCH_MAX) {
5221
+ throw new RefusalError(
5222
+ `the batch is capped at ${CREATE_BATCH_MAX} events \u2014 got ${inputs.length}`
5223
+ );
5224
+ }
5225
+ let current = contents;
5226
+ const lines = [];
5227
+ for (const [index, input] of inputs.entries()) {
5228
+ let outcome;
5229
+ try {
5230
+ outcome = performCreateEvent(current, input, input.key, pin);
5231
+ } catch (error) {
5232
+ if (error instanceof RefusalError) {
5233
+ throw new RefusalError(`event ${index + 1} of ${inputs.length}: ${error.message}`);
5234
+ }
5235
+ throw error;
5236
+ }
5237
+ current = { ...current, ...outcome.mutated };
5238
+ lines.push(outcome.message);
5239
+ }
5240
+ return { mutated: carried(current), message: lines.join("\n") };
5241
+ }
5242
+ var PRIORITY_RANK = { normal: 0, high: 1, urgent: 2 };
5243
+ function requireTask(contents, id, tool) {
5244
+ const task = contents.tasks.find((t) => t.id === id);
5245
+ if (task) return task;
5246
+ if (contents.events.some((e) => e.id === id)) {
5247
+ throw new RefusalError(`"${id}" is an event, not a task \u2014 ${tool} works on tasks`);
5248
+ }
5249
+ throw notFound(contents, id, "task");
5250
+ }
5251
+ function requireValidTask(assembled) {
5252
+ const task = readTask(assembled);
5253
+ if (!task) throw new RefusalError("the assembled task failed validation (check date and time)");
5254
+ return task;
5255
+ }
5256
+ function performCreateTask(contents, input, pin) {
5257
+ if (!input.date) throw new RefusalError('a task needs "date" (YYYY-MM-DD), its due day');
5258
+ const day = parseDay(input.date);
5259
+ const calendar = resolveCalendar(input.calendarId, contents.calendars, contents.settings, pin);
5260
+ const dueAllDay = input.time === void 0 || input.time === null;
5261
+ const task = requireValidTask({
5262
+ id: newId(),
5263
+ title: input.title ?? "",
5264
+ notes: input.notes ?? "",
5265
+ calendarId: calendar.id,
5266
+ icon: input.icon ?? "",
5267
+ color: input.color ?? "",
5268
+ due: dueAllDay ? startOfDay(day).getTime() : atMinutes(day, parseTime(input.time, "time")).getTime(),
5269
+ dueAllDay,
5270
+ estimate: input.estimate ?? null,
5271
+ priority: PRIORITY_RANK[input.priority ?? "normal"],
5272
+ plans: [],
5273
+ done: false,
5274
+ updatedAt: Date.now()
5275
+ });
5276
+ return {
5277
+ mutated: { ...carried(contents), tasks: [...contents.tasks, task] },
5278
+ message: `Created ${taskSummaryLine(task, contents.calendars, contents.settings.clock)}` + defaultedSuffix(defaultedCalendar(input.calendarId, pin))
4256
5279
  };
4257
5280
  }
4258
- function performCreateEvent(contents, input) {
4259
- const record = buildEventRecord(input, contents.calendars, contents.settings);
4260
- const event = { ...record, id: newId() };
5281
+ function performUpdateTask(contents, id, patch) {
5282
+ const original = requireTask(contents, id, "update_task");
5283
+ const day = patch.date ? parseDay(patch.date) : startOfDay(new Date(original.due));
5284
+ const dueAllDay = patch.time === null ? true : patch.time !== void 0 ? false : original.dueAllDay;
5285
+ const dueMin = typeof patch.time === "string" ? parseTime(patch.time, "time") : minutesOfDay(new Date(original.due));
5286
+ const calendar = patch.calendarId ? resolveCalendar(patch.calendarId, contents.calendars, contents.settings) : void 0;
5287
+ const next = requireValidTask({
5288
+ ...original,
5289
+ title: patch.title ?? original.title,
5290
+ notes: patch.notes ?? original.notes,
5291
+ calendarId: calendar?.id ?? original.calendarId,
5292
+ color: patch.color ?? original.color,
5293
+ icon: patch.icon ?? original.icon,
5294
+ due: dueAllDay ? startOfDay(day).getTime() : atMinutes(day, dueMin).getTime(),
5295
+ dueAllDay,
5296
+ estimate: patch.estimate !== void 0 ? patch.estimate : original.estimate,
5297
+ priority: patch.priority !== void 0 ? PRIORITY_RANK[patch.priority] : original.priority,
5298
+ updatedAt: Date.now()
5299
+ });
4261
5300
  return {
4262
5301
  mutated: {
4263
- events: [...contents.events, event],
4264
- calendars: contents.calendars,
4265
- settings: contents.settings
5302
+ ...carried(contents),
5303
+ tasks: contents.tasks.map((t) => t.id === id ? next : t)
4266
5304
  },
4267
- message: `Created ${eventSummaryLine(event, contents.calendars, contents.settings.clock)}`
5305
+ message: `Updated ${taskSummaryLine(next, contents.calendars, contents.settings.clock)}`
5306
+ };
5307
+ }
5308
+ function performPlanTask(contents, id, date, start, end, duration) {
5309
+ const original = requireTask(contents, id, "plan_task");
5310
+ if (original.done) throw new RefusalError("this task is already done \u2014 nothing to plan");
5311
+ if (original.plans.length >= PLANS_MAX) {
5312
+ throw new RefusalError(`this task already carries ${PLANS_MAX} plans, the cap`);
5313
+ }
5314
+ const day = parseDay(date);
5315
+ const startMin = parseTime(start, "start");
5316
+ const endMin = end !== void 0 ? parseTime(end, "end") : startMin + (duration ?? original.estimate ?? contents.settings.defaultDuration);
5317
+ if (endMin <= startMin) throw new RefusalError('"end" must be after "start"');
5318
+ const plan = { start: atMinutes(day, startMin).getTime(), end: atMinutes(day, endMin).getTime() };
5319
+ const next = {
5320
+ ...original,
5321
+ plans: [...original.plans, plan].sort((a, b) => a.start - b.start || a.end - b.end),
5322
+ updatedAt: Date.now()
5323
+ };
5324
+ const clock = contents.settings.clock;
5325
+ return {
5326
+ mutated: { ...carried(contents), tasks: contents.tasks.map((t) => t.id === id ? next : t) },
5327
+ message: `Planned "${next.title || "Untitled"}" ${date} ${formatTimeShort(new Date(plan.start), clock)}\u2013${formatTimeShort(new Date(plan.end), clock)}`
4268
5328
  };
4269
5329
  }
5330
+ function listTasks(contents, now = /* @__PURE__ */ new Date()) {
5331
+ if (contents.tasks.length === 0) return "No tasks.";
5332
+ const clock = contents.settings.clock;
5333
+ const groups = [
5334
+ ["Overdue", []],
5335
+ ["Due soon", []],
5336
+ ["Later", []],
5337
+ ["Done", []]
5338
+ ];
5339
+ const index = { overdue: 0, "due-soon": 1, later: 2, done: 3 };
5340
+ for (const task of contents.tasks) groups[index[taskState(task, now)]][1].push(task);
5341
+ const sections = groups.filter(([, rows]) => rows.length > 0).map(
5342
+ ([label, rows]) => [
5343
+ `${label} (${rows.length}):`,
5344
+ ...rows.sort(compareTasks).map((t) => ` ${taskSummaryLine(t, contents.calendars, clock)}`)
5345
+ ].join("\n")
5346
+ );
5347
+ return sections.join("\n\n");
5348
+ }
4270
5349
  function performUpdateEvent(contents, id, scopeArg, occurrence, patch) {
4271
5350
  const original = contents.events.find((e) => e.id === id);
4272
- if (!original) throw new RefusalError(`no event with id "${id}"`);
5351
+ if (!original) {
5352
+ if (contents.tasks.some((t) => t.id === id)) {
5353
+ throw new RefusalError(`id "${id}" is a task \u2014 use update_task, plan_task or toggle_done.`);
5354
+ }
5355
+ throw notFound(contents, id, "event");
5356
+ }
4273
5357
  refuseIfFeed(original, contents.calendars);
4274
5358
  const scope = requireScope(original, scopeArg);
4275
5359
  const clock = contents.settings.clock;
4276
5360
  if (scope === "all") {
4277
5361
  const record2 = applyPatchToEvent(original, patch, contents.calendars, contents.settings);
4278
- const events = applyUpdate(contents.events, original.id, record2);
5362
+ const events = applyUpdate(contents.events, original.id, record2, Date.now());
4279
5363
  const updated = events.find((e) => e.id === original.id);
4280
5364
  return {
4281
- mutated: { events, calendars: contents.calendars, settings: contents.settings },
5365
+ mutated: { ...carried(contents), events },
4282
5366
  message: `Updated ${eventSummaryLine(updated, contents.calendars, clock)}`
4283
5367
  };
4284
5368
  }
@@ -4290,71 +5374,102 @@ function performUpdateEvent(contents, id, scopeArg, occurrence, patch) {
4290
5374
  ...NEVER,
4291
5375
  seriesId: original.id,
4292
5376
  seriesKey: instantKey(at),
4293
- id: newId()
5377
+ id: newId(),
5378
+ // An agent's write is a mutation like the app's, so it stamps like one:
5379
+ // unstamped, the browser's own copy of this document wins on the next
5380
+ // merge and the agent's work is quietly dropped.
5381
+ updatedAt: Date.now()
4294
5382
  };
4295
5383
  return {
4296
- mutated: {
4297
- events: [...contents.events, override],
4298
- calendars: contents.calendars,
4299
- settings: contents.settings
4300
- },
5384
+ mutated: { ...carried(contents), events: [...contents.events, override] },
4301
5385
  message: `Updated one occurrence: ${eventSummaryLine(override, contents.calendars, clock)}`
4302
5386
  };
4303
5387
  }
4304
5388
  const record = applyPatchToEvent(original, patch, contents.calendars, contents.settings, at);
4305
- const split = applySplit(contents.events, original.id, at, record, newId);
5389
+ const split = applySplit(contents.events, original.id, at, record, newId, Date.now());
4306
5390
  if (!split.created) throw new RefusalError(`no event with id "${id}"`);
4307
5391
  return {
4308
- mutated: { events: split.events, calendars: contents.calendars, settings: contents.settings },
5392
+ mutated: { ...carried(contents), events: split.events },
4309
5393
  message: `Updated from ${dayKey(new Date(at))} on: ${eventSummaryLine(split.created, contents.calendars, clock)}`
4310
5394
  };
4311
5395
  }
4312
5396
  function performDeleteEvent(contents, id, scopeArg, occurrence) {
4313
5397
  const original = contents.events.find((e) => e.id === id);
4314
- if (!original) throw new RefusalError(`no event with id "${id}"`);
5398
+ if (!original) {
5399
+ if (contents.tasks.some((t) => t.id === id)) return performDeleteTask(contents, id);
5400
+ throw notFound(contents, id, "event");
5401
+ }
4315
5402
  refuseIfFeed(original, contents.calendars);
4316
5403
  const scope = requireScope(original, scopeArg);
4317
5404
  const title = original.title || "Untitled";
4318
5405
  if (scope === "all") {
5406
+ const removed = applyRemove(contents.events, original.id, Date.now());
4319
5407
  return {
5408
+ // The graves ride with the removal, because an agent's deletion has to
5409
+ // survive the browser's next merge: without them the tab still holding
5410
+ // the event wins on stamps and writes it straight back.
4320
5411
  mutated: {
4321
- events: applyRemove(contents.events, original.id),
4322
- calendars: contents.calendars,
4323
- settings: contents.settings
5412
+ ...carried(contents),
5413
+ events: removed.events,
5414
+ graves: [...contents.graves, ...removed.graves]
4324
5415
  },
4325
5416
  message: `Deleted "${title}" (the whole series).`
4326
5417
  };
4327
5418
  }
4328
5419
  const at = resolveOccurrenceRequired(original, occurrence, scope);
4329
5420
  if (scope === "one") {
4330
- const events = applyUpdate(contents.events, original.id, {
4331
- exdates: [...original.exdates, instantKey(at)]
4332
- });
5421
+ const events = applyUpdate(
5422
+ contents.events,
5423
+ original.id,
5424
+ { exdates: [...original.exdates, instantKey(at)] },
5425
+ Date.now()
5426
+ );
4333
5427
  return {
4334
- mutated: { events, calendars: contents.calendars, settings: contents.settings },
5428
+ mutated: { ...carried(contents), events },
4335
5429
  message: `Deleted "${title}" on ${dayKey(new Date(at))} only \u2014 the series continues.`
4336
5430
  };
4337
5431
  }
5432
+ const cut = applyTruncate(contents.events, original.id, at, Date.now());
4338
5433
  return {
4339
5434
  mutated: {
4340
- events: applyTruncate(contents.events, original.id, at),
4341
- calendars: contents.calendars,
4342
- settings: contents.settings
5435
+ ...carried(contents),
5436
+ events: cut.events,
5437
+ graves: [...contents.graves, ...cut.graves]
4343
5438
  },
4344
5439
  message: `Deleted "${title}" from ${dayKey(new Date(at))} on.`
4345
5440
  };
4346
5441
  }
4347
- function performToggleDone(contents, id, occurrence) {
4348
- const original = contents.events.find((e) => e.id === id);
4349
- if (!original) throw new RefusalError(`no event with id "${id}"`);
4350
- if (!original.task) throw new RefusalError(`"${original.title || "Untitled"}" is not a task`);
4351
- refuseIfFeed(original, contents.calendars);
4352
- const at = resolveInstant(original, occurrence);
4353
- const wasDone = original.doneKeys.includes(instantKey(at));
4354
- const events = applyToggleDone(contents.events, original.id, at);
5442
+ function performDeleteTask(contents, id) {
5443
+ const task = contents.tasks.find((t) => t.id === id);
5444
+ if (!task) {
5445
+ if (contents.events.some((e) => e.id === id)) {
5446
+ throw new RefusalError(`"${id}" is an event, not a task \u2014 use delete_event`);
5447
+ }
5448
+ throw notFound(contents, id, "task");
5449
+ }
5450
+ return {
5451
+ mutated: {
5452
+ ...carried(contents),
5453
+ tasks: contents.tasks.filter((t) => t.id !== id),
5454
+ graves: [...contents.graves, { id, at: Date.now() }]
5455
+ },
5456
+ message: `Deleted the task "${task.title || "Untitled"}".`
5457
+ };
5458
+ }
5459
+ function performToggleDone(contents, id, _occurrence) {
5460
+ const original = contents.tasks.find((t) => t.id === id);
5461
+ if (!original) {
5462
+ if (contents.events.some((e) => e.id === id)) {
5463
+ throw new RefusalError(`"${id}" is an event, not a task`);
5464
+ }
5465
+ throw notFound(contents, id, "task");
5466
+ }
5467
+ const tasks = contents.tasks.map(
5468
+ (t) => t.id === id ? { ...t, done: !t.done, updatedAt: Date.now() } : t
5469
+ );
4355
5470
  return {
4356
- mutated: { events, calendars: contents.calendars, settings: contents.settings },
4357
- message: `${wasDone ? "Reopened" : "Completed"} "${original.title || "Untitled"}" on ${dayKey(new Date(at))}.`
5471
+ mutated: { ...carried(contents), tasks },
5472
+ message: `${original.done ? "Reopened" : "Completed"} "${original.title || "Untitled"}".`
4358
5473
  };
4359
5474
  }
4360
5475
  function performCreateCalendar(contents, name, color, icon) {
@@ -4365,15 +5480,12 @@ function performCreateCalendar(contents, name, color, icon) {
4365
5480
  name: trimmed,
4366
5481
  color: color || PALETTE[contents.calendars.length * 3 % PALETTE.length],
4367
5482
  icon: icon ?? "",
4368
- visible: true
5483
+ visible: true,
5484
+ updatedAt: Date.now()
4369
5485
  });
4370
5486
  if (!calendar) throw new RefusalError("the assembled calendar failed validation");
4371
5487
  return {
4372
- mutated: {
4373
- events: contents.events,
4374
- calendars: [...contents.calendars, calendar],
4375
- settings: contents.settings
4376
- },
5488
+ mutated: { ...carried(contents), calendars: [...contents.calendars, calendar] },
4377
5489
  message: `Created calendar "${calendar.name}" \u2014 id=${calendar.id}`
4378
5490
  };
4379
5491
  }
@@ -4434,13 +5546,12 @@ function eventFieldsShape() {
4434
5546
  return {
4435
5547
  title: z.string().optional(),
4436
5548
  date: dateKey("YYYY-MM-DD, local.").optional(),
4437
- start: z.string().describe("HH:MM, 24-hour local clock. Required for a timed event or a task.").optional(),
5549
+ start: z.string().describe("HH:MM, 24-hour local clock. Required for a timed event.").optional(),
4438
5550
  end: z.string().describe(
4439
5551
  "HH:MM for a timed event, or YYYY-MM-DD (inclusive) for a multi-day all-day event. Alternative to duration."
4440
5552
  ).optional(),
4441
5553
  duration: z.number().describe("Minutes \u2014 an alternative to end, for a timed event.").optional(),
4442
5554
  allDay: z.boolean().describe("Forces an all-day event. Inferred when start, end and duration are all omitted.").optional(),
4443
- task: z.boolean().describe("A dated to-do (one moment, not a span) rather than an event.").optional(),
4444
5555
  calendarId: z.string().describe(
4445
5556
  "Defaults to the user's default calendar. Must not be a subscription \u2014 those are read-only."
4446
5557
  ).optional(),
@@ -4449,29 +5560,65 @@ function eventFieldsShape() {
4449
5560
  location: z.string().optional(),
4450
5561
  url: z.string().optional(),
4451
5562
  notes: z.string().optional(),
5563
+ zone: z.string().describe(
5564
+ `IANA zone the date and times are written in, e.g. "Asia/Seoul" \u2014 for a deadline abroad. Stored on the event so every surface prints both clocks; timed events only. On update, "" clears it back to floating (local) time, and patched times are read on the event's own zone.`
5565
+ ).optional(),
5566
+ remindLead: z.union([z.number().int().min(0).max(REMIND_LEAD_MAX), z.enum(["inherit", "silent"])]).describe(
5567
+ 'Reminder lead: minutes before the start (0 = as it starts), "silent" (never fires), or "inherit" (follow the calendar, then the setting \u2014 the default).'
5568
+ ).optional(),
4452
5569
  recurrence: recurrenceSchema.optional()
4453
5570
  };
4454
5571
  }
5572
+ var upsertKeySchema = z.string().regex(/^[A-Za-z0-9._:-]{1,64}$/, "letters, digits, . _ : -, up to 64").describe(
5573
+ "Caller-chosen external key for idempotent upsert: the same key always names the same event, so re-running a mirror updates in place instead of duplicating. Omitted, every call creates a fresh event."
5574
+ );
4455
5575
  var createEventFieldsSchema = z.strictObject({
4456
5576
  ...eventFieldsShape(),
4457
- date: dateKey("YYYY-MM-DD, local.")
5577
+ date: dateKey("YYYY-MM-DD, local."),
5578
+ key: upsertKeySchema.optional()
4458
5579
  });
4459
5580
  var patchEventFieldsSchema = z.strictObject(eventFieldsShape());
5581
+ function taskFieldsShape() {
5582
+ return {
5583
+ title: z.string().optional(),
5584
+ date: dateKey("YYYY-MM-DD, local \u2014 the due day.").optional(),
5585
+ time: z.string().nullable().describe(
5586
+ "HH:MM due time, 24-hour local clock. Omit for a deadline that is a date with no time on it; null (on update) clears an existing due time back to that."
5587
+ ).optional(),
5588
+ estimate: z.number().nullable().describe("Minutes of work this looks like. null (on update) clears it back to unknown.").optional(),
5589
+ priority: z.enum(["normal", "high", "urgent"]).optional(),
5590
+ calendarId: z.string().describe(
5591
+ "Defaults to the user's default calendar. Must not be a subscription \u2014 those are read-only."
5592
+ ).optional(),
5593
+ color: z.string().describe("#rrggbb. Empty follows the calendar's own colour.").optional(),
5594
+ icon: z.string().describe("A Phosphor icon name or one emoji. Empty follows the calendar's own mark.").optional(),
5595
+ notes: z.string().optional()
5596
+ };
5597
+ }
5598
+ var createTaskFieldsSchema = z.strictObject({
5599
+ ...taskFieldsShape(),
5600
+ date: dateKey("YYYY-MM-DD, local \u2014 the due day.")
5601
+ });
5602
+ var patchTaskFieldsSchema = z.strictObject(taskFieldsShape());
4460
5603
  var SCOPE_DESCRIPTION = 'Required when the target event repeats: "one" (just this occurrence), "future" (this one and everything after), or "all" (the whole series). Ignored when the event does not repeat.';
4461
5604
  var OCCURRENCE_DESCRIPTION = 'Which occurrence, as a YYYY-MM-DD date or a full "YYYY-MM-DDTHH:mm:ss" instant. Required with scope "one" or "future".';
4462
5605
  var scopeSchema = z.enum(["one", "future", "all"]).describe(SCOPE_DESCRIPTION);
4463
5606
  var occurrenceSchema = z.string().describe(OCCURRENCE_DESCRIPTION);
4464
- function registerTools(server, ctx2) {
5607
+ var calendarFilterSchema = z.string().describe("Scope to one calendar \u2014 its id, or a unique name prefix. Omit for every calendar.");
5608
+ function registerTools(server, store3, pin) {
4465
5609
  server.registerTool(
4466
5610
  "list_day",
4467
5611
  {
4468
5612
  description: "The agenda for one local day: recurrence expanded, all-day and timed occurrences listed separately, each with its id (for update_event/delete_event/toggle_done), its occurrence key, and \u2014 for a multi-day span \u2014 its Day N/M position.",
4469
- inputSchema: z.strictObject({ date: dateKey("YYYY-MM-DD, local.") }),
5613
+ inputSchema: z.strictObject({
5614
+ date: dateKey("YYYY-MM-DD, local."),
5615
+ calendar: calendarFilterSchema.optional()
5616
+ }),
4470
5617
  annotations: { readOnlyHint: true }
4471
5618
  },
4472
- async ({ date }) => guarded(async () => {
4473
- const { contents, warning } = await loadForRead(ctx2);
4474
- return withWarning(listDay(contents, date), warning);
5619
+ async ({ date, calendar }) => guarded(async () => {
5620
+ const { contents, warning } = await store3.load();
5621
+ return withWarning(listDay(scopedToCalendar(contents, calendar), date), warning);
4475
5622
  })
4476
5623
  );
4477
5624
  server.registerTool(
@@ -4480,28 +5627,33 @@ function registerTools(server, ctx2) {
4480
5627
  description: `The agenda across several local days, one section per day that has something on it. Capped at ${MAX_RANGE_DAYS} days of span and ${MAX_RANGE_OCCURRENCES} occurrences total \u2014 a wide range may come back truncated, which the response says.`,
4481
5628
  inputSchema: z.strictObject({
4482
5629
  from: dateKey("YYYY-MM-DD, local, inclusive."),
4483
- to: dateKey("YYYY-MM-DD, local, inclusive.")
5630
+ to: dateKey("YYYY-MM-DD, local, inclusive."),
5631
+ calendar: calendarFilterSchema.optional()
4484
5632
  }),
4485
5633
  annotations: { readOnlyHint: true }
4486
5634
  },
4487
- async ({ from, to }) => guarded(async () => {
4488
- const { contents, warning } = await loadForRead(ctx2);
4489
- return withWarning(listRange(contents, from, to), warning);
5635
+ async ({ from, to, calendar }) => guarded(async () => {
5636
+ const { contents, warning } = await store3.load();
5637
+ return withWarning(listRange(scopedToCalendar(contents, calendar), from, to), warning);
4490
5638
  })
4491
5639
  );
4492
5640
  server.registerTool(
4493
5641
  "search",
4494
5642
  {
4495
- description: "Ranked title/location/notes search over the user's own events (never a subscription's).",
5643
+ description: "Ranked title/location/notes search over the user's own events (never a subscription's). Each result names the field it matched when it was not the title.",
4496
5644
  inputSchema: z.strictObject({
4497
5645
  query: z.string(),
4498
- limit: z.number().int().min(1).describe("Default 20.").optional()
5646
+ limit: z.number().int().min(1).describe("Default 20.").optional(),
5647
+ calendar: calendarFilterSchema.optional()
4499
5648
  }),
4500
5649
  annotations: { readOnlyHint: true }
4501
5650
  },
4502
- async ({ query, limit }) => guarded(async () => {
4503
- const { contents, warning } = await loadForRead(ctx2);
4504
- return withWarning(searchEvents(contents, query, limit), warning);
5651
+ async ({ query, limit, calendar }) => guarded(async () => {
5652
+ const { contents, warning } = await store3.load();
5653
+ return withWarning(
5654
+ searchEvents(scopedToCalendar(contents, calendar), query, limit),
5655
+ warning
5656
+ );
4505
5657
  })
4506
5658
  );
4507
5659
  server.registerTool(
@@ -4512,13 +5664,17 @@ function registerTools(server, ctx2) {
4512
5664
  date: dateKey("YYYY-MM-DD, local."),
4513
5665
  minutes: z.number().describe("Minimum slot length, in minutes."),
4514
5666
  from: z.string().describe("HH:MM, local. Defaults to the user's waking-hours start.").optional(),
4515
- to: z.string().describe("HH:MM, local. Defaults to the user's waking-hours end.").optional()
5667
+ to: z.string().describe("HH:MM, local. Defaults to the user's waking-hours end.").optional(),
5668
+ calendar: calendarFilterSchema.optional()
4516
5669
  }),
4517
5670
  annotations: { readOnlyHint: true }
4518
5671
  },
4519
- async ({ date, minutes, from, to }) => guarded(async () => {
4520
- const { contents, warning } = await loadForRead(ctx2);
4521
- return withWarning(freeSlots(contents, date, minutes, from, to), warning);
5672
+ async ({ date, minutes, from, to, calendar }) => guarded(async () => {
5673
+ const { contents, warning } = await store3.load();
5674
+ return withWarning(
5675
+ freeSlots(scopedToCalendar(contents, calendar), date, minutes, from, to),
5676
+ warning
5677
+ );
4522
5678
  })
4523
5679
  );
4524
5680
  server.registerTool(
@@ -4529,7 +5685,7 @@ function registerTools(server, ctx2) {
4529
5685
  annotations: { readOnlyHint: true }
4530
5686
  },
4531
5687
  async () => guarded(async () => {
4532
- const { contents, warning } = await loadForRead(ctx2);
5688
+ const { contents, warning } = await store3.load();
4533
5689
  return withWarning(listCalendars(contents), warning);
4534
5690
  })
4535
5691
  );
@@ -4541,7 +5697,7 @@ function registerTools(server, ctx2) {
4541
5697
  annotations: { readOnlyHint: true }
4542
5698
  },
4543
5699
  async ({ id }) => guarded(async () => {
4544
- const { contents, warning } = await loadForRead(ctx2);
5700
+ const { contents, warning } = await store3.load();
4545
5701
  return withWarning(describeEvent(contents, id), warning);
4546
5702
  })
4547
5703
  );
@@ -4558,8 +5714,8 @@ function registerTools(server, ctx2) {
4558
5714
  annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false }
4559
5715
  },
4560
5716
  async ({ text, date }) => guarded(
4561
- () => casWrite(ctx2, (contents) => {
4562
- const outcome = performQuickAdd(contents, text, date);
5717
+ () => store3.write((contents) => {
5718
+ const outcome = performQuickAdd(contents, text, date, pin);
4563
5719
  return { result: outcome.mutated, summary: outcome.message };
4564
5720
  })
4565
5721
  )
@@ -4567,13 +5723,30 @@ function registerTools(server, ctx2) {
4567
5723
  server.registerTool(
4568
5724
  "create_event",
4569
5725
  {
4570
- description: "Creates an event from explicit fields, for anything quick_add's grammar cannot spell \u2014 a full recurrence, a specific calendar, notes. Every field is validated the same way an imported backup is.",
5726
+ description: "Creates an event from explicit fields, for anything quick_add's grammar cannot spell \u2014 a full recurrence, a specific calendar, notes, a foreign time zone, a reminder lead. Every field is validated the same way an imported backup is. `key` makes the call an idempotent upsert.",
4571
5727
  inputSchema: createEventFieldsSchema,
4572
5728
  annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false }
4573
5729
  },
4574
5730
  async (input) => guarded(
4575
- () => casWrite(ctx2, (contents) => {
4576
- const outcome = performCreateEvent(contents, input);
5731
+ () => store3.write((contents) => {
5732
+ const { key, ...fields } = input;
5733
+ const outcome = performCreateEvent(contents, fields, key, pin);
5734
+ return { result: outcome.mutated, summary: outcome.message };
5735
+ })
5736
+ )
5737
+ );
5738
+ server.registerTool(
5739
+ "create_events",
5740
+ {
5741
+ description: `Creates (or, with a per-record \`key\`, upserts) up to ${CREATE_BATCH_MAX} events in one write \u2014 one revision bump, all-or-nothing: any refused record refuses the whole batch naming which. The convergent way to mirror an external document into the calendar.`,
5742
+ inputSchema: z.strictObject({
5743
+ events: z.array(createEventFieldsSchema).min(1).max(CREATE_BATCH_MAX)
5744
+ }),
5745
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false }
5746
+ },
5747
+ async ({ events }) => guarded(
5748
+ () => store3.write((contents) => {
5749
+ const outcome = performCreateEvents(contents, events, pin);
4577
5750
  return { result: outcome.mutated, summary: outcome.message };
4578
5751
  })
4579
5752
  )
@@ -4591,7 +5764,7 @@ function registerTools(server, ctx2) {
4591
5764
  annotations: { destructiveHint: true, idempotentHint: false }
4592
5765
  },
4593
5766
  async ({ id, scope, occurrence, patch }) => guarded(
4594
- () => casWrite(ctx2, (contents) => {
5767
+ () => store3.write((contents) => {
4595
5768
  const outcome = performUpdateEvent(contents, id, scope, occurrence, patch);
4596
5769
  return { result: outcome.mutated, summary: outcome.message };
4597
5770
  })
@@ -4609,29 +5782,103 @@ function registerTools(server, ctx2) {
4609
5782
  annotations: { destructiveHint: true, idempotentHint: false }
4610
5783
  },
4611
5784
  async ({ id, scope, occurrence }) => guarded(
4612
- () => casWrite(ctx2, (contents) => {
5785
+ () => store3.write((contents) => {
4613
5786
  const outcome = performDeleteEvent(contents, id, scope, occurrence);
4614
5787
  return { result: outcome.mutated, summary: outcome.message };
4615
5788
  })
4616
5789
  )
4617
5790
  );
5791
+ server.registerTool(
5792
+ "delete_task",
5793
+ {
5794
+ description: "Deletes a task. The same removal delete_event performs on a task id \u2014 this name exists so the task verbs read as the set they are (create_task, update_task, delete_task).",
5795
+ inputSchema: z.strictObject({ id: z.string() }),
5796
+ annotations: { destructiveHint: true, idempotentHint: false }
5797
+ },
5798
+ async ({ id }) => guarded(
5799
+ () => store3.write((contents) => {
5800
+ const outcome = performDeleteTask(contents, id);
5801
+ return { result: outcome.mutated, summary: outcome.message };
5802
+ })
5803
+ )
5804
+ );
4618
5805
  server.registerTool(
4619
5806
  "toggle_done",
4620
5807
  {
4621
- description: "Ticks a task off, or back on, one occurrence at a time \u2014 never the whole series at once.",
5808
+ description: "Ticks a task off, or back on \u2014 a task has one deadline and is finished once.",
4622
5809
  inputSchema: z.strictObject({
4623
5810
  id: z.string(),
4624
- occurrence: z.string().describe("YYYY-MM-DD or a full instant. Required for a repeating task.").optional()
5811
+ occurrence: z.string().describe("Accepted for compatibility and ignored \u2014 a task is ticked whole.").optional()
4625
5812
  }),
4626
5813
  annotations: { destructiveHint: false, idempotentHint: false }
4627
5814
  },
4628
5815
  async ({ id, occurrence }) => guarded(
4629
- () => casWrite(ctx2, (contents) => {
5816
+ () => store3.write((contents) => {
4630
5817
  const outcome = performToggleDone(contents, id, occurrence);
4631
5818
  return { result: outcome.mutated, summary: outcome.message };
4632
5819
  })
4633
5820
  )
4634
5821
  );
5822
+ server.registerTool(
5823
+ "list_tasks",
5824
+ {
5825
+ description: "Every task, grouped the way the app's own sidebar groups them \u2014 Overdue, Due soon, Later, Done \u2014 soonest deadline first inside each group.",
5826
+ inputSchema: z.strictObject({}),
5827
+ annotations: { readOnlyHint: true }
5828
+ },
5829
+ async () => guarded(async () => {
5830
+ const { contents, warning } = await store3.load();
5831
+ return withWarning(listTasks(contents), warning);
5832
+ })
5833
+ );
5834
+ server.registerTool(
5835
+ "create_task",
5836
+ {
5837
+ description: "Creates a task: a piece of work with a deadline, distinct from an event. `date` is the due day; add `time` for a timed deadline, `estimate` so auto-schedule can size it.",
5838
+ inputSchema: createTaskFieldsSchema,
5839
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false }
5840
+ },
5841
+ async (input) => guarded(
5842
+ () => store3.write((contents) => {
5843
+ const outcome = performCreateTask(contents, input, pin);
5844
+ return { result: outcome.mutated, summary: outcome.message };
5845
+ })
5846
+ )
5847
+ );
5848
+ server.registerTool(
5849
+ "update_task",
5850
+ {
5851
+ description: "Patches an existing task. Every field in `patch` is optional and left alone when omitted; ticking is toggle_done's job and planning is plan_task's.",
5852
+ inputSchema: z.strictObject({ id: z.string(), patch: patchTaskFieldsSchema }),
5853
+ annotations: { destructiveHint: true, idempotentHint: false }
5854
+ },
5855
+ async ({ id, patch }) => guarded(
5856
+ () => store3.write((contents) => {
5857
+ const outcome = performUpdateTask(contents, id, patch);
5858
+ return { result: outcome.mutated, summary: outcome.message };
5859
+ })
5860
+ )
5861
+ );
5862
+ server.registerTool(
5863
+ "plan_task",
5864
+ {
5865
+ description: "Books a block of time for a task on the clock \u2014 the drag onto the dial, performed by the assistant. Length falls back to the task's estimate, then the default duration.",
5866
+ inputSchema: z.strictObject({
5867
+ id: z.string(),
5868
+ date: dateKey("YYYY-MM-DD, local \u2014 the day to work on it."),
5869
+ start: z.string().describe("HH:MM, 24-hour local clock."),
5870
+ end: z.string().describe("HH:MM. Alternative to duration.").optional(),
5871
+ duration: z.number().describe("Minutes \u2014 an alternative to end.").optional()
5872
+ }),
5873
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false }
5874
+ },
5875
+ async ({ id, date, start, end, duration }) => guarded(
5876
+ () => store3.write((contents) => {
5877
+ const outcome = performPlanTask(contents, id, date, start, end, duration);
5878
+ return { result: outcome.mutated, summary: outcome.message };
5879
+ })
5880
+ )
5881
+ );
4635
5882
  server.registerTool(
4636
5883
  "create_calendar",
4637
5884
  {
@@ -4644,19 +5891,43 @@ function registerTools(server, ctx2) {
4644
5891
  annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false }
4645
5892
  },
4646
5893
  async ({ name, color, icon }) => guarded(
4647
- () => casWrite(ctx2, (contents) => {
5894
+ () => store3.write((contents) => {
4648
5895
  const outcome = performCreateCalendar(contents, name, color, icon);
4649
5896
  return { result: outcome.mutated, summary: outcome.message };
4650
5897
  })
4651
5898
  )
4652
5899
  );
5900
+ server.registerTool(
5901
+ "sync_status",
5902
+ {
5903
+ description: "The document's envelope and shape \u2014 revision, format version, recorded zone, transport, and record counts (events, tasks, calendars, graves). The first tool to reach for when reasoning about sync, a lost record, or a revision that moved.",
5904
+ inputSchema: z.strictObject({}),
5905
+ annotations: { readOnlyHint: true }
5906
+ },
5907
+ async () => guarded(async () => {
5908
+ const { contents, warning } = await store3.load();
5909
+ return withWarning(syncStatus(contents, store3.transport), warning);
5910
+ })
5911
+ );
4653
5912
  }
4654
5913
 
4655
5914
  // mcp/index.ts
4656
- var HELP = `circal-mcp \u2014 reads and writes circal's mirror file over stdio (MCP)
5915
+ var HELP = `circal-mcp \u2014 reads and writes a circal calendar over stdio (MCP)
4657
5916
 
4658
5917
  Usage:
4659
5918
  circal-mcp --file <path> Start the server against a mirror file
5919
+ circal-mcp --cloud Start the server against the cloud copy
5920
+ (sign in first with \`circal-mcp login\`)
5921
+ circal-mcp --calendar <c> Pin this connection's default calendar (an id or
5922
+ unique name prefix): writes that name none land
5923
+ on it instead of the user default. Optional,
5924
+ beside either transport
5925
+ circal-mcp login Sign in to the cloud copy and remember the
5926
+ derived key on this machine (interactive:
5927
+ email, emailed code, passphrase; asks circal.day
5928
+ which project it uses \u2014 --site <url> for a fork,
5929
+ or --url and --key to name one outright)
5930
+ circal-mcp logout Forget the remembered key and session
4660
5931
  circal-mcp --find Search common locations for a mirror file and
4661
5932
  print a ready-to-paste client config (read-only,
4662
5933
  does not start the server)
@@ -4664,8 +5935,11 @@ Usage:
4664
5935
 
4665
5936
  Environment:
4666
5937
  CIRCAL_FILE Mirror file path, same as --file
5938
+ CIRCAL_CALENDAR Default calendar for writes, same as --calendar
5939
+ CIRCAL_CLOUD_CREDENTIALS Where \`login\` keeps its record (default:
5940
+ the platform config dir, circal/cloud.json)
4667
5941
  CIRCAL_ALLOW_ZONE_MISMATCH Set to 1 to allow writes when this process's
4668
- zone does not match the file's recorded zone
5942
+ zone does not match the document's recorded zone
4669
5943
  `;
4670
5944
  var argv = process.argv.slice(2);
4671
5945
  if (argv.includes("--help") || argv.includes("-h")) {
@@ -4676,18 +5950,43 @@ if (argv.includes("--find")) {
4676
5950
  await runFind();
4677
5951
  process.exit(0);
4678
5952
  }
4679
- var path;
4680
- try {
4681
- path = resolveFilePath(argv, process.env);
4682
- } catch (error) {
4683
- const message = error instanceof Error ? error.message : String(error);
4684
- console.error(`${message} Run \`circal-mcp --find\` to search common locations for one.`);
4685
- process.exit(1);
5953
+ if (argv[0] === "login" || argv[0] === "logout") {
5954
+ try {
5955
+ if (argv[0] === "login") await runLogin(argv, process.env);
5956
+ else await runLogout(process.env);
5957
+ process.exit(0);
5958
+ } catch (error) {
5959
+ console.error(error instanceof Error ? error.message : String(error));
5960
+ process.exit(1);
5961
+ }
5962
+ }
5963
+ var store2;
5964
+ if (argv.includes("--cloud")) {
5965
+ const path = credentialsPath(process.env);
5966
+ const credentials = await readCredentials(path);
5967
+ if (!credentials) {
5968
+ console.error(
5969
+ `circal-mcp --cloud needs a sign-in and found none at ${path}. Run \`circal-mcp login\` first.`
5970
+ );
5971
+ process.exit(1);
5972
+ }
5973
+ store2 = cloudStore({ credentials, path, env: process.env });
5974
+ } else {
5975
+ let path;
5976
+ try {
5977
+ path = resolveFilePath(argv, process.env);
5978
+ } catch (error) {
5979
+ const message = error instanceof Error ? error.message : String(error);
5980
+ console.error(`${message} Run \`circal-mcp --find\` to search common locations for one.`);
5981
+ process.exit(1);
5982
+ }
5983
+ store2 = fileStore({ path, env: process.env });
4686
5984
  }
4687
- var ctx = { path, env: process.env };
5985
+ var calendarFlag = argv.indexOf("--calendar");
5986
+ var pinned = (calendarFlag >= 0 ? argv[calendarFlag + 1] : void 0) || process.env.CIRCAL_CALENDAR;
4688
5987
  function createServer() {
4689
5988
  const server = new McpServer({ name: "circal", version: "1.0.0" });
4690
- registerTools(server, ctx);
5989
+ registerTools(server, store2, pinned);
4691
5990
  return server;
4692
5991
  }
4693
5992
  void serveStdio(createServer);