orzarua 0.5.0 → 0.6.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 (2) hide show
  1. package/dist/orzarua.mjs +1308 -197
  2. package/package.json +1 -1
package/dist/orzarua.mjs CHANGED
@@ -7973,8 +7973,8 @@ function cleanRegex(source) {
7973
7973
  const end = source.endsWith("$") ? source.length - 1 : source.length;
7974
7974
  return source.slice(start, end);
7975
7975
  }
7976
- function floatSafeRemainder(val, step) {
7977
- const ratio = val / step;
7976
+ function floatSafeRemainder(val, step2) {
7977
+ const ratio = val / step2;
7978
7978
  const roundedRatio = Math.round(ratio);
7979
7979
  const tolerance = 4 * Number.EPSILON * Math.max(Math.abs(ratio), 1);
7980
7980
  if (Math.abs(ratio - roundedRatio) < tolerance)
@@ -27933,6 +27933,17 @@ var hebrewDateSchema = external_exports.strictObject({
27933
27933
  month: external_exports.number().int().describe("Nisan 1 ... Tishrei 7 ... Adar (Adar I in a leap year) 12, Adar II 13."),
27934
27934
  day: external_exports.number().int().describe("The day of the month, 1 to 30.")
27935
27935
  });
27936
+ var HEBREW_RANGES = { year: [3761, 6e3], month: [1, 13], day: [1, 30] };
27937
+ var HEBREW_DATE = {
27938
+ type: "object",
27939
+ properties: Object.fromEntries(Object.entries(HEBREW_RANGES).map(([at, [minimum, maximum]]) => [at, { type: "integer", minimum, maximum }])),
27940
+ required: Object.keys(HEBREW_RANGES)
27941
+ };
27942
+ function hebrewDateShape(v) {
27943
+ if (!isObject2(v)) return null;
27944
+ const { year, month, day: day2 } = v;
27945
+ return isWhole(year, ...HEBREW_RANGES.year) && isWhole(month, ...HEBREW_RANGES.month) && isWhole(day2, ...HEBREW_RANGES.day) ? { year, month, day: day2 } : null;
27946
+ }
27936
27947
  var occasionDateShape = {
27937
27948
  civil: civilDate("The civil date as entered"),
27938
27949
  afterSunset: external_exports.boolean().describe("Whether it was after sunset, which moves the Hebrew date to the next day."),
@@ -27986,6 +27997,7 @@ var datesViewSchema = external_exports.strictObject({
27986
27997
  }).readonly();
27987
27998
  var resolveResultSchema = external_exports.strictObject({
27988
27999
  hebrew: hebrewDateSchema.describe("The Hebrew date, after sunset applied."),
28000
+ civil: civilDate("The civil date: the one sent, or for a Hebrew date sent, the civil day whose daytime is that Hebrew day"),
27989
28001
  hebrewWords: external_exports.string().describe('The Hebrew date in words ("14 Adar II 5787").'),
27990
28002
  affects: dateAffectsSchema.describe("Which choices apply to the date."),
27991
28003
  next: occasionOccurrenceSchema.nullable().describe("Its next anniversary on or after today, by the choices sent and the congregation's defaults; null when none falls in the next 390 days.")
@@ -28103,14 +28115,34 @@ function datesBodyUntil(today, office) {
28103
28115
  )
28104
28116
  });
28105
28117
  }
28118
+ var RESOLVE_HEBREW = { rule: "hebrewDate", required: false, schema: HEBREW_DATE };
28119
+ var HEBREW_SHAPE_WORDS = "date.hebrew must be { year, month, day }: whole numbers, the year 3761 to 6000, the month 1 to 13 (as the siddur numbers them), the day 1 to 30";
28120
+ var HEBREW_OR_CIVIL_WORDS = "date.hebrew takes the place of date.civil and date.afterSunset: send one or the other";
28106
28121
  function resolveBodyUntil(today) {
28107
28122
  return external_exports.object({
28108
- civil: reading({ rule: "civilDate", required: true, schema: PARTS.civil, params: CIVIL_RANGE }, (raw, refuse) => part.civil("date", raw, today, refuse)).describe(`The civil date, YYYY-MM-DD, from ${EARLIEST_OCCASION_DATE} to today in the congregation's time zone; required.`),
28109
- afterSunset: reading({ rule: "boolean", required: true, schema: PARTS.afterSunset }, (raw, refuse) => part.afterSunset("date", raw, refuse)).describe("Whether it was after sunset (the Hebrew date is then the next day's); required."),
28123
+ // Left out is read here as left out, and refused below unless a Hebrew date takes its place, in the same words and the same order.
28124
+ civil: reading({ rule: "civilDate", required: false, schema: PARTS.civil, params: CIVIL_RANGE }, (raw, refuse) => raw === void 0 ? void 0 : part.civil("date", raw, today, refuse)).describe(`The civil date, YYYY-MM-DD, from ${EARLIEST_OCCASION_DATE} to today in the congregation's time zone; required unless hebrew is sent.`),
28125
+ afterSunset: reading({ rule: "boolean", required: false, schema: PARTS.afterSunset }, (raw, refuse) => raw === void 0 ? void 0 : part.afterSunset("date", raw, refuse)).describe("Whether it was after sunset (the Hebrew date is then the next day's); required unless hebrew is sent."),
28110
28126
  calendar: reading({ rule: "oneOf", required: true, schema: PARTS.calendar }, (raw, refuse) => part.calendar("date", raw, refuse)).describe("civil or hebrew: which calendar its anniversary is kept by; required."),
28111
28127
  adarObservance: reading({ rule: "oneOf", required: false, schema: PARTS.adarObservance }, (raw, refuse) => part.adarObservance("date", raw, refuse)).describe("adar-i or adar-ii, for a date in Adar of a common year; null or left out for any other (a choice it does not allow is refused)."),
28112
- shortMonthChoice: reading({ rule: "oneOf", required: false, schema: PARTS.shortMonthChoice }, (raw, refuse) => part.shortMonthChoice("date", raw, refuse)).describe(`${OCCASION_SHORT_MONTHS.join(", ")}, for the 30th of Cheshvan or Kislev; null or left out for any other.`)
28113
- });
28128
+ shortMonthChoice: reading({ rule: "oneOf", required: false, schema: PARTS.shortMonthChoice }, (raw, refuse) => part.shortMonthChoice("date", raw, refuse)).describe(`${OCCASION_SHORT_MONTHS.join(", ")}, for the 30th of Cheshvan or Kislev; null or left out for any other.`),
28129
+ hebrew: reading(RESOLVE_HEBREW, (raw, refuse) => {
28130
+ if (raw === void 0) return void 0;
28131
+ const hebrew = hebrewDateShape(raw);
28132
+ if (hebrew === null) refuse(HEBREW_SHAPE_WORDS);
28133
+ return hebrew;
28134
+ }).describe(
28135
+ "A Hebrew date { year, month, day } (months as the siddur numbers them: Nisan 1 ... Tishrei 7 ... Adar, or Adar I in a leap year, 12, Adar II 13), in place of civil and afterSunset: a day the calendar has, no later than the Hebrew day that begins this evening. The answer gives its civil day too."
28136
+ )
28137
+ }).superRefine((body, ctx) => {
28138
+ const at = (path) => (message) => ctx.addIssue({ code: "custom", path: [path], message });
28139
+ if (body.hebrew === void 0) {
28140
+ if (body.civil === void 0) part.civil("date", void 0, today, at("civil"));
28141
+ if (body.afterSunset === void 0) part.afterSunset("date", void 0, at("afterSunset"));
28142
+ } else if (body.civil !== void 0 || body.afterSunset !== void 0) {
28143
+ at("hebrew")(HEBREW_OR_CIVIL_WORDS);
28144
+ }
28145
+ }, { when: (payload) => isObject2(payload.value) });
28114
28146
  }
28115
28147
  var officeDatesBody = datesBodyUntil(null, true);
28116
28148
  var memberDatesBody = datesBodyUntil(null, false);
@@ -28128,8 +28160,8 @@ var datesContracts = [
28128
28160
  path: "/congs/{congId}/dates/resolve",
28129
28161
  auth: "user",
28130
28162
  lock: "allowed-when-locked",
28131
- summary: "Show the Hebrew date a civil date resolves to, before saving it.",
28132
- description: "Takes one date as the dates body holds it and answers its Hebrew date (after sunset applied), in words, which Adar and short-month choices apply to it, and its next anniversary. It computes and saves nothing, so it is allowed while the congregation is locked. Use it before dates.updateForMember to see the Hebrew date and which choices to offer, and before adding a yahrzeit by a civil date of death (yahrzeits.createForMember, congregationYahrzeits.create) to see the Hebrew date it will be kept on. The office (member.record.edit) and any active or lapsed member, for their own dates, may call it.",
28163
+ summary: "Show the Hebrew date a civil date resolves to (or the civil day of a Hebrew date), before saving it.",
28164
+ description: "Takes one date as the dates body holds it and answers its Hebrew date (after sunset applied), in words, its civil date, which Adar and short-month choices apply to it, and its next anniversary. It may take a Hebrew date (hebrew) in place of the civil date and after-sunset: that date is checked as a yahrzeit's is (a day the calendar has, not in the future), and the answer gives its civil day. It computes and saves nothing, so it is allowed while the congregation is locked. Use it before dates.updateForMember to see the Hebrew date and which choices to offer, and before adding a yahrzeit (yahrzeits.createForMember, congregationYahrzeits.create) to see the Hebrew date it will be kept on, or whether a Hebrew date will be taken. The office (member.record.edit) and any active or lapsed member, for their own dates, may call it.",
28133
28165
  capability: "any-member",
28134
28166
  effect: "read",
28135
28167
  params: congParams,
@@ -28144,7 +28176,12 @@ var datesContracts = [
28144
28176
  hint: "Act as a current member, or someone whose role has member.record.edit."
28145
28177
  },
28146
28178
  NO_CONGREGATION,
28147
- DATE_REFUSED
28179
+ DATE_REFUSED,
28180
+ {
28181
+ code: "invalid",
28182
+ when: "A Hebrew date (date.hebrew) is not a day of the calendar (a 30th the month does not have that year, Adar II in a common year), is in the future, or is sent with a civil date or after-sunset.",
28183
+ hint: "Send a Hebrew date the calendar has and that is past, or the civil date and after-sunset instead, never both."
28184
+ }
28148
28185
  ]
28149
28186
  }),
28150
28187
  full({
@@ -30066,22 +30103,9 @@ var yahrzeitMonthSchema = external_exports.strictObject({
30066
30103
  count: external_exports.number().int().describe("How many names the month reads.")
30067
30104
  }).readonly();
30068
30105
  var intIn = (v, min, max2) => typeof v === "number" && Number.isInteger(v) && v >= min && v <= max2 ? v : null;
30069
- var HEBREW_RANGES = { year: [3761, 6e3], month: [1, 13], day: [1, 30] };
30070
- var HEBREW_DATE = {
30071
- type: "object",
30072
- properties: Object.fromEntries(Object.entries(HEBREW_RANGES).map(([part2, [minimum, maximum]]) => [part2, { type: "integer", minimum, maximum }])),
30073
- required: Object.keys(HEBREW_RANGES)
30074
- };
30075
30106
  var EPOCH_MS = { type: "integer", minimum: 0, maximum: Number.MAX_SAFE_INTEGER };
30076
30107
  var YAHRZEIT_ID = { type: "string", pattern: SIDDUR_YAHRZEIT_ID.source };
30077
30108
  var collapse = (v) => v.trim().replace(/\s+/g, " ");
30078
- function hebrewDateShape(v) {
30079
- if (!isObject2(v)) return null;
30080
- const year = intIn(v["year"], ...HEBREW_RANGES.year);
30081
- const month = intIn(v["month"], ...HEBREW_RANGES.month);
30082
- const day2 = intIn(v["day"], ...HEBREW_RANGES.day);
30083
- return year === null || month === null || day2 === null ? null : { year, month, day: day2 };
30084
- }
30085
30109
  var nameField2 = reading({ rule: "name", required: true, schema: { type: "string", minLength: 1, maxLength: NAME_MAX } }, (raw, refuse) => {
30086
30110
  const name = typeof raw === "string" ? collapse(raw) : "";
30087
30111
  if (name.length >= 1 && name.length <= NAME_MAX) return name;
@@ -34898,7 +34922,7 @@ function stepOf(raw, at, mediaPrefix, reading2) {
34898
34922
  return null;
34899
34923
  }
34900
34924
  const before = errors.length;
34901
- const step = {
34925
+ const step2 = {
34902
34926
  stepId: idOf(f, "stepId", `${at}.`, errors),
34903
34927
  kind: "message",
34904
34928
  enabled: booleanOf(f, "enabled", `${at}.`, errors),
@@ -34910,12 +34934,12 @@ function stepOf(raw, at, mediaPrefix, reading2) {
34910
34934
  push: pushOf(f["push"], `${at}.push`, errors),
34911
34935
  whatsapp: booleanOf(f, "whatsapp", `${at}.`, errors)
34912
34936
  };
34913
- const chosen2 = new Set(step.channels.map((c) => c.channel));
34914
- if (chosen2.has("whatsapp") !== step.whatsapp) errors.push(`${at}.whatsapp must be true when WhatsApp is one of the channels, and only then`);
34915
- if (chosen2.has("email") && step.email === null && errors.length === before) errors.push(`${at}.email: write the email, or take email off the channels`);
34916
- if (chosen2.has("sms") && step.sms === null && errors.length === before) errors.push(`${at}.sms: write the SMS, or take SMS off the channels`);
34917
- if (chosen2.has("push") && step.push === null && errors.length === before) errors.push(`${at}.push: write the push notification, or take push off the channels`);
34918
- return errors.length === before ? step : null;
34937
+ const chosen2 = new Set(step2.channels.map((c) => c.channel));
34938
+ if (chosen2.has("whatsapp") !== step2.whatsapp) errors.push(`${at}.whatsapp must be true when WhatsApp is one of the channels, and only then`);
34939
+ if (chosen2.has("email") && step2.email === null && errors.length === before) errors.push(`${at}.email: write the email, or take email off the channels`);
34940
+ if (chosen2.has("sms") && step2.sms === null && errors.length === before) errors.push(`${at}.sms: write the SMS, or take SMS off the channels`);
34941
+ if (chosen2.has("push") && step2.push === null && errors.length === before) errors.push(`${at}.push: write the push notification, or take push off the channels`);
34942
+ return errors.length === before ? step2 : null;
34919
34943
  }
34920
34944
  function stepsOf(raw, mediaPrefix, reading2) {
34921
34945
  const { errors } = reading2;
@@ -37189,9 +37213,9 @@ function readRounding(parent, refuse) {
37189
37213
  refuse("time.rounding must be an object");
37190
37214
  return null;
37191
37215
  }
37192
- const step = ROUNDING_STEPS.find((s) => s === fields["step"]);
37193
- if (step === void 0) refuse(`time.rounding.step must be one of ${ROUNDING_STEPS.join(", ")}`);
37194
- return { direction: readPart(ROUNDING_DIRECTION, fields, "direction", "time.rounding", refuse) ?? "nearest", step: step ?? 5 };
37216
+ const step2 = ROUNDING_STEPS.find((s) => s === fields["step"]);
37217
+ if (step2 === void 0) refuse(`time.rounding.step must be one of ${ROUNDING_STEPS.join(", ")}`);
37218
+ return { direction: readPart(ROUNDING_DIRECTION, fields, "direction", "time.rounding", refuse) ?? "nearest", step: step2 ?? 5 };
37195
37219
  }
37196
37220
  var TIME_KIND = oneOf(["clock", "zman"], "clock");
37197
37221
  var CLOCK_MINUTES = integerField(0, MINUTES_IN_DAY - 1, true);
@@ -38123,6 +38147,11 @@ function commandByWords(area, action) {
38123
38147
  function commandById(id2) {
38124
38148
  return BY_ID2.get(id2) ?? null;
38125
38149
  }
38150
+ function needCommand(workflowId, id2) {
38151
+ const cmd = commandById(id2);
38152
+ if (cmd === null) throw new Error(`${workflowId} uses ${id2}, which this CLI has no command for.`);
38153
+ return cmd;
38154
+ }
38126
38155
  function idParams(cmd) {
38127
38156
  return Object.keys(cmd.contract.params.shape).filter((key) => key !== "congId");
38128
38157
  }
@@ -38488,7 +38517,7 @@ var isStarted = (entry) => "kind" in entry && entry.kind === "started";
38488
38517
  var resultsOf = (backup) => backup.results.filter((entry) => !("kind" in entry));
38489
38518
  var unanswered = (backup) => {
38490
38519
  const answered = new Set(resultsOf(backup).map((entry) => entry.step));
38491
- return backup.results.filter(isStarted).map((entry) => entry.step).filter((step) => !answered.has(step));
38520
+ return backup.results.filter(isStarted).map((entry) => entry.step).filter((step2) => !answered.has(step2));
38492
38521
  };
38493
38522
  var changedNothing = (entry) => !entry.ok && entry.error?.mayHaveLanded !== true && entry.error?.code !== "network";
38494
38523
  var restoredBy = (backup) => backup.results.find(isRestored);
@@ -38949,6 +38978,26 @@ var RESTORERS = {
38949
38978
  "tickets.saveTypes": ticketTypes
38950
38979
  };
38951
38980
  var RESTORER_BY_ID = new Map(Object.entries(RESTORERS).flatMap(([id2, restorer]) => restorer === void 0 ? [] : [[id2, restorer]]));
38981
+ var TAKE_BACK = {
38982
+ "yahrzeits.createForMember": "yahrzeits.deleteForMember",
38983
+ "congregationYahrzeits.create": "congregationYahrzeits.delete"
38984
+ };
38985
+ var TAKE_BACK_BY_ID = new Map(Object.entries(TAKE_BACK).flatMap(([id2, del]) => del === void 0 ? [] : [[id2, del]]));
38986
+ var takeBackFor = (cmd) => {
38987
+ const del = TAKE_BACK_BY_ID.get(cmd.id);
38988
+ return del === void 0 ? null : commandById(del);
38989
+ };
38990
+ function takeBack(del, created2, current, label) {
38991
+ const left = (words) => ({ ok: true, steps: [], changes: [], kept: [], left: [words] });
38992
+ if (current === null) return left(`${label} was already removed.`);
38993
+ if (current["adoptedFromOffice"] === true) return left(`${label}: the member took it into their own list since, so it is theirs and is left as it is now.`);
38994
+ if (!sameValue(keeperOf(current), keeperOf(created2))) return left(`${label} was moved to another keeper since it was added, so it is left as it is now.`);
38995
+ if (current["version"] !== created2["version"]) return left(`${label} was changed since it was added, so it is left as it is now.`);
38996
+ const memberId = keeperOf(current);
38997
+ const input2 = { ...typeof memberId === "string" && "memberId" in del.input.shape ? { memberId } : {}, yahrzeitId: current["yahrzeitId"], version: current["version"] };
38998
+ const record3 = recordLabel(del, input2);
38999
+ return { ok: true, steps: [{ commandId: del.id, input: input2, record: record3 }], changes: deleteChanges(yahrzeitBody2(current), record3), kept: [] };
39000
+ }
38952
39001
  var RECORD_KEYS = {
38953
39002
  "series.listOverrides": "override",
38954
39003
  "calendarSettings.get": "settings",
@@ -39000,6 +39049,206 @@ var WHOLE_READS = new Map(Object.entries(BACKUP_READS).flatMap(([id2, options])
39000
39049
  var MAY_BE_ABSENT = /* @__PURE__ */ new Set(["series.saveOverride", "me.updateYahrzeit"]);
39001
39050
  var mayBeAbsent = (cmd) => MAY_BE_ABSENT.has(cmd.id);
39002
39051
 
39052
+ // ../backend/src/core/blocks.ts
39053
+ function plainRun(text2, bold = false) {
39054
+ return { text: text2, bold, italic: false, href: null };
39055
+ }
39056
+ function paragraphBlock(text2) {
39057
+ return { type: "paragraph", runs: [plainRun(text2)] };
39058
+ }
39059
+
39060
+ // ../backend/src/core/automations/presets.ts
39061
+ var NINE_AM = 540;
39062
+ function step(title, subject, body, push) {
39063
+ return {
39064
+ kind: "message",
39065
+ enabled: true,
39066
+ to: { kind: "subject" },
39067
+ channels: [{ channel: "push", mode: "everyone" }, { channel: "email", mode: "fallback" }],
39068
+ title,
39069
+ email: { subject, blocks: [paragraphBlock(body)] },
39070
+ sms: null,
39071
+ push,
39072
+ whatsapp: false
39073
+ };
39074
+ }
39075
+ var WARM_WISHES = "With warm wishes from everyone at {congregation}.";
39076
+ var AUTOMATION_PRESETS = [
39077
+ {
39078
+ presetId: "yahrzeit-reminder",
39079
+ name: "Yahrzeit reminder",
39080
+ sendAt: NINE_AM,
39081
+ triggers: [{ kind: "occasion", occasion: "yahrzeit", leadDays: 3 }],
39082
+ step: step(
39083
+ "The yahrzeit of {deceased_name}",
39084
+ "The yahrzeit of {deceased_name}",
39085
+ "{first_name}, the yahrzeit of {deceased_name} begins {begins} ({hebrew_date}). We will say kaddish with you. {congregation}.",
39086
+ { title: "The yahrzeit of {deceased_name}", body: "It begins {begins}." }
39087
+ )
39088
+ },
39089
+ {
39090
+ presetId: "birthday",
39091
+ name: "Birthday greeting",
39092
+ sendAt: NINE_AM,
39093
+ triggers: [{ kind: "occasion", occasion: "birthday", leadDays: 0 }],
39094
+ step: step(
39095
+ "Mazal tov, {first_name}",
39096
+ "Mazal tov on your birthday",
39097
+ `Mazal tov on your birthday, {first_name}. ${WARM_WISHES}`,
39098
+ { title: "Mazal tov, {first_name}", body: WARM_WISHES }
39099
+ )
39100
+ },
39101
+ {
39102
+ presetId: "wedding",
39103
+ name: "Wedding anniversary",
39104
+ sendAt: NINE_AM,
39105
+ triggers: [{ kind: "occasion", occasion: "wedding", leadDays: 0 }],
39106
+ step: step(
39107
+ "Mazal tov, {first_name}",
39108
+ "Mazal tov on your wedding anniversary",
39109
+ `Mazal tov on your {years} wedding anniversary, {first_name}. ${WARM_WISHES}`,
39110
+ { title: "Mazal tov, {first_name}", body: WARM_WISHES }
39111
+ )
39112
+ },
39113
+ {
39114
+ presetId: "b-mitzvah",
39115
+ name: "B'mitzvah anniversary",
39116
+ sendAt: NINE_AM,
39117
+ triggers: [{ kind: "occasion", occasion: "b-mitzvah", leadDays: 0 }],
39118
+ step: step(
39119
+ "Mazal tov, {first_name}",
39120
+ "Mazal tov on your b'mitzvah anniversary",
39121
+ `Mazal tov on the {years} anniversary of your b'mitzvah, {first_name}. ${WARM_WISHES}`,
39122
+ { title: "Mazal tov, {first_name}", body: WARM_WISHES }
39123
+ )
39124
+ },
39125
+ {
39126
+ presetId: "shabbat-list",
39127
+ name: "Weekly Shabbat list",
39128
+ sendAt: NINE_AM,
39129
+ triggers: [{ kind: "weekly", weekday: 4 }],
39130
+ step: {
39131
+ kind: "shabbat-digest",
39132
+ enabled: true,
39133
+ to: { kind: "capability", capability: "shabbat-list.view" },
39134
+ channels: [{ channel: "email", mode: "everyone" }, { channel: "push", mode: "everyone" }]
39135
+ }
39136
+ }
39137
+ ];
39138
+ function presetWrite(preset) {
39139
+ const weekly = preset.triggers.some((t) => t.kind === "weekly");
39140
+ return {
39141
+ name: preset.name,
39142
+ triggers: preset.triggers,
39143
+ sendAt: preset.sendAt,
39144
+ restDays: "before",
39145
+ shabbat: "following",
39146
+ grouping: weekly ? "per-occurrence" : "per-shabbat",
39147
+ steps: [preset.step]
39148
+ };
39149
+ }
39150
+
39151
+ // src/tool/workflows/automation-presets.ts
39152
+ var presetIds = () => AUTOMATION_PRESETS.map((p) => p.presetId);
39153
+ function presetNamed(workflowId, given) {
39154
+ const ids = presetIds();
39155
+ const found = AUTOMATION_PRESETS.find((p) => p.presetId === given);
39156
+ if (found !== void 0) return { ok: true, data: found };
39157
+ const word = typeof given === "string" ? given.toLowerCase() : "";
39158
+ const near = word === "" ? null : ids.find((id2) => id2.startsWith(word) || word.startsWith(id2)) ?? suggest(word, ids);
39159
+ return fail2(cliError("invalid-input", `There is no preset "${String(given)}".${near === null ? "" : ` Did you mean "${near}"?`}`, {
39160
+ page: workflowId,
39161
+ hint: `The presets are ${ids.join(", ")}. The list of presets (automations presets) says what each one does.`,
39162
+ details: [{ path: ["from"], message: `one of ${ids.join(", ")}` }]
39163
+ }));
39164
+ }
39165
+ var summaryOf = (preset) => ({
39166
+ presetId: preset.presetId,
39167
+ name: preset.name,
39168
+ triggers: preset.triggers,
39169
+ sendAt: preset.sendAt,
39170
+ channels: preset.step.channels.map((c) => c.channel)
39171
+ });
39172
+ var WEEKDAYS = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"];
39173
+ function presetWords(preset) {
39174
+ const when = preset.triggers.map((t) => {
39175
+ if (t.kind === "weekly") return `every ${WEEKDAYS[t.weekday] ?? "week"}`;
39176
+ return t.leadDays === 0 ? `on the day of a ${t.occasion}` : `${t.leadDays} day${t.leadDays === 1 ? "" : "s"} before a ${t.occasion}`;
39177
+ }).join(", ");
39178
+ const at = `${Math.floor(preset.sendAt / 60)}:${String(preset.sendAt % 60).padStart(2, "0")}`;
39179
+ return `${when}, at ${at}, by ${preset.step.channels.map((c) => c.channel).join(" and ")}`;
39180
+ }
39181
+ var presetList = () => AUTOMATION_PRESETS.map((p) => `- \`${p.presetId}\`: ${p.name}, ${presetWords(p)}`).join("\n");
39182
+ var automationsPresets = {
39183
+ id: "automations.presets",
39184
+ words: ["automations", "presets"],
39185
+ summary: "List the gallery's automation presets: what sets each off, when it goes and the channels it uses.",
39186
+ description: "The starting points an automation is made from (automations create, from a preset): its id, name, triggers, send time (minutes after midnight) and channels. Nothing is saved or sent.",
39187
+ effect: "read",
39188
+ uses: [],
39189
+ input: external_exports.strictObject({}),
39190
+ run: () => Promise.resolve({ ok: true, data: AUTOMATION_PRESETS.map(summaryOf) })
39191
+ };
39192
+ var inputSchema = external_exports.strictObject({
39193
+ presetId: external_exports.string().min(1).describe("The preset to start from (`automations presets` lists them)."),
39194
+ overrides: external_exports.record(external_exports.string(), external_exports.unknown()).optional().describe("Fields of automations.create to change from the preset, by name (the line's own flags, set and body).")
39195
+ });
39196
+ function layOver(base, over) {
39197
+ if (!isRecord(over)) return over;
39198
+ if (Array.isArray(base)) {
39199
+ const out2 = [...base];
39200
+ for (const [key, value] of Object.entries(over)) {
39201
+ const at = /^\d+$/.test(key) ? Number(key) : -1;
39202
+ if (at < 0) return over;
39203
+ out2[at] = layOver(base[at], value);
39204
+ }
39205
+ return out2;
39206
+ }
39207
+ if (!isRecord(base)) return over;
39208
+ const out = { ...base };
39209
+ for (const [key, value] of Object.entries(over)) out[key] = layOver(base[key], value);
39210
+ return out;
39211
+ }
39212
+ function suggestedOf(data) {
39213
+ const saved = asObject(asObject(asObject(data)["automation"])["automation"]);
39214
+ const automationId = saved["automationId"];
39215
+ const version3 = saved["version"];
39216
+ const steps = Array.isArray(saved["steps"]) ? saved["steps"] : [];
39217
+ const stepId = isRecord(steps[0]) ? steps[0]["stepId"] : void 0;
39218
+ if (typeof automationId !== "string" || typeof version3 !== "number") return [];
39219
+ return [
39220
+ {
39221
+ commandId: "automations.get",
39222
+ input: { automationId },
39223
+ why: "Read it back, then see a step as an email, push and in-app copy with automations preview, its fields as the body (--body): nothing is saved or sent."
39224
+ },
39225
+ ...typeof stepId === "string" ? [{ commandId: "automations.test", input: { automationId, stepId, channels: ["email"] }, why: "Send yourself the email, to see how it arrives." }] : [],
39226
+ { commandId: "automations.enable", input: { automationId, version: version3, confirmCost: true }, why: "Turn it on: a plan that says who it will reach and what it costs." }
39227
+ ];
39228
+ }
39229
+ var automationsCreateFromPreset = {
39230
+ id: "automations.createFromPreset",
39231
+ words: ["automations", "create"],
39232
+ summary: "Make an automation from one of the gallery's presets, turned off.",
39233
+ description: "Starts from a preset (`automations presets`) and saves it as a new automation, turned off, at version 1. Any other field of automations.create changes only what it names: a new name renames it and leaves the rest as the preset has it. Preview it, test it on yourself and turn it on (`automations enable`, a plan) as for any automation.",
39234
+ effect: "write",
39235
+ uses: ["automations.create"],
39236
+ claims: "--from",
39237
+ flagsFrom: "automations.create",
39238
+ input: inputSchema,
39239
+ async run(ctx, input2) {
39240
+ const parsed = inputSchema.safeParse(input2);
39241
+ if (!parsed.success) return fail2(cliError("invalid-input", "The input is not a preset's id and the fields to change.", { page: "automations.createFromPreset" }));
39242
+ const preset = presetNamed("automations.createFromPreset", parsed.data.presetId);
39243
+ if (!preset.ok) return preset;
39244
+ const write = layOver({ ...presetWrite(preset.data), version: 0 }, parsed.data.overrides ?? {});
39245
+ const made = await executeUnguarded(ctx, needCommand("automations.createFromPreset", "automations.create"), isRecord(write) ? write : {});
39246
+ if (!made.ok) return made;
39247
+ const suggested = suggestedOf(made.data);
39248
+ return { ok: true, data: made.data, ...suggested.length === 0 ? {} : { suggested } };
39249
+ }
39250
+ };
39251
+
39003
39252
  // ../backend/src/core/geo/locales.ts
39004
39253
  var DEFAULT_LOCALE = "en-AU";
39005
39254
  var PLATFORM_LOCALE = DEFAULT_LOCALE;
@@ -39106,6 +39355,30 @@ function scanCsv(text2) {
39106
39355
  const isSepHint = (r) => /^sep=.?$/i.test(r.cells[0] ?? "") && r.cells.slice(1).every((c) => c === "");
39107
39356
  return { lines: records.filter((r) => !isSepHint(r)) };
39108
39357
  }
39358
+ var isBlankLine = (r) => r.cells.every((c) => cleanCell(c) === "");
39359
+ function headerAndData(lines2) {
39360
+ const [header, ...data] = lines2.filter((r, i2, all) => !isBlankLine(r) || all.slice(0, i2).some((p) => !isBlankLine(p)));
39361
+ return { header, data };
39362
+ }
39363
+ function columnsByMapping(headerCells2, mapping, aliases) {
39364
+ const mapped = /* @__PURE__ */ new Map();
39365
+ const skipped = [];
39366
+ const unmapped = headerCells2.map((raw, index3) => {
39367
+ const name = cleanCell(raw);
39368
+ if (!Object.prototype.hasOwnProperty.call(mapping, name)) return raw;
39369
+ const field = mapping[name] ?? null;
39370
+ if (field === null || [...mapped.values()].includes(field)) skipped.push(name);
39371
+ else mapped.set(index3, field);
39372
+ return "";
39373
+ });
39374
+ const mappedFields = new Set(mapped.values());
39375
+ const left = {};
39376
+ for (const f of Object.keys(aliases)) if (!mappedFields.has(f)) left[f] = aliases[f];
39377
+ const byAlias = mapColumns(unmapped, left);
39378
+ return { columns: new Map([...byAlias.columns, ...mapped]), ignored: [...skipped, ...byAlias.ignored] };
39379
+ }
39380
+ var HEBREW_MARKS = /[֑-ׇ]/u;
39381
+ var WORD_SPLITTERS = /[-‐-―־׀׃׆]/u;
39109
39382
 
39110
39383
  // ../backend/src/core/member-import.ts
39111
39384
  var MAX_IMPORT_ROWS = 2e3;
@@ -39127,28 +39400,8 @@ var IMPORT_ALIASES = {
39127
39400
  countsForMinyan: ["counts for minyan", "counts_for_minyan"]
39128
39401
  };
39129
39402
  var fatal = (error62, ignoredColumns = []) => ({ rows: [], ignoredColumns, error: error62 });
39130
- var isBlankLine = (r) => r.cells.every((c) => cleanCell(c) === "");
39131
- function headerAndData(lines2) {
39132
- const [header, ...data] = lines2.filter((r, i2, all) => !isBlankLine(r) || all.slice(0, i2).some((p) => !isBlankLine(p)));
39133
- return { header, data };
39134
- }
39135
- var headerOf = (lines2) => headerAndData(lines2).header;
39136
- function importColumns(headerCells, mapping = {}) {
39137
- const mapped = /* @__PURE__ */ new Map();
39138
- const skipped = [];
39139
- const unmapped = headerCells.map((raw, index3) => {
39140
- const name = cleanCell(raw);
39141
- if (!Object.prototype.hasOwnProperty.call(mapping, name)) return raw;
39142
- const field = mapping[name] ?? null;
39143
- if (field === null || [...mapped.values()].includes(field)) skipped.push(name);
39144
- else mapped.set(index3, field);
39145
- return "";
39146
- });
39147
- const mappedFields = new Set(mapped.values());
39148
- const aliases = {};
39149
- for (const f of IMPORT_FIELDS) if (!mappedFields.has(f)) aliases[f] = IMPORT_ALIASES[f];
39150
- const byAlias = mapColumns(unmapped, aliases);
39151
- return { columns: new Map([...byAlias.columns, ...mapped]), ignored: [...skipped, ...byAlias.ignored] };
39403
+ function importColumns(headerCells2, mapping = {}) {
39404
+ return columnsByMapping(headerCells2, mapping, IMPORT_ALIASES);
39152
39405
  }
39153
39406
  var NEEDS_NAME_COLUMNS = "The first row needs a column headed with a first name and a last name (for example First name and Surname).";
39154
39407
  function rowsFromTable(lines2, mapping = {}) {
@@ -39195,18 +39448,6 @@ function duplicateNameRows(rows) {
39195
39448
  return duplicates;
39196
39449
  }
39197
39450
 
39198
- // src/tool/column-letters.ts
39199
- function columnLetters(index3) {
39200
- let n = index3 + 1;
39201
- let out = "";
39202
- while (n > 0) {
39203
- const rest = (n - 1) % 26;
39204
- out = String.fromCharCode(65 + rest) + out;
39205
- n = Math.floor((n - 1) / 26);
39206
- }
39207
- return out;
39208
- }
39209
-
39210
39451
  // node_modules/read-excel-file/modules/saxen/parser.js
39211
39452
  function _typeof(o) {
39212
39453
  "@babel/helpers - typeof";
@@ -43019,6 +43260,18 @@ function readXlsxFile(input2, options) {
43019
43260
  });
43020
43261
  }
43021
43262
 
43263
+ // src/tool/column-letters.ts
43264
+ function columnLetters(index3) {
43265
+ let n = index3 + 1;
43266
+ let out = "";
43267
+ while (n > 0) {
43268
+ const rest = (n - 1) % 26;
43269
+ out = String.fromCharCode(65 + rest) + out;
43270
+ n = Math.floor((n - 1) / 26);
43271
+ }
43272
+ return out;
43273
+ }
43274
+
43022
43275
  // src/tool/sheet.ts
43023
43276
  var tableSchema = external_exports.strictObject({
43024
43277
  name: external_exports.string().describe("The file's name."),
@@ -43030,6 +43283,39 @@ var tableSchema = external_exports.strictObject({
43030
43283
  cells: external_exports.array(external_exports.string()).readonly().describe("Its cells as text.")
43031
43284
  }).readonly()).readonly().describe("The lines of the sheet, blank ones left out, each with its own number.")
43032
43285
  }).readonly();
43286
+ var headerCells = (table2) => headerAndData(table2.lines).header?.cells ?? [];
43287
+ function headerKeyed(table2, mapping) {
43288
+ const headers = headerCells(table2).map(cleanCell).filter((name) => name !== "");
43289
+ const keyed = {};
43290
+ const unused = [];
43291
+ for (const [key, field] of Object.entries(mapping)) {
43292
+ const header = headers.includes(key) ? key : headers.find((name) => squash(name) === squash(key));
43293
+ if (header === void 0) unused.push(key);
43294
+ else keyed[header] = field;
43295
+ }
43296
+ return { mapping: keyed, unused };
43297
+ }
43298
+ function proposalOf(header, columns) {
43299
+ const out = {};
43300
+ header.forEach((raw, index3) => {
43301
+ const name = cleanCell(raw);
43302
+ if (name !== "") out[name] = columns.get(index3) ?? null;
43303
+ });
43304
+ return out;
43305
+ }
43306
+ function columnName(header, columns, field) {
43307
+ const at = field === null || field === void 0 ? void 0 : [...columns].find(([, f]) => f === field)?.[0];
43308
+ return at === void 0 ? void 0 : `${columnLetters(at)} (${cleanCell(header[at] ?? "")})`;
43309
+ }
43310
+ function unreadableSheet(table2, error62, words) {
43311
+ const headers = Object.keys(words.propose(table2));
43312
+ const names = words.columnsMissing(error62) ? ` The headers found: ${headers.length === 0 ? "none" : headers.join(", ")}.${words.after === void 0 ? "" : ` ${words.after}`}` : "";
43313
+ return fail2(cliError("invalid-input", `${table2.name}: ${error62}${names}`, {
43314
+ page: words.page,
43315
+ hint: names === "" ? "Fix the file and plan again." : words.mapHint,
43316
+ ...names === "" ? {} : { next: [{ commandId: words.page, input: { file: table2.path ?? table2.name, map: "map.json" }, why: "Writes the headers it found to map.json, to edit." }] }
43317
+ }));
43318
+ }
43033
43319
  var KINDS = "Save it as .xlsx or .csv and try again";
43034
43320
  function textOf2(cell2) {
43035
43321
  if (typeof cell2 === "string") return cell2;
@@ -43079,48 +43365,28 @@ async function readTable(bytes, name, sheet) {
43079
43365
  // src/tool/workflows/members-import.ts
43080
43366
  var importMappingSchema = external_exports.record(external_exports.string(), external_exports.enum(IMPORT_FIELDS).nullable());
43081
43367
  var CREATE_ONLY_WORDS = "Only add people: a row whose email is on file is skipped, never updated.";
43082
- var inputSchema = external_exports.strictObject({
43368
+ var inputSchema2 = external_exports.strictObject({
43083
43369
  table: tableSchema.describe("The sheet, read from the file (a front end does that: give it the file, not this)."),
43084
43370
  mapping: importMappingSchema.optional().describe("Which column is which field, by the header as written (null leaves a column out). It overrides the headers the reader knows."),
43085
43371
  createOnly: external_exports.boolean().optional().describe(CREATE_ONLY_WORDS)
43086
43372
  });
43087
- function headerKeyed(table2, mapping) {
43088
- const headers = (headerOf(table2.lines)?.cells ?? []).map(cleanCell).filter((name) => name !== "");
43089
- const keyed = {};
43090
- const unused = [];
43091
- for (const [key, field] of Object.entries(mapping)) {
43092
- const header = headers.includes(key) ? key : headers.find((name) => squash(name) === squash(key));
43093
- if (header === void 0) unused.push(key);
43094
- else keyed[header] = field;
43095
- }
43096
- return { mapping: keyed, unused };
43097
- }
43098
- var need = (id2) => {
43099
- const cmd = commandById(id2);
43100
- if (cmd === null) throw new Error(`members.importSheet uses ${id2}, which this CLI has no command for.`);
43101
- return cmd;
43102
- };
43373
+ var need = (id2) => needCommand("members.importSheet", id2);
43103
43374
  function columnsOf(table2, mapping) {
43104
- const header = headerOf(table2.lines)?.cells ?? [];
43375
+ const header = headerCells(table2);
43105
43376
  return { header, ...importColumns(header, mapping) };
43106
43377
  }
43107
43378
  function mappingOf(table2, mapping = {}) {
43108
43379
  const { header, columns } = columnsOf(table2, mapping);
43109
- const out = {};
43110
- header.forEach((raw, index3) => {
43111
- const name = cleanCell(raw);
43112
- if (name !== "") out[name] = columns.get(index3) ?? null;
43113
- });
43114
- return out;
43380
+ return proposalOf(header, columns);
43115
43381
  }
43116
43382
  function unreadable(table2, error62) {
43117
- const headers = Object.keys(mappingOf(table2));
43118
- const names = error62.startsWith(NEEDS_NAME_COLUMNS) ? ` The headers found: ${headers.length === 0 ? "none" : headers.join(", ")}. Name the first and last name columns in a map file.` : "";
43119
- return fail2(cliError("invalid-input", `${table2.name}: ${error62}${names}`, {
43383
+ return unreadableSheet(table2, error62, {
43120
43384
  page: "members.importSheet",
43121
- hint: names === "" ? "Fix the file and plan again." : "A map file says which header is which field (firstName, lastName, email, phone and so on), and one that is not there yet is written with these headers to edit. Map the columns, then plan again.",
43122
- ...names === "" ? {} : { next: [{ commandId: "members.importSheet", input: { file: table2.path ?? table2.name, map: "map.json" }, why: "Writes the headers it found to map.json, to edit." }] }
43123
- }));
43385
+ columnsMissing: (e) => e.startsWith(NEEDS_NAME_COLUMNS),
43386
+ after: "Name the first and last name columns in a map file.",
43387
+ propose: (t) => mappingOf(t),
43388
+ mapHint: "A map file says which header is which field (firstName, lastName, email, phone and so on), and one that is not there yet is written with these headers to edit. Map the columns, then plan again."
43389
+ });
43124
43390
  }
43125
43391
  function batches(items, size) {
43126
43392
  return Array.from({ length: Math.ceil(items.length / size) }, (_, i2) => items.slice(i2 * size, (i2 + 1) * size));
@@ -43134,19 +43400,16 @@ var membersImportSheet = {
43134
43400
  effect: "destructive",
43135
43401
  // members.getMinyanFlag keeps a flag an update sets; members.update and members.setMinyanFlag are how a restore puts them back.
43136
43402
  uses: ["members.import", "members.list", "households.list", MINYAN_READ, "members.update", "members.setMinyanFlag"],
43137
- input: inputSchema,
43403
+ input: inputSchema2,
43138
43404
  claims: "positional",
43139
43405
  async plan(ctx, given) {
43140
- const { table: table2, mapping: asGiven = {}, createOnly = false } = inputSchema.parse(given);
43406
+ const { table: table2, mapping: asGiven = {}, createOnly = false } = inputSchema2.parse(given);
43141
43407
  const { mapping, unused } = headerKeyed(table2, asGiven);
43142
43408
  const read2 = rowsFromTable(table2.lines, mapping);
43143
43409
  if (read2.error !== null) return unreadable(table2, read2.error);
43144
43410
  if (read2.rows.length === 0) return fail2(cliError("invalid-input", `${table2.name} has a header and no members under it.`, { page: "members.importSheet", hint: "Add the members under the header row and plan again." }));
43145
43411
  const { header, columns, ignored } = columnsOf(table2, mapping);
43146
- const columnOf = (field) => {
43147
- const at = field === null || field === void 0 ? void 0 : [...columns].find(([, f]) => f === field)?.[0];
43148
- return at === void 0 ? void 0 : `${columnLetters(at)} (${cleanCell(header[at] ?? "")})`;
43149
- };
43412
+ const columnOf = (field) => columnName(header, columns, field);
43150
43413
  const emailRepeats = duplicateEmailRows(read2.rows);
43151
43414
  const nameRepeats = duplicateNameRows(read2.rows);
43152
43415
  const repeats = [];
@@ -43242,8 +43505,551 @@ var membersImportSheet = {
43242
43505
  }
43243
43506
  };
43244
43507
 
43508
+ // ../backend/src/contracts/hebrew-months.ts
43509
+ var NAMES = {
43510
+ 1: "Nisan",
43511
+ 2: "Iyyar",
43512
+ 3: "Sivan",
43513
+ 4: "Tamuz",
43514
+ 5: "Av",
43515
+ 6: "Elul",
43516
+ 7: "Tishrei",
43517
+ 8: "Cheshvan",
43518
+ 9: "Kislev",
43519
+ 10: "Tevet",
43520
+ 11: "Sh'vat",
43521
+ 13: "Adar II"
43522
+ };
43523
+ function isHebrewLeapYear(year) {
43524
+ return (7 * year + 1) % 19 < 7;
43525
+ }
43526
+ function monthName(month, year) {
43527
+ if (month === 12) return isHebrewLeapYear(year) ? "Adar I" : "Adar";
43528
+ return NAMES[month] ?? "";
43529
+ }
43530
+ function hebrewDateWords(d) {
43531
+ return `${String(d.day)} ${monthName(d.month, d.year)} ${String(d.year)}`;
43532
+ }
43533
+ var isPlainAdarDeath = (d) => d.month === 12 && !isHebrewLeapYear(d.year);
43534
+ var isShortMonthDeath = (d) => d.day === 30 && (d.month === 8 || d.month === 9);
43535
+ var YEARS = [3761, 6e3];
43536
+ var SPELLINGS = {
43537
+ nisan: 1,
43538
+ nissan: 1,
43539
+ iyyar: 2,
43540
+ iyar: 2,
43541
+ sivan: 3,
43542
+ tamuz: 4,
43543
+ tammuz: 4,
43544
+ av: 5,
43545
+ ab: 5,
43546
+ elul: 6,
43547
+ tishrei: 7,
43548
+ tishri: 7,
43549
+ cheshvan: 8,
43550
+ heshvan: 8,
43551
+ marcheshvan: 8,
43552
+ kislev: 9,
43553
+ tevet: 10,
43554
+ teves: 10,
43555
+ shvat: 11,
43556
+ shevat: 11,
43557
+ adar: 12,
43558
+ // Adar I and Adar II, by number and by their Hebrew names (Aleph and Bet, Rishon and Sheni).
43559
+ adari: 12,
43560
+ adar1: 12,
43561
+ adaraleph: 12,
43562
+ adaralef: 12,
43563
+ adarrishon: 12,
43564
+ adarii: 13,
43565
+ adar2: 13,
43566
+ adarbet: 13,
43567
+ adarbeth: 13,
43568
+ adarsheni: 13
43569
+ };
43570
+ var ADAR_I = /* @__PURE__ */ new Set(["adari", "adar1", "adaraleph", "adaralef", "adarrishon"]);
43571
+ var spelledOf = (name) => name.toLowerCase().replace(/[^a-z0-9]/g, "");
43572
+ var DATE_WORDS2 = /^(\d{1,2})\s+(.+?)\s+(\d{4})$/;
43573
+ function mostDays(month, year) {
43574
+ if (month === 2 || month === 4 || month === 6 || month === 10 || month === 13) return 29;
43575
+ return month === 12 && !isHebrewLeapYear(year) ? 29 : 30;
43576
+ }
43577
+ function parseHebrewDateWords(text2) {
43578
+ const read2 = DATE_WORDS2.exec(text2.trim().replace(/\s+/g, " "));
43579
+ if (read2 === null) return null;
43580
+ const [, dayText = "", name = "", yearText = ""] = read2;
43581
+ const day2 = Number(dayText);
43582
+ const year = Number(yearText);
43583
+ const spelled = spelledOf(name);
43584
+ const month = Object.hasOwn(SPELLINGS, spelled) ? SPELLINGS[spelled] : void 0;
43585
+ if (month === void 0 || year < YEARS[0] || year > YEARS[1]) return null;
43586
+ if (!isHebrewLeapYear(year) && (month === 13 || ADAR_I.has(spelled))) return null;
43587
+ if (day2 < 1 || day2 > mostDays(month, year)) return null;
43588
+ return { year, month, day: day2 };
43589
+ }
43590
+ function namesPlainAdar(text2) {
43591
+ const [, , name = ""] = DATE_WORDS2.exec(text2.trim().replace(/\s+/g, " ")) ?? [];
43592
+ return spelledOf(name) === "adar";
43593
+ }
43594
+
43595
+ // ../backend/src/core/yahrzeit-types.ts
43596
+ var MAX_YAHRZEITS_PER_MEMBER = 200;
43597
+
43598
+ // src/tool/workflows/yahrzeit-sheet.ts
43599
+ var YAHRZEIT_COLUMNS = ["id", "keeper", "name", "hebrewName", "hebrewDate", "civilDate", "afterSunset", "adarObservance", "shortMonthChoice"];
43600
+ var NEXT_DATE_COLUMN = "nextDate";
43601
+ var YAHRZEIT_ALIASES = {
43602
+ id: ["id", "yahrzeit id", "yahrzeitid"],
43603
+ keeper: ["keeper", "kept by", "keeper email", "member", "member email", "email", "owner"],
43604
+ name: ["name", "deceased", "deceased name", "name of deceased", "name of the deceased"],
43605
+ hebrewName: ["hebrew name", "hebrew"],
43606
+ hebrewDate: ["hebrew date", "hebrew date of death", "date of death hebrew"],
43607
+ civilDate: ["civil date", "civil date of death", "date of death", "date", "gregorian date"],
43608
+ afterSunset: ["after sunset", "after nightfall", "after dark"],
43609
+ adarObservance: ["adar observance", "adar"],
43610
+ shortMonthChoice: ["short month choice", "short month"]
43611
+ };
43612
+ var yahrzeitMappingSchema = external_exports.record(external_exports.string(), external_exports.enum(YAHRZEIT_COLUMNS).nullable());
43613
+ var CONGREGATION_KEEPER = "congregation";
43614
+ var MEMBER_KEEPER = "member:";
43615
+ function deceasedKey(name) {
43616
+ const parted = name.replace(INVISIBLE, "").normalize("NFD").replace(new RegExp(WORD_SPLITTERS, "gu"), " ");
43617
+ return nameKey(parted.replace(new RegExp(HEBREW_MARKS, "gu"), ""));
43618
+ }
43619
+ var NEEDS_KEEPER_AND_NAME = "The first row needs a column headed Keeper (a member's email, or congregation) and a column headed Name (the person who died).";
43620
+ var yahrzeitColumnsOf = (headerCells2, mapping = {}) => columnsByMapping(headerCells2, mapping, YAHRZEIT_ALIASES);
43621
+ function readYahrzeitSheet(lines2, mapping = {}) {
43622
+ const { header, data } = headerAndData(lines2);
43623
+ if (header === void 0) return { ok: false, error: "The file is empty." };
43624
+ const { columns, ignored } = yahrzeitColumnsOf(header.cells, mapping);
43625
+ const taken = new Set(columns.values());
43626
+ if (!taken.has("keeper") || !taken.has("name")) return { ok: false, error: NEEDS_KEEPER_AND_NAME };
43627
+ const rows = data.filter((line) => !isBlankLine(line)).map((line) => {
43628
+ const cells = { id: "", keeper: "", name: "", hebrewName: "", hebrewDate: "", civilDate: "", afterSunset: "", adarObservance: "", shortMonthChoice: "" };
43629
+ columns.forEach((column, index3) => {
43630
+ cells[column] = cleanCell(line.cells[index3] ?? "");
43631
+ });
43632
+ return { row: line.line, cells };
43633
+ });
43634
+ return { ok: true, rows, header: header.cells, columns, ignored };
43635
+ }
43636
+ function readYesNo(cell2) {
43637
+ const word = squash(cell2);
43638
+ if (["yes", "y", "true", "1"].includes(word)) return true;
43639
+ if (["no", "n", "false", "0"].includes(word)) return false;
43640
+ return null;
43641
+ }
43642
+ function readAdar(cell2) {
43643
+ const word = squash(cell2);
43644
+ if (word === "adari" || word === "adar1") return "adar-i";
43645
+ if (word === "adarii" || word === "adar2") return "adar-ii";
43646
+ return ADAR_OBSERVANCES.find((choice) => squash(choice) === word) ?? null;
43647
+ }
43648
+ function readShortMonth(cell2) {
43649
+ const word = squash(cell2);
43650
+ if (["29", "29th", "samemonth29"].includes(word)) return "same-month-29";
43651
+ if (["1", "1st", "nextmonth1", "1stofnextmonth"].includes(word)) return "next-month-1";
43652
+ return null;
43653
+ }
43654
+ function exportRow(y, emailOf2) {
43655
+ const keeper = y.keeper.kind === "congregation" ? CONGREGATION_KEEPER : emailOf2(y.keeper.memberId) ?? `${MEMBER_KEEPER}${y.keeper.memberId}`;
43656
+ const cells = {
43657
+ id: y.yahrzeitId,
43658
+ keeper,
43659
+ name: y.name,
43660
+ hebrewName: y.hebrewName ?? "",
43661
+ hebrewDate: hebrewDateWords(y.hebrewDate),
43662
+ // Only the Hebrew date of death is kept: the civil date a person typed is not, so this column is left for a sheet to fill.
43663
+ civilDate: "",
43664
+ afterSunset: "",
43665
+ adarObservance: y.adarObservance ?? "",
43666
+ shortMonthChoice: y.shortMonthChoice ?? ""
43667
+ };
43668
+ return [...YAHRZEIT_COLUMNS.map((column) => cells[column]), y.nextDate];
43669
+ }
43670
+ var EXPORT_HEADERS = [...YAHRZEIT_COLUMNS, NEXT_DATE_COLUMN];
43671
+ function peopleOf(data) {
43672
+ const list2 = Array.isArray(data) ? data.filter(isRecord) : [];
43673
+ const emailOf2 = (m) => typeof m["email"] === "string" && m["email"] !== "" ? normaliseEmail(m["email"]) : null;
43674
+ const held = /* @__PURE__ */ new Map();
43675
+ for (const m of list2) {
43676
+ const email4 = emailOf2(m);
43677
+ if (email4 !== null) held.set(email4, (held.get(email4) ?? 0) + 1);
43678
+ }
43679
+ return list2.flatMap((m) => {
43680
+ if (typeof m["memberId"] !== "string") return [];
43681
+ const email4 = emailOf2(m);
43682
+ return [{
43683
+ memberId: m["memberId"],
43684
+ email: email4,
43685
+ onlyOne: email4 !== null && held.get(email4) === 1,
43686
+ status: String(m["status"] ?? ""),
43687
+ name: `${String(m["firstName"] ?? "")} ${String(m["lastName"] ?? "")}`.trim()
43688
+ }];
43689
+ });
43690
+ }
43691
+ async function readYahrzeitsAndMembers(ctx, workflowId) {
43692
+ const all = needCommand(workflowId, "yahrzeits.all");
43693
+ const listed = await executeUnguarded(ctx, all, {});
43694
+ if (!listed.ok) return listed;
43695
+ const parsed = yahrzeitAllSchema.safeParse(listed.data);
43696
+ if (!parsed.success) return fail2(cliError("internal", "yahrzeits.all answered a list this CLI cannot read.", { contract: all.contract, hint: "Upgrade the CLI, and report it if it stays." }));
43697
+ const members2 = await executeUnguarded(ctx, needCommand(workflowId, "members.list"), {});
43698
+ if (!members2.ok) return members2;
43699
+ return { ok: true, data: { yahrzeits: parsed.data, people: peopleOf(members2.data) } };
43700
+ }
43701
+
43702
+ // src/tool/workflows/yahrzeits-bulk.ts
43703
+ var MAX_BULK_YAHRZEIT_ROWS = 1e3;
43704
+ var WORKFLOW_ID = "yahrzeits.bulk";
43705
+ var inputSchema3 = external_exports.strictObject({
43706
+ table: tableSchema.describe("The sheet, read from the file (a front end does that: give it the file, not this)."),
43707
+ mapping: yahrzeitMappingSchema.optional().describe("Which column is which, by the header as written (null leaves a column out). It overrides the headers the reader knows.")
43708
+ });
43709
+ var need2 = (id2) => needCommand(WORKFLOW_ID, id2);
43710
+ var CONGREGATION = { key: CONGREGATION_KEEPER, memberId: null, words: "the congregation" };
43711
+ var keeperOfView = (y) => y.keeper.kind === "member" ? { key: y.keeper.memberId, memberId: y.keeper.memberId, words: y.keeper.name } : CONGREGATION;
43712
+ function keeperOfCell(cell2, people2) {
43713
+ if (cell2 === "") return { why: "The keeper is empty: write the member's email, or congregation." };
43714
+ if (cell2.toLowerCase() === CONGREGATION_KEEPER) return { keeper: CONGREGATION };
43715
+ const byId = cell2.toLowerCase().startsWith(MEMBER_KEEPER);
43716
+ const matches = byId ? people2.filter((p) => p.memberId === cell2.slice(MEMBER_KEEPER.length)) : people2.filter((p) => p.email !== null && p.email === normaliseEmail(cell2));
43717
+ const [only] = matches;
43718
+ if (only === void 0) return { why: byId ? `No member has the id ${cell2.slice(MEMBER_KEEPER.length)}.` : `No member has the email ${cell2}: write a member's email, or congregation.` };
43719
+ if (matches.length > 1) return { why: `The email ${cell2} matches ${String(matches.length)} records, so the keeper is not clear: write member:<id> for the one meant.` };
43720
+ if (only.status === "pending") return { why: `${only.name} is a join request, not a member yet, so the office cannot keep a yahrzeit on their record.` };
43721
+ return { keeper: { key: only.memberId, memberId: only.memberId, words: only.name } };
43722
+ }
43723
+ var sameDate = (a, b) => a.year === b.year && a.month === b.month && a.day === b.day;
43724
+ var heb = (d) => ({ year: d.year, month: d.month, day: d.day });
43725
+ function labelOf(row) {
43726
+ return row.cells.name === "" ? `Row ${String(row.row)}` : row.cells.name;
43727
+ }
43728
+ var onlyHebrewName = (changes) => changes.length > 0 && changes.every((c) => c.field === "hebrewName");
43729
+ var CORRECTION_NOTE = "The member's app takes the correction, and they see the office corrected it.";
43730
+ var PORTAL_CORRECTION_NOTE = "The member sees the office corrected it.";
43731
+ var HEBREW_NAME_NOTE = "Only the Hebrew name changes: it is the office's own, so this is not a correction, and the member's app does not take it.";
43732
+ var LEAP_ADAR_NOTE = "Adar in a leap year is read as Adar I: write Adar II if that was meant.";
43733
+ var SUNSET_UNREAD = "after sunset goes with a civil date, so the afterSunset cell is not read here.";
43734
+ var ADDED_NOTE = "A yahrzeit the sheet added is removed by the restore, unless it was changed since. Each removal is backed up too, so the restore can itself be put back.";
43735
+ var ALL_PUT_BACK = "A restore puts every change back, through the same routes, with the version each record has then; a record changed since is left and named.";
43736
+ var yahrzeitsBulk = {
43737
+ id: WORKFLOW_ID,
43738
+ words: ["yahrzeits", "bulk"],
43739
+ summary: "Add or change many yahrzeits from a spreadsheet: matched by id, or by keeper and name.",
43740
+ description: "Reads yahrzeits from an Excel (.xlsx) or CSV file, as yahrzeits export writes them, and plans every row: added, changed (with each change), unchanged, ambiguous (more than one yahrzeit of that name for the keeper: add the id) or skipped with its reason and its column. A date is a Hebrew date in words, or a civil date with after-sunset; the plan shows both, and a Hebrew date the year does not have is skipped at once. Nothing is written until the plan is confirmed, and a restore puts the changes back and removes the yahrzeits it added.",
43741
+ effect: "destructive",
43742
+ // yahrzeits.all and members.list are read; dates.resolve shows each date's Hebrew and civil day. The writes are the per-yahrzeit routes; a restore
43743
+ // sends the updates back through them, and removes what it added through the deletes (whose backup read is yahrzeits.listForMember or yahrzeits.all).
43744
+ uses: [
43745
+ "yahrzeits.all",
43746
+ "members.list",
43747
+ "dates.resolve",
43748
+ "yahrzeits.createForMember",
43749
+ "yahrzeits.updateForMember",
43750
+ "congregationYahrzeits.create",
43751
+ "congregationYahrzeits.update",
43752
+ "yahrzeits.deleteForMember",
43753
+ "congregationYahrzeits.delete",
43754
+ "yahrzeits.listForMember"
43755
+ ],
43756
+ input: inputSchema3,
43757
+ async plan(ctx, given) {
43758
+ const { table: table2, mapping: asGiven = {} } = inputSchema3.parse(given);
43759
+ const { mapping, unused } = headerKeyed(table2, asGiven);
43760
+ const sheet = readYahrzeitSheet(table2.lines, mapping);
43761
+ if (!sheet.ok) return unreadable2(table2, sheet.error);
43762
+ if (sheet.rows.length === 0) return fail2(cliError("invalid-input", `${table2.name} has a header and no yahrzeits under it.`, { page: WORKFLOW_ID, hint: "Add the yahrzeits under the header row and plan again." }));
43763
+ if (sheet.rows.length > MAX_BULK_YAHRZEIT_ROWS) {
43764
+ return fail2(cliError("invalid-input", `${table2.name} has ${String(sheet.rows.length)} rows, and a sheet may hold at most ${String(MAX_BULK_YAHRZEIT_ROWS)}: each row is a call of its own.`, { page: WORKFLOW_ID, hint: `Split it into sheets of ${String(MAX_BULK_YAHRZEIT_ROWS)} rows or fewer.` }));
43765
+ }
43766
+ const columnOf = (field) => columnName(sheet.header, sheet.columns, field);
43767
+ const read2 = await readYahrzeitsAndMembers(ctx, WORKFLOW_ID);
43768
+ if (!read2.ok) return read2;
43769
+ const { yahrzeits, people: people2 } = read2.data;
43770
+ const byId = new Map(yahrzeits.map((y) => [y.yahrzeitId, y]));
43771
+ const heldBy = /* @__PURE__ */ new Map();
43772
+ for (const y of yahrzeits) heldBy.set(keeperOfView(y).key, (heldBy.get(keeperOfView(y).key) ?? 0) + 1);
43773
+ const answers = /* @__PURE__ */ new Map();
43774
+ const resolve = async (body) => {
43775
+ const key = JSON.stringify(body);
43776
+ const held = answers.get(key);
43777
+ if (held !== void 0) return held;
43778
+ const answered = await executeUnguarded(ctx, need2("dates.resolve"), { ...body, calendar: "hebrew" });
43779
+ const one2 = answered.ok ? resolveResultSchema.safeParse(answered.data) : null;
43780
+ const outcome = !answered.ok ? answered : one2?.success === true ? { ok: true, data: one2.data } : fail2(cliError("internal", "dates.resolve answered a date this CLI cannot read.", { contract: need2("dates.resolve").contract, hint: "Upgrade the CLI, and report it if it stays." }));
43781
+ answers.set(key, outcome);
43782
+ return outcome;
43783
+ };
43784
+ const resolvedOn = async (column, body, notes2, afterSunset) => {
43785
+ const answered = await resolve(body);
43786
+ if (!answered.ok) return answered.error.code === "invalid" ? { ok: true, data: { kind: "wrong", column, why: answered.error.message } } : answered;
43787
+ const { hebrew, civil, affects } = answered.data;
43788
+ return { ok: true, data: { kind: "date", date: { hebrew: heb(hebrew), civil, affects, ...afterSunset === void 0 ? {} : { afterSunset } }, notes: notes2 } };
43789
+ };
43790
+ const readDate = async (cells) => {
43791
+ if (cells.hebrewDate !== "" && cells.civilDate !== "") {
43792
+ return { ok: true, data: { kind: "wrong", column: "civilDate", why: "The row gives both hebrewDate and civilDate: keep one of them." } };
43793
+ }
43794
+ const sunsetUnread = cells.afterSunset === "" ? [] : [`${cells.hebrewDate === "" ? "With no date, " : "With a Hebrew date, "}${SUNSET_UNREAD}`];
43795
+ if (cells.hebrewDate !== "") {
43796
+ const hebrew = parseHebrewDateWords(cells.hebrewDate);
43797
+ if (hebrew === null) {
43798
+ return { ok: true, data: { kind: "wrong", column: "hebrewDate", why: `"${cells.hebrewDate}" is not a Hebrew date: write it as 15 Nisan 5780 (Adar I and Adar II only in a leap year).` } };
43799
+ }
43800
+ const leapAdar = hebrew.month === 12 && namesPlainAdar(cells.hebrewDate) && !isPlainAdarDeath(hebrew) ? [LEAP_ADAR_NOTE] : [];
43801
+ return resolvedOn("hebrewDate", { hebrew }, [...leapAdar, ...sunsetUnread]);
43802
+ }
43803
+ if (cells.civilDate === "") return { ok: true, data: { kind: "none", notes: sunsetUnread } };
43804
+ if (!/^\d{4}-\d{2}-\d{2}$/.test(cells.civilDate)) {
43805
+ const why = parseHebrewDateWords(cells.civilDate) === null ? `"${cells.civilDate}" is not a date: write it as YYYY-MM-DD.` : `"${cells.civilDate}" is a Hebrew date: head the column Hebrew date, or map it to hebrewDate.`;
43806
+ return { ok: true, data: { kind: "wrong", column: "civilDate", why } };
43807
+ }
43808
+ if (cells.afterSunset === "") return { ok: true, data: { kind: "wrong", column: "afterSunset", why: "afterSunset is empty: a civil date needs yes or no, whether the death was after sunset." } };
43809
+ const afterSunset = readYesNo(cells.afterSunset);
43810
+ if (afterSunset === null) return { ok: true, data: { kind: "wrong", column: "afterSunset", why: `"${cells.afterSunset}" is not yes or no: say whether the death was after sunset.` } };
43811
+ return resolvedOn("civilDate", { civil: cells.civilDate, afterSunset }, [], afterSunset);
43812
+ };
43813
+ const steps = [];
43814
+ const rows = [];
43815
+ const changes = [];
43816
+ const claimed = /* @__PURE__ */ new Map();
43817
+ const added = /* @__PURE__ */ new Map();
43818
+ let shares = 0;
43819
+ for (const sheetRow of sheet.rows) {
43820
+ const { row, cells } = sheetRow;
43821
+ const label = labelOf(sheetRow);
43822
+ const skip = (column, reason) => ({ row, label, action: "skip", reason, ...columnOf(column) === void 0 ? {} : { column: columnOf(column) } });
43823
+ const done = (planned, step2, made2 = []) => {
43824
+ rows.push(planned);
43825
+ if (step2 !== void 0) steps.push(step2);
43826
+ changes.push(...made2);
43827
+ };
43828
+ const who = keeperOfCell(cells.keeper, people2);
43829
+ if ("why" in who) {
43830
+ done(skip("keeper", who.why));
43831
+ continue;
43832
+ }
43833
+ const { keeper } = who;
43834
+ let target = null;
43835
+ if (cells.id !== "") {
43836
+ const found = byId.get(cells.id);
43837
+ if (found === void 0) {
43838
+ done(skip("id", `No yahrzeit has the id ${cells.id}: it may have been removed.`));
43839
+ continue;
43840
+ }
43841
+ if (keeperOfView(found).key !== keeper.key) {
43842
+ done(skip("keeper", `${found.name} is kept by ${keeperOfView(found).words}, not ${keeper.words}: move it first (yahrzeits move).`));
43843
+ continue;
43844
+ }
43845
+ target = found;
43846
+ } else {
43847
+ if (cells.name === "") {
43848
+ done(skip("name", "The name is empty: a row with no id needs the name of the person who died."));
43849
+ continue;
43850
+ }
43851
+ const matches = yahrzeits.filter((y) => keeperOfView(y).key === keeper.key && deceasedKey(y.name) === deceasedKey(cells.name));
43852
+ if (matches.length > 1) {
43853
+ done({
43854
+ row,
43855
+ label,
43856
+ action: "ambiguous",
43857
+ reason: `${String(matches.length)} yahrzeits kept by ${keeper.words} are named like this (ids ${matches.map((y) => y.yahrzeitId).join(", ")}): add the id column (orzarua yahrzeits export writes it).`
43858
+ });
43859
+ continue;
43860
+ }
43861
+ target = matches[0] ?? null;
43862
+ }
43863
+ if (target !== null) {
43864
+ const earlier = claimed.get(`old|${target.yahrzeitId}`);
43865
+ if (earlier !== void 0) {
43866
+ done({ row, label, action: "skip", reason: `Repeats row ${String(earlier)} (the same yahrzeit).` });
43867
+ continue;
43868
+ }
43869
+ claimed.set(`old|${target.yahrzeitId}`, row);
43870
+ }
43871
+ const outcome = await readDate(cells);
43872
+ if (!outcome.ok) return outcome;
43873
+ if (outcome.data.kind === "wrong") {
43874
+ done(skip(outcome.data.column, outcome.data.why));
43875
+ continue;
43876
+ }
43877
+ const dateNotes = outcome.data.notes;
43878
+ const adar = cells.adarObservance === "" ? null : readAdar(cells.adarObservance);
43879
+ if (cells.adarObservance !== "" && adar === null) {
43880
+ done(skip("adarObservance", `"${cells.adarObservance}" is not an Adar choice: write adar-i or adar-ii.`));
43881
+ continue;
43882
+ }
43883
+ const short = cells.shortMonthChoice === "" ? null : readShortMonth(cells.shortMonthChoice);
43884
+ if (cells.shortMonthChoice !== "" && short === null) {
43885
+ done(skip("shortMonthChoice", `"${cells.shortMonthChoice}" is not a short-month choice: write same-month-29 or next-month-1.`));
43886
+ continue;
43887
+ }
43888
+ const date5 = outcome.data.kind === "date" ? outcome.data.date : null;
43889
+ const shown = (d) => ({ hebrew: hebrewDateWords(d.hebrew), civil: d.civil, ...d.afterSunset === void 0 ? {} : { afterSunset: d.afterSunset } });
43890
+ const finalDate = date5?.hebrew ?? target?.hebrewDate ?? null;
43891
+ if (finalDate === null) {
43892
+ done(skip("hebrewDate", "A new yahrzeit needs its date of death: hebrewDate (such as 15 Nisan 5780), or civilDate with afterSunset."));
43893
+ continue;
43894
+ }
43895
+ const affects = date5?.affects ?? { adar: isPlainAdarDeath(finalDate), shortMonth: isShortMonthDeath(finalDate) };
43896
+ if (adar !== null && !affects.adar) {
43897
+ done(skip("adarObservance", "An Adar choice applies only to a death in plain Adar of a common year."));
43898
+ continue;
43899
+ }
43900
+ if (short !== null && !affects.shortMonth) {
43901
+ done(skip("shortMonthChoice", "A short-month choice applies only to a death on the 30th of Cheshvan or Kislev."));
43902
+ continue;
43903
+ }
43904
+ const adarNow = adar ?? (target !== null && affects.adar ? target.adarObservance : null);
43905
+ const shortNow = short ?? (target !== null && affects.shortMonth ? target.shortMonthChoice : null);
43906
+ const dateBody = { kind: "hebrew", hebrew: heb(finalDate) };
43907
+ const reasonOf = (sentences) => sentences.length === 0 ? {} : { reason: sentences.join(" ") };
43908
+ const shownDates = date5 === null ? {} : { dates: shown(date5) };
43909
+ if (target === null) {
43910
+ const name2 = `${keeper.key}|${deceasedKey(cells.name)}`;
43911
+ const claim2 = `new|${name2}|${hebrewDateWords(finalDate)}`;
43912
+ const earlier = claimed.get(claim2);
43913
+ if (earlier !== void 0) {
43914
+ done({ row, label, action: "skip", reason: `Repeats row ${String(earlier)} (the same name for the same keeper). Two people of one name are told apart by their dates of death.` });
43915
+ continue;
43916
+ }
43917
+ if (keeper.memberId !== null && (heldBy.get(keeper.key) ?? 0) >= MAX_YAHRZEITS_PER_MEMBER) {
43918
+ done(skip("keeper", `${keeper.words}'s record already holds ${String(MAX_YAHRZEITS_PER_MEMBER)} yahrzeits, the most it may.`));
43919
+ continue;
43920
+ }
43921
+ claimed.set(claim2, row);
43922
+ if (keeper.memberId !== null) heldBy.set(keeper.key, (heldBy.get(keeper.key) ?? 0) + 1);
43923
+ const sameName = added.get(name2);
43924
+ if (sameName === void 0) added.set(name2, row);
43925
+ const asHebrewName = yahrzeits.filter((y) => keeperOfView(y).key === keeper.key && y.hebrewName !== null && deceasedKey(y.hebrewName) === deceasedKey(cells.name));
43926
+ const input2 = {
43927
+ ...keeper.memberId === null ? {} : { memberId: keeper.memberId },
43928
+ name: cells.name,
43929
+ ...cells.hebrewName === "" ? {} : { hebrewName: cells.hebrewName },
43930
+ date: dateBody,
43931
+ ...adarNow === null ? {} : { adarObservance: adarNow },
43932
+ ...shortNow === null ? {} : { shortMonthChoice: shortNow }
43933
+ };
43934
+ const sentences = [
43935
+ `A new yahrzeit kept by ${keeper.words}.`,
43936
+ ...sameName === void 0 ? [] : [`Row ${String(sameName)} adds one of the same name with another date of death, so these are two people.`],
43937
+ ...asHebrewName.map((y) => `Kept by the same keeper: ${y.yahrzeitId} has this as its Hebrew name. If it is the same person, add the id.`),
43938
+ ...dateNotes
43939
+ ];
43940
+ done(
43941
+ { row, label, action: "create", ...reasonOf(sentences), ...shownDates },
43942
+ { commandId: keeper.memberId === null ? "congregationYahrzeits.create" : "yahrzeits.createForMember", input: input2, rows: [row], before: null }
43943
+ );
43944
+ continue;
43945
+ }
43946
+ const record3 = `yahrzeits/${target.yahrzeitId}`;
43947
+ const renames = cells.id !== "" && cells.name !== "";
43948
+ const name = renames ? cells.name : target.name;
43949
+ const spelling = !renames && cells.name !== "" && cells.name !== target.name ? [`The sheet writes this name as ${cells.name}; the record's spelling ${target.name} is kept (matched ignoring case, spacing and nikkud): add the id to rename it.`] : [];
43950
+ const hebrewName = cells.hebrewName === "" ? target.hebrewName : cells.hebrewName;
43951
+ const made = [
43952
+ ...name === target.name ? [] : [{ record: record3, field: "name", from: target.name, to: name }],
43953
+ ...hebrewName === target.hebrewName ? [] : [{ record: record3, field: "hebrewName", from: target.hebrewName, to: hebrewName }],
43954
+ ...sameDate(finalDate, target.hebrewDate) ? [] : [{ record: record3, field: "hebrewDate", from: hebrewDateWords(target.hebrewDate), to: hebrewDateWords(finalDate) }],
43955
+ ...adarNow === target.adarObservance ? [] : [{ record: record3, field: "adarObservance", from: target.adarObservance, to: adarNow }],
43956
+ ...shortNow === target.shortMonthChoice ? [] : [{ record: record3, field: "shortMonthChoice", from: target.shortMonthChoice, to: shortNow }]
43957
+ ];
43958
+ if (made.length === 0) {
43959
+ done({ row, label, action: "unchanged", ...reasonOf([...spelling, ...dateNotes]), ...shownDates });
43960
+ continue;
43961
+ }
43962
+ const correction = !target.correctionOnly ? [] : [onlyHebrewName(made) ? HEBREW_NAME_NOTE : target.siddurYahrzeitId === null ? PORTAL_CORRECTION_NOTE : CORRECTION_NOTE];
43963
+ if (target.correctionOnly) shares += 1;
43964
+ const { nextDate: _next, ...held } = target;
43965
+ const cmd = need2(keeper.memberId === null ? "congregationYahrzeits.update" : "yahrzeits.updateForMember");
43966
+ const readId = cmd.contract.backup === "none" ? "yahrzeits.all" : cmd.contract.backup.reads[0] ?? "yahrzeits.all";
43967
+ done(
43968
+ { row, label, action: "update", ...reasonOf([...correction, ...spelling, ...dateNotes]), ...shownDates, changes: made },
43969
+ {
43970
+ commandId: cmd.id,
43971
+ rows: [row],
43972
+ before: { [readId]: held },
43973
+ input: {
43974
+ ...keeper.memberId === null ? {} : { memberId: keeper.memberId },
43975
+ yahrzeitId: target.yahrzeitId,
43976
+ version: target.version,
43977
+ name,
43978
+ hebrewName,
43979
+ adarObservance: adarNow,
43980
+ shortMonthChoice: shortNow,
43981
+ date: dateBody
43982
+ }
43983
+ },
43984
+ made
43985
+ );
43986
+ }
43987
+ const count = (action) => rows.filter((r) => r.action === action).length;
43988
+ const ambiguous = count("ambiguous");
43989
+ const backup = need2("yahrzeits.updateForMember").contract.backup;
43990
+ const undo = shares > 0 && backup !== "none" && backup.note !== void 0 ? { restore: "partial", note: backup.note } : { restore: "full", note: ALL_PUT_BACK };
43991
+ const notes = [
43992
+ ...steps.length === 0 ? ["Nothing to do: every row is already as the sheet says, or is skipped."] : [],
43993
+ ...ambiguous === 0 ? [] : [`${String(ambiguous)} ${ambiguous === 1 ? "row matches" : "rows match"} more than one yahrzeit and ${ambiguous === 1 ? "is" : "are"} left out: add the id column to say which one.`],
43994
+ ...count("create") === 0 ? [] : [ADDED_NOTE],
43995
+ ...unused.length === 0 ? [] : [`The column mapping names ${unused.map((key) => JSON.stringify(key)).join(", ")}, which no header of the sheet matches, so ${unused.length === 1 ? "it is" : "they are"} not used.`]
43996
+ ];
43997
+ return {
43998
+ ok: true,
43999
+ data: {
44000
+ commandId: "yahrzeits.updateForMember",
44001
+ steps,
44002
+ rows,
44003
+ changes,
44004
+ notes,
44005
+ undo,
44006
+ extra: { mapping: proposalOf(sheet.header, sheet.columns), ignoredColumns: sheet.ignored }
44007
+ }
44008
+ };
44009
+ }
44010
+ };
44011
+ function yahrzeitMappingOf(table2, mapping = {}) {
44012
+ const header = headerCells(table2);
44013
+ return proposalOf(header, yahrzeitColumnsOf(header, mapping).columns);
44014
+ }
44015
+ function unreadable2(table2, error62) {
44016
+ return unreadableSheet(table2, error62, {
44017
+ page: WORKFLOW_ID,
44018
+ columnsMissing: (e) => e === NEEDS_KEEPER_AND_NAME,
44019
+ propose: (t) => yahrzeitMappingOf(t),
44020
+ mapHint: `A map file says which header is which column (${YAHRZEIT_COLUMNS.join(", ")}), and one that is not there yet is written with these headers to edit. Map the columns, then plan again.`
44021
+ });
44022
+ }
44023
+
44024
+ // ../backend/src/core/csv.ts
44025
+ function csvCell(value) {
44026
+ const text2 = value ?? "";
44027
+ const safe = /^[=+\-@\t\r]/.test(text2) ? `'${text2}` : text2;
44028
+ return `"${safe.replace(/"/g, '""')}"`;
44029
+ }
44030
+ function csvDocument(headers, rows) {
44031
+ return `\uFEFF${[headers, ...rows].map((row) => row.map(csvCell).join(",")).join("\r\n")}`;
44032
+ }
44033
+
44034
+ // src/tool/workflows/yahrzeits-export.ts
44035
+ var yahrzeitsExport = {
44036
+ id: "yahrzeits.export",
44037
+ words: ["yahrzeits", "export"],
44038
+ summary: "Export every yahrzeit as a spreadsheet (CSV): one row each, with its id, keeper and dates.",
44039
+ description: "Writes every yahrzeit the congregation remembers, the office's and the members' shares, as CSV text: id, keeper (a member's email, or congregation), name, Hebrew name, Hebrew date, the choices, and the next anniversary. Edit it and give it back to yahrzeits bulk, which matches each row by its id.",
44040
+ effect: "read",
44041
+ uses: ["yahrzeits.all", "members.list"],
44042
+ input: external_exports.strictObject({}),
44043
+ async run(ctx) {
44044
+ const read2 = await readYahrzeitsAndMembers(ctx, "yahrzeits.export");
44045
+ if (!read2.ok) return read2;
44046
+ const emails = new Map(read2.data.people.map((p) => [p.memberId, p.onlyOne ? p.email : null]));
44047
+ return { ok: true, data: csvDocument(EXPORT_HEADERS, read2.data.yahrzeits.map((y) => exportRow(y, (id2) => emails.get(id2) ?? null))) };
44048
+ }
44049
+ };
44050
+
43245
44051
  // src/tool/workflows/index.ts
43246
- var WORKFLOWS = [membersImportSheet];
44052
+ var WORKFLOWS = [membersImportSheet, yahrzeitsExport, yahrzeitsBulk, automationsPresets, automationsCreateFromPreset];
43247
44053
 
43248
44054
  // src/tool/workflows/workflow.ts
43249
44055
  var workflowById = (id2) => WORKFLOWS.find((wf) => wf.id === id2) ?? null;
@@ -43343,7 +44149,7 @@ function draftOf(automation) {
43343
44149
  var PROPOSED_REACH = "automations.comingUp (new words)";
43344
44150
  function channelsOfAutomation(automation) {
43345
44151
  const steps = Array.isArray(automation["steps"]) ? automation["steps"].filter(isRecord) : [];
43346
- return [...new Set(steps.flatMap((step) => Array.isArray(step["channels"]) ? step["channels"].filter(isRecord).map((c) => String(c["channel"])) : []))];
44152
+ return [...new Set(steps.flatMap((step2) => Array.isArray(step2["channels"]) ? step2["channels"].filter(isRecord).map((c) => String(c["channel"])) : []))];
43347
44153
  }
43348
44154
  async function enableReach(ctx, cmd, draft) {
43349
44155
  if (draft === null) return { ok: true, data: null };
@@ -43802,8 +44608,8 @@ async function planWorkflow(ctx, wf, input2, options = {}) {
43802
44608
  const made = await finish(ctx, {
43803
44609
  commandId: draft.commandId,
43804
44610
  input: {},
43805
- steps: draft.steps.map(({ before: _before, ...step }) => step),
43806
- before: { steps: draft.steps.map((step) => step.before) },
44611
+ steps: draft.steps.map(({ before: _before, ...step2 }) => step2),
44612
+ before: { steps: draft.steps.map((step2) => step2.before) },
43807
44613
  changes: draft.changes,
43808
44614
  rows: draft.rows,
43809
44615
  ...draft.extra === void 0 ? {} : { extra: draft.extra },
@@ -43821,10 +44627,10 @@ async function backedUp(ctx, store, run2, steps, before) {
43821
44627
  const file2 = opened.data;
43822
44628
  const calls = [];
43823
44629
  let last2 = { ok: true, data: null };
43824
- for (const [index3, step] of steps.entries()) {
43825
- const cmd = commandById(step.commandId);
43826
- if (cmd === null) return { calls, result: fail2(unknownCall(step)) };
43827
- const input2 = index3 === 0 || !last2.ok ? step.input : withVersionFrom(cmd, step.input, last2.data);
44630
+ for (const [index3, step2] of steps.entries()) {
44631
+ const cmd = commandById(step2.commandId);
44632
+ if (cmd === null) return { calls, result: fail2(unknownCall(step2)) };
44633
+ const input2 = index3 === 0 || !last2.ok ? step2.input : withVersionFrom(cmd, step2.input, last2.data);
43828
44634
  const result = await executeUnguarded(ctx, cmd, input2);
43829
44635
  calls.push({ at: await recordResult(ctx, store, file2, result), ok: result.ok });
43830
44636
  if (!result.ok) {
@@ -43861,26 +44667,26 @@ async function openBackup(ctx, store, run2, before) {
43861
44667
  }));
43862
44668
  }
43863
44669
  }
43864
- async function recordResult(ctx, store, file2, result, step) {
44670
+ async function recordResult(ctx, store, file2, result, step2) {
43865
44671
  const at = ctx.now().toISOString();
43866
- const which = step === void 0 ? {} : { step };
44672
+ const which = step2 === void 0 ? {} : { step: step2 };
43867
44673
  try {
43868
44674
  await store.append(file2, result.ok ? { at, ok: true, data: result.data, ...which } : { at, ok: false, error: result.error, ...which });
43869
44675
  } catch {
43870
44676
  }
43871
44677
  return at;
43872
44678
  }
43873
- async function recordStarted(ctx, store, file2, step) {
44679
+ async function recordStarted(ctx, store, file2, step2) {
43874
44680
  try {
43875
- await store.append(file2, { kind: "started", at: ctx.now().toISOString(), step });
44681
+ await store.append(file2, { kind: "started", at: ctx.now().toISOString(), step: step2 });
43876
44682
  } catch {
43877
44683
  }
43878
44684
  }
43879
- var unknownCall = (step) => cliError("internal", `The plan calls ${step.commandId}, which this CLI does not know.`, { hint: "Upgrade the CLI, or make a new plan." });
44685
+ var unknownCall = (step2) => cliError("internal", `The plan calls ${step2.commandId}, which this CLI does not know.`, { hint: "Upgrade the CLI, or make a new plan." });
43880
44686
  var rowWords = (rows) => `${rows.length === 1 ? "row" : "rows"} ${rows.join(", ")}`;
43881
44687
  async function independentSteps(ctx, store, stored, before) {
43882
44688
  const wf = stored.workflowId === void 0 ? null : workflowById(stored.workflowId);
43883
- const outside = wf === null ? void 0 : stored.steps.find((step) => !wf.uses.includes(step.commandId));
44689
+ const outside = wf === null ? void 0 : stored.steps.find((step2) => !wf.uses.includes(step2.commandId));
43884
44690
  if (wf !== null && outside !== void 0) return { calls: [], result: fail2(outsideUses(wf.id, outside.commandId)) };
43885
44691
  const opened = await openBackup(ctx, store, {
43886
44692
  commandId: stored.commandId,
@@ -43891,12 +44697,12 @@ async function independentSteps(ctx, store, stored, before) {
43891
44697
  if (!opened.ok) return { calls: [], result: opened };
43892
44698
  const calls = [];
43893
44699
  const failed2 = [];
43894
- for (const [index3, step] of stored.steps.entries()) {
43895
- const cmd = commandById(step.commandId);
44700
+ for (const [index3, step2] of stored.steps.entries()) {
44701
+ const cmd = commandById(step2.commandId);
43896
44702
  if (cmd !== null) await recordStarted(ctx, store, opened.data, index3);
43897
- const result = cmd === null ? fail2(unknownCall(step)) : await executeUnguarded(ctx, cmd, step.input);
44703
+ const result = cmd === null ? fail2(unknownCall(step2)) : await executeUnguarded(ctx, cmd, step2.input);
43898
44704
  calls.push({ at: await recordResult(ctx, store, opened.data, result, index3), ok: result.ok });
43899
- if (!result.ok) failed2.push({ rows: step.rows ?? [], error: result.error });
44705
+ if (!result.ok) failed2.push({ rows: step2.rows ?? [], error: result.error });
43900
44706
  }
43901
44707
  const total = stored.steps.length;
43902
44708
  const answer2 = { steps: total, ok: total - failed2.length, failed: failed2, backup: opened.data };
@@ -44018,7 +44824,7 @@ function gatedPlan(made) {
44018
44824
  function plannedCommands(made) {
44019
44825
  const wf = made.restoreOf === void 0 && made.workflowId !== void 0 ? workflowById(made.workflowId) : null;
44020
44826
  if (wf !== null) return usedCommands(wf);
44021
- const ids = made.workflowId === void 0 ? [made.commandId] : [...new Set(made.steps.map((step) => step.commandId))];
44827
+ const ids = made.workflowId === void 0 ? [made.commandId] : [...new Set(made.steps.map((step2) => step2.commandId))];
44022
44828
  return ids.flatMap((id2) => {
44023
44829
  const cmd = commandById(id2);
44024
44830
  return cmd === null ? [] : [cmd];
@@ -44071,7 +44877,7 @@ async function runWorkflow(ctx, wf, input2) {
44071
44877
  if (wf.run === void 0) return fail2(cliError("internal", `${wf.id} has nothing to run: it is a mistake in this CLI.`, { page: wf.id, hint: "Upgrade the CLI, and report it if it stays." }));
44072
44878
  return wf.run({ ...ctx, only: usesOnly(wf) }, asObject(input2));
44073
44879
  }
44074
- function changedSince(cmd, restorer, original, written2, current, label) {
44880
+ function changedSince(cmd, restorer, original, written2, current, label, was) {
44075
44881
  if (written2 !== void 0) {
44076
44882
  const back = restorer({ before: written2, current, originalInput: original });
44077
44883
  if (!back.ok) return [back.message];
@@ -44080,6 +44886,8 @@ function changedSince(cmd, restorer, original, written2, current, label) {
44080
44886
  const record3 = namedIn(cmd, current, original);
44081
44887
  if (record3 === null) return [];
44082
44888
  const now = projectionFor(cmd)(record3);
44889
+ const held = namedIn(cmd, was, original);
44890
+ if (held !== null && fieldKeys(cmd).every((key) => !(key in now) || sameSent(projectionFor(cmd)(held)[key], now[key]))) return [];
44083
44891
  return fieldKeys(cmd).filter((key) => original[key] !== void 0 && key in now && !sameSent(original[key], now[key]));
44084
44892
  }
44085
44893
  async function restorePlan(ctx, backup, backupFile, save = true) {
@@ -44153,17 +44961,17 @@ async function restoreOne(here, cmd, restorer, was, original, written2, results,
44153
44961
  const spec = cmd.contract.backup;
44154
44962
  if (spec === "none" || spec.restore !== "recreate") {
44155
44963
  if (current === null && was !== null) return { kind: "gone" };
44156
- const since = current === null || judgesItself(cmd) ? [] : changedSince(cmd, restorer, original, written2, current, recordLabel(cmd, original));
44964
+ const since = current === null || judgesItself(cmd) ? [] : changedSince(cmd, restorer, original, written2, current, recordLabel(cmd, original), was);
44157
44965
  if (since.length > 0) return { kind: "changed", fields: since };
44158
44966
  }
44159
44967
  const back = restorer({ before: was, current, originalInput: original, ...results === void 0 ? {} : { results }, ...more });
44160
44968
  if (!back.ok) return { kind: "refused", code: back.code, message: back.message };
44161
44969
  if (back.steps.length === 0) return { kind: "same" };
44162
- for (const step of back.steps) {
44163
- const stepCmd = commandById(step.commandId);
44164
- const refused3 = stepCmd === null ? cliError("internal", `This CLI has no command ${step.commandId}.`) : checkInput(stepCmd, step.input);
44970
+ for (const step2 of back.steps) {
44971
+ const stepCmd = commandById(step2.commandId);
44972
+ const refused3 = stepCmd === null ? cliError("internal", `This CLI has no command ${step2.commandId}.`) : checkInput(stepCmd, step2.input);
44165
44973
  if (refused3 !== null) {
44166
- return { kind: "failed", result: fail2(cliError("internal", `The restore of ${recordLabel(cmd, original)} would send what ${step.commandId} refuses (${refused3.message}), so it was not planned.`, {
44974
+ return { kind: "failed", result: fail2(cliError("internal", `The restore of ${recordLabel(cmd, original)} would send what ${step2.commandId} refuses (${refused3.message}), so it was not planned.`, {
44167
44975
  contract: cmd.contract,
44168
44976
  hint: "This is a mistake in the CLI: upgrade it, and report it if it stays."
44169
44977
  })) };
@@ -44239,24 +45047,55 @@ async function restoreWorkflowPlan(ctx, backup, backupFile, save = true) {
44239
45047
  const left = [];
44240
45048
  const partial2 = /* @__PURE__ */ new Set();
44241
45049
  let stays = 0;
44242
- for (const [index3, step] of (backup.steps ?? []).entries()) {
45050
+ let removed = 0;
45051
+ let yahrzeitsRead = null;
45052
+ const yahrzeitsNow = async () => {
45053
+ if (yahrzeitsRead === null) {
45054
+ const all = commandById("yahrzeits.all");
45055
+ const answered = all === null ? fail2(cliError("internal", "This CLI has no command yahrzeits.all.")) : await executeUnguarded(here, all, {});
45056
+ yahrzeitsRead = answered.ok ? { ok: true, data: Array.isArray(answered.data) ? answered.data : [] } : answered;
45057
+ }
45058
+ return yahrzeitsRead;
45059
+ };
45060
+ for (const [index3, step2] of (backup.steps ?? []).entries()) {
44243
45061
  const ran = results.find((entry) => entry.step === index3);
44244
45062
  const uncertain = ran === void 0 ? sentOnly.has(index3) : !ran.ok && !changedNothing(ran);
44245
45063
  if (!uncertain && (ran === void 0 || !ran.ok)) continue;
44246
- const cmd = commandById(step.commandId);
44247
- if (cmd === null) return fail2(cliError("internal", `This backup calls ${step.commandId}, which this CLI does not know.`, { hint: "Upgrade the CLI." }));
45064
+ const cmd = commandById(step2.commandId);
45065
+ if (cmd === null) return fail2(cliError("internal", `This backup calls ${step2.commandId}, which this CLI does not know.`, { hint: "Upgrade the CLI." }));
44248
45066
  const restorer = restorerFor(cmd);
44249
- const label = `${recordLabel(cmd, step.input)}${step.rows === void 0 || step.rows.length === 0 ? "" : ` (${rowWords(step.rows)})`}`;
45067
+ const label = `${recordLabel(cmd, step2.input)}${step2.rows === void 0 || step2.rows.length === 0 ? "" : ` (${rowWords(step2.rows)})`}`;
44250
45068
  if (uncertain) left.push(mayHaveChanged(label, index3, ran));
45069
+ const del = takeBackFor(cmd);
45070
+ if (del !== null && ran?.ok === true && isRecord(ran.data)) {
45071
+ const now = await yahrzeitsNow();
45072
+ if (!now.ok) return now;
45073
+ const id2 = ran.data["yahrzeitId"];
45074
+ const current = now.data.find((y) => isRecord(y) && y["yahrzeitId"] === id2) ?? null;
45075
+ const back = takeBack(del, ran.data, current, label);
45076
+ if (!back.ok) return fail2(cliError(back.code, back.message));
45077
+ for (const call of back.steps) {
45078
+ const refused3 = checkInput(del, call.input);
45079
+ if (refused3 !== null) return fail2(cliError("internal", `The restore of ${label} would send what ${del.id} refuses (${refused3.message}), so it was not planned.`, { hint: "This is a mistake in the CLI: upgrade it, and report it if it stays." }));
45080
+ const before = await readBefore(here, del, call.input);
45081
+ if (!before.ok) return before;
45082
+ steps.push({ commandId: call.commandId, input: call.input, ...step2.rows === void 0 ? {} : { rows: step2.rows } });
45083
+ stepBefores.push(before.data);
45084
+ }
45085
+ if (back.steps.length > 0) removed += 1;
45086
+ changes.push(...back.changes);
45087
+ left.push(...back.left ?? []);
45088
+ continue;
45089
+ }
44251
45090
  if (restorer === null) {
44252
- if (uncertain) left.push(`${label}: what it may have made stays, as no route takes a new record back.`);
45091
+ if (uncertain) left.push(takeBackFor(cmd) === null ? `${label}: what it may have made stays, as no route takes a new record back.` : `${label}: what it may have added stays, as this restore cannot tell what it made: check yahrzeits all.`);
44253
45092
  else stays += 1;
44254
45093
  continue;
44255
45094
  }
44256
45095
  const written2 = ran?.ok === true && (isRecord(ran.data) || Array.isArray(ran.data)) ? ran.data : void 0;
44257
45096
  const extra = await extraReads(here, cmd, befores[index3] ?? null);
44258
45097
  if (!extra.ok) return extra;
44259
- const one2 = await restoreOne(here, cmd, restorer, primaryOf(cmd, befores[index3] ?? null), step.input, written2, ran?.ok === true ? ran.data : void 0, {
45098
+ const one2 = await restoreOne(here, cmd, restorer, primaryOf(cmd, befores[index3] ?? null), step2.input, written2, ran?.ok === true ? ran.data : void 0, {
44260
45099
  ...uncertain ? { uncertain: true } : {},
44261
45100
  ...extra.data === void 0 ? {} : { extra: extra.data }
44262
45101
  });
@@ -44270,7 +45109,7 @@ async function restoreWorkflowPlan(ctx, backup, backupFile, save = true) {
44270
45109
  if (callCmd === null) return fail2(cliError("internal", `This CLI has no command ${call.commandId}.`));
44271
45110
  const before = await readBefore(here, callCmd, call.input);
44272
45111
  if (!before.ok) return before;
44273
- steps.push({ commandId: call.commandId, input: call.input, ...step.rows === void 0 ? {} : { rows: step.rows } });
45112
+ steps.push({ commandId: call.commandId, input: call.input, ...step2.rows === void 0 ? {} : { rows: step2.rows } });
44274
45113
  stepBefores.push(before.data);
44275
45114
  }
44276
45115
  changes.push(...one2.back.changes);
@@ -44287,7 +45126,10 @@ async function restoreWorkflowPlan(ctx, backup, backupFile, save = true) {
44287
45126
  hint: left.length > 0 ? "Look at the records as they are now, and change them by hand if that is still wanted." : "Nothing was changed."
44288
45127
  }));
44289
45128
  }
44290
- const notes = [...partial2, ...left, ...kept.length > 0 ? [`Left as they are now: ${kept.join(", ")} (${KEPT_WORDS}).`] : [], ...stayed];
45129
+ const takenBack = removed === 0 ? [] : [
45130
+ `${String(removed)} ${removed === 1 ? "record it created is" : "records it created are"} removed. Each removal is backed up, so this restore can itself be put back.`
45131
+ ];
45132
+ const notes = [...partial2, ...left, ...kept.length > 0 ? [`Left as they are now: ${kept.join(", ")} (${KEPT_WORDS}).`] : [], ...stayed, ...takenBack];
44291
45133
  const made = await finish(here, {
44292
45134
  commandId: first.commandId,
44293
45135
  input: {},
@@ -44302,6 +45144,19 @@ async function restoreWorkflowPlan(ctx, backup, backupFile, save = true) {
44302
45144
  return { ok: true, data: made };
44303
45145
  }
44304
45146
 
45147
+ // src/tool/next.ts
45148
+ var hinted = (next) => `\`${next.command}\`${next.input === void 0 ? "" : ` with ${JSON.stringify(next.input)}`}`;
45149
+ function withNext(result, write) {
45150
+ if (!result.ok) {
45151
+ const { error: error62 } = result;
45152
+ if (error62.next === void 0 || error62.next.length === 0) return result;
45153
+ return { ok: false, error: { ...error62, hint: `${error62.hint} Run: ${error62.next.map((s) => hinted(write(s))).join(", then ")}.` } };
45154
+ }
45155
+ if (result.suggested === void 0) return result;
45156
+ const { suggested, ...rest } = result;
45157
+ return { ...rest, next: [...result.next ?? [], ...suggested.map(write)] };
45158
+ }
45159
+
44305
45160
  // examples/shared.ts
44306
45161
  var day = (ahead) => new Date(Date.now() + ahead * 864e5).toISOString().slice(0, 10);
44307
45162
  var withVersion = (fields, version3) => JSON.stringify(fields).replace(/\}$/, version3 === void 0 || version3 === null ? "}" : `,"version":${version3}}`);
@@ -45304,13 +46159,28 @@ var AUTOMATIONS_EXAMPLES = [
45304
46159
  expect: { exit: 2, json: { ok: false, "error.message": { $contains: "{deceased_name} needs a yahrzeit trigger" } } }
45305
46160
  },
45306
46161
  {
45307
- id: "automations-create",
45308
- command: "automations.create",
45309
- title: "Make the yahrzeit reminder (the gallery's preset): it is saved turned off, at version 1, and sends nothing; a version of 0 says it is new",
45310
- argv: ["automations", "create", "--body", "-"],
45311
- stdin: withVersion(YAHRZEIT(), "0"),
46162
+ id: "automations-create-from-typo",
46163
+ command: "automations.createFromPreset",
46164
+ title: "A realistic mistake: a preset's name is misspelt, so nothing is made, exit 2, with the nearest id and the list of ids",
46165
+ argv: ["automations", "create", "--from", "yahrzeit"],
46166
+ as: "admin",
46167
+ expect: { exit: 2, json: { ok: false, "error.code": "invalid-input", "error.message": { $contains: 'Did you mean "yahrzeit-reminder"?' }, "error.hint": { $contains: "yahrzeit-reminder, birthday, wedding, b-mitzvah, shabbat-list" } } }
46168
+ },
46169
+ {
46170
+ id: "automations-presets",
46171
+ command: "automations.presets",
46172
+ title: "List the gallery's presets: each one's id, what sets it off, when it goes and its channels. Nothing is saved or sent",
46173
+ argv: ["automations", "presets"],
46174
+ as: "admin",
46175
+ expect: { exit: 0, json: { ok: true, "data.[presetId=yahrzeit-reminder].sendAt": 540, "data.[presetId=yahrzeit-reminder].channels": ["push", "email"] } }
46176
+ },
46177
+ {
46178
+ id: "automations-create-from",
46179
+ command: "automations.createFromPreset",
46180
+ title: "Make the yahrzeit reminder from the gallery's preset: it is saved turned off, at version 1, and sends nothing; the answer names what to run next",
46181
+ argv: ["automations", "create", "--from", "yahrzeit-reminder"],
45312
46182
  as: "admin",
45313
- expect: { exit: 0, json: { ok: true, "data.automation.usable": true, "data.automation.automation.enabled": false, "data.automation.automation.version": 1 } },
46183
+ expect: { exit: 0, json: { ok: true, "data.automation.usable": true, "data.automation.automation.name": "Yahrzeit reminder", "data.automation.automation.enabled": false, "data.automation.automation.version": 1 } },
45314
46184
  saves: { autoId: "data.automation.automation.automationId", autoVersion: "data.automation.automation.version", autoStepId: "data.automation.automation.steps.0.stepId" }
45315
46185
  },
45316
46186
  {
@@ -46749,6 +47619,22 @@ var DATES_EXAMPLES = [
46749
47619
  as: "admin",
46750
47620
  expect: { exit: 0, json: { ok: true, "data.hebrew.year": 5779, "data.hebrew.month": 1, "data.hebrew.day": 8 } }
46751
47621
  },
47622
+ {
47623
+ id: "dates-resolve-hebrew",
47624
+ command: "dates.resolve",
47625
+ title: "See the civil day of a Hebrew date, checked as a yahrzeit's is (8 Nisan 5779)",
47626
+ argv: ["dates", "resolve", "--hebrew", '{"year":5779,"month":1,"day":8}', "--calendar", "hebrew"],
47627
+ as: "admin",
47628
+ expect: { exit: 0, json: { ok: true, "data.civil": "2019-04-13", "data.hebrewWords": "8 Nisan 5779" } }
47629
+ },
47630
+ {
47631
+ id: "dates-resolve-hebrew-not-a-day",
47632
+ command: "dates.resolve",
47633
+ title: "A Hebrew date the year does not have is refused: Cheshvan 5784 has 29 days",
47634
+ argv: ["dates", "resolve", "--hebrew", '{"year":5784,"month":8,"day":30}', "--calendar", "hebrew"],
47635
+ as: "admin",
47636
+ expect: { exit: 2, json: { ok: false, "error.message": { $contains: "date.hebrew is not a day of the Hebrew calendar" } } }
47637
+ },
46752
47638
  {
46753
47639
  id: "dates-resolve-future",
46754
47640
  command: "dates.resolve",
@@ -48701,6 +49587,162 @@ var AVRAHAM2 = (day2, version3) => withVersion({ name: "Avraham Levi", hebrewNam
48701
49587
  var SARA = JSON.stringify({ name: "Sara Levi", date: { kind: "gregorian", gregorian: "2015-06-10", afterSunset: false } });
48702
49588
  var FUTURE2 = JSON.stringify({ name: "Avraham Levi", date: { kind: "gregorian", gregorian: "2999-01-01", afterSunset: false } });
48703
49589
  var MOVE = (from, version3) => `{"yahrzeitId":"{yahrzeitId}","fromMemberId":"${from}","toMemberId":null,"version":${version3}}`;
49590
+ var KEEPER2 = "dina.{scopeSlug}@example.test";
49591
+ var SEED = (name, month, day2) => JSON.stringify({ name, date: { kind: "hebrew", hebrew: { year: 5779, month, day: day2 } } });
49592
+ var BULK_SHEET = [
49593
+ "Id,Keeper,Name,Hebrew date,Civil date,After sunset",
49594
+ `{bulkFirstId},${KEEPER2},Sarah Cohen,9 Nisan 5779,,`,
49595
+ `{bulkSecondId},${KEEPER2},Sarah Cohen,,2015-06-10,no`,
49596
+ "{bulkCongId},congregation,Rabbi Moshe Stein,5 Sivan 5750,,",
49597
+ `,${KEEPER2},Miriam Shapiro,12 Tishrei 5760,,`
49598
+ ].join("\n");
49599
+ var BULK_EXAMPLES = [
49600
+ {
49601
+ id: "yahrzeits-bulk-keeper",
49602
+ command: "members.create",
49603
+ title: "Add the member whose yahrzeits a sheet will change: her email holds this run's own slug, so no other record can match it",
49604
+ argv: ["members", "create", "--first-name", "Dina", "--last-name", "Mayer", "--email", KEEPER2],
49605
+ as: "admin",
49606
+ expect: { exit: 0, json: { ok: true, "data.firstName": "Dina" } },
49607
+ saves: { bulkKeeperId: "data.memberId" }
49608
+ },
49609
+ {
49610
+ id: "yahrzeits-bulk-seed-first",
49611
+ command: "yahrzeits.createForMember",
49612
+ title: "Add Sarah Cohen's yahrzeit to her record",
49613
+ argv: ["yahrzeits", "create-for-member", "--member-id", "{bulkKeeperId}", "--body", "-"],
49614
+ stdin: SEED("Sarah Cohen", 1, 8),
49615
+ as: "admin",
49616
+ expect: { exit: 0, json: { ok: true, "data.name": "Sarah Cohen" } },
49617
+ saves: { bulkFirstId: "data.yahrzeitId" }
49618
+ },
49619
+ {
49620
+ id: "yahrzeits-bulk-seed-second",
49621
+ command: "yahrzeits.createForMember",
49622
+ title: "Add a second yahrzeit with the same name on the same record: a different Sarah Cohen, died on another day",
49623
+ argv: ["yahrzeits", "create-for-member", "--member-id", "{bulkKeeperId}", "--body", "-"],
49624
+ stdin: SEED("Sarah Cohen", 2, 3),
49625
+ as: "admin",
49626
+ expect: { exit: 0, json: { ok: true, "data.name": "Sarah Cohen" } },
49627
+ saves: { bulkSecondId: "data.yahrzeitId" }
49628
+ },
49629
+ {
49630
+ id: "yahrzeits-bulk-seed-congregation",
49631
+ command: "congregationYahrzeits.create",
49632
+ title: "Add a yahrzeit the congregation keeps itself",
49633
+ argv: ["congregation-yahrzeits", "create", "--body", "-"],
49634
+ stdin: SEED("Rabbi Moshe Stein", 3, 3),
49635
+ as: "admin",
49636
+ expect: { exit: 0, json: { ok: true, "data.keeper.kind": "congregation" } },
49637
+ saves: { bulkCongId: "data.yahrzeitId" }
49638
+ },
49639
+ {
49640
+ id: "yahrzeits-export",
49641
+ command: "yahrzeits.export",
49642
+ title: "Export every yahrzeit as CSV: a header row, then one row each with its id, keeper and dates",
49643
+ argv: ["yahrzeits", "export"],
49644
+ as: "admin",
49645
+ expect: { exit: 0, json: { ok: true, data: { $contains: '"id","keeper","name","hebrewName","hebrewDate"' } } }
49646
+ },
49647
+ {
49648
+ id: "yahrzeits-bulk-unmapped",
49649
+ command: "yahrzeits.bulk",
49650
+ title: "A sheet whose keeper and name columns the reader does not know is refused, exit 2, naming the headers it found and how to map them",
49651
+ argv: ["yahrzeits", "bulk", "unmapped.csv"],
49652
+ files: { "unmapped.csv": ["Contact,Deceased person,Died", `${KEEPER2},Sarah Cohen,9 Nisan 5779`].join("\n") },
49653
+ as: "admin",
49654
+ expect: {
49655
+ exit: 2,
49656
+ json: { ok: false, "error.message": { $contains: "The headers found: Contact, Deceased person, Died" }, "error.hint": { $contains: "orzarua yahrzeits bulk unmapped.csv --map map.json" } }
49657
+ }
49658
+ },
49659
+ {
49660
+ id: "yahrzeits-bulk-ambiguous-plan",
49661
+ command: "yahrzeits.bulk",
49662
+ title: "A sheet that names Sarah Cohen without her id: two yahrzeits of that name for this keeper, so the row is ambiguous and left out, with the hint to add the id",
49663
+ argv: ["yahrzeits", "bulk", "ambiguous.csv"],
49664
+ files: { "ambiguous.csv": ["Keeper,Name,Hebrew date", `${KEEPER2},Sarah Cohen,9 Nisan 5779`].join("\n") },
49665
+ as: "admin",
49666
+ expect: { exit: 7, json: { "error.plan.rows.0.action": "ambiguous", "error.plan.rows.0.reason": { $contains: "add the id column" }, "error.plan.steps": [] } }
49667
+ },
49668
+ {
49669
+ id: "yahrzeits-bulk-plan",
49670
+ command: "yahrzeits.bulk",
49671
+ title: "The same sheet with the ids filled in, a civil date and a congregation row: a plan that shows both dates of the civil row, and adds Miriam Shapiro",
49672
+ argv: ["yahrzeits", "bulk", "yahrzeits.csv"],
49673
+ files: { "yahrzeits.csv": BULK_SHEET },
49674
+ as: "admin",
49675
+ expect: {
49676
+ exit: 7,
49677
+ json: {
49678
+ "error.plan.rows.0.action": "update",
49679
+ "error.plan.rows.0.changes.0.to": "9 Nisan 5779",
49680
+ "error.plan.rows.0.dates.civil": "2019-04-14",
49681
+ "error.plan.rows.1.action": "update",
49682
+ "error.plan.rows.1.dates.civil": "2015-06-10",
49683
+ "error.plan.rows.1.dates.hebrew": "23 Sivan 5775",
49684
+ "error.plan.rows.2.action": "update",
49685
+ "error.plan.rows.3.action": "create"
49686
+ }
49687
+ }
49688
+ },
49689
+ {
49690
+ id: "yahrzeits-bulk-confirm",
49691
+ command: "yahrzeits.bulk",
49692
+ title: "Confirm the plan: three yahrzeits are changed and one is added; the answer names the backup the run left",
49693
+ argv: ["yahrzeits", "bulk", "--confirm", "{planId}"],
49694
+ as: "admin",
49695
+ expect: { exit: 0, json: { ok: true, "data.steps": 4, "data.ok": 4, "data.backup": { $present: true } } },
49696
+ saves: { bulkBackup: "data.backup" }
49697
+ },
49698
+ {
49699
+ id: "yahrzeits-bulk-all",
49700
+ command: "yahrzeits.all",
49701
+ title: "List every yahrzeit again: the changes are there",
49702
+ argv: ["yahrzeits", "all"],
49703
+ as: "admin",
49704
+ expect: { exit: 0, json: { ok: true, "data.[yahrzeitId={bulkFirstId}].hebrewDate.day": 9, "data.[yahrzeitId={bulkSecondId}].hebrewDate.day": 23, "data.[yahrzeitId={bulkSecondId}].hebrewDate.year": 5775 } }
49705
+ },
49706
+ {
49707
+ id: "yahrzeits-bulk-restore-plan",
49708
+ command: "restore",
49709
+ title: "Plan putting the sheet's changes back, from its own backup: the three changed yahrzeits go back, and the one it added (Miriam Shapiro) is removed",
49710
+ argv: ["restore", "{bulkBackup}"],
49711
+ as: "admin",
49712
+ expect: {
49713
+ exit: 7,
49714
+ json: {
49715
+ "error.plan.changes.0.field": "date.hebrew.day",
49716
+ "error.plan.steps.3.commandId": "yahrzeits.deleteForMember",
49717
+ "error.plan.notes": { $contains: "1 record it created is removed" }
49718
+ }
49719
+ }
49720
+ },
49721
+ {
49722
+ id: "yahrzeits-bulk-restore-confirm",
49723
+ command: "restore",
49724
+ title: "Confirm the restore: three put back and one removed",
49725
+ argv: ["restore", "--confirm", "{planId}"],
49726
+ as: "admin",
49727
+ expect: { exit: 0, json: { ok: true, "data.steps": 4, "data.ok": 4 } }
49728
+ },
49729
+ {
49730
+ id: "yahrzeits-bulk-restored",
49731
+ command: "yahrzeits.all",
49732
+ title: "List them once more: the days are as they were",
49733
+ argv: ["yahrzeits", "all"],
49734
+ as: "admin",
49735
+ expect: { exit: 0, json: { ok: true, "data.[yahrzeitId={bulkFirstId}].hebrewDate.day": 8, "data.[yahrzeitId={bulkSecondId}].hebrewDate.day": 3 } }
49736
+ },
49737
+ {
49738
+ id: "yahrzeits-bulk-restored-removed",
49739
+ command: "yahrzeits.listForMember",
49740
+ title: "The keeper's own list: the yahrzeit the sheet added is gone",
49741
+ argv: ["yahrzeits", "list-for-member", "--member-id", "{bulkKeeperId}"],
49742
+ as: "admin",
49743
+ expect: { exit: 0, json: { ok: true, "data.[name=Miriam Shapiro]": { $present: false }, "data.[name=Sarah Cohen].name": "Sarah Cohen" } }
49744
+ }
49745
+ ];
48704
49746
  var YAHRZEITS_EXAMPLES = [
48705
49747
  {
48706
49748
  id: "yahrzeits-member",
@@ -48849,7 +49891,8 @@ var YAHRZEITS_EXAMPLES = [
48849
49891
  argv: ["yahrzeits", "move", "--confirm", "{planId}"],
48850
49892
  as: "admin",
48851
49893
  expect: { exit: 0, json: { ok: true, "data.keeper.kind": "congregation" } }
48852
- }
49894
+ },
49895
+ ...BULK_EXAMPLES
48853
49896
  ];
48854
49897
 
48855
49898
  // examples/recipes.ts
@@ -48892,6 +49935,21 @@ var RECIPE_STEPS = {
48892
49935
  "congregation-yahrzeits-create",
48893
49936
  "yahrzeits-month"
48894
49937
  ],
49938
+ "update-yahrzeits-from-a-spreadsheet": [
49939
+ "yahrzeits-bulk-keeper",
49940
+ "yahrzeits-bulk-seed-first",
49941
+ "yahrzeits-bulk-seed-second",
49942
+ "yahrzeits-bulk-seed-congregation",
49943
+ "yahrzeits-export",
49944
+ "yahrzeits-bulk-unmapped",
49945
+ "yahrzeits-bulk-ambiguous-plan",
49946
+ "yahrzeits-bulk-plan",
49947
+ "yahrzeits-bulk-confirm",
49948
+ "yahrzeits-bulk-all",
49949
+ "yahrzeits-bulk-restore-plan",
49950
+ "yahrzeits-bulk-restore-confirm",
49951
+ "yahrzeits-bulk-restored"
49952
+ ],
48895
49953
  "move-a-yahrzeit-to-the-congregation": [
48896
49954
  "yahrzeits-member",
48897
49955
  "yahrzeits-create-for-member",
@@ -49040,8 +50098,9 @@ var RECIPE_STEPS = {
49040
50098
  "automations-comms-settings-get",
49041
50099
  "automations-reply-to-plan",
49042
50100
  "automations-reply-to-confirm",
49043
- "automations-create-bad-placeholder",
49044
- "automations-create",
50101
+ "automations-create-from-typo",
50102
+ "automations-presets",
50103
+ "automations-create-from",
49045
50104
  "automations-preview",
49046
50105
  "automations-test-plan",
49047
50106
  "automations-test-confirm",
@@ -49049,7 +50108,10 @@ var RECIPE_STEPS = {
49049
50108
  "automations-enable-no-cost-plan",
49050
50109
  "automations-enable-no-cost-confirm",
49051
50110
  "automations-enable-plan",
49052
- "automations-enable-confirm"
50111
+ "automations-enable-confirm",
50112
+ "automations-disable",
50113
+ "automations-delete-plan",
50114
+ "automations-delete-confirm"
49053
50115
  ],
49054
50116
  "email-on-a-date": [
49055
50117
  "automations-comms-settings-get",
@@ -49213,7 +50275,7 @@ var attendance_and_visitors_default = "# Attendance, the minyan and visitors\n\n
49213
50275
  var billing_and_credit_default = "# Billing, messaging credit and printed books\n\nWhat a congregation pays Or Zarua for: messaging credit (SMS and WhatsApp messages), the subscription that keeps it open after its trial, and printed books. Ticket money is different: it goes to the congregation's own Stripe account (`orzarua docs tickets`).\n\n```\norzarua credit get\norzarua billing get\norzarua books list-orders\n```\n\nEvery amount is in cents of Australian dollars: `amountMinor: 2000` is A$20.00. Only `payments.configure` or `org.configure` buys, subscribes or orders; staff who draft or send messages read the credit too.\n\n## Stripe's own pages: open them in a browser\n\nThe command line never takes a card, a bank account or an address. A purchase answers a page of Stripe's, which the command line only prints, after the answer:\n\n- **\"Open this link to pay: <url>\"**: Stripe's Checkout, for a pack of credit, the subscription or a book order. A person opens it in a browser and pays there. Nothing is charged until they do, and a page nobody pays closes by itself (about 30 minutes for the subscription, 24 hours otherwise).\n- **\"Open this link in a browser: <url>\"**: any other page, such as Stripe's billing portal (`billing portal`) or Settings, Payments.\n\nWith `--json`, and through the MCP server, the same sentences are in the answer's `links`. An agent gives the link to the person; it never fills in the page for them.\n\nA payment is recorded when Stripe confirms it, a few seconds after the page says paid. Read it again until it shows: `billing get-purchase` for a pack or the subscription, `books get-order` for books.\n\n## GST\n\nEvery platform price is GST-exclusive. For a congregation in Australia, 10% GST is added on top: A$20 of credit costs A$22, the subscription's invoices are tax invoices \"+ GST\", and each top-up has a tax invoice. For a congregation overseas the sale is a GST-free export: the card pays the price alone, and the invoice says so. `credit get` and `billing get` say which applies (`gst`: `taxable`, `export`, or `none` while the platform is not registered). A printed book's price already includes its GST.\n\n## Messaging credit\n\nSMS and WhatsApp messages are paid from the congregation's messaging credit; email and push cost nothing. `credit get` answers what is `available`, what is `held` for messages on their way, the price of each unit by destination country (ex GST), and the credit terms.\n\n**The terms first.** No credit can be bought until the congregation agrees to the credit terms of its country: credit is non-refundable, and credit left when a congregation leaves is forfeited. An admin agrees in their own name:\n\n```\norzarua credit agree --terms-id <terms.text.id>\n```\n\nThis records a legal agreement in the person's name, so it is always a plan, and the person must say yes after reading `terms.text`. An API token can never agree. If the terms changed since they were read, it is refused (`conflict`) and nothing is recorded.\n\n**A pack by card.** `credit buy-pack --amount-minor 2000` (or 5000, 10000) is a plan; its confirm answers the Checkout page and the `purchaseId`. The credit is added once the card is paid there. `orzarua docs top-up-messaging-credit` walks through it.\n\n**A bank transfer.** `credit get` answers `bankTransfer`, the account and the reference to use (the congregation's address); Or Zarua records the transfer, at least A$20. From outside Australia it is an international transfer.\n\n**The history.** `credit ledger` pages through every entry, newest first: a top-up adds credit; a send reserves credit, is charged what it used, and the rest is released; an adjustment is Or Zarua's correction. Pass the page's `next` as `--before` for the page after. `credit receipt --entry-id <id>` is a top-up's invoice. When the available credit falls below the low-balance threshold (comms settings), the admins are emailed once.\n\n## The subscription\n\nA congregation that signed itself up has a 14-day trial. After it, the subscription keeps the congregation open; without one it is locked (reads still work). `billing get` answers where it stands (`standing`: open, in its trial with the days left, or locked), the active records it counts by billing class (members, and everyone else), this month's estimate at today's counts with its GST, the monthly price tiers, and the subscription terms. A congregation in the pilot is exempt (`state: exempt`) and is not billed.\n\n```\norzarua billing subscribe --terms-id <terms.id>\n```\n\nThis is a plan: the person accepts the subscription terms of their country, read in `billing get`. That acceptance is recorded in the person's name, so they must say yes after reading the terms. An API token can never subscribe. Its confirm answers the Checkout page (card, or BECS direct debit in Australia). It is billed monthly, and Or Zarua gives at least 30 days' notice by email before a price change. It works while the congregation is locked: subscribing is what lifts the lock. One Checkout at a time; it is refused when the congregation is exempt or already subscribed.\n\n`billing portal` answers Stripe's billing portal for one visit: change the card, read the invoices, cancel, or mend a subscription that needs attention (a failed payment). It needs a Stripe customer first (a subscription or a card purchase).\n\n## Printed books\n\nThe printed siddurim and the Guide, ordered from Or Zarua at the catalogue's prices, with lower prices from 10 copies of an edition. Or Zarua places each paid order with the printer, usually within two business days, and posts it within Australia only, usually within two to three weeks.\n\n```\norzarua books quote --body order.json\norzarua books start-order --body order.json\n```\n\n`order.json` names editions and copies: `{ \"lines\": [{ \"id\": \"weekday-hardcover-en\", \"quantity\": 10 }] }`. The editions are `weekday-hardcover-en`, `shabbat-hardcover-en`, `hebrew-hardcover-en` and `guide-paperback-en`; only those on sale can be ordered. `books quote` prices it and changes nothing; prices, discounts and shipping are always the catalogue's. From 50 copies there is no price (`quote: true`): email Or Zarua for a quote, and `start-order` refuses it.\n\n`books start-order` is a plan whose preview is the quote. Its confirm answers the Checkout page, where the card and the Australian address are given, and the `orderId`. `books get-order` then follows it: `open` until paid, `paid`, `placed` with the printer (its reference), `shipped` (the tracking), or `refunded`. Until it is placed, the order can be cancelled for a full refund by emailing Or Zarua; after that it is printed to order, and a damaged or faulty copy is replaced or refunded.\n\n## What can't be undone\n\nBuying credit, subscribing and ordering books move money, so each is a plan with its notes and a confirm. None of them can be put back with `restore`: credit is non-refundable, a subscription is cancelled in the billing portal, and a book order is cancelled by email until it is placed.\n\n## Sources\n\nThe congregation platform design (Messaging credit; Congregation billing; Printed books), the prepaid messaging, platform purchases and book orders plans, the international design (GST and the terms by country), the self-serve sign-up design (the trial and the lock), and the CLI design (Stripe-hosted steps).\n";
49214
50276
 
49215
50277
  // docs/guides/automations.md
49216
- var automations_default = '# Automations, the Shabbat list and the Hebrew-date defaults\n\nAn automation is a message that goes by itself on a member\'s date: a yahrzeit reminder three days before, a birthday greeting on the day, a wedding anniversary. A weekly automation sends the Shabbat list to the staff who read it. Members\' dates are in `orzarua docs dates` and `orzarua docs yahrzeits`.\n\n```\norzarua automations list\norzarua automations get --automation-id <automationId>\norzarua automations runs --automation-id <automationId>\norzarua automation-runs list\n```\n\n## Three things must be on before anything sends\n\n1. **The automation is on.** `automations create` saves it turned off.\n2. **The congregation\'s switch is on** (`orzarua automations update-settings --enabled --version <settingsVersion>`, a plan). While it is off nothing sends, whatever each automation says. Turning it on lets every automation that is on send from now: a date due before then is never sent, so nothing old goes out.\n3. **A channel can be used.** Email needs the office\'s reply-to address (`orzarua docs messages`).\n\nTurning an automation on (`automations enable`) and the switch both let people be told, so they are plans the user confirms. Neither sends at that moment: a daily sweep sends each message at its due time.\n\n## Writing an automation\n\nThe body says what fires it, when, and what it sends. A new automation has `"version": 0`; `get` gives the version a change sends back. Triggers and steps get their ids when they are saved, and keep them when the whole automation is sent back.\n\n```json\n{\n "name": "Yahrzeit reminder",\n "triggers": [{ "kind": "occasion", "occasion": "yahrzeit", "leadDays": 3 }],\n "sendAt": 540, "restDays": "before", "shabbat": "following", "grouping": "per-shabbat",\n "steps": [{\n "kind": "message", "enabled": true, "to": { "kind": "subject" },\n "channels": [{ "channel": "push", "mode": "everyone" }, { "channel": "email", "mode": "fallback" }],\n "title": "The yahrzeit of {deceased_name}",\n "email": { "subject": "The yahrzeit of {deceased_name}", "blocks": [{ "type": "paragraph", "runs": [{ "text": "{first_name}, the yahrzeit of {deceased_name} begins {begins}." }] }] },\n "sms": null, "push": { "title": "The yahrzeit of {deceased_name}", "body": "It begins {begins}." }, "whatsapp": false\n }],\n "version": 0\n}\n```\n\n- **Triggers:** `occasion` (yahrzeit, birthday, wedding or b-mitzvah, `leadDays` 0 to 30 before it), each occasion once; or one `weekly` trigger (`weekday` 0 Sunday to 6 Saturday), which stands alone. At most 6 triggers and 10 steps; a congregation may have 20 automations.\n- **Time:** `sendAt` is minutes after midnight in the congregation\'s time zone, in steps of 15 (540 is 9:00). SMS, push and WhatsApp cannot be timed inside the overnight quiet hours (`orzarua comms get-settings`).\n- **Rest days:** a send that falls on Shabbat or Yom Tov goes the day before (`restDays: before`) or after it; there is no "send now anyway" for an automation. `shabbat` says which Shabbat an occurrence belongs to; `grouping` makes one message for each person and Shabbat (`per-shabbat`), naming every occasion, or one for each occurrence.\n- **Steps:** a message to the person the date is about, on the channels chosen (each `everyone`, or `fallback` for those no `everyone` channel reaches), with the text each chosen channel needs. An email takes no images and at most 5,000 characters. A weekly automation\'s step is the Shabbat list: `{ "kind": "shabbat-digest", "enabled": true, "to": { "kind": "capability", "capability": "shabbat-list.view" }, "channels": [{ "channel": "email", "mode": "everyone" }] }` (or `"to": { "kind": "roles", "roleIds": [...] }`, never the Member role), by email or push only. Weekly automations are fixed at `grouping: per-occurrence`, `shabbat: following`, `restDays: before`.\n- **Placeholders:** `{first_name}`, `{last_name}`, `{hebrew_name}`, `{congregation}`, `{contact}`, `{occasions}`, `{shabbat}`, `{occasion}`, `{date}`, `{hebrew_date}`, `{hebrew_date_he}`, `{years}`, and for one occasion only `{deceased_name}`, `{begins}` and `{reading_shabbat}` (yahrzeit) and `{partner_first_name}` (wedding). A placeholder the triggers do not have is refused when saving. A missing value reads as its fallback, never blank.\n\n## Checking before turning on\n\n- `orzarua automations preview --body preview.json`: one step as an email, push, SMS and in-app copy, filled with a member (`memberId`) or a sample, with any fallback named. The draft may be unsaved.\n- `orzarua automations coming-up --body draft.json`: who it would message in the next days (1 to 60), when each goes and whether a rest day moved it, the 30-day estimate and the credit available. This is the cost `--confirm-cost` confirms.\n- `orzarua automations test --automation-id <id> --step-id <id> --channels email`: a test to your own record, at once, at any hour. It reaches a person, so it is a plan; SMS and WhatsApp are charged from the credit.\n\n`automations enable` needs `--confirm-cost` and the version read; its plan says how many people it will message over the next 30 days, on which channels, and what that costs. `automations disable` runs at once and tells nobody.\n\n`automations update` of an automation that is **on** retargets a live sender, so its plan carries the same reach and cost as turning it on (people over the next 30 days, channels, cost, whether the credit covers it), and a confirm answers `plan-changed` if either moved. The server does not ask for the cost to be confirmed on an update, so read it in the plan. Of one that is off, the plan is the field changes alone.\n\n## Runs\n\nEach person and Shabbat is one run. `automations runs` and `automation-runs list` list them newest first (`--before` a run id, `--limit` up to 100); `automation-runs get` shows each delivery with the plain reason when it did not go (opted out, no mobile number, not enough credit).\n\n## The Hebrew-date defaults and the Shabbat list\'s week\n\n- `orzarua settings get-occasion-dates` and `update-occasion-dates --adar adar-i|adar-ii --short-month same-month-29|next-month-1|first-anniversary`: where a plain-Adar date falls in a leap year, and what a 30 Cheshvan or Kislev date does when that month has 29 days. They apply to every date that carries no choice of its own.\n- `orzarua settings get-shabbat-list` and `update-shabbat-list --week shabbat-to-friday|sunday-to-shabbat`: which week of yahrzeits the list holds, the congregation\'s custom.\n- `orzarua shabbat-list get --shabbat 2026-11-07`: the list for one Saturday (the coming one when left out): seven days, each with its Hebrew date, the names read, who remembers each, and the times.\n\nThe switch, the occasion defaults and the week share one settings version: read it again before each save.\n\n## Undo\n\n`automations update` and `delete`, and both settings, are backed up first: `orzarua restore` puts an automation back as it was (a deleted one is made again, turned off, under a new id), and the settings as they were. Turning on and the switch cannot be unsent: turn them off, and messages already sent stay sent.\n\n## When it goes wrong\n\n- **`invalid`: a placeholder "needs a yahrzeit trigger".** The trigger for that occasion is not in the automation.\n- **`invalid`: "write the email, or take email off the channels".** A chosen channel has no text.\n- **`invalid`: "Confirm what it will cost".** Enable without `--confirm-cost`.\n- **`invalid` about quiet hours.** Move `sendAt` out of the overnight window, or use email only.\n- **`conflict`.** Someone changed it: read it again and send the new version.\n- **`forbidden`: made by a newer release.** It can be turned off or deleted, not changed.\n\nSources: the congregation automations design (Triggers, Quiet windows, Steps, Placeholders, The Shabbat list digest step, The Shabbat list, Capabilities, Delivery records); the automations plans 1 and 2 (2026-10-21, 2026-10-22); the CLI design\'s section 8 (turning on is outward); and the code\'s own rules (core/automation-validation.ts, core/automations/automations.ts, core/shabbat-list/shabbat-list.ts).\n';
50278
+ var automations_default = '# Automations, the Shabbat list and the Hebrew-date defaults\n\nAn automation is a message that goes by itself on a member\'s date: a yahrzeit reminder three days before, a birthday greeting on the day, a wedding anniversary. A weekly automation sends the Shabbat list to the staff who read it. Members\' dates are in `orzarua docs dates` and `orzarua docs yahrzeits`.\n\n```\norzarua automations list\norzarua automations get --automation-id <automationId>\norzarua automations runs --automation-id <automationId>\norzarua automation-runs list\n```\n\n## Three things must be on before anything sends\n\n1. **The automation is on.** `automations create` saves it turned off.\n2. **The congregation\'s switch is on** (`orzarua automations update-settings --enabled --version <settingsVersion>`, a plan). While it is off nothing sends, whatever each automation says. Turning it on lets every automation that is on send from now: a date due before then is never sent, so nothing old goes out.\n3. **A channel can be used.** Email needs the office\'s reply-to address (`orzarua docs messages`).\n\nTurning an automation on (`automations enable`) and the switch both let people be told, so they are plans the user confirms. Neither sends at that moment: a daily sweep sends each message at its due time.\n\n## Starting from a preset\n\nThe gallery\'s presets are the quickest start: `orzarua automations presets` lists them, and `orzarua automations create --from <preset>` saves one as a new automation, turned off, at version 1. Any other flag of `automations create`, `--set` or `--body` changes only what it names, so the rest stays as the preset has it: `--set steps.0.title=Happy birthday` changes only the first step\'s title. A list given whole, in `--body` or as a flag (`--steps`, `--triggers`), replaces the preset\'s list.\n\n```\norzarua automations create --from yahrzeit-reminder\norzarua automations create --from yahrzeit-reminder --name "Yahrzeit reminder (office)"\n```\n\n{{automation-presets}}\n\nA misspelt name is refused with the nearest id and the list. The answer names what to run next (as lines on the command line, and as tool calls with their input over MCP): read it back (`automations get`, whose fields are the body `automations preview` shows a step from), test it on yourself, and turn it on (`automations enable`, a plan that says who it will reach and what it costs). Making one from a preset never turns it on.\n\n## Writing an automation\n\nThe body says what fires it, when, and what it sends. A new automation has `"version": 0`; `get` gives the version a change sends back. Triggers and steps get their ids when they are saved, and keep them when the whole automation is sent back.\n\n```json\n{\n "name": "Yahrzeit reminder",\n "triggers": [{ "kind": "occasion", "occasion": "yahrzeit", "leadDays": 3 }],\n "sendAt": 540, "restDays": "before", "shabbat": "following", "grouping": "per-shabbat",\n "steps": [{\n "kind": "message", "enabled": true, "to": { "kind": "subject" },\n "channels": [{ "channel": "push", "mode": "everyone" }, { "channel": "email", "mode": "fallback" }],\n "title": "The yahrzeit of {deceased_name}",\n "email": { "subject": "The yahrzeit of {deceased_name}", "blocks": [{ "type": "paragraph", "runs": [{ "text": "{first_name}, the yahrzeit of {deceased_name} begins {begins}." }] }] },\n "sms": null, "push": { "title": "The yahrzeit of {deceased_name}", "body": "It begins {begins}." }, "whatsapp": false\n }],\n "version": 0\n}\n```\n\n- **Triggers:** `occasion` (yahrzeit, birthday, wedding or b-mitzvah, `leadDays` 0 to 30 before it), each occasion once; or one `weekly` trigger (`weekday` 0 Sunday to 6 Saturday), which stands alone. At most 6 triggers and 10 steps; a congregation may have 20 automations.\n- **Time:** `sendAt` is minutes after midnight in the congregation\'s time zone, in steps of 15 (540 is 9:00). SMS, push and WhatsApp cannot be timed inside the overnight quiet hours (`orzarua comms get-settings`).\n- **Rest days:** a send that falls on Shabbat or Yom Tov goes the day before (`restDays: before`) or after it; there is no "send now anyway" for an automation. `shabbat` says which Shabbat an occurrence belongs to; `grouping` makes one message for each person and Shabbat (`per-shabbat`), naming every occasion, or one for each occurrence.\n- **Steps:** a message to the person the date is about, on the channels chosen (each `everyone`, or `fallback` for those no `everyone` channel reaches), with the text each chosen channel needs. An email takes no images and at most 5,000 characters. A weekly automation\'s step is the Shabbat list: `{ "kind": "shabbat-digest", "enabled": true, "to": { "kind": "capability", "capability": "shabbat-list.view" }, "channels": [{ "channel": "email", "mode": "everyone" }] }` (or `"to": { "kind": "roles", "roleIds": [...] }`, never the Member role), by email or push only. Weekly automations are fixed at `grouping: per-occurrence`, `shabbat: following`, `restDays: before`.\n- **Placeholders:** `{first_name}`, `{last_name}`, `{hebrew_name}`, `{congregation}`, `{contact}`, `{occasions}`, `{shabbat}`, `{occasion}`, `{date}`, `{hebrew_date}`, `{hebrew_date_he}`, `{years}`, and for one occasion only `{deceased_name}`, `{begins}` and `{reading_shabbat}` (yahrzeit) and `{partner_first_name}` (wedding). A placeholder the triggers do not have is refused when saving. A missing value reads as its fallback, never blank.\n\n## Checking before turning on\n\n- `orzarua automations preview --body preview.json`: one step as an email, push, SMS and in-app copy, filled with a member (`memberId`) or a sample, with any fallback named. The draft may be unsaved.\n- `orzarua automations coming-up --body draft.json`: who it would message in the next days (1 to 60), when each goes and whether a rest day moved it, the 30-day estimate and the credit available. This is the cost `--confirm-cost` confirms.\n- `orzarua automations test --automation-id <id> --step-id <id> --channels email`: a test to your own record, at once, at any hour. It reaches a person, so it is a plan; SMS and WhatsApp are charged from the credit.\n\n`automations enable` needs `--confirm-cost` and the version read; its plan says how many people it will message over the next 30 days, on which channels, and what that costs. `automations disable` runs at once and tells nobody.\n\n`automations update` of an automation that is **on** retargets a live sender, so its plan carries the same reach and cost as turning it on (people over the next 30 days, channels, cost, whether the credit covers it), and a confirm answers `plan-changed` if either moved. The server does not ask for the cost to be confirmed on an update, so read it in the plan. Of one that is off, the plan is the field changes alone.\n\n## Runs\n\nEach person and Shabbat is one run. `automations runs` and `automation-runs list` list them newest first (`--before` a run id, `--limit` up to 100); `automation-runs get` shows each delivery with the plain reason when it did not go (opted out, no mobile number, not enough credit).\n\n## The Hebrew-date defaults and the Shabbat list\'s week\n\n- `orzarua settings get-occasion-dates` and `update-occasion-dates --adar adar-i|adar-ii --short-month same-month-29|next-month-1|first-anniversary`: where a plain-Adar date falls in a leap year, and what a 30 Cheshvan or Kislev date does when that month has 29 days. They apply to every date that carries no choice of its own.\n- `orzarua settings get-shabbat-list` and `update-shabbat-list --week shabbat-to-friday|sunday-to-shabbat`: which week of yahrzeits the list holds, the congregation\'s custom.\n- `orzarua shabbat-list get --shabbat 2026-11-07`: the list for one Saturday (the coming one when left out): seven days, each with its Hebrew date, the names read, who remembers each, and the times.\n\nThe switch, the occasion defaults and the week share one settings version: read it again before each save.\n\n## Undo\n\n`automations update` and `delete`, and both settings, are backed up first: `orzarua restore` puts an automation back as it was (a deleted one is made again, turned off, under a new id), and the settings as they were. Turning on and the switch cannot be unsent: turn them off, and messages already sent stay sent.\n\n## When it goes wrong\n\n- **`invalid`: a placeholder "needs a yahrzeit trigger".** The trigger for that occasion is not in the automation.\n- **`invalid`: "write the email, or take email off the channels".** A chosen channel has no text.\n- **`invalid`: "Confirm what it will cost".** Enable without `--confirm-cost`.\n- **`invalid` about quiet hours.** Move `sendAt` out of the overnight window, or use email only.\n- **`conflict`.** Someone changed it: read it again and send the new version.\n- **`forbidden`: made by a newer release.** It can be turned off or deleted, not changed.\n\nSources: the congregation automations design (Triggers, Quiet windows, Steps, Placeholders, The Shabbat list digest step, The Shabbat list, Capabilities, Delivery records); the automations plans 1 and 2 (2026-10-21, 2026-10-22); the CLI design\'s section 8 (turning on is outward); and the code\'s own rules (core/automation-validation.ts, core/automations/automations.ts, core/shabbat-list/shabbat-list.ts).\n';
49217
50279
 
49218
50280
  // docs/guides/backups.md
49219
50281
  var backups_default = "# Backups and restore\n\nBefore every confirmed change to existing data, the CLI saves the records as they stood. A plain write that overwrites something is backed up too.\n\n## What a backup holds\n\nOne file per change, in `~/.local/share/orzarua/backups/<congregation>/`, named by when it ran and the command. It holds:\n\n- the before-state, read with the command's own backup reads;\n- the command and its input;\n- who ran it, the environment, and the CLI's version;\n- the result of each record, appended as it goes.\n\nA run that stops halfway leaves a precise record of what changed and what didn't.\n\n```\norzarua backups\norzarua backups show <file>\norzarua backups prune --days 90\n```\n\n- `backups` lists them, newest first: the command, how many results, how many failed, and whether it was restored. `--cong <slug>` lists one congregation's.\n- `backups show` prints one: what ran, what the records held before, and what came of each.\n- `backups prune` removes those older than 90 days (or `--days`).\n\n## How far a change can be undone\n\nEach command says how far its effect can be undone, and its plan shows it before the user says yes, so nobody confirms in the belief that something is undoable when it isn't.\n\n| Restore | What it covers | How it is put back |\n|---|---|---|\n| `full` | Field edits | Through the normal update, with the record's current version |\n| `recreate` | Deletes | The record is created again under a new id. Links to the old id don't come back |\n| `partial` | Edits with side effects a later edit doesn't reverse | The fields go back; the side effects stay, as the plan names them |\n| `none` | Sends, refunds and payments; anything with no backup | Can't be undone |\n\nFor example, putting a member record's fields back restores the details, but an added opt-out can't be removed by staff, and a resigned member's shared yahrzeits stay deleted. `orzarua describe <command>` names each command's own.\n\n## Restoring\n\n```\norzarua restore <file>\norzarua restore --confirm <planId>\n```\n\n`restore` builds a plan that puts the records back, through the same plan and confirm as any change (`orzarua docs safety`):\n\n- A record someone else changed since the run is refused and named, never overwritten.\n- A record that already reads as the backup holds it is said to be so, and left alone.\n- A backup is put back only once: a restore marks it, and a second restore of it is refused.\n- The records are backed up again before the restore changes them.\n- A workflow's backup (a plan of many rows) is put back row by row: a record changed since is left as it is and named in the plan's notes, the others are still put back, and what the run created stays. A call of it that failed after it was sent (no answer, a timeout, a server error), or was sent and never answered, may have changed some of its records: each is judged by its version, and any the restore cannot settle is named. A call the server refused changed nothing. If some of the restore's own calls fail, its answer names them, to put back by hand.\n\nShow the user the restore plan in plain words, and confirm it only after their explicit yes.\n\n## The activity log\n\nThe server keeps its own activity log of who did what, for staff with permission to see the activity log. The CLI names itself on every request, and the server logs it. Changes made with an API token are marked \"via token\" and the token's name.\n";
@@ -49264,7 +50326,7 @@ var start_default = '# Start here: the orzarua command line\n\n`orzarua` runs th
49264
50326
  var tickets_default = '# Tickets, payments and the ledger\n\nA ticketed event sells tickets through the congregation\'s own Stripe account. The congregation is the seller: the money goes to its account, its name is on the buyer\'s statement, and its refunds and disputes are its own. Or Zarua takes its part of a booking fee the buyer pays on top of each paid ticket.\n\n```\norzarua payments get\norzarua tickets list-types --event-id <eventId>\norzarua tickets door --event-id <eventId>\n```\n\nEvery amount is in minor units (cents) of the congregation\'s own currency: `priceMinor: 4000` is A$40.00 in Australia and \xA340.00 in the United Kingdom.\n\n## Stripe\'s own pages: open them in a browser\n\nThe command line never takes a card or a bank account. Two steps happen on pages of Stripe\'s, which the command line only prints, after the answer: "Open this link in a browser: <url>" for the account\'s page, "Open this link to pay: <url>" for the buyer\'s (in `links` with `--json` and through the MCP server):\n\n- **The congregation\'s account.** `payments setup` makes it, and Stripe then needs the rest (who represents the congregation, its bank account) in its own forms. The answer\'s `settingsUrl` is Settings, Payments in the web admin, where those forms open: open it in a browser. `payments session` answers the same page. Once the forms are done, `payments refresh` reads the account again; `state` turns `ready` and tickets can be sold.\n- **The buyer\'s card.** `tickets checkout` holds the places and answers `payUrl`, the event\'s page, where Stripe\'s own form takes the card. Open it in a browser (or pay in the app). The order turns `paid` once Stripe confirms the payment; read it with `tickets get-order`.\n\nAn agent never fills in either page for a person.\n\n## Setting up payments\n\n```\norzarua payments setup --body setup.json\n```\n\n`setup.json` may name the congregation\'s business number, of a type its country takes (`idTypes` in `payments get`): `{ "businessId": { "type": "au_abn", "value": "51 824 753 556" } }` in Australia; `us_ein`, `gb_crn`, `ca_crarr` or `nz_bn` elsewhere. Leave it out and Stripe asks for it. A type of another country is refused (exit 2). Where tickets can\'t be sold (the congregation\'s country or currency), `payments get` says why in `unavailableWords`, and the set-up is refused in the same words; events without tickets, and RSVPs, still work there.\n\nOnly `payments.configure` or `org.configure` sets payments up: whoever fills in Stripe\'s forms first becomes the account\'s representative. Staff with `tickets.view` read where it stands.\n\n`payments setup-again` is only for an account Stripe closed or rejected: it lets go of the old account, so the next set-up makes a new one. It is a plan, and it is refused while the account is open or ticket sales are open on it.\n\n## Ticket types and prices\n\nAn event sells tickets when its `rsvp.mode` is `ticketed` (`orzarua docs events`). Its ticket types are saved as a whole list:\n\n```json\n{\n "types": [\n { "typeId": null, "name": "Adult", "priceMinor": 4000, "categoryPrices": [{ "categoryId": "member", "priceMinor": 2500 }], "limit": null },\n { "typeId": null, "name": "Child", "priceMinor": 1500, "categoryPrices": [], "limit": 20 },\n { "typeId": null, "name": "Volunteer", "priceMinor": 0, "categoryPrices": [], "limit": null }\n ]\n}\n```\n\n```\norzarua tickets save-types --event-id <eventId> --body types.json\n```\n\n- **The whole list.** A type with `typeId: null` is added; a type sent with its `typeId` and the `version` read (`tickets list-types`) is changed; a type left out is deleted. A type with tickets sold can\'t be removed or limited below what is sold. It is a plan, and its backup can put the list back.\n- **Prices.** `priceMinor` is the public price; `0` is a ticket at no charge. `categoryPrices` gives a membership category\'s active members their own price (one each, from `categories list`). A buyer pays their price, decided when the places are held, and the order records why. A paid price with its booking fee must reach Stripe\'s smallest charge in the currency (50 cents in Australian dollars).\n- **Limits.** `limit` is the type\'s own places, 1 or more, or `null` for only the event\'s capacity. A limit of 0 is refused: to stop selling a type, leave it out (when none are sold), or close the event\'s sales.\n- **The booking fee** is added to each paid ticket, shown beside its price, so the congregation receives the price. A ticket at no charge carries none.\n\n## Buying, as a member\n\n```\norzarua tickets checkout --event-id <eventId> --body \'{"lines":[{"typeId":"<typeId>","quantity":2}]}\'\n```\n\nBuying costs money, so it is a plan the person confirms. Confirmed, it holds the places for 15 minutes and answers `payUrl`. Refused when a type is sold out or has fewer left than asked for (`full`, exit 6, with how many are left), when the order is larger than the event allows, or when sales have closed. Only active members buy here; anyone else buys on the event\'s public page. Tickets at no charge are booked at once, with no page to pay on.\n\n- `tickets get-order` reads your own order: pending, paid (with each ticket\'s code), expired, or refunded.\n- `tickets release` gives an unpaid hold back (a plan): nothing was charged.\n- `tickets cancel` cancels your own paid tickets while the event\'s refund policy allows (a plan): the tickets\' prices go back to the card, and the booking fee is kept.\n- `tickets set-reminder --reminder` asks for a reminder before the event.\n\n## At the door\n\n`tickets door` lists every paid ticket and walk-in by name, with each ticket\'s code and whether it is in. A refunded ticket is shown, never let in.\n\n- `tickets check-in --order-id <orderId> --index <n> --body \'{"checked":true,"version":<version>}\'` checks one ticket in at the version the list showed. A second door that checks the same ticket in from the same list is refused (`conflict`, exit 6): read the list again.\n- `tickets add-walk-in --body \'{"name":"Avi Cohen","typeId":"<typeId>","quantity":3}\'` adds people who paid at the door, already checked in. No card is charged. Past the event\'s capacity it is refused (`full`) with the count it would make; send it again with `"confirm": true` in the body (or `--past-capacity` on the line) once staff have said yes.\n\n## Refunds\n\n```\norzarua tickets refund --event-id <eventId> --order-id <orderId> --body \'{"scope":"tickets","perLine":[1],"version":<version>}\'\n```\n\nA refund moves money back to the buyer\'s card, from the congregation\'s Stripe account, so it is a plan, and it can\'t be undone. The plan says how much goes back.\n\n- `scope: "tickets"` refunds some tickets\' prices (`perLine`: how many of each line, from `tickets list-orders`); the booking fees are kept, and the places are freed.\n- `scope: "everything"` refunds what is left of the order, booking fees included; Or Zarua returns its part of the fee to the congregation.\n- **Never more than was paid.** A line can give back at most what is left of it (`refundedPerLine` says what already went): asking for more is refused (`conflict`), and the plan says so first.\n- Stripe keeps its card fee either way. When money goes back, the buyer is emailed what was refunded.\n- The order\'s `version` makes a refund happen once: a second confirm of the same version is refused, never refunded twice.\n- An order that took no money (tickets at no charge, a walk-in) is refunded by its tickets: only its places are freed.\n\nA paid ticket is refunded here, not through `events cancel-rsvp` (RSVPs without payment only). Cancelling the whole event refunds every paid order in full by itself (`events cancel`).\n\n## The money\n\n- `tickets sales` shows an event\'s tickets sold at each price, and the money from the ledger: ticket prices, booking fees, refunds, Or Zarua\'s part (with its GST in Australia), Stripe\'s known fees, and the net. It also says what cancelling the event now would refund.\n- `ledger statement --from <YYYY-MM-DD> --to <YYYY-MM-DD>` totals every movement of ticket money between two days (at most 400), in the congregation\'s time zone, by kind and by event. Each entry is written once, from Stripe\'s own events: a sale shows once it is paid, a refund once Stripe made it.\n\nSources: the congregation platform design 2026-10-13 (Tickets and payments: Stripe setup, onboarding, price, checkout, tickets and guests, refunds); the tickets plan 2026-10-17 (Connect onboarding, ticket types and the booking fee, checkout, refunds, the door list and the sales view); the international design 2026-10-23 and its tickets in local currency plan 2026-10-25 (each country\'s currency, business ids and where tickets can\'t be sold); the CLI design 2026-10-29 (Stripe-hosted steps are printed, never handled); and the code\'s own rules in `core/tickets/` (`ticket-types.ts`, `checkout.ts`, `orders.ts`, `door.ts`, `refunds.ts`, `ledger.ts`, `onboarding.ts`, `validation.ts`).\n';
49265
50327
 
49266
50328
  // docs/guides/yahrzeits.md
49267
- var yahrzeits_default = "# Yahrzeits: kept by a member, or by the congregation\n\nA yahrzeit is the anniversary of a death, kept by its Hebrew date. The congregation reads the names on Shabbat, and its automations can remind the family.\n\n## Who keeps a yahrzeit\n\nEvery yahrzeit has a **keeper**:\n\n- **A member's record.** Either the member shared it from the Or Zarua app or their portal (`source: member`), or the office recorded it for them (`source: office`).\n- **The congregation itself**, for someone no member keeps: a founder, a past rabbi. It shows as \"Remembered by\" the congregation, has no member, and no limit on how many.\n\nA member's record holds up to 200 yahrzeits. A record that is a join request holds none.\n\n## Member-shared yahrzeits are the member's\n\nA yahrzeit the member shared belongs to them; the congregation's copy exists by their consent. The office may **correct** it, never remove or move it:\n\n- a correction also changes the member's own copy in their app, and the member sees that the office corrected it (changing only the Hebrew name is not a correction). A yahrzeit entered in the portal has no app copy, so only the congregation's copy changes;\n- the member's own later edit, made after the correction reached their app, wins;\n- stopping sharing deletes the congregation's copy, and leaving the congregation deletes every yahrzeit the member shared. Resigning does too.\n\n`yahrzeits all` marks these `correctionOnly`.\n\n## Hebrew and civil dates\n\nA yahrzeit is kept by its Hebrew date: `{ year, month, day }`, months as the siddur numbers them (Nisan 1 ... Tishrei 7 ... Adar, or Adar I in a leap year, 12, Adar II 13).\n\nYou may give the civil date of death instead, with whether it was **after sunset**. After sunset moves it to the next Hebrew day. The civil date is resolved to its Hebrew date once, at save, and only the Hebrew date is kept. To see the Hebrew date first:\n\n```\norzarua dates resolve --civil 2019-03-14 --after-sunset --calendar hebrew\n```\n\nA date in the future is refused, as is a day the Hebrew calendar does not have (Adar II in a common year, a 30th the month lacks that year). The question of sunset is only for entering a death date. A yahrzeit itself is kept from the evening before its civil date: the Shabbat list's `beginsEvening` is the civil day before `date`, and the platform counts that evening from nightfall.\n\n## Adar and short months\n\nTwo choices apply only to some dates, and are kept on each yahrzeit:\n\n- `adarObservance`: for a death in plain Adar (month 12 of a common year), which Adar a leap year keeps it in: `adar-i` or `adar-ii`. Left out: Adar I.\n- `shortMonthChoice`: for a death on 30 Cheshvan or 30 Kislev, where a year that month has 29 days keeps it: `same-month-29` or `next-month-1`. Left out: none chosen.\n\n`dates resolve` says which choices apply to a date.\n\n## Which Shabbat a name is read\n\nEach congregation has a week setting:\n\n- `shabbat-to-friday` (the default): a name is read on the Shabbat that begins its week;\n- `sunday-to-shabbat`: on the Shabbat that ends it. A yahrzeit that begins Friday evening or on Shabbat is read that Shabbat; one that begins Saturday night belongs to the next week.\n\n```\norzarua yahrzeits month --month 2026-11\norzarua yahrzeits all\n```\n\n- `yahrzeits month` lists every Shabbat of a month with the names read that week. A name read on the 1st of next month is in next month's list.\n- `yahrzeits all` lists every yahrzeit, soonest next anniversary first, with who keeps it, its id and its version.\n- Both leave out join requests' and resigned members' yahrzeits. A deceased member's stay.\n\n## Recording, correcting, moving\n\n| Task | Command | Effect |\n|---|---|---|\n| Add to a member's record | `orzarua yahrzeits create-for-member` | write |\n| Add for someone no member keeps | `orzarua congregation-yahrzeits create` | write |\n| Correct one on a member's record | `orzarua yahrzeits update-for-member` | destructive |\n| Correct one the congregation keeps | `orzarua congregation-yahrzeits update` | destructive |\n| Remove an office entry | `orzarua yahrzeits delete-for-member`, `orzarua congregation-yahrzeits delete` | destructive |\n| Move between the congregation and a member | `orzarua yahrzeits move` | destructive |\n\n- An edit replaces the name, Hebrew name, date and choices: send them all as read, with your changes, and the version read. A Hebrew name left out is cleared.\n- A move is one change under the same id: from the congregation's list to a member when a family turns up, or from a member's record to the congregation. It never goes between two members directly: move it to the congregation, then on.\n- A member's share can't be moved or removed.\n- Reading yahrzeits needs permission to see members' contact details or to edit member records; changing them needs permission to edit member records.\n\nCongregation-kept yahrzeits send no reminder to a member, because no member keeps them.\n\nRecipes: `orzarua how record a yahrzeit`, `orzarua how move a yahrzeit to the congregation`, `orzarua how correct a yahrzeit date`.\n";
50329
+ var yahrzeits_default = "# Yahrzeits: kept by a member, or by the congregation\n\nA yahrzeit is the anniversary of a death, kept by its Hebrew date. The congregation reads the names on Shabbat, and its automations can remind the family.\n\n## Who keeps a yahrzeit\n\nEvery yahrzeit has a **keeper**:\n\n- **A member's record.** Either the member shared it from the Or Zarua app or their portal (`source: member`), or the office recorded it for them (`source: office`).\n- **The congregation itself**, for someone no member keeps: a founder, a past rabbi. It shows as \"Remembered by\" the congregation, has no member, and no limit on how many.\n\nA member's record holds up to 200 yahrzeits. A record that is a join request holds none.\n\n## Member-shared yahrzeits are the member's\n\nA yahrzeit the member shared belongs to them; the congregation's copy exists by their consent. The office may **correct** it, never remove or move it:\n\n- a correction also changes the member's own copy in their app, and the member sees that the office corrected it (changing only the Hebrew name is not a correction). A yahrzeit entered in the portal has no app copy, so only the congregation's copy changes;\n- the member's own later edit, made after the correction reached their app, wins;\n- stopping sharing deletes the congregation's copy, and leaving the congregation deletes every yahrzeit the member shared. Resigning does too.\n\n`yahrzeits all` marks these `correctionOnly`.\n\n## Hebrew and civil dates\n\nA yahrzeit is kept by its Hebrew date: `{ year, month, day }`, months as the siddur numbers them (Nisan 1 ... Tishrei 7 ... Adar, or Adar I in a leap year, 12, Adar II 13).\n\nYou may give the civil date of death instead, with whether it was **after sunset**. After sunset moves it to the next Hebrew day. The civil date is resolved to its Hebrew date once, at save, and only the Hebrew date is kept. To see the Hebrew date first:\n\n```\norzarua dates resolve --civil 2019-03-14 --after-sunset --calendar hebrew\n```\n\nA date in the future is refused, as is a day the Hebrew calendar does not have (Adar II in a common year, a 30th the month lacks that year). To check a Hebrew date before saving it, and see its civil day, give it in place of the civil date: `orzarua dates resolve --hebrew '{\"year\":5779,\"month\":1,\"day\":8}' --calendar hebrew` answers `civil` (2019-04-13), and refuses a day the year does not have, or a date after tonight, as the save would. The question of sunset is only for entering a death date. A yahrzeit itself is kept from the evening before its civil date: the Shabbat list's `beginsEvening` is the civil day before `date`, and the platform counts that evening from nightfall.\n\n## Adar and short months\n\nTwo choices apply only to some dates, and are kept on each yahrzeit:\n\n- `adarObservance`: for a death in plain Adar (month 12 of a common year), which Adar a leap year keeps it in: `adar-i` or `adar-ii`. Left out: Adar I.\n- `shortMonthChoice`: for a death on 30 Cheshvan or 30 Kislev, where a year that month has 29 days keeps it: `same-month-29` or `next-month-1`. Left out: none chosen.\n\n`dates resolve` says which choices apply to a date.\n\n## Which Shabbat a name is read\n\nEach congregation has a week setting:\n\n- `shabbat-to-friday` (the default): a name is read on the Shabbat that begins its week;\n- `sunday-to-shabbat`: on the Shabbat that ends it. A yahrzeit that begins Friday evening or on Shabbat is read that Shabbat; one that begins Saturday night belongs to the next week.\n\n```\norzarua yahrzeits month --month 2026-11\norzarua yahrzeits all\n```\n\n- `yahrzeits month` lists every Shabbat of a month with the names read that week. A name read on the 1st of next month is in next month's list.\n- `yahrzeits all` lists every yahrzeit, soonest next anniversary first, with who keeps it, its id and its version.\n- Both leave out join requests' and resigned members' yahrzeits. A deceased member's stay.\n\n## Recording, correcting, moving\n\n| Task | Command | Effect |\n|---|---|---|\n| Add to a member's record | `orzarua yahrzeits create-for-member` | write |\n| Add for someone no member keeps | `orzarua congregation-yahrzeits create` | write |\n| Correct one on a member's record | `orzarua yahrzeits update-for-member` | destructive |\n| Correct one the congregation keeps | `orzarua congregation-yahrzeits update` | destructive |\n| Remove an office entry | `orzarua yahrzeits delete-for-member`, `orzarua congregation-yahrzeits delete` | destructive |\n| Move between the congregation and a member | `orzarua yahrzeits move` | destructive |\n\n- An edit replaces the name, Hebrew name, date and choices: send them all as read, with your changes, and the version read. A Hebrew name left out is cleared.\n- A move is one change under the same id: from the congregation's list to a member when a family turns up, or from a member's record to the congregation. It never goes between two members directly: move it to the congregation, then on.\n- A member's share can't be moved or removed.\n- Reading yahrzeits needs permission to see members' contact details or to edit member records; changing them needs permission to edit member records.\n\nCongregation-kept yahrzeits send no reminder to a member, because no member keeps them.\n\n## Many yahrzeits at once\n\n`orzarua yahrzeits export` writes every yahrzeit as CSV (a header row, then one row each), and `orzarua yahrzeits bulk <file>` reads an Excel (`.xlsx`) or CSV file back, plans every row and, once confirmed, adds and changes yahrzeits (`orzarua how update yahrzeits from a spreadsheet`). The first row names the columns. These headers are read (case, spaces and punctuation don't matter); any other column is left out, and the plan lists it (the export's `nextDate` is one: it moves every day, so it is never read):\n{{yahrzeit-columns}}\n\n- **The keeper** is a member's email, or `congregation`. A keeper with no email is written `member:<id>`, which is read too. An email no member has, or two records share, or a join request's, is skipped, naming the keeper column.\n- **How a row finds its yahrzeit.**\n - A row with an `id` changes that yahrzeit. An unknown `id` is skipped. An `id` whose keeper is not the row's is skipped: move it first (`orzarua yahrzeits move`).\n - A row with no `id` matches the keeper's yahrzeit of the same name, ignoring case, spacing, nikkud and joiners (a maqaf or a hyphen reads as a space). One match changes it, none adds a new one, and more than one is `ambiguous`: it is left out and never guessed, so add the `id` column (`orzarua yahrzeits export` writes it).\n - A row matched by its name keeps the name as the record spells it: the name found the record, it is not an edit (on a member's share, a new spelling would be a correction their app takes). The plan notes a different spelling. Only a row with an `id` renames.\n - Two new rows of one name for one keeper are one person when their dates agree (the second is a repeat), and two people when their dates differ. A new name that is the Hebrew name of another of the keeper's yahrzeits is still added, with a note naming that yahrzeit: add its `id` if it is the same person.\n- **Dates.** The Hebrew date is written in words, `15 Nisan 5780` (Sh'vat, Shevat and Shvat; Iyyar and Iyar; Tamuz and Tammuz; Cheshvan, Heshvan and Marcheshvan; Adar, Adar I and Adar II, Adar Aleph and Adar Bet, Adar Rishon and Adar Sheni are all read; Adar I and Adar II only in a leap year, where plain Adar is read as Adar I and the plan says so). The civil date is `YYYY-MM-DD` with `afterSunset` as `yes` or `no`, required with it; with a Hebrew date, `afterSunset` is not read, and the plan says so. Either date is resolved by `dates resolve` at plan time, so the plan shows both dates (a Hebrew date's civil day, and a civil date's Hebrew date), and what is saved is the Hebrew date the plan showed. A Hebrew date the year does not have (30 Cheshvan in a year Cheshvan has 29 days) or one after tonight is skipped at plan time, naming the `hebrewDate` column. Give one of the two on a row, never both.\n- **What changes and what stays.** Only a cell with text changes anything: an empty cell never clears a field, so a sheet cannot remove a Hebrew name. A row that says what the yahrzeit says is `unchanged`. An Adar or short-month choice is refused where the date takes none.\n- **A member's share** (`correctionOnly`) is updated as a correction: the member's app takes it, and they see the office corrected it (a share entered in the member's portal has no app copy: they see the office corrected it). A row that changes only the Hebrew name is not a correction, and the plan says so.\n- **Every row is its own call.** A sheet holds at most 1000 rows. A confirm makes one call per row that does something, under the version `yahrzeits all` read, so one that fails (a yahrzeit changed since) leaves the others standing, and a plan whose yahrzeits changed since answers `plan-changed` before anything is written.\n- **Backed up.** The run's backup holds each changed yahrzeit as it was, and `orzarua restore <backup>` puts them back. A yahrzeit the sheet added is removed by the restore, unless it was changed since (or the member took it into their own list, or it was moved): that one is left as it is and named. Each removal is backed up too, so the restore can itself be put back. A plan that touches a member's share says that putting it back is another correction.\n\nRecipes: `orzarua how record a yahrzeit`, `orzarua how move a yahrzeit to the congregation`, `orzarua how correct a yahrzeit date`, `orzarua how update yahrzeits from a spreadsheet`.\n";
49268
50330
 
49269
50331
  // docs/recipes/add-a-member.md
49270
50332
  var add_a_member_default = '# Add a member\n\nAdd one person\'s record, put them in a household, and invite them to the Or Zarua app.\n\n1. Check you may add records here (it needs permission to edit member records):\n\n ```\n orzarua can members.create\n ```\n\n2. Make sure they are not on file already. Look for their name or email in the list:\n\n ```\n orzarua members list\n ```\n\n3. Find their household, or make one. Note its `householdId`:\n\n ```\n orzarua households list\n orzarua households create --name "Cohen family"\n ```\n\n4. Add the record. Write the phone as people write it in the congregation\'s country, or with + and the country code:\n\n ```\n orzarua members create --first-name Ruth --last-name Cohen --email ruth@example.com --phone "+61 412 345 678" --household-id <householdId>\n ```\n\n It runs at once and answers the new record, with its `memberId` and version 1. It holds the Member role and the default category unless you name others.\n\n5. Invite them, so they can use the Or Zarua app. This sends an email, so it answers a plan first:\n\n ```\n orzarua members invite --member-id <memberId>\n ```\n\n6. Show the user the plan in plain words and ask for an explicit yes. Then, in a separate step:\n\n ```\n orzarua members invite --confirm <planId>\n ```\n\n## If it goes wrong\n\n- **`invalid-input` naming `phone`**: the number does not read as a phone in the congregation\'s country. Write it with + and the country code ("+44 7400 123456") and run step 4 again.\n- **`invalid` naming `householdId`**: the id names no household here. Take it from `orzarua households list`.\n- **`forbidden`**: you lack permission to edit member records, or the record has a role beyond Member and you lack permission to manage roles. `orzarua can members.create` says which, and which roles have it.\n';
@@ -49305,6 +50367,9 @@ var move_a_yahrzeit_to_the_congregation_default = '# Move a yahrzeit to the cong
49305
50367
  // docs/recipes/record-a-yahrzeit.md
49306
50368
  var record_a_yahrzeit_default = '# Record a yahrzeit\n\nAdd a yahrzeit a member asked the office to keep, or one the congregation keeps itself, for someone no member keeps: a founder, a past rabbi. Read `orzarua docs yahrzeits` for the rules.\n\n1. If you have the civil date of death, see the Hebrew date it resolves to. Say whether the death was after sunset: that moves it to the next Hebrew day.\n\n ```\n orzarua dates resolve --civil 2019-03-14 --after-sunset --calendar hebrew\n ```\n\n The answer also says whether an Adar or short-month choice applies to this date. If one does, ask the user which they want.\n\n2. Write the yahrzeit in `yahrzeit.json`: the deceased\'s name, the Hebrew name if known, and the date, either as the civil date with after sunset, or as a Hebrew date:\n\n ```json\n { "name": "Avraham Levi", "hebrewName": "Avraham ben Yitzchak", "date": { "kind": "gregorian", "gregorian": "2019-03-14", "afterSunset": true } }\n ```\n\n ```json\n { "name": "Avraham Levi", "date": { "kind": "hebrew", "hebrew": { "year": 5779, "month": 1, "day": 8 } }, "adarObservance": null }\n ```\n\n Months are numbered as the siddur numbers them: Nisan 1 ... Tishrei 7 ... Adar, or Adar I in a leap year, 12, Adar II 13.\n\n3. **Kept by a member.** Find the member\'s record, then add it there:\n\n ```\n orzarua members list\n orzarua yahrzeits create-for-member --member-id <memberId> --body yahrzeit.json\n ```\n\n It is the office\'s entry, not a share: the member sees it, and may take it into their own list in the app.\n\n4. **Kept by the congregation**, for someone no member keeps:\n\n ```\n orzarua congregation-yahrzeits create --body yahrzeit.json\n ```\n\n Either way it runs at once and answers the new yahrzeit at version 1, with its id.\n\n5. Check where it falls: the month list shows the Shabbat its name is read on.\n\n ```\n orzarua yahrzeits month --month 2026-11\n ```\n\n## If it goes wrong\n\n- **`invalid`: the date is in the future, or not a day of the Hebrew calendar** (Adar II in a common year, a 30th the month lacks that year). Check the date with `orzarua dates resolve`, and send the Hebrew date it answers.\n- **`invalid`: the record already holds 200 yahrzeits.** Remove one the office keeps there (`orzarua yahrzeits delete-for-member`), or keep this one on the congregation\'s list.\n- **`invalid`: the record is a join request.** It holds no yahrzeits: approve the request first (`orzarua members approve`).\n';
49307
50369
 
50370
+ // docs/recipes/update-yahrzeits-from-a-spreadsheet.md
50371
+ var update_yahrzeits_from_a_spreadsheet_default = '# Update yahrzeits from a spreadsheet\n\nAdd or change many yahrzeits from an Excel (`.xlsx`) or CSV file: export what is there, edit the sheet, plan the whole file, show the user what will happen to every row, then confirm. Read `orzarua docs yahrzeits` for the columns, how rows are matched and what a member\'s share allows.\n\n1. Export every yahrzeit. The file is the sheet to edit: one row each, with its `id`, its keeper (a member\'s email, or `congregation`), the name, the Hebrew date and any Adar or short-month choice.\n\n ```\n orzarua yahrzeits export --out yahrzeits.csv\n ```\n\n Without `--out` the CSV text is the answer. Open it in Excel or Sheets, change what needs changing, and add the new people as new rows with no `id`.\n\n2. Plan the sheet. Nothing is written:\n\n ```\n orzarua yahrzeits bulk yahrzeits.csv\n ```\n\n The answer is a plan (exit 7): every row, numbered as the sheet numbers it, with what happens to it:\n\n - **update**: the yahrzeit its `id` names, or the keeper\'s one of that name, with each change as `field: from \u2192 to`;\n - **create**: a new yahrzeit, kept by the member or by the congregation;\n - **unchanged**: it already reads as the sheet says;\n - **ambiguous**: no `id`, and the keeper has more than one yahrzeit of that name. It is never guessed: the row is left out, with the ids it could be;\n - **skip**: with its reason and its column, such as `column A (Keeper)`.\n\n A row\'s dates are shown both ways: the Hebrew date that will be saved, and its civil day (or the civil date and after-sunset that were written).\n\n A row with no `id` that found its yahrzeit by name keeps the name as the record spells it; the plan notes a different spelling. To rename, put the `id` in the row.\n\n3. **If a row is `ambiguous`** (two yahrzeits for one keeper have the same name, such as two Sarah Cohens), the plan says to add the `id` column: `orzarua yahrzeits export` writes it. Put the id of the one meant in that row and plan again.\n\n4. **Dates.** Write the Hebrew date in words (`15 Nisan 5780`, `3 Adar II 5784`) in `hebrewDate`, or the civil date as `YYYY-MM-DD` in `civilDate` with `afterSunset` as `yes` or `no`. Both are checked by `dates resolve` when the plan is made: a Hebrew date the year does not have (30 Cheshvan in a year Cheshvan has 29 days) or one after tonight is skipped there and then, naming the `hebrewDate` column, never at the confirm. A civil date with no `afterSunset` is skipped, naming that column. In a leap year plain `Adar` is read as Adar I, and the plan says so: write `Adar II` if that was meant. Give one of the two dates, never both.\n\n5. **A member\'s share.** A yahrzeit the member shared (`correctionOnly`) is theirs: the office may correct it. The plan notes that the member\'s app takes the correction and they see the office corrected it. A row that changes only the Hebrew name is not a correction, and the plan says so.\n\n6. Show the user the plan in plain words. Changing yahrzeits is destructive, so ask for an explicit yes. Then, in a separate step:\n\n ```\n orzarua yahrzeits bulk --confirm <planId>\n ```\n\n Each row that does something is a call of its own, so one that fails leaves the others standing. The answer says how many went and names the backup it left (`backup`).\n\n7. Check the result with `orzarua yahrzeits all`.\n\n## Put it back\n\nThe confirm\'s answer names the backup (`backup`; `orzarua backups` lists them too). To put the changed yahrzeits back as they were, and remove the ones the sheet added:\n\n```\norzarua restore <backup>\norzarua restore --confirm <planId>\n```\n\nA yahrzeit the sheet added is removed by the restore, unless it was changed since. A yahrzeit someone changed since the sheet (or one it added that the member took into their own list, or that was moved) is left as it is now, and the plan names it. Each removal is backed up too, so the restore can itself be put back. See `orzarua how undo a change`.\n\n## Over MCP\n\nAn agent plans the same file with `plan`, `command: "yahrzeits.bulk"` and `input: { "file": "<path>", "map": "<path>", "sheet": "<name or number>" }` (only `file` is needed), shows the user the plan, and runs it with `apply` after their yes. `run` with `command: "yahrzeits.export"` answers the CSV text.\n\n## If it goes wrong\n\n- **A row is skipped for its keeper**: the email matches no member (or matches two records, or a join request). Write the member\'s email as it is on their record, `member:<id>` for one record among several with one email, or `congregation`.\n- **An `id` is skipped as kept by someone else**: the row\'s keeper is not the yahrzeit\'s. Move it first (`orzarua yahrzeits move`), or correct the keeper cell.\n- **It refuses to read the sheet** (exit 2, "The first row needs a column headed Keeper ... and a column headed Name"): the headers are not ones the reader knows, and the answer lists the ones it found. Name the right columns in a map file: `--map map.json` (written with the columns found when it is not there yet; edit it and plan again). A header the reader does not use at all is listed in the plan as an ignored column.\n- **A sheet of more than 1000 rows** is refused: split it.\n- **`plan-changed`** on the confirm: a yahrzeit changed between the plan and the confirm. Nothing was done. Show the user the new plan in the answer, and confirm that one after their yes.\n';
50372
+
49308
50373
  // docs/recipes/recover-from-a-conflict.md
49309
50374
  var recover_from_a_conflict_default = "# Recover from a conflict\n\nSomeone else changed a record while you were working on it. Records carry a `version`, and a change sent with an old one is refused rather than overwriting their work.\n\n1. Recognise it. The command exits 6 with one of:\n\n - `conflict`: the change was sent with an old version, or another change ran at the same moment. Nothing was saved.\n - `plan-changed`: the record changed between the plan and the confirm. Nothing was saved, and the answer holds a new plan.\n\n2. Read the record again, as it is now:\n\n ```\n orzarua members get --member-id <memberId>\n ```\n\n The hint in the error names the read to use for each command (for a yahrzeit, `orzarua yahrzeits all`; for a household, `orzarua households list`).\n\n3. Compare it with what you meant to change. Someone else's edit may already do it, or may change what the user wants. If in doubt, ask the user.\n\n4. Apply your change to what is there now, with the fresh version, and send it again. Never resend the old body unchanged: it would undo the other person's edit.\n\n ```\n orzarua members update --member-id <memberId> --body member.json\n ```\n\n5. A change that needs a plan answers a new one. Show the user the new plan in plain words and ask for an explicit yes; the yes they gave the old plan does not carry over. Then, in a separate step:\n\n ```\n orzarua members update --confirm <planId>\n ```\n\n For `plan-changed`, the new plan is already in the answer: show it to the user, and confirm its id once they say yes.\n\n## If it goes wrong\n\n- **It conflicts again**: someone is editing the same record right now. Wait a moment, then start again from step 2.\n- **`conflict` from `orzarua members import`**: another import is running. Wait for it to finish (about a minute at most), then run the dry run again before committing.\n- **A network failure on the change**: it may have landed. Read the record first (step 2), and repeat the change only if it did not.\n";
49310
50375
 
@@ -49312,7 +50377,7 @@ var recover_from_a_conflict_default = "# Recover from a conflict\n\nSomeone else
49312
50377
  var refund_a_ticket_default = '# Refund a ticket\n\nGive money back to a buyer as the office: some tickets, or everything left of the order. Read `orzarua docs tickets` for the rules. A refund goes back to the buyer\'s card and can\'t be undone, so it is a plan the user confirms.\n\n1. Find the order, what it bought, what is already refunded, and its version.\n\n ```\n orzarua tickets list-orders --event-id <eventId>\n ```\n\n Each order lists its `lines`, `refundedPerLine` and `version`.\n\n2. Make the plan: how many tickets of each line, at the version read.\n\n ```\n orzarua tickets refund --event-id <eventId> --order-id <orderId> --body \'{"scope":"tickets","perLine":[1],"version":<version>}\'\n ```\n\n The plan says how much goes back to the buyer\'s card. `scope: "tickets"` gives back the tickets\' prices and keeps the booking fees; `{"scope":"everything","version":<version>}` gives back what is left of the order, booking fees included. Show the plan to the user in plain words and ask for an explicit yes.\n\n3. Once the user has said yes, confirm the plan\'s id.\n\n ```\n orzarua tickets refund --confirm <planId>\n ```\n\n The answer is the order with what is now refunded. The buyer is emailed, and the places can be taken again. The money shows in `orzarua ledger statement --from <day> --to <day>` once Stripe has made the refund.\n\nA buyer can cancel their own tickets while the event\'s refund policy allows (`tickets cancel`). To refund everyone, cancel the event (`orzarua docs events`).\n\n## If it goes wrong\n\n- **More tickets than are left on a line.** The plan already says it will be refused, and the confirm is (`conflict`, exit 6): nothing went back. A line gives back at most its quantity less `refundedPerLine`. Make a new plan with the right number.\n- **`conflict` because the order changed.** Someone checked a ticket in or refunded it since you read the version. Read the orders again and make a new plan; never resend the old one.\n- **`payments-unavailable`.** Stripe could not make the refund. Try again later: the same version makes the same refund, never a second one.\n- **"Nothing is left to refund".** Everything has been refunded already, or the order took no money (tickets at no charge): refund its tickets by count to release their places.\n';
49313
50378
 
49314
50379
  // docs/recipes/remind-families-of-a-yahrzeit.md
49315
- var remind_families_of_a_yahrzeit_default = '# Remind families of a yahrzeit\n\nMessage each mourner three days before a yahrzeit, by themselves, from the gallery\'s yahrzeit reminder: write it, preview it, test it on yourself, check the cost, then turn it on. Read `orzarua docs automations` first: nothing sends until the automation and the congregation\'s switch are both on, and turning either on is a plan the user confirms.\n\n1. Make sure email can be sent (`orzarua comms get-settings`; set the reply-to address if it is missing, as in `orzarua docs send-an-announcement`).\n\n2. Write the reminder as `yahrzeit.json`: the yahrzeit trigger three days before, a send time of 9:00 (`540`), and one message step with the email and push text (the example in `orzarua docs automations`). Save it with a version of 0.\n\n ```\n orzarua automations create --body yahrzeit.json\n ```\n\n A placeholder the trigger does not have, such as `{deceased_name}` in a birthday automation, is refused with the step and the placeholder named; fix the text and save again. The automation is saved turned off. Note its `automationId`, its `version` and its step\'s `stepId`.\n\n3. Preview the step with a sample yahrzeit. Nothing is saved or sent.\n\n ```\n orzarua automations preview --body preview.json\n ```\n\n `preview.json` holds `{ "automation": <the draft>, "stepIndex": 0, "sample": "yahrzeit" }`.\n\n4. Send yourself the email, to see how it arrives. It reaches a person, so it answers a plan that names the file with the step as it renders: show it to the user and ask for an explicit yes, then confirm.\n\n ```\n orzarua automations test --automation-id <automationId> --step-id <stepId> --channels email\n orzarua automations test --confirm <planId>\n ```\n\n5. Check who it would message in the next 30 days and what that costs.\n\n ```\n orzarua automations coming-up --body draft.json\n ```\n\n6. Turn it on, with the cost confirmed and the version read. The plan says how many people it will message, on which channels and what it costs; without `--confirm-cost` the confirm is refused, so make the plan again with it. Show the plan to the user and ask for an explicit yes.\n\n ```\n orzarua automations enable --automation-id <automationId> --version <version> --confirm-cost\n orzarua automations enable --confirm <planId>\n ```\n\n It sends nothing at that moment: the daily sweep sends each reminder at its due time, and only while the congregation\'s switch is on (`orzarua automations update-settings --enabled --version <settingsVersion>`, another plan). A reminder due before it was turned on is never sent.\n\n7. Watch what it did: `orzarua automations runs --automation-id <automationId>`, and `orzarua automation-runs get --run-id <runId>` for each delivery and why any did not go.\n\n## If it goes wrong\n\n- **`invalid`: "{deceased_name} needs a yahrzeit trigger".** The text uses a placeholder the triggers do not have: fix it, or add the trigger.\n- **`invalid`: "Confirm what it will cost to turn this automation on."** The enable plan was made without `--confirm-cost`: check the cost with `coming-up`, and plan again with it.\n- **`conflict`.** The automation changed: read it again (`automations get`) and plan with its new version.\n- **A reminder did not go.** `automation-runs get` gives the reason for each delivery: opted out, no email address, not enough credit.\n';
50380
+ var remind_families_of_a_yahrzeit_default = '# Remind families of a yahrzeit\n\nMessage each mourner three days before a yahrzeit, by themselves, from the gallery\'s yahrzeit reminder: make it from the preset, preview it, test it on yourself, check the cost, then turn it on. Read `orzarua docs automations` first: nothing sends until the automation and the congregation\'s switch are both on, and turning either on is a plan the user confirms.\n\n1. Make sure email can be sent (`orzarua comms get-settings`; set the reply-to address if it is missing, as in `orzarua docs send-an-announcement`).\n\n2. Start from the gallery\'s yahrzeit reminder. `automations presets` lists the presets; `--from` makes one from the preset you name, and any other flag changes only what it names (`--name "Yahrzeit reminder (office)"`). The text and timing are the preset\'s: three days before a yahrzeit, at 9:00, push to everyone it reaches and email to the rest.\n\n ```\n orzarua automations presets\n orzarua automations create --from yahrzeit-reminder\n ```\n\n A name that is not a preset is refused with the nearest one ("Did you mean yahrzeit-reminder?") and the list: fix it and run it again. The automation is saved turned off. Note its `automationId`, its `version` and its step\'s `stepId`. To write your own text instead, save a body with a version of 0 (`orzarua automations create --body yahrzeit.json`, the example in `orzarua docs automations`).\n\n3. Preview the step with a sample yahrzeit. Nothing is saved or sent.\n\n ```\n orzarua automations preview --body preview.json\n ```\n\n `preview.json` holds `{ "automation": <the draft: the saved automation\'s fields, from `orzarua automations get`>, "stepIndex": 0, "sample": "yahrzeit" }`.\n\n4. Send yourself the email, to see how it arrives. It reaches a person, so it answers a plan that names the file with the step as it renders: show it to the user and ask for an explicit yes, then confirm.\n\n ```\n orzarua automations test --automation-id <automationId> --step-id <stepId> --channels email\n orzarua automations test --confirm <planId>\n ```\n\n5. Check who it would message in the next 30 days and what that costs.\n\n ```\n orzarua automations coming-up --body draft.json\n ```\n\n6. Turn it on, with the cost confirmed and the version read. The plan says how many people it will message, on which channels and what it costs; without `--confirm-cost` the confirm is refused, so make the plan again with it. Show the plan to the user and ask for an explicit yes.\n\n ```\n orzarua automations enable --automation-id <automationId> --version <version> --confirm-cost\n orzarua automations enable --confirm <planId>\n ```\n\n It sends nothing at that moment: the daily sweep sends each reminder at its due time, and only while the congregation\'s switch is on (`orzarua automations update-settings --enabled --version <settingsVersion>`, another plan). A reminder due before it was turned on is never sent.\n\n7. Watch what it did: `orzarua automations runs --automation-id <automationId>`, and `orzarua automation-runs get --run-id <runId>` for each delivery and why any did not go.\n\n8. To stop it, turn it off (`orzarua automations disable --automation-id <automationId> --version <version>`, at once, and nobody is told), or delete it (`orzarua automations delete`, a plan: its runs stay in the log, and a backup can make it again).\n\n## If it goes wrong\n\n- **`invalid-input`: "There is no preset".** `--from` names a preset that is not in the gallery: use one of the ids `automations presets` lists.\n- **`invalid`: "{deceased_name} needs a yahrzeit trigger".** Only in an automation you wrote yourself: the text uses a placeholder the triggers do not have. Fix it, or add the trigger.\n- **`invalid`: "Confirm what it will cost to turn this automation on."** The enable plan was made without `--confirm-cost`: check the cost with `coming-up`, and plan again with it.\n- **`conflict`.** The automation changed: read it again (`automations get`) and plan with its new version.\n- **A reminder did not go.** `automation-runs get` gives the reason for each delivery: opted out, no email address, not enough credit.\n';
49316
50381
 
49317
50382
  // docs/recipes/schedule-a-message.md
49318
50383
  var schedule_a_message_default = "# Schedule a message\n\nSchedule a newsletter for a later time in the congregation's own clock, and unschedule it if plans change. Read `orzarua docs messages` for the quiet windows and the credit. A scheduled message goes without anyone pressing anything, so scheduling it is a plan the user confirms.\n\n1. Make sure email can be sent (`orzarua comms get-settings`; set the reply-to address if it is missing, as in `orzarua docs send-an-announcement`).\n\n2. Write the newsletter as a draft.\n\n ```\n orzarua messages create --body newsletter.json\n ```\n\n3. Check when it would really go: a time inside Shabbat or Yom Tov moves to when the window ends, and the night holds SMS and push.\n\n ```\n orzarua messages timing --body timing.json\n ```\n\n4. Make the schedule's plan, with the version you read and the time in the congregation's own clock (`YYYY-MM-DDTHH:mm`).\n\n ```\n orzarua messages schedule --message-id <messageId> --version <version> --at 2026-12-14T09:00\n ```\n\n The plan says who it reaches on each channel, what it costs (reserved now, held until it goes), where the rendered email is, and when it goes if a quiet window moves it. Show it to the user and ask for an explicit yes.\n\n5. Once the user has said yes, confirm the plan's id.\n\n ```\n orzarua messages schedule --confirm <planId>\n ```\n\n The answer is the message, scheduled, and when it goes. The audience is read when it goes.\n\n6. To stop it before its time, unschedule it (a plan, then its confirm): it is a draft again, and its credit is given back.\n\n ```\n orzarua messages unschedule --message-id <messageId> --version <version>\n orzarua messages unschedule --confirm <planId>\n ```\n\n## If it goes wrong\n\n- **`invalid`: \"Choose a time at least a minute from now.\"** The time has passed, or is under a minute away. The plan is made either way, since the time is checked when it is sent: make a new plan with a later time.\n- **`invalid`: \"Choose a time within a year.\"** Choose an earlier date.\n- **`insufficient-credit`.** The credit does not cover the paid channels: top up first, or choose email only.\n- **`conflict`.** The message changed since its version was read: read it again and plan with the new version.\n";
@@ -49387,6 +50452,7 @@ var RECIPES = {
49387
50452
  "import-a-spreadsheet": import_a_spreadsheet_default,
49388
50453
  "give-someone-a-role": give_someone_a_role_default,
49389
50454
  "record-a-yahrzeit": record_a_yahrzeit_default,
50455
+ "update-yahrzeits-from-a-spreadsheet": update_yahrzeits_from_a_spreadsheet_default,
49390
50456
  "move-a-yahrzeit-to-the-congregation": move_a_yahrzeit_to_the_congregation_default,
49391
50457
  "correct-a-yahrzeit-date": correct_a_yahrzeit_date_default,
49392
50458
  "undo-a-change": undo_a_change_default,
@@ -49515,6 +50581,8 @@ var ALIASES = {
49515
50581
  var FILLS = {
49516
50582
  "capabilities": () => Object.entries(CAPABILITY_WORDS).map(([capability, words]) => `- \`${capability}\`: ${words}`).join("\n"),
49517
50583
  "import-columns": () => IMPORT_FIELDS.map((field) => `- \`${field}\`: ${IMPORT_ALIASES[field].map((alias) => `"${alias}"`).join(", ")}`).join("\n"),
50584
+ "yahrzeit-columns": () => YAHRZEIT_COLUMNS.map((column) => `- \`${column}\`: ${YAHRZEIT_ALIASES[column].map((alias) => `"${alias}"`).join(", ")}`).join("\n"),
50585
+ "automation-presets": presetList,
49518
50586
  "preset-roles": () => PRESET_ROLES.map((role) => `- **${role.name}** (\`${role.roleId}\`): ${role.capabilities.length === 0 ? "nothing beyond what any member may do" : role.capabilities.join(", ")}`).join("\n")
49519
50587
  };
49520
50588
  function filled(text2) {
@@ -52051,16 +53119,16 @@ function setPath(into, root, assignment) {
52051
53119
  const path = assignment.slice(0, at).split(".");
52052
53120
  const raw = assignment.slice(at + 1);
52053
53121
  let schema = root;
52054
- for (const step of path) schema = schema?.properties?.[step];
53122
+ for (const step2 of path) schema = schema?.properties?.[step2];
52055
53123
  const types = typesOf(schema);
52056
53124
  const value = types.includes("array") && schema !== void 0 ? coerceList([raw], schema) : coerce(raw, schema);
52057
53125
  if (!value.ok) return problem("set", `--set ${path.join(".")} ${value.message}`);
52058
53126
  let target = into;
52059
- for (const step of path.slice(0, -1)) {
52060
- const next = target[step];
52061
- if (next !== void 0 && next !== null && !isRecord(next)) return problem("set", `--set ${path.join(".")}: "${step}" is already a value, not an object`);
53127
+ for (const step2 of path.slice(0, -1)) {
53128
+ const next = target[step2];
53129
+ if (next !== void 0 && next !== null && !isRecord(next)) return problem("set", `--set ${path.join(".")}: "${step2}" is already a value, not an object`);
52062
53130
  const child = isRecord(next) ? { ...next } : {};
52063
- target[step] = child;
53131
+ target[step2] = child;
52064
53132
  target = child;
52065
53133
  }
52066
53134
  target[path[path.length - 1] ?? ""] = value.value;
@@ -52512,8 +53580,10 @@ function commandLine(suggestion) {
52512
53580
  const file2 = wf !== null && typeof suggestion.input["file"] === "string" ? [quote(suggestion.input["file"])] : [];
52513
53581
  const flags = Object.entries(suggestion.input).flatMap(([key, value]) => {
52514
53582
  if (value === void 0 || value === null || wf !== null && key === "file" && file2.length > 0) return [];
52515
- const text2 = typeof value === "string" ? value : JSON.stringify(value);
52516
- return [`--${flagFor({ id: suggestion.commandId }, key)} ${quote(text2)}`];
53583
+ const flag = flagFor({ id: suggestion.commandId }, key);
53584
+ if (typeof value === "boolean") return [value ? `--${flag}` : `--no-${flag}`];
53585
+ if (Array.isArray(value) && value.every((item) => typeof item === "string")) return value.map((item) => `--${flag} ${quote(item)}`);
53586
+ return [`--${flag} ${quote(typeof value === "string" ? value : JSON.stringify(value))}`];
52517
53587
  });
52518
53588
  return ["orzarua", ...words, ...file2, ...flags].join(" ");
52519
53589
  }
@@ -52527,6 +53597,7 @@ function confirmCommand(made) {
52527
53597
  const base = made.restoreOf !== void 0 ? "orzarua restore" : `orzarua ${words}`;
52528
53598
  return `${base}${made.env === "prod" ? "" : ` --env ${made.env}`} --confirm ${made.planId}`;
52529
53599
  }
53600
+ var lineNext = (s) => ({ command: commandLine(s), why: s.why });
52530
53601
  function withLines(error62) {
52531
53602
  if (error62.plan !== void 0 && (error62.code === "confirm-required" || error62.code === "plan-changed")) {
52532
53603
  const which = error62.code === "plan-changed" ? "the new plan" : "the plan";
@@ -52538,13 +53609,11 @@ function withLines(error62) {
52538
53609
  if (error62.code === "confirm-required") {
52539
53610
  return { ...error62, hint: "Run the command without --confirm to make a plan, show it to the user, and confirm it with --confirm <planId> once they say yes." };
52540
53611
  }
52541
- if (error62.next !== void 0 && error62.next.length > 0) {
52542
- return { ...error62, hint: `${error62.hint} Run: ${error62.next.map((s) => `\`${commandLine(s)}\``).join(", then ")}.` };
52543
- }
52544
- return error62;
53612
+ return null;
52545
53613
  }
52546
53614
  function forTheLine(result) {
52547
- return result.ok ? result : { ok: false, error: withLines(result.error) };
53615
+ const own2 = result.ok ? null : withLines(result.error);
53616
+ return own2 === null ? withNext(result, lineNext) : { ok: false, error: own2 };
52548
53617
  }
52549
53618
 
52550
53619
  // src/cli/commands/plans.ts
@@ -52942,26 +54011,25 @@ async function prepare(rt, globals, needs) {
52942
54011
  }
52943
54012
 
52944
54013
  // src/cli/workflow-files.ts
52945
- var PAGE = "members.importSheet";
52946
54014
  var isMissing = (error62) => error62 instanceof Error && "code" in error62 && error62.code === "ENOENT";
52947
54015
  var baseName = (path) => path.split(/[\\/]/).pop() ?? path;
52948
- function readMapping(path, text2) {
54016
+ function readMapping(spec, path, text2) {
52949
54017
  let parsed;
52950
54018
  try {
52951
54019
  parsed = JSON.parse(text2);
52952
54020
  } catch {
52953
- return fail2(cliError("invalid-input", `${path} is not JSON.`, { page: PAGE, hint: 'It maps each header to a field: {"Forename": "firstName", "Notes": null}.' }));
54021
+ return fail2(cliError("invalid-input", `${path} is not JSON.`, { page: spec.page, hint: 'It maps each header to a field: {"Forename": "firstName", "Notes": null}.' }));
52954
54022
  }
52955
- if (!isRecord(parsed)) return fail2(cliError("invalid-input", `${path} must be a JSON object of headers and fields.`, { page: PAGE, hint: 'For example {"Forename": "firstName", "Notes": null}.' }));
52956
- const mapping = importMappingSchema.safeParse(parsed);
54023
+ if (!isRecord(parsed)) return fail2(cliError("invalid-input", `${path} must be a JSON object of headers and fields.`, { page: spec.page, hint: 'For example {"Forename": "firstName", "Notes": null}.' }));
54024
+ const mapping = spec.mapping.safeParse(parsed);
52957
54025
  if (mapping.success) return { ok: true, data: mapping.data };
52958
- const [header, value] = Object.entries(parsed).find(([key, field]) => !importMappingSchema.safeParse({ [key]: field }).success) ?? ["", null];
52959
- return fail2(cliError("invalid-input", `${path}: ${JSON.stringify(header)} is mapped to ${JSON.stringify(value)}, which is not a field the import reads.`, {
52960
- page: PAGE,
52961
- hint: `Use one of ${IMPORT_FIELDS.join(", ")}, or null to leave the column out.`
54026
+ const [header, value] = Object.entries(parsed).find(([key, field]) => !spec.mapping.safeParse({ [key]: field }).success) ?? ["", null];
54027
+ return fail2(cliError("invalid-input", `${path}: ${JSON.stringify(header)} is mapped to ${JSON.stringify(value)}, which is not a ${spec.noun}.`, {
54028
+ page: spec.page,
54029
+ hint: `Use one of ${spec.fields.join(", ")}, or null to leave the column out.`
52962
54030
  }));
52963
54031
  }
52964
- var membersImportSheet2 = async (rt, given) => {
54032
+ async function loadSheet(rt, given, spec) {
52965
54033
  const file2 = String(given["file"]);
52966
54034
  const sheet = typeof given["sheet"] === "string" ? given["sheet"] : null;
52967
54035
  const map2 = typeof given["map"] === "string" ? given["map"] : null;
@@ -52969,43 +54037,79 @@ var membersImportSheet2 = async (rt, given) => {
52969
54037
  try {
52970
54038
  bytes = rt.readBytes(file2);
52971
54039
  } catch (error62) {
52972
- return fail2(cliError(isMissing(error62) ? "not-found" : "invalid-input", `Could not read ${file2}${isMissing(error62) ? ": there is no such file" : ""}.`, { page: PAGE, hint: "Give the path of an .xlsx or .csv file." }));
54040
+ return fail2(cliError(isMissing(error62) ? "not-found" : "invalid-input", `Could not read ${file2}${isMissing(error62) ? ": there is no such file" : ""}.`, { page: spec.page, hint: "Give the path of an .xlsx or .csv file." }));
52973
54041
  }
52974
54042
  const name = baseName(file2);
52975
54043
  const table2 = await readTable(bytes, name, sheet);
52976
- if (!table2.ok) return fail2({ ...table2.error, docs: docsForPage(PAGE) });
54044
+ if (!table2.ok) return fail2({ ...table2.error, docs: docsForPage(spec.page) });
52977
54045
  const read2 = name === file2 ? table2.data : { ...table2.data, path: file2 };
52978
- const input2 = { table: read2, ...given["createOnly"] === true ? { createOnly: true } : {} };
54046
+ const input2 = { table: read2 };
52979
54047
  if (map2 === null) return { ok: true, data: input2 };
52980
54048
  if (!map2.toLowerCase().endsWith(".json")) {
52981
- return fail2(cliError("invalid-input", `The map file must be a .json file, not ${map2}.`, { page: PAGE, hint: "Name a .json file, such as map.json: one that is not there yet is written with the columns found, to edit." }));
54049
+ return fail2(cliError("invalid-input", `The map file must be a .json file, not ${map2}.`, { page: spec.page, hint: "Name a .json file, such as map.json: one that is not there yet is written with the columns found, to edit." }));
52982
54050
  }
52983
54051
  let text2 = null;
52984
54052
  try {
52985
54053
  text2 = rt.readFile(map2);
52986
54054
  } catch (error62) {
52987
- if (!isMissing(error62)) return fail2(cliError("invalid-input", `Could not read the map file ${map2}.`, { page: PAGE }));
54055
+ if (!isMissing(error62)) return fail2(cliError("invalid-input", `Could not read the map file ${map2}.`, { page: spec.page }));
52988
54056
  }
52989
54057
  if (text2 === null) {
52990
54058
  try {
52991
- rt.writeFile(map2, `${JSON.stringify(mappingOf(table2.data), null, 2)}
54059
+ rt.writeFile(map2, `${JSON.stringify(spec.propose(table2.data), null, 2)}
52992
54060
  `);
52993
54061
  } catch {
52994
- return fail2(cliError("invalid-input", `Could not write the proposed column mapping to ${map2}.`, { page: PAGE, hint: "Check the folder is there and can be written to." }));
54062
+ return fail2(cliError("invalid-input", `Could not write the proposed column mapping to ${map2}.`, { page: spec.page, hint: "Check the folder is there and can be written to." }));
52995
54063
  }
52996
54064
  return { ok: true, data: input2, warnings: [`Wrote the proposed column mapping to ${map2}: edit it and plan again`] };
52997
54065
  }
52998
- const mapping = readMapping(map2, text2);
54066
+ const mapping = readMapping(spec, map2, text2);
52999
54067
  if (!mapping.ok) return mapping;
53000
54068
  return { ok: true, data: { ...input2, mapping: mapping.data } };
54069
+ }
54070
+ var MEMBERS = {
54071
+ page: "members.importSheet",
54072
+ noun: "field the import reads",
54073
+ fields: IMPORT_FIELDS,
54074
+ mapping: importMappingSchema,
54075
+ propose: (table2) => mappingOf(table2)
54076
+ };
54077
+ var YAHRZEITS = {
54078
+ page: "yahrzeits.bulk",
54079
+ noun: "column the yahrzeit sheet reads",
54080
+ fields: YAHRZEIT_COLUMNS,
54081
+ mapping: yahrzeitMappingSchema,
54082
+ propose: (table2) => yahrzeitMappingOf(table2)
54083
+ };
54084
+ var membersImportSheet2 = async (rt, given) => {
54085
+ const loaded = await loadSheet(rt, given, MEMBERS);
54086
+ return loaded.ok && given["createOnly"] === true ? { ...loaded, data: { ...loaded.data, createOnly: true } } : loaded;
54087
+ };
54088
+ var yahrzeitsBulk2 = (rt, given) => loadSheet(rt, given, YAHRZEITS);
54089
+ var automationsCreateFromPreset2 = (_rt, given) => {
54090
+ const fields = isRecord(given["fields"]) ? given["fields"] : {};
54091
+ return Promise.resolve({ ok: true, data: { presetId: given["from"], overrides: fields } });
54092
+ };
54093
+ var LOADERS = {
54094
+ "members.importSheet": membersImportSheet2,
54095
+ "yahrzeits.bulk": yahrzeitsBulk2,
54096
+ "automations.createFromPreset": automationsCreateFromPreset2
53001
54097
  };
53002
- var LOADERS = { "members.importSheet": membersImportSheet2 };
53003
54098
  var LOADER_INPUTS = {
53004
54099
  "members.importSheet": external_exports.strictObject({
53005
54100
  file: external_exports.string().min(1).describe("The member list: an .xlsx or .csv file."),
53006
54101
  sheet: external_exports.string().optional().describe("The sheet of a workbook, by name or by number (1 is the first). The first sheet when left out."),
53007
54102
  map: external_exports.string().optional().describe("A JSON file mapping each header to a field. If it is not there yet, it is written with the columns found, to edit."),
53008
54103
  createOnly: external_exports.boolean().optional().describe(CREATE_ONLY_WORDS)
54104
+ }),
54105
+ "yahrzeits.bulk": external_exports.strictObject({
54106
+ file: external_exports.string().min(1).describe("The yahrzeit list: an .xlsx or .csv file, such as the one yahrzeits export writes."),
54107
+ sheet: external_exports.string().optional().describe("The sheet of a workbook, by name or by number (1 is the first). The first sheet when left out."),
54108
+ map: external_exports.string().optional().describe("A JSON file mapping each header to a column. If it is not there yet, it is written with the columns found, to edit.")
54109
+ }),
54110
+ "automations.createFromPreset": external_exports.strictObject({
54111
+ from: external_exports.string().min(1).describe("The preset to start from: one of those `automations presets` lists."),
54112
+ fields: external_exports.record(external_exports.string(), external_exports.unknown()).optional().describe("Fields of automations.create to change from the preset, by name. On the command line these are its flags, --set and --body.")
53009
54113
  })
53010
54114
  };
53011
54115
  var frontInputOf = (wf) => LOADER_INPUTS[wf.id] ?? wf.input;
@@ -53055,8 +54159,8 @@ function humanBackup(data) {
53055
54159
  if (!isRecord(entry)) return " ?";
53056
54160
  if (entry["kind"] === "restored") return ` ${String(entry["at"])} restored by plan ${String(entry["planId"])}`;
53057
54161
  if (entry["kind"] === "started") {
53058
- const step = Number(entry["step"]) + 1;
53059
- return ` ${String(entry["at"])} step ${String(step)} sent${answered.has(entry["step"]) ? "" : "; its answer was not recorded, so it may have changed something"}`;
54162
+ const step2 = Number(entry["step"]) + 1;
54163
+ return ` ${String(entry["at"])} step ${String(step2)} sent${answered.has(entry["step"]) ? "" : "; its answer was not recorded, so it may have changed something"}`;
53060
54164
  }
53061
54165
  return ` ${String(entry["at"])} ${entry["ok"] === true ? "ok" : "failed"}`;
53062
54166
  };
@@ -53468,7 +54572,8 @@ async function runContract(rt, cmd, argv2, globals, finish2, refuse) {
53468
54572
  return finish2(withNote(cmd, explain401(result, s)), outputFor(cmd));
53469
54573
  }
53470
54574
  function workflowLine(wf) {
53471
- const input2 = frontInputOf(wf);
54575
+ const front = frontInputOf(wf);
54576
+ const input2 = wf.flagsFrom !== void 0 && "fields" in front.shape ? front.omit({ fields: true }) : front;
53472
54577
  return { id: wf.id, input: input2, body: "fields", positionals: "file" in input2.shape ? ["file"] : [] };
53473
54578
  }
53474
54579
  function workflowFlags(wf) {
@@ -55055,12 +56160,12 @@ ZodString2.create = (params) => {
55055
56160
  ...processCreateParams(params)
55056
56161
  });
55057
56162
  };
55058
- function floatSafeRemainder2(val, step) {
56163
+ function floatSafeRemainder2(val, step2) {
55059
56164
  const valDecCount = (val.toString().split(".")[1] || "").length;
55060
- const stepDecCount = (step.toString().split(".")[1] || "").length;
56165
+ const stepDecCount = (step2.toString().split(".")[1] || "").length;
55061
56166
  const decCount = valDecCount > stepDecCount ? valDecCount : stepDecCount;
55062
56167
  const valInt = Number.parseInt(val.toFixed(decCount).replace(".", ""));
55063
- const stepInt = Number.parseInt(step.toFixed(decCount).replace(".", ""));
56168
+ const stepInt = Number.parseInt(step2.toFixed(decCount).replace(".", ""));
55064
56169
  return valInt % stepInt / 10 ** decCount;
55065
56170
  }
55066
56171
  var ZodNumber2 = class _ZodNumber extends ZodType2 {
@@ -63155,12 +64260,12 @@ var McpServer = class {
63155
64260
  }
63156
64261
  return registeredPrompt;
63157
64262
  }
63158
- _createRegisteredTool(name, title, description, inputSchema2, outputSchema, annotations, execution, _meta, handler) {
64263
+ _createRegisteredTool(name, title, description, inputSchema4, outputSchema, annotations, execution, _meta, handler) {
63159
64264
  validateAndWarnToolName(name);
63160
64265
  const registeredTool = {
63161
64266
  title,
63162
64267
  description,
63163
- inputSchema: getZodSchemaObject(inputSchema2),
64268
+ inputSchema: getZodSchemaObject(inputSchema4),
63164
64269
  outputSchema: getZodSchemaObject(outputSchema),
63165
64270
  annotations,
63166
64271
  execution,
@@ -63211,7 +64316,7 @@ var McpServer = class {
63211
64316
  throw new Error(`Tool ${name} is already registered`);
63212
64317
  }
63213
64318
  let description;
63214
- let inputSchema2;
64319
+ let inputSchema4;
63215
64320
  let outputSchema;
63216
64321
  let annotations;
63217
64322
  if (typeof rest[0] === "string") {
@@ -63220,7 +64325,7 @@ var McpServer = class {
63220
64325
  if (rest.length > 1) {
63221
64326
  const firstArg = rest[0];
63222
64327
  if (isZodRawShapeCompat(firstArg)) {
63223
- inputSchema2 = rest.shift();
64328
+ inputSchema4 = rest.shift();
63224
64329
  if (rest.length > 1 && typeof rest[0] === "object" && rest[0] !== null && !isZodRawShapeCompat(rest[0])) {
63225
64330
  annotations = rest.shift();
63226
64331
  }
@@ -63232,7 +64337,7 @@ var McpServer = class {
63232
64337
  }
63233
64338
  }
63234
64339
  const callback = rest[0];
63235
- return this._createRegisteredTool(name, void 0, description, inputSchema2, outputSchema, annotations, { taskSupport: "forbidden" }, void 0, callback);
64340
+ return this._createRegisteredTool(name, void 0, description, inputSchema4, outputSchema, annotations, { taskSupport: "forbidden" }, void 0, callback);
63236
64341
  }
63237
64342
  /**
63238
64343
  * Registers a tool with a config object and callback.
@@ -63241,8 +64346,8 @@ var McpServer = class {
63241
64346
  if (this._registeredTools[name]) {
63242
64347
  throw new Error(`Tool ${name} is already registered`);
63243
64348
  }
63244
- const { title, description, inputSchema: inputSchema2, outputSchema, annotations, _meta } = config2;
63245
- return this._createRegisteredTool(name, title, description, inputSchema2, outputSchema, annotations, { taskSupport: "forbidden" }, _meta, cb);
64349
+ const { title, description, inputSchema: inputSchema4, outputSchema, annotations, _meta } = config2;
64350
+ return this._createRegisteredTool(name, title, description, inputSchema4, outputSchema, annotations, { taskSupport: "forbidden" }, _meta, cb);
63246
64351
  }
63247
64352
  prompt(name, ...rest) {
63248
64353
  if (this._registeredPrompts[name]) {
@@ -63533,8 +64638,14 @@ function refuseTokenCreate(cmd) {
63533
64638
  return cmd.id === "tokens.create" ? cliError("forbidden", TOKEN_CREATE_REFUSAL, { contract: cmd.contract, hint: "Ask the user to run `orzarua tokens create` themselves, then give you the token." }) : null;
63534
64639
  }
63535
64640
  var read = { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false };
64641
+ var toolNext = (s) => {
64642
+ const cmd = commandById(s.commandId);
64643
+ const wf = cmd === null ? workflowById(s.commandId) : null;
64644
+ const risky = cmd !== null ? isRisky(cmd) : wf !== null && isRiskyWorkflow(wf);
64645
+ return { command: `${risky ? "plan" : "run"} ${s.commandId}`, why: s.why, input: s.input };
64646
+ };
63536
64647
  function forTheTools(result) {
63537
- if (result.ok) return result;
64648
+ if (result.ok) return withNext(result, toolNext);
63538
64649
  const { error: error62 } = result;
63539
64650
  if ((error62.code === "confirm-required" || error62.code === "plan-changed") && error62.plan !== void 0) {
63540
64651
  const which = error62.code === "plan-changed" ? "the new plan" : "the plan";
@@ -63542,7 +64653,7 @@ function forTheTools(result) {
63542
64653
  }
63543
64654
  if (error62.code === "confirm-required") return fail2({ ...error62, hint: PLAN_FIRST_HINT });
63544
64655
  if (error62.code === "no-congregation") return fail2({ ...error62, hint: "Call use_congregation with a slug (whoami lists the congregations you belong to)." });
63545
- return result;
64656
+ return withNext(result, toolNext);
63546
64657
  }
63547
64658
  var previewUri = (planId) => `orzarua://plans/${planId}/preview.html`;
63548
64659
  function withoutHtml(plan2) {
@@ -63865,9 +64976,9 @@ var runtime = {
63865
64976
  stdout: process.stdout,
63866
64977
  stderr: process.stderr,
63867
64978
  stdinIsTTY: process.stdin.isTTY === true,
63868
- version: "0.5.0",
64979
+ version: "0.6.0",
63869
64980
  now: () => /* @__PURE__ */ new Date(),
63870
- client: (baseUrl) => fetchClient(baseUrl, "0.5.0"),
64981
+ client: (baseUrl) => fetchClient(baseUrl, "0.6.0"),
63871
64982
  prompt,
63872
64983
  readStdin: () => readFileSync3(0, "utf8"),
63873
64984
  readFile: (path) => readFileSync3(path, "utf8"),