/** * `dench crm tasks repeat weekly --on mon,wed --every 2 --ends-after 10` * → the structured repeat spec the API takes. Pure, so the CLI test can pin * every shorthand without a Convex client. `--rrule` bypasses all of it. */ export type RepeatCliInput = { freq?: string; every?: string; on?: string; day?: string; nth?: string; endsAfter?: string; until?: string; onSchedule?: boolean; skipWeekends?: boolean; noCopyNotes?: boolean; noKeepAssignee?: boolean; noCopySubtasks?: boolean; rrule?: string; }; type Weekday = "mo" | "tu" | "we" | "th" | "fr" | "sa" | "su"; const WEEKDAY_ALIASES: Record = { mo: "mo", mon: "mo", monday: "mo", tu: "tu", tue: "tu", tues: "tu", tuesday: "tu", we: "we", wed: "we", wednesday: "we", th: "th", thu: "th", thur: "th", thurs: "th", thursday: "th", fr: "fr", fri: "fr", friday: "fr", sa: "sa", sat: "sa", saturday: "sa", su: "su", sun: "su", sunday: "su", }; export class RepeatArgsError extends Error {} function positiveInt( raw: string | undefined, flag: string, ): number | undefined { if (raw === undefined) return undefined; const n = Number(raw); if (!Number.isInteger(n) || n < 1) { throw new RepeatArgsError( `${flag} must be a positive integer (got "${raw}").`, ); } return n; } export function parseWeekdays(raw: string): Weekday[] { const days = raw .split(",") .map((token) => token.trim().toLowerCase()) .filter(Boolean) .map((token) => { const day = WEEKDAY_ALIASES[token]; if (!day) throw new RepeatArgsError(`Unknown weekday "${token}" in --on.`); return day; }); if (days.length === 0) throw new RepeatArgsError("--on needs at least one weekday."); return Array.from(new Set(days)); } /** * Build the `crm.tasks.repeat.set` body (minus `entryId`). `dueKey` fills * in weekly / monthly / yearly rules that don't say which day; without it * those fall back to a bare RRULE (`FREQ=WEEKLY`) and the server reads the * day from the task's due date — or from today, anchoring an undated task. */ export function buildRepeatBody( input: RepeatCliInput, dueKey?: string | null, ): Record { const body: Record = {}; if (input.rrule) { body.rrule = input.rrule; } else if (input.freq) { const interval = positiveInt(input.every, "--every") ?? 1; const freq = input.freq.trim().toLowerCase(); switch (freq) { case "daily": body.rule = { freq: "daily", interval }; break; case "weekdays": body.rule = { freq: "weekly", interval, byWeekday: ["mo", "tu", "we", "th", "fr"], }; break; case "weekly": { const byWeekday = input.on ? parseWeekdays(input.on) : dueKey ? [weekdayOfKey(dueKey)] : null; if (!byWeekday) { body.rrule = bareRRule("WEEKLY", interval); break; } body.rule = { freq: "weekly", interval, byWeekday }; break; } case "monthly": { if (input.nth !== undefined) { const ordinal = input.nth.trim().toLowerCase() === "last" ? -1 : Number(input.nth); if ( !Number.isInteger(ordinal) || ordinal === 0 || ordinal < -1 || ordinal > 4 ) { throw new RepeatArgsError('--nth must be 1–4 or "last".'); } if (!input.on) throw new RepeatArgsError("--nth needs --on ."); const [weekday] = parseWeekdays(input.on); body.rule = { freq: "monthly", interval, bySetPos: { ordinal, weekday }, }; } else { const day = input.day === undefined ? dueKey ? Number(dueKey.slice(8, 10)) : null : input.day.trim().toLowerCase() === "last" ? -1 : Number(input.day); if (day === null) { body.rrule = bareRRule("MONTHLY", interval); break; } if (!Number.isInteger(day) || day === 0 || day < -1 || day > 31) { throw new RepeatArgsError('--day must be 1–31 or "last".'); } body.rule = { freq: "monthly", interval, byMonthDay: day }; } break; } case "yearly": { if (!dueKey) { body.rrule = bareRRule("YEARLY", interval); break; } body.rule = { freq: "yearly", interval, month: Number(dueKey.slice(5, 7)), day: Number(dueKey.slice(8, 10)), }; break; } default: throw new RepeatArgsError( `Unknown repeat "${input.freq}". Use daily | weekdays | weekly | monthly | yearly, or --rrule.`, ); } } const count = positiveInt(input.endsAfter, "--ends-after"); if (count !== undefined && input.until) { throw new RepeatArgsError("Pass --ends-after or --until, not both."); } if (count !== undefined) body.ends = { kind: "count", count }; if (input.until) { if (!/^\d{4}-\d{2}-\d{2}$/.test(input.until)) { throw new RepeatArgsError("--until must be YYYY-MM-DD."); } body.ends = { kind: "until", dateKey: input.until }; } if (input.onSchedule) body.trigger = "on_schedule"; const options: Record = {}; if (input.skipWeekends) options.skipWeekends = true; if (input.noCopyNotes) options.copyNotes = false; if (input.noKeepAssignee) options.keepAssignee = false; if (input.noCopySubtasks) options.copySubtasks = false; if (Object.keys(options).length > 0) body.options = options; return body; } /** `FREQ=WEEKLY[;INTERVAL=n]` — the server fills the day from the task. */ function bareRRule(freq: string, interval: number): string { return interval === 1 ? `FREQ=${freq}` : `FREQ=${freq};INTERVAL=${interval}`; } function weekdayOfKey(key: string): Weekday { const [y, m, d] = key.split("-").map(Number) as [number, number, number]; const js = new Date(Date.UTC(y, m - 1, d)).getUTCDay(); return (["su", "mo", "tu", "we", "th", "fr", "sa"] as Weekday[])[ js ] as Weekday; }