@rackbops/plugin-tracker 0.4.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +42 -10
- package/dist/plugin.js +984 -212
- package/package.json +2 -2
package/dist/plugin.js
CHANGED
|
@@ -2381,6 +2381,19 @@ function serial() {
|
|
|
2381
2381
|
};
|
|
2382
2382
|
}
|
|
2383
2383
|
|
|
2384
|
+
// plugins/tracker/src/limits.ts
|
|
2385
|
+
var MAX_TITLE = 100;
|
|
2386
|
+
var MAX_REMINDER_TEXT = 1500;
|
|
2387
|
+
var MAX_WHEN = 100;
|
|
2388
|
+
var MAX_NOTE = 300;
|
|
2389
|
+
var MAX_URL = 1000;
|
|
2390
|
+
var MAX_NEAR = 100;
|
|
2391
|
+
var CURRENCY_LENGTH = 3;
|
|
2392
|
+
var DATE_LENGTH = 10;
|
|
2393
|
+
var MAX_ZONE = 64;
|
|
2394
|
+
var MAX_TASK_ID = 24;
|
|
2395
|
+
var MAX_LIVE_TASKS = 200;
|
|
2396
|
+
|
|
2384
2397
|
// plugins/tracker/src/people.ts
|
|
2385
2398
|
var DEFAULT_TIME_ZONE = "America/New_York";
|
|
2386
2399
|
var DEFAULT_PREFERRED_HOUR = 9;
|
|
@@ -2447,10 +2460,48 @@ async function setPreferences(store, userId, prefs) {
|
|
|
2447
2460
|
});
|
|
2448
2461
|
}
|
|
2449
2462
|
|
|
2463
|
+
// plugins/tracker/src/retime.ts
|
|
2464
|
+
var DAY_MS2 = 24 * 60 * 60 * 1000;
|
|
2465
|
+
function localDay(at, zone) {
|
|
2466
|
+
const w = wallClock(at, zone);
|
|
2467
|
+
return `${w.year}-${String(w.month).padStart(2, "0")}-${String(w.day).padStart(2, "0")}`;
|
|
2468
|
+
}
|
|
2469
|
+
function occurrenceDay(s, due, zone) {
|
|
2470
|
+
return s.kind === "period" ? periodDate(s, due, zone) : localDay(due, zone);
|
|
2471
|
+
}
|
|
2472
|
+
function retimed(s, day, owner) {
|
|
2473
|
+
const ctx = { zone: owner.timeZone, preferredHour: owner.preferredHour };
|
|
2474
|
+
const lead = s.kind === "period" ? s.leadDays ?? 0 : 0;
|
|
2475
|
+
let after = new Date(Date.parse(`${day}T00:00:00Z`) - (lead + 2) * DAY_MS2);
|
|
2476
|
+
for (let i = 0;i < 1000; i++) {
|
|
2477
|
+
const due = nextDue(s, after, ctx);
|
|
2478
|
+
if (!due)
|
|
2479
|
+
return null;
|
|
2480
|
+
const on = occurrenceDay(s, due, owner.timeZone);
|
|
2481
|
+
if (on === day)
|
|
2482
|
+
return due;
|
|
2483
|
+
if (on > day)
|
|
2484
|
+
return null;
|
|
2485
|
+
after = due;
|
|
2486
|
+
}
|
|
2487
|
+
return null;
|
|
2488
|
+
}
|
|
2489
|
+
async function heldRuns(store, task) {
|
|
2490
|
+
return (await store.listOccurrences({ taskId: task.id, status: "queued" })).filter((o) => !o.dedupeKey.startsWith(SNOOZE_PREFIX));
|
|
2491
|
+
}
|
|
2492
|
+
async function restoreHeldRun(store, task, held, before, after, now) {
|
|
2493
|
+
const s = task.schedule;
|
|
2494
|
+
const first = held[0];
|
|
2495
|
+
if (!s || !first)
|
|
2496
|
+
return null;
|
|
2497
|
+
const due = retimed(s, occurrenceDay(s, new Date(first.dueAt), before.timeZone), after);
|
|
2498
|
+
if (!due)
|
|
2499
|
+
return null;
|
|
2500
|
+
return store.createOccurrence({ taskId: task.id, lane: task.lane, dueAt: due.toISOString(), dedupeKey: scheduledKey(task.id, due), at: now.toISOString() });
|
|
2501
|
+
}
|
|
2502
|
+
|
|
2450
2503
|
// plugins/tracker/src/actions.ts
|
|
2451
2504
|
var MAX_ANSWER = 2000;
|
|
2452
|
-
var MAX_REMINDER_TEXT = 1500;
|
|
2453
|
-
var MAX_TITLE = 100;
|
|
2454
2505
|
var NO_SUCH_TASK = "You have no task with that id. `/tasks` lists yours.";
|
|
2455
2506
|
function clip(text, max = MAX_ANSWER) {
|
|
2456
2507
|
return text.length <= max ? text : `${text.slice(0, max - 3)}...`;
|
|
@@ -2505,7 +2556,7 @@ async function registerPerson(d, user, input) {
|
|
|
2505
2556
|
d.admissions.markRegistered(user.id, d.clock.now().toISOString());
|
|
2506
2557
|
const zoneChanged = updated.timeZone !== user.timeZone;
|
|
2507
2558
|
if (zoneChanged || updated.preferredHour !== user.preferredHour)
|
|
2508
|
-
await rescheduleOwned(d, updated, zoneChanged);
|
|
2559
|
+
await rescheduleOwned(d, updated, zoneChanged, user);
|
|
2509
2560
|
return registeredText(updated, first);
|
|
2510
2561
|
}
|
|
2511
2562
|
async function saveSettings(d, user, input) {
|
|
@@ -2519,7 +2570,7 @@ async function saveSettings(d, user, input) {
|
|
|
2519
2570
|
}
|
|
2520
2571
|
const zoneChanged = updated.timeZone !== user.timeZone;
|
|
2521
2572
|
if (zoneChanged || updated.preferredHour !== user.preferredHour)
|
|
2522
|
-
await rescheduleOwned(d, updated, zoneChanged);
|
|
2573
|
+
await rescheduleOwned(d, updated, zoneChanged, user);
|
|
2523
2574
|
return null;
|
|
2524
2575
|
}
|
|
2525
2576
|
async function setHour(d, user, hour) {
|
|
@@ -2533,73 +2584,46 @@ async function setHour(d, user, hour) {
|
|
|
2533
2584
|
return err.message;
|
|
2534
2585
|
throw err;
|
|
2535
2586
|
}
|
|
2536
|
-
const moved = await rescheduleOwned(d, updated, false);
|
|
2587
|
+
const moved = await rescheduleOwned(d, updated, false, user);
|
|
2537
2588
|
const note = moved > 0 ? ` ${moved} recurring reminder(s) moved to it.` : "";
|
|
2538
2589
|
return `Your preferred hour is now ${String(hour).padStart(2, "0")}:00, ${updated.timeZone} time.${note}`;
|
|
2539
2590
|
}
|
|
2540
|
-
async function rescheduleOwned(d, owner, zoneChanged) {
|
|
2591
|
+
async function rescheduleOwned(d, owner, zoneChanged, before) {
|
|
2541
2592
|
let moved = 0;
|
|
2542
|
-
|
|
2593
|
+
const live = [...await d.store.listTasks({ ownerId: owner.id, status: "active" }), ...await d.store.listTasks({ ownerId: owner.id, status: "paused" })];
|
|
2594
|
+
for (const task of live) {
|
|
2543
2595
|
const s = task.schedule;
|
|
2544
2596
|
if (!s || s.kind !== "calendar" && s.kind !== "period")
|
|
2545
2597
|
continue;
|
|
2546
2598
|
if (!zoneChanged && s.hour !== undefined)
|
|
2547
2599
|
continue;
|
|
2548
|
-
const
|
|
2549
|
-
const
|
|
2550
|
-
|
|
2551
|
-
|
|
2552
|
-
await d.store.createOccurrence({ taskId: o.taskId, lane: o.lane, dueAt: o.dueAt, dedupeKey: o.dedupeKey, at: now.toISOString() });
|
|
2553
|
-
}
|
|
2600
|
+
const held = task.status === "paused" ? await heldRuns(d.store, task) : [];
|
|
2601
|
+
const updated = await rescheduleKeepingSnoozes(d, task, owner, s, owner.id);
|
|
2602
|
+
if (held.length > 0)
|
|
2603
|
+
await restoreHeldRun(d.store, updated, held, before, owner, d.clock.now());
|
|
2554
2604
|
moved++;
|
|
2555
2605
|
}
|
|
2556
2606
|
return moved;
|
|
2557
2607
|
}
|
|
2558
|
-
function
|
|
2559
|
-
|
|
2560
|
-
if (!when)
|
|
2561
|
-
return 'Say when: for example "in 20 minutes", "tomorrow 9am" or "friday at 17:30".';
|
|
2562
|
-
return { kind: "once", at: when.toISOString() };
|
|
2563
|
-
}
|
|
2564
|
-
const w = wallClock(when ?? now, zone);
|
|
2565
|
-
const start = `${w.year}-${String(w.month).padStart(2, "0")}-${String(w.day).padStart(2, "0")}`;
|
|
2566
|
-
const base = { kind: "calendar", every: 1, unit: repeat, start };
|
|
2567
|
-
if (!when)
|
|
2568
|
-
return base;
|
|
2569
|
-
return {
|
|
2570
|
-
...base,
|
|
2571
|
-
hour: w.hour,
|
|
2572
|
-
minute: w.minute,
|
|
2573
|
-
...repeat === "week" ? { weekdays: [w.weekday] } : {},
|
|
2574
|
-
...repeat === "month" ? { dayOfMonth: w.day } : {}
|
|
2575
|
-
};
|
|
2576
|
-
}
|
|
2577
|
-
async function remind(d, user, input) {
|
|
2578
|
-
const type = d.types.reminder;
|
|
2579
|
-
if (!type)
|
|
2580
|
-
return "Reminders are not available on this bot.";
|
|
2581
|
-
const text = input.text.trim();
|
|
2582
|
-
if (text.length === 0)
|
|
2583
|
-
return "Say what to remind you of.";
|
|
2584
|
-
if (text.length > MAX_REMINDER_TEXT)
|
|
2585
|
-
return `That reminder is longer than ${MAX_REMINDER_TEXT} characters.`;
|
|
2608
|
+
async function rescheduleKeepingSnoozes(d, task, owner, schedule, actorId) {
|
|
2609
|
+
const snoozes = (await d.store.listOccurrences({ taskId: task.id, status: "queued" })).filter((o) => o.dedupeKey.startsWith(SNOOZE_PREFIX));
|
|
2586
2610
|
const now = d.clock.now();
|
|
2587
|
-
|
|
2588
|
-
|
|
2589
|
-
|
|
2590
|
-
if (!parsed.ok)
|
|
2591
|
-
return parsed.error;
|
|
2592
|
-
when = parsed.at;
|
|
2611
|
+
const { task: updated } = await reschedule(d.store, task, owner, schedule, actorId, now);
|
|
2612
|
+
for (const o of snoozes) {
|
|
2613
|
+
await d.store.createOccurrence({ taskId: o.taskId, lane: o.lane, dueAt: o.dueAt, dedupeKey: o.dedupeKey, at: now.toISOString() });
|
|
2593
2614
|
}
|
|
2594
|
-
|
|
2595
|
-
|
|
2596
|
-
|
|
2597
|
-
const
|
|
2598
|
-
const
|
|
2599
|
-
|
|
2600
|
-
|
|
2601
|
-
|
|
2602
|
-
|
|
2615
|
+
return updated;
|
|
2616
|
+
}
|
|
2617
|
+
async function liveTaskCap(d, owner) {
|
|
2618
|
+
const active = await d.store.listTasks({ ownerId: owner.id, status: "active" });
|
|
2619
|
+
const paused = await d.store.listTasks({ ownerId: owner.id, status: "paused" });
|
|
2620
|
+
if (active.length + paused.length < MAX_LIVE_TASKS)
|
|
2621
|
+
return null;
|
|
2622
|
+
const where = d.webEditor ? " or delete one on the web" : "";
|
|
2623
|
+
return `You already have ${MAX_LIVE_TASKS} active or paused tasks, the most one person may. Finish one${where} first.`;
|
|
2624
|
+
}
|
|
2625
|
+
function said(r) {
|
|
2626
|
+
return r.ok ? r.text : r.error;
|
|
2603
2627
|
}
|
|
2604
2628
|
async function loadTaskList(d, user) {
|
|
2605
2629
|
const entries = await taskList(d.store, { userId: user.id, admin: user.admin });
|
|
@@ -2632,6 +2656,8 @@ async function ownTask(d, user, taskId) {
|
|
|
2632
2656
|
return task && task.ownerId === user.id ? task : null;
|
|
2633
2657
|
}
|
|
2634
2658
|
async function answerLatest(d, user, input) {
|
|
2659
|
+
if (input.until !== undefined && input.until.trim().length > MAX_WHEN)
|
|
2660
|
+
return `\`until\` is longer than ${MAX_WHEN} characters.`;
|
|
2635
2661
|
const task = await ownTask(d, user, input.taskId);
|
|
2636
2662
|
if (!task)
|
|
2637
2663
|
return NO_SUCH_TASK;
|
|
@@ -2662,18 +2688,136 @@ function answeredText(kind, snoozeUntil, user, now) {
|
|
|
2662
2688
|
return "Marked done.";
|
|
2663
2689
|
return "Recorded.";
|
|
2664
2690
|
}
|
|
2665
|
-
|
|
2691
|
+
|
|
2692
|
+
// plugins/tracker/src/manage.ts
|
|
2693
|
+
async function ownLiveTask(d, user, taskId) {
|
|
2666
2694
|
const task = await ownTask(d, user, taskId);
|
|
2695
|
+
return task && task.status !== "archived" ? task : null;
|
|
2696
|
+
}
|
|
2697
|
+
function editable(task) {
|
|
2698
|
+
return task.status === "active" || task.status === "paused";
|
|
2699
|
+
}
|
|
2700
|
+
var FINISHED = "That task has finished, so it cannot be changed. Set a new one instead.";
|
|
2701
|
+
async function pauseTask(d, user, taskId) {
|
|
2702
|
+
const task = await ownLiveTask(d, user, taskId);
|
|
2667
2703
|
if (!task)
|
|
2668
|
-
return NO_SUCH_TASK;
|
|
2669
|
-
|
|
2670
|
-
|
|
2671
|
-
|
|
2704
|
+
return { ok: false, error: NO_SUCH_TASK };
|
|
2705
|
+
if (task.status !== "active")
|
|
2706
|
+
return { ok: false, error: `That task is ${task.status}, not active.` };
|
|
2707
|
+
const at = d.clock.now().toISOString();
|
|
2708
|
+
await d.store.updateTask(task.id, { status: "paused", at });
|
|
2709
|
+
await d.store.addTaskEvent({ taskId: task.id, actorId: user.id, kind: "paused", detail: "paused by the owner", at });
|
|
2710
|
+
return { ok: true, text: `Paused \`${task.id}\`: nothing is sent until you resume it.` };
|
|
2711
|
+
}
|
|
2712
|
+
async function resumeTask(d, user, taskId) {
|
|
2713
|
+
const task = await ownLiveTask(d, user, taskId);
|
|
2714
|
+
if (!task)
|
|
2715
|
+
return { ok: false, error: NO_SUCH_TASK };
|
|
2716
|
+
if (task.status !== "paused")
|
|
2717
|
+
return { ok: false, error: `That task is ${task.status}, not paused.` };
|
|
2718
|
+
const now = d.clock.now();
|
|
2719
|
+
if (d.health.isPaused(user.id))
|
|
2720
|
+
await d.health.resume(user.id, now);
|
|
2721
|
+
const removed = await d.health.resumeTask(task, user.id, now);
|
|
2722
|
+
if (removed === null) {
|
|
2723
|
+
const current = await d.store.getTask(task.id);
|
|
2724
|
+
if (current?.status === "paused") {
|
|
2725
|
+
const at = now.toISOString();
|
|
2726
|
+
const active = await d.store.updateTask(task.id, { status: "active", at });
|
|
2727
|
+
await d.store.addTaskEvent({ taskId: task.id, actorId: user.id, kind: "resumed", detail: "resumed by the owner", at });
|
|
2728
|
+
await materialize(d.store, active, user, now);
|
|
2729
|
+
}
|
|
2730
|
+
return { ok: true, text: `Resumed \`${task.id}\`.` };
|
|
2731
|
+
}
|
|
2672
2732
|
const names = [];
|
|
2673
2733
|
for (const id of removed)
|
|
2674
2734
|
names.push((await d.store.getUser(id))?.displayName ?? id);
|
|
2675
2735
|
const without = names.length > 0 ? `, without ${names.join(", ")}` : "";
|
|
2676
|
-
return `Resumed \`${task.id}\`${without}
|
|
2736
|
+
return { ok: true, text: `Resumed \`${task.id}\`${without}.` };
|
|
2737
|
+
}
|
|
2738
|
+
async function deleteTask(d, user, taskId) {
|
|
2739
|
+
const task = await ownLiveTask(d, user, taskId);
|
|
2740
|
+
if (!task)
|
|
2741
|
+
return { ok: false, error: NO_SUCH_TASK };
|
|
2742
|
+
const at = d.clock.now().toISOString();
|
|
2743
|
+
await d.store.deleteQueuedOccurrences(task.id);
|
|
2744
|
+
await d.store.updateTask(task.id, { status: "archived", at });
|
|
2745
|
+
await d.store.addTaskEvent({ taskId: task.id, actorId: user.id, kind: "archived", detail: "deleted by the owner", at });
|
|
2746
|
+
return { ok: true, text: `Deleted \`${task.id}\` ${task.title}.` };
|
|
2747
|
+
}
|
|
2748
|
+
|
|
2749
|
+
// plugins/tracker/src/reminders.ts
|
|
2750
|
+
var REPEATS = ["none", "day", "week", "month"];
|
|
2751
|
+
function reminderSchedule(when, repeat, zone, now) {
|
|
2752
|
+
if (repeat === "none") {
|
|
2753
|
+
if (!when)
|
|
2754
|
+
return 'Say when: for example "in 20 minutes", "tomorrow 9am" or "friday at 17:30".';
|
|
2755
|
+
return { kind: "once", at: when.toISOString() };
|
|
2756
|
+
}
|
|
2757
|
+
const w = wallClock(when ?? now, zone);
|
|
2758
|
+
const start = `${w.year}-${String(w.month).padStart(2, "0")}-${String(w.day).padStart(2, "0")}`;
|
|
2759
|
+
const base = { kind: "calendar", every: 1, unit: repeat, start };
|
|
2760
|
+
if (!when)
|
|
2761
|
+
return base;
|
|
2762
|
+
return {
|
|
2763
|
+
...base,
|
|
2764
|
+
hour: w.hour,
|
|
2765
|
+
minute: w.minute,
|
|
2766
|
+
...repeat === "week" ? { weekdays: [w.weekday] } : {},
|
|
2767
|
+
...repeat === "month" ? { dayOfMonth: w.day } : {}
|
|
2768
|
+
};
|
|
2769
|
+
}
|
|
2770
|
+
function repeatOf(schedule) {
|
|
2771
|
+
if (schedule?.kind !== "calendar")
|
|
2772
|
+
return "none";
|
|
2773
|
+
return schedule.unit;
|
|
2774
|
+
}
|
|
2775
|
+
function reminderPlan(d, user, input, keep) {
|
|
2776
|
+
const text = input.text.trim();
|
|
2777
|
+
if (text.length === 0)
|
|
2778
|
+
return { ok: false, error: "Say what to remind you of." };
|
|
2779
|
+
if (text.length > MAX_REMINDER_TEXT)
|
|
2780
|
+
return { ok: false, error: `That reminder is longer than ${MAX_REMINDER_TEXT} characters.` };
|
|
2781
|
+
if (!REPEATS.includes(input.repeat))
|
|
2782
|
+
return { ok: false, error: "`repeat` is once, daily, weekly or monthly." };
|
|
2783
|
+
const now = d.clock.now();
|
|
2784
|
+
let when = null;
|
|
2785
|
+
if (input.when !== undefined && input.when.trim().length > MAX_WHEN)
|
|
2786
|
+
return { ok: false, error: `\`when\` is longer than ${MAX_WHEN} characters.` };
|
|
2787
|
+
if (input.when !== undefined && input.when.trim() !== "") {
|
|
2788
|
+
const parsed = parseWhen(input.when, now, { zone: user.timeZone, defaultHour: user.preferredHour });
|
|
2789
|
+
if (!parsed.ok)
|
|
2790
|
+
return { ok: false, error: parsed.error };
|
|
2791
|
+
when = parsed.at;
|
|
2792
|
+
}
|
|
2793
|
+
if (when === null && keep && repeatOf(keep) === input.repeat)
|
|
2794
|
+
return { ok: true, text, schedule: keep };
|
|
2795
|
+
const schedule = reminderSchedule(when, input.repeat, user.timeZone, now);
|
|
2796
|
+
if (typeof schedule === "string")
|
|
2797
|
+
return { ok: false, error: schedule };
|
|
2798
|
+
return { ok: true, text, schedule };
|
|
2799
|
+
}
|
|
2800
|
+
async function createReminder(d, user, input) {
|
|
2801
|
+
const type = d.types.reminder;
|
|
2802
|
+
if (!type)
|
|
2803
|
+
return { ok: false, error: "Reminders are not available on this bot." };
|
|
2804
|
+
const plan = reminderPlan(d, user, input);
|
|
2805
|
+
if (!plan.ok)
|
|
2806
|
+
return plan;
|
|
2807
|
+
const capped = await liveTaskCap(d, user);
|
|
2808
|
+
if (capped)
|
|
2809
|
+
return { ok: false, error: capped };
|
|
2810
|
+
const { text, schedule } = plan;
|
|
2811
|
+
const now = d.clock.now();
|
|
2812
|
+
const actor = { userId: user.id, admin: user.admin };
|
|
2813
|
+
const { task, next } = await createTask(d.store, actor, user, { type, title: clip(text, MAX_TITLE), config: { text }, schedule }, now);
|
|
2814
|
+
const at = next ? formatInstant(next.dueAt, user.timeZone, now) : "never (that time has no next run)";
|
|
2815
|
+
const cadence = schedule.kind === "once" ? "" : `, ${describeSchedule(schedule, user, user.timeZone, now)}`;
|
|
2816
|
+
return { ok: true, task, text: clip(`Reminder \`${task.id}\` set: ${text}
|
|
2817
|
+
Next: ${at}${cadence}.`) };
|
|
2818
|
+
}
|
|
2819
|
+
async function remind(d, user, input) {
|
|
2820
|
+
return said(await createReminder(d, user, input));
|
|
2677
2821
|
}
|
|
2678
2822
|
|
|
2679
2823
|
// plugins/tracker/src/interactions.ts
|
|
@@ -3204,86 +3348,33 @@ ${block}
|
|
|
3204
3348
|
${clip(rest, Math.max(3, MAX_ANSWER - head.length - block.length - 2))}`);
|
|
3205
3349
|
}
|
|
3206
3350
|
|
|
3207
|
-
// plugins/tracker/src/
|
|
3208
|
-
var MAX_TITLE2 = 100;
|
|
3209
|
-
var MAX_NOTE = 300;
|
|
3210
|
-
var MAX_NEAR = 100;
|
|
3351
|
+
// plugins/tracker/src/price.ts
|
|
3211
3352
|
var MAX_PRICE_TASKS = 20;
|
|
3212
3353
|
var DEFAULT_POLL_HOURS = 12;
|
|
3213
3354
|
var MAX_POLL_HOURS = 168;
|
|
3214
|
-
var DEFAULT_LEAD_DAYS = 7;
|
|
3215
|
-
var MAX_LEAD_DAYS = 365;
|
|
3216
|
-
var MAX_EVERY = 100;
|
|
3217
3355
|
function plural2(n, word) {
|
|
3218
3356
|
return `${n} ${word}${n === 1 ? "" : "s"}`;
|
|
3219
3357
|
}
|
|
3220
|
-
async function addRenewal(d, user, input) {
|
|
3221
|
-
const type = d.types.renewal;
|
|
3222
|
-
if (!type)
|
|
3223
|
-
return "Renewals are not available on this bot.";
|
|
3224
|
-
const name = input.name.trim();
|
|
3225
|
-
if (name.length === 0)
|
|
3226
|
-
return "Say what renews: a subscription, a domain, a warranty.";
|
|
3227
|
-
if (name.length > MAX_TITLE2)
|
|
3228
|
-
return `That name is longer than ${MAX_TITLE2} characters.`;
|
|
3229
|
-
if (!Number.isFinite(input.amount) || input.amount < 0)
|
|
3230
|
-
return "The amount has to be zero or more.";
|
|
3231
|
-
const currency = input.currency.trim().toUpperCase();
|
|
3232
|
-
if (!/^[A-Z]{3}$/.test(currency))
|
|
3233
|
-
return "The currency is a three-letter code, such as USD or EUR.";
|
|
3234
|
-
const note = input.note?.trim() ?? "";
|
|
3235
|
-
if (note.length > MAX_NOTE)
|
|
3236
|
-
return `The note is longer than ${MAX_NOTE} characters.`;
|
|
3237
|
-
const every = input.every ?? 1;
|
|
3238
|
-
if (!Number.isInteger(every) || every < 1 || every > MAX_EVERY)
|
|
3239
|
-
return `\`every\` is a whole number from 1 to ${MAX_EVERY}.`;
|
|
3240
|
-
const lead = input.lead ?? DEFAULT_LEAD_DAYS;
|
|
3241
|
-
if (!Number.isInteger(lead) || lead < 0 || lead > MAX_LEAD_DAYS)
|
|
3242
|
-
return `\`lead\` is a whole number of days from 0 to ${MAX_LEAD_DAYS}.`;
|
|
3243
|
-
const renews = input.renews.trim();
|
|
3244
|
-
const now = d.clock.now();
|
|
3245
|
-
const days = /^\d{4}-\d{2}-\d{2}$/.test(renews) ? daysUntil(renews, now, user.timeZone) : null;
|
|
3246
|
-
if (days === null)
|
|
3247
|
-
return "Give the date as YYYY-MM-DD, for example 2026-12-01.";
|
|
3248
|
-
if (days < 0)
|
|
3249
|
-
return "That date has passed: give the next renewal or expiry date.";
|
|
3250
|
-
const schedule = { kind: "period", every, unit: input.unit ?? "year", anchor: renews, leadDays: lead };
|
|
3251
|
-
const config = { amount: input.amount, currency, ...note ? { note } : {} };
|
|
3252
|
-
const actor = { userId: user.id, admin: user.admin };
|
|
3253
|
-
let created;
|
|
3254
|
-
try {
|
|
3255
|
-
created = await createTask(d.store, actor, user, { type, title: name, config, schedule }, now);
|
|
3256
|
-
} catch (err) {
|
|
3257
|
-
if (err instanceof ScheduleError)
|
|
3258
|
-
return `That schedule does not work: ${err.message}.`;
|
|
3259
|
-
throw err;
|
|
3260
|
-
}
|
|
3261
|
-
const { task } = created;
|
|
3262
|
-
let next = created.next;
|
|
3263
|
-
const first = nextDue(schedule, new Date(0), { zone: user.timeZone, preferredHour: user.preferredHour });
|
|
3264
|
-
if (first && first.getTime() <= now.getTime()) {
|
|
3265
|
-
next = await d.store.createOccurrence({
|
|
3266
|
-
taskId: task.id,
|
|
3267
|
-
lane: task.lane,
|
|
3268
|
-
dueAt: first.toISOString(),
|
|
3269
|
-
dedupeKey: scheduledKey(task.id, first),
|
|
3270
|
-
at: now.toISOString()
|
|
3271
|
-
});
|
|
3272
|
-
}
|
|
3273
|
-
const cadence = describeSchedule(schedule, user, user.timeZone, now);
|
|
3274
|
-
const when = days === 0 ? "today" : `in ${plural2(days, "day")}`;
|
|
3275
|
-
const ask = !next ? "never (that schedule has no next date)" : Date.parse(next.dueAt) <= now.getTime() ? `in the next minute (it renews ${when})` : formatInstant(next.dueAt, user.timeZone, now);
|
|
3276
|
-
return clip([
|
|
3277
|
-
`Renewal \`${task.id}\` set: ${name}, ${money(input.amount, currency)}, ${cadence}.`,
|
|
3278
|
-
`First ask: ${ask}. Each ask has Keep, Cancel and Renewed buttons; paid a different amount? Answer with \`/task decide\` instead of a button.`
|
|
3279
|
-
].join(`
|
|
3280
|
-
`));
|
|
3281
|
-
}
|
|
3282
3358
|
function nearPattern(near) {
|
|
3283
3359
|
const literal = near.trim().replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
3284
3360
|
return `${literal}[^0-9]{0,40}([0-9][0-9.,]{0,15})`;
|
|
3285
3361
|
}
|
|
3286
|
-
|
|
3362
|
+
function priceSettingsPlan(input) {
|
|
3363
|
+
const hours = input.hours ?? DEFAULT_POLL_HOURS;
|
|
3364
|
+
if (!Number.isInteger(hours) || hours < 1 || hours > MAX_POLL_HOURS)
|
|
3365
|
+
return { ok: false, error: `\`hours\` is a whole number from 1 to ${MAX_POLL_HOURS}.` };
|
|
3366
|
+
const drop = input.drop ?? DEFAULT_DROP_PERCENT;
|
|
3367
|
+
if (!Number.isFinite(drop) || drop < 1 || drop > 90)
|
|
3368
|
+
return { ok: false, error: "`drop` is a percentage from 1 to 90." };
|
|
3369
|
+
const baseline = input.baseline ?? DEFAULT_BASELINE;
|
|
3370
|
+
if (!BASELINE_RULES.includes(baseline))
|
|
3371
|
+
return { ok: false, error: "`baseline` is first, last or peak." };
|
|
3372
|
+
const name = input.name?.trim() ?? "";
|
|
3373
|
+
if (name.length > MAX_TITLE)
|
|
3374
|
+
return { ok: false, error: `That name is longer than ${MAX_TITLE} characters.` };
|
|
3375
|
+
return { ok: true, name, hours, drop, baseline };
|
|
3376
|
+
}
|
|
3377
|
+
async function previewPrice(d, v) {
|
|
3287
3378
|
if (!d.fetch)
|
|
3288
3379
|
return { ok: false, error: "Price tracking is not available on this bot." };
|
|
3289
3380
|
try {
|
|
@@ -3309,65 +3400,165 @@ async function ownedPriceTasks(d, user) {
|
|
|
3309
3400
|
const paused = await d.store.listTasks({ ownerId: user.id, status: "paused" });
|
|
3310
3401
|
return [...active, ...paused].filter((t) => t.type === "price").length;
|
|
3311
3402
|
}
|
|
3403
|
+
function atCap(d) {
|
|
3404
|
+
const where = d.webEditor ? ", or delete one on the web," : "";
|
|
3405
|
+
return `You already track ${MAX_PRICE_TASKS} prices, the most one person may. Stop one with \`/task done\`${where} first.`;
|
|
3406
|
+
}
|
|
3312
3407
|
function titleFor(url) {
|
|
3313
3408
|
const u = new URL(url);
|
|
3314
|
-
return clip(`${u.hostname}${u.pathname === "/" ? "" : u.pathname}`,
|
|
3409
|
+
return clip(`${u.hostname}${u.pathname === "/" ? "" : u.pathname}`, MAX_TITLE);
|
|
3315
3410
|
}
|
|
3316
|
-
async function
|
|
3411
|
+
async function startPrice(d, user, input) {
|
|
3317
3412
|
if (!d.types.price || !d.fetch)
|
|
3318
|
-
return "Price tracking is not available on this bot.";
|
|
3413
|
+
return { ok: false, error: "Price tracking is not available on this bot." };
|
|
3319
3414
|
const url = input.url.trim();
|
|
3415
|
+
if (url.length > MAX_URL)
|
|
3416
|
+
return { ok: false, error: `That address is longer than ${MAX_URL} characters.` };
|
|
3320
3417
|
const problem = urlProblem(url);
|
|
3321
3418
|
if (problem)
|
|
3322
|
-
return problem;
|
|
3323
|
-
const
|
|
3324
|
-
if (!
|
|
3325
|
-
return
|
|
3326
|
-
const drop = input.drop ?? DEFAULT_DROP_PERCENT;
|
|
3327
|
-
if (!Number.isFinite(drop) || drop < 1 || drop > 90)
|
|
3328
|
-
return "`drop` is a percentage from 1 to 90.";
|
|
3329
|
-
const baseline = input.baseline ?? DEFAULT_BASELINE;
|
|
3330
|
-
if (!BASELINE_RULES.includes(baseline))
|
|
3331
|
-
return "`baseline` is first, last or peak.";
|
|
3419
|
+
return { ok: false, error: problem };
|
|
3420
|
+
const settings = priceSettingsPlan(input);
|
|
3421
|
+
if (!settings.ok)
|
|
3422
|
+
return settings;
|
|
3332
3423
|
const near = input.near?.trim() ?? "";
|
|
3333
3424
|
if (near.length > MAX_NEAR)
|
|
3334
|
-
return `\`near\` is longer than ${MAX_NEAR} characters
|
|
3335
|
-
|
|
3336
|
-
|
|
3337
|
-
|
|
3338
|
-
if (
|
|
3339
|
-
return
|
|
3340
|
-
}
|
|
3341
|
-
const
|
|
3342
|
-
|
|
3343
|
-
|
|
3344
|
-
|
|
3345
|
-
|
|
3346
|
-
|
|
3425
|
+
return { ok: false, error: `\`near\` is longer than ${MAX_NEAR} characters.` };
|
|
3426
|
+
if (await ownedPriceTasks(d, user) >= MAX_PRICE_TASKS)
|
|
3427
|
+
return { ok: false, error: atCap(d) };
|
|
3428
|
+
const capped = await liveTaskCap(d, user);
|
|
3429
|
+
if (capped)
|
|
3430
|
+
return { ok: false, error: capped };
|
|
3431
|
+
const { name, hours, drop, baseline } = settings;
|
|
3432
|
+
const config = { url, dropPercent: drop, baseline, ...near ? { pattern: nearPattern(near) } : {} };
|
|
3433
|
+
return { ok: true, pending: { url, title: name || titleFor(url), hours, config } };
|
|
3434
|
+
}
|
|
3435
|
+
async function trackPrice(d, user, input) {
|
|
3436
|
+
const started = await startPrice(d, user, input);
|
|
3437
|
+
if (!started.ok)
|
|
3438
|
+
return started.error;
|
|
3439
|
+
const { pending } = started;
|
|
3347
3440
|
return {
|
|
3348
|
-
outside: () =>
|
|
3349
|
-
finish: (result) => finishPrice(d, user,
|
|
3441
|
+
outside: () => previewPrice(d, pending),
|
|
3442
|
+
finish: async (result) => said(await finishPrice(d, user, pending, result))
|
|
3350
3443
|
};
|
|
3351
3444
|
}
|
|
3352
3445
|
async function finishPrice(d, user, v, seen) {
|
|
3353
3446
|
if (!seen.ok)
|
|
3354
|
-
return seen
|
|
3447
|
+
return seen;
|
|
3355
3448
|
const type = d.types.price;
|
|
3356
3449
|
if (!type)
|
|
3357
|
-
return "Price tracking is not available on this bot.";
|
|
3358
|
-
if (await ownedPriceTasks(d, user) >= MAX_PRICE_TASKS)
|
|
3359
|
-
return
|
|
3360
|
-
|
|
3450
|
+
return { ok: false, error: "Price tracking is not available on this bot." };
|
|
3451
|
+
if (await ownedPriceTasks(d, user) >= MAX_PRICE_TASKS)
|
|
3452
|
+
return { ok: false, error: atCap(d) };
|
|
3453
|
+
const capped = await liveTaskCap(d, user);
|
|
3454
|
+
if (capped)
|
|
3455
|
+
return { ok: false, error: capped };
|
|
3361
3456
|
const now = d.clock.now();
|
|
3362
3457
|
const schedule = { kind: "poll", every: v.hours, unit: "hour", start: now.toISOString() };
|
|
3363
3458
|
const actor = { userId: user.id, admin: user.admin };
|
|
3364
3459
|
const { task } = await createTask(d.store, actor, user, { type, title: v.title, config: v.config, schedule }, now);
|
|
3365
3460
|
const rule = v.config.baseline ?? DEFAULT_BASELINE;
|
|
3366
|
-
return
|
|
3367
|
-
|
|
3368
|
-
|
|
3369
|
-
|
|
3370
|
-
`
|
|
3461
|
+
return {
|
|
3462
|
+
ok: true,
|
|
3463
|
+
task,
|
|
3464
|
+
text: clip([
|
|
3465
|
+
`Tracking \`${task.id}\`: ${v.title}. I read ${seen.text} just now.`,
|
|
3466
|
+
`I check every ${plural2(v.hours, "hour")} and DM you when it drops ${v.config.dropPercent}% or more from the ${rule} price seen; the first check, within a minute, DMs the starting price. \`/task done ${task.id}\` stops it.`
|
|
3467
|
+
].join(`
|
|
3468
|
+
`))
|
|
3469
|
+
};
|
|
3470
|
+
}
|
|
3471
|
+
|
|
3472
|
+
// plugins/tracker/src/tracked.ts
|
|
3473
|
+
var DEFAULT_LEAD_DAYS = 7;
|
|
3474
|
+
var MAX_LEAD_DAYS = 365;
|
|
3475
|
+
var MAX_EVERY = 100;
|
|
3476
|
+
function plural3(n, word) {
|
|
3477
|
+
return `${n} ${word}${n === 1 ? "" : "s"}`;
|
|
3478
|
+
}
|
|
3479
|
+
var PERIOD_UNITS = ["year", "month", "week", "day"];
|
|
3480
|
+
function renewalPlan(d, user, input) {
|
|
3481
|
+
const bad = (error) => ({ ok: false, error });
|
|
3482
|
+
const name = input.name.trim();
|
|
3483
|
+
if (name.length === 0)
|
|
3484
|
+
return bad("Say what renews: a subscription, a domain, a warranty.");
|
|
3485
|
+
if (name.length > MAX_TITLE)
|
|
3486
|
+
return bad(`That name is longer than ${MAX_TITLE} characters.`);
|
|
3487
|
+
if (!Number.isFinite(input.amount) || input.amount < 0)
|
|
3488
|
+
return bad("The amount has to be zero or more.");
|
|
3489
|
+
const currency = input.currency.trim().toUpperCase();
|
|
3490
|
+
if (!new RegExp(`^[A-Z]{${CURRENCY_LENGTH}}$`).test(currency))
|
|
3491
|
+
return bad("The currency is a three-letter code, such as USD or EUR.");
|
|
3492
|
+
const note = input.note?.trim() ?? "";
|
|
3493
|
+
if (note.length > MAX_NOTE)
|
|
3494
|
+
return bad(`The note is longer than ${MAX_NOTE} characters.`);
|
|
3495
|
+
const unit = input.unit ?? "year";
|
|
3496
|
+
if (!PERIOD_UNITS.includes(unit))
|
|
3497
|
+
return bad("`unit` is yearly, monthly, weekly or daily.");
|
|
3498
|
+
const every = input.every ?? 1;
|
|
3499
|
+
if (!Number.isInteger(every) || every < 1 || every > MAX_EVERY)
|
|
3500
|
+
return bad(`\`every\` is a whole number from 1 to ${MAX_EVERY}.`);
|
|
3501
|
+
const lead = input.lead ?? DEFAULT_LEAD_DAYS;
|
|
3502
|
+
if (!Number.isInteger(lead) || lead < 0 || lead > MAX_LEAD_DAYS)
|
|
3503
|
+
return bad(`\`lead\` is a whole number of days from 0 to ${MAX_LEAD_DAYS}.`);
|
|
3504
|
+
const renews = input.renews.trim();
|
|
3505
|
+
const days = renews.length === DATE_LENGTH && /^\d{4}-\d{2}-\d{2}$/.test(renews) ? daysUntil(renews, d.clock.now(), user.timeZone) : null;
|
|
3506
|
+
if (days === null)
|
|
3507
|
+
return bad("Give the date as YYYY-MM-DD, for example 2026-12-01.");
|
|
3508
|
+
if (days < 0)
|
|
3509
|
+
return bad("That date has passed: give the next renewal or expiry date.");
|
|
3510
|
+
return { ok: true, name, amount: input.amount, currency, note, days, schedule: { kind: "period", every, unit, anchor: renews, leadDays: lead } };
|
|
3511
|
+
}
|
|
3512
|
+
async function firstAskIfDue(d, task, owner, schedule) {
|
|
3513
|
+
const now = d.clock.now();
|
|
3514
|
+
const first = nextDue(schedule, new Date(0), { zone: owner.timeZone, preferredHour: owner.preferredHour });
|
|
3515
|
+
if (!first || first.getTime() > now.getTime())
|
|
3516
|
+
return null;
|
|
3517
|
+
return d.store.createOccurrence({ taskId: task.id, lane: task.lane, dueAt: first.toISOString(), dedupeKey: scheduledKey(task.id, first), at: now.toISOString() });
|
|
3518
|
+
}
|
|
3519
|
+
function askText(next, days, user, now) {
|
|
3520
|
+
const when = days === 0 ? "today" : `in ${plural3(days, "day")}`;
|
|
3521
|
+
if (!next)
|
|
3522
|
+
return "never (that schedule has no next date)";
|
|
3523
|
+
return Date.parse(next.dueAt) <= now.getTime() ? `in the next minute (it renews ${when})` : formatInstant(next.dueAt, user.timeZone, now);
|
|
3524
|
+
}
|
|
3525
|
+
async function createRenewal(d, user, input) {
|
|
3526
|
+
const type = d.types.renewal;
|
|
3527
|
+
if (!type)
|
|
3528
|
+
return { ok: false, error: "Renewals are not available on this bot." };
|
|
3529
|
+
const plan = renewalPlan(d, user, input);
|
|
3530
|
+
if (!plan.ok)
|
|
3531
|
+
return plan;
|
|
3532
|
+
const capped = await liveTaskCap(d, user);
|
|
3533
|
+
if (capped)
|
|
3534
|
+
return { ok: false, error: capped };
|
|
3535
|
+
const { name, currency, note, schedule } = plan;
|
|
3536
|
+
const now = d.clock.now();
|
|
3537
|
+
const config = { amount: plan.amount, currency, ...note ? { note } : {} };
|
|
3538
|
+
const actor = { userId: user.id, admin: user.admin };
|
|
3539
|
+
let created;
|
|
3540
|
+
try {
|
|
3541
|
+
created = await createTask(d.store, actor, user, { type, title: name, config, schedule }, now);
|
|
3542
|
+
} catch (err) {
|
|
3543
|
+
if (err instanceof ScheduleError)
|
|
3544
|
+
return { ok: false, error: `That schedule does not work: ${err.message}.` };
|
|
3545
|
+
throw err;
|
|
3546
|
+
}
|
|
3547
|
+
const { task } = created;
|
|
3548
|
+
const next = await firstAskIfDue(d, task, user, schedule) ?? created.next;
|
|
3549
|
+
const cadence = describeSchedule(schedule, user, user.timeZone, now);
|
|
3550
|
+
return {
|
|
3551
|
+
ok: true,
|
|
3552
|
+
task,
|
|
3553
|
+
text: clip([
|
|
3554
|
+
`Renewal \`${task.id}\` set: ${name}, ${money(plan.amount, currency)}, ${cadence}.`,
|
|
3555
|
+
`First ask: ${askText(next, plan.days, user, now)}. Each ask has Keep, Cancel and Renewed buttons; paid a different amount? Answer with \`/task decide\` instead of a button.`
|
|
3556
|
+
].join(`
|
|
3557
|
+
`))
|
|
3558
|
+
};
|
|
3559
|
+
}
|
|
3560
|
+
async function addRenewal(d, user, input) {
|
|
3561
|
+
return said(await createRenewal(d, user, input));
|
|
3371
3562
|
}
|
|
3372
3563
|
async function decideRenewal(d, user, input) {
|
|
3373
3564
|
const task = await visibleTask(d.store, { userId: user.id, admin: false }, input.taskId.trim());
|
|
@@ -3517,7 +3708,7 @@ ${body}` : body);
|
|
|
3517
3708
|
},
|
|
3518
3709
|
{
|
|
3519
3710
|
name: "register",
|
|
3520
|
-
build: (b) => b.setDescription("Sign up for reminders, or change your hour and time zone").addIntegerOption((o) => o.setName("hour").setDescription("Hour (0-23) a reminder without a time arrives; default 9").setMinValue(0).setMaxValue(23)).addStringOption((o) => o.setName("zone").setDescription("Your time zone, e.g. America/New_York (the default)").setMaxLength(
|
|
3711
|
+
build: (b) => b.setDescription("Sign up for reminders, or change your hour and time zone").addIntegerOption((o) => o.setName("hour").setDescription("Hour (0-23) a reminder without a time arrives; default 9").setMinValue(0).setMaxValue(23)).addStringOption((o) => o.setName("zone").setDescription("Your time zone, e.g. America/New_York (the default)").setMaxLength(MAX_ZONE)),
|
|
3521
3712
|
handle: (interaction) => run(interaction, "admitted", (d, user) => {
|
|
3522
3713
|
const hour = interaction.options.getInteger("hour");
|
|
3523
3714
|
const zone = interaction.options.getString("zone");
|
|
@@ -3530,7 +3721,7 @@ ${body}` : body);
|
|
|
3530
3721
|
},
|
|
3531
3722
|
{
|
|
3532
3723
|
name: "remind",
|
|
3533
|
-
build: (b) => b.setDescription("Set a reminder, sent to you by DM").addStringOption((o) => o.setName("text").setDescription("What to remind you of").setRequired(true).setMaxLength(MAX_REMINDER_TEXT)).addStringOption((o) => o.setName("when").setDescription('When: "in 20 minutes", "tomorrow 9am", "fri at 17:30"').setMaxLength(
|
|
3724
|
+
build: (b) => b.setDescription("Set a reminder, sent to you by DM").addStringOption((o) => o.setName("text").setDescription("What to remind you of").setRequired(true).setMaxLength(MAX_REMINDER_TEXT)).addStringOption((o) => o.setName("when").setDescription('When: "in 20 minutes", "tomorrow 9am", "fri at 17:30"').setMaxLength(MAX_WHEN)).addStringOption((o) => o.setName("repeat").setDescription("Repeat it (default: once)").addChoices({ name: "once", value: "none" }, { name: "daily", value: "day" }, { name: "weekly", value: "week" }, { name: "monthly", value: "month" })),
|
|
3534
3725
|
handle: (interaction) => run(interaction, "registered", (d, user) => {
|
|
3535
3726
|
const when = interaction.options.getString("when");
|
|
3536
3727
|
return remind(d, user, {
|
|
@@ -3542,7 +3733,7 @@ ${body}` : body);
|
|
|
3542
3733
|
},
|
|
3543
3734
|
{
|
|
3544
3735
|
name: "renewal",
|
|
3545
|
-
build: (b) => b.setDescription("Track a subscription, domain or warranty: I ask before each renewal").addStringOption((o) => o.setName("name").setDescription("What renews, e.g. Netflix").setRequired(true).setMaxLength(
|
|
3736
|
+
build: (b) => b.setDescription("Track a subscription, domain or warranty: I ask before each renewal").addStringOption((o) => o.setName("name").setDescription("What renews, e.g. Netflix").setRequired(true).setMaxLength(MAX_TITLE)).addNumberOption((o) => o.setName("amount").setDescription("What one period costs").setRequired(true).setMinValue(0)).addStringOption((o) => o.setName("currency").setDescription("Currency code, e.g. USD").setRequired(true).setMinLength(CURRENCY_LENGTH).setMaxLength(CURRENCY_LENGTH)).addStringOption((o) => o.setName("renews").setDescription("The next renewal or expiry date, YYYY-MM-DD").setRequired(true).setMaxLength(DATE_LENGTH)).addStringOption((o) => o.setName("unit").setDescription("How it renews (default: yearly)").addChoices({ name: "yearly", value: "year" }, { name: "monthly", value: "month" }, { name: "weekly", value: "week" }, { name: "daily", value: "day" })).addIntegerOption((o) => o.setName("every").setDescription("Every how many of those, e.g. 2 for every two years (default 1)").setMinValue(1).setMaxValue(MAX_EVERY)).addIntegerOption((o) => o.setName("lead").setDescription(`Days before the date to ask (default ${DEFAULT_LEAD_DAYS})`).setMinValue(0).setMaxValue(MAX_LEAD_DAYS)).addStringOption((o) => o.setName("note").setDescription("Carried on every ask: where to cancel, which account").setMaxLength(MAX_NOTE)),
|
|
3546
3737
|
handle: (interaction) => run(interaction, "registered", (d, user) => {
|
|
3547
3738
|
const every = interaction.options.getInteger("every");
|
|
3548
3739
|
const lead = interaction.options.getInteger("lead");
|
|
@@ -3562,7 +3753,7 @@ ${body}` : body);
|
|
|
3562
3753
|
},
|
|
3563
3754
|
{
|
|
3564
3755
|
name: "price",
|
|
3565
|
-
build: (b) => b.setDescription("Track a product's price: I DM you when it drops").addStringOption((o) => o.setName("url").setDescription("The product page").setRequired(true).setMaxLength(
|
|
3756
|
+
build: (b) => b.setDescription("Track a product's price: I DM you when it drops").addStringOption((o) => o.setName("url").setDescription("The product page").setRequired(true).setMaxLength(MAX_URL)).addStringOption((o) => o.setName("name").setDescription("What to call it (default: the page address)").setMaxLength(MAX_TITLE)).addIntegerOption((o) => o.setName("hours").setDescription(`Hours between checks (default ${DEFAULT_POLL_HOURS})`).setMinValue(1).setMaxValue(MAX_POLL_HOURS)).addNumberOption((o) => o.setName("drop").setDescription("Alert on a drop of at least this percent (default 10)").setMinValue(1).setMaxValue(90)).addStringOption((o) => o.setName("baseline").setDescription("Measure the drop from which price seen (default: the last)").addChoices({ name: "the last", value: "last" }, { name: "the first", value: "first" }, { name: "the highest", value: "peak" })).addStringOption((o) => o.setName("near").setDescription("The words just before the price, if I cannot find it on my own").setMaxLength(MAX_NEAR)),
|
|
3566
3757
|
handle: (interaction) => run(interaction, "registered", (d, user) => {
|
|
3567
3758
|
const name = interaction.options.getString("name");
|
|
3568
3759
|
const hours = interaction.options.getInteger("hours");
|
|
@@ -3586,7 +3777,7 @@ ${body}` : body);
|
|
|
3586
3777
|
},
|
|
3587
3778
|
{
|
|
3588
3779
|
name: "task",
|
|
3589
|
-
build: (b) => b.setDescription("Answer, look back at, or share one of your tasks").addSubcommand((s) => s.setName("done").setDescription("Mark the task's latest reminder done").addStringOption((o) => o.setName("task").setDescription("The task id from /tasks, e.g. t3").setRequired(true).setMaxLength(
|
|
3780
|
+
build: (b) => b.setDescription("Answer, look back at, or share one of your tasks").addSubcommand((s) => s.setName("done").setDescription("Mark the task's latest reminder done").addStringOption((o) => o.setName("task").setDescription("The task id from /tasks, e.g. t3").setRequired(true).setMaxLength(MAX_TASK_ID))).addSubcommand((s) => s.setName("snooze").setDescription("Snooze the task's latest reminder (default: an hour)").addStringOption((o) => o.setName("task").setDescription("The task id from /tasks, e.g. t3").setRequired(true).setMaxLength(MAX_TASK_ID)).addStringOption((o) => o.setName("until").setDescription('Until when: "in 2h", "tomorrow 9am"').setMaxLength(MAX_WHEN))).addSubcommand((s) => s.setName("decide").setDescription("Answer a renewal's latest ask, with what you paid if it changed").addStringOption((o) => o.setName("task").setDescription("The task id from /tasks, e.g. t3").setRequired(true).setMaxLength(MAX_TASK_ID)).addStringOption((o) => o.setName("choice").setDescription("Keep it, cancel it, or it has renewed").setRequired(true).addChoices({ name: "keep", value: "keep" }, { name: "cancel", value: "cancel" }, { name: "renewed", value: "renewed" })).addNumberOption((o) => o.setName("amount").setDescription("What you paid this time, if not the usual").setMinValue(0))).addSubcommand((s) => s.setName("history").setDescription("Every run of the task, how it was answered, and its changes").addStringOption((o) => o.setName("task").setDescription("The task id from /tasks, e.g. t3").setRequired(true).setMaxLength(MAX_TASK_ID))).addSubcommand((s) => s.setName("resume").setDescription("Resume a paused task; one paused for failed DMs goes on without them").addStringOption((o) => o.setName("task").setDescription("The task id from /tasks, e.g. t3").setRequired(true).setMaxLength(MAX_TASK_ID))).addSubcommand((s) => s.setName("share").setDescription("Ask someone to receive this task's messages too").addStringOption((o) => o.setName("task").setDescription("The task id from /tasks, e.g. t3").setRequired(true).setMaxLength(MAX_TASK_ID)).addUserOption((o) => o.setName("user").setDescription("Who to invite").setRequired(true))),
|
|
3590
3781
|
handle: (interaction) => {
|
|
3591
3782
|
const sub = interaction.options.getSubcommand();
|
|
3592
3783
|
const taskId = interaction.options.getString("task", true);
|
|
@@ -3610,7 +3801,7 @@ ${body}` : body);
|
|
|
3610
3801
|
case "history":
|
|
3611
3802
|
return historyWithSeries(d, user, taskId);
|
|
3612
3803
|
case "resume":
|
|
3613
|
-
return resumeTask(d, user, taskId);
|
|
3804
|
+
return said(await resumeTask(d, user, taskId));
|
|
3614
3805
|
case "share": {
|
|
3615
3806
|
const pending = await prepareShare(d, user, taskId, { discordId: shareWith?.id ?? "", membership: membership ?? "unknown" });
|
|
3616
3807
|
if (typeof pending === "string")
|
|
@@ -4237,6 +4428,162 @@ class SqliteStore {
|
|
|
4237
4428
|
}
|
|
4238
4429
|
}
|
|
4239
4430
|
|
|
4431
|
+
// plugins/tracker/src/edit.ts
|
|
4432
|
+
async function owned(d, user, taskId, type) {
|
|
4433
|
+
const task = await ownLiveTask(d, user, taskId);
|
|
4434
|
+
if (!task || task.type !== type)
|
|
4435
|
+
return NO_SUCH_TASK;
|
|
4436
|
+
if (!editable(task))
|
|
4437
|
+
return FINISHED;
|
|
4438
|
+
return task;
|
|
4439
|
+
}
|
|
4440
|
+
function same(a, b) {
|
|
4441
|
+
return JSON.stringify(a) === JSON.stringify(b);
|
|
4442
|
+
}
|
|
4443
|
+
async function applyEdit(d, task, user, patch) {
|
|
4444
|
+
const fields = Object.keys(patch);
|
|
4445
|
+
if (fields.length === 0)
|
|
4446
|
+
return task;
|
|
4447
|
+
const at = d.clock.now().toISOString();
|
|
4448
|
+
const updated = await d.store.updateTask(task.id, { ...patch, at });
|
|
4449
|
+
await d.store.addTaskEvent({ taskId: task.id, actorId: user.id, kind: "edited", detail: fields.join(", "), at });
|
|
4450
|
+
return updated;
|
|
4451
|
+
}
|
|
4452
|
+
async function nextRun(d, task) {
|
|
4453
|
+
const queued = await d.store.listOccurrences({ taskId: task.id, status: "queued" });
|
|
4454
|
+
return queued[0] ?? null;
|
|
4455
|
+
}
|
|
4456
|
+
function savedText(task, user, next, now, what) {
|
|
4457
|
+
const cadence = task.schedule ? describeSchedule(task.schedule, user, user.timeZone, now) : "no schedule";
|
|
4458
|
+
const when = task.status === "paused" ? "paused" : next ? formatInstant(next.dueAt, user.timeZone, now) : "nothing scheduled";
|
|
4459
|
+
return clip(`Saved \`${task.id}\` ${what}: ${cadence}. Next: ${when}.`);
|
|
4460
|
+
}
|
|
4461
|
+
var blank = (v) => v === undefined || v.trim() === "";
|
|
4462
|
+
async function editReminder(d, user, taskId, input) {
|
|
4463
|
+
const task = await owned(d, user, taskId, "reminder");
|
|
4464
|
+
if (typeof task === "string")
|
|
4465
|
+
return { ok: false, error: task };
|
|
4466
|
+
const config = task.config ?? {};
|
|
4467
|
+
const text = blank(input.text) ? String(config.text ?? "") : input.text;
|
|
4468
|
+
const repeat = input.repeat ?? repeatOf(task.schedule);
|
|
4469
|
+
const plan = reminderPlan(d, user, { text, repeat, ...input.when !== undefined ? { when: input.when } : {} }, task.schedule);
|
|
4470
|
+
if (!plan.ok)
|
|
4471
|
+
return plan;
|
|
4472
|
+
let updated = config.text === plan.text ? task : await applyEdit(d, task, user, { title: clip(plan.text, MAX_TITLE), config: { ...config, text: plan.text } });
|
|
4473
|
+
if (!same(plan.schedule, task.schedule))
|
|
4474
|
+
updated = await rescheduleKeepingSnoozes(d, updated, user, plan.schedule, user.id);
|
|
4475
|
+
return { ok: true, task: updated, text: savedText(updated, user, await nextRun(d, updated), d.clock.now(), "(reminder)") };
|
|
4476
|
+
}
|
|
4477
|
+
var DAY_MS3 = 24 * 60 * 60 * 1000;
|
|
4478
|
+
function periodDateFrom(s, date, owner) {
|
|
4479
|
+
const ctx = { zone: owner.timeZone, preferredHour: owner.preferredHour };
|
|
4480
|
+
let after = new Date(Date.parse(`${date}T00:00:00Z`) - ((s.leadDays ?? 0) + 2) * DAY_MS3);
|
|
4481
|
+
for (let i = 0;i < 1000; i++) {
|
|
4482
|
+
const due = nextDue(s, after, ctx);
|
|
4483
|
+
if (!due)
|
|
4484
|
+
return null;
|
|
4485
|
+
const on = periodDate(s, due, owner.timeZone);
|
|
4486
|
+
if (on >= date)
|
|
4487
|
+
return on;
|
|
4488
|
+
after = due;
|
|
4489
|
+
}
|
|
4490
|
+
return null;
|
|
4491
|
+
}
|
|
4492
|
+
function renewalDate(task, owner, now) {
|
|
4493
|
+
const s = task.schedule;
|
|
4494
|
+
if (s?.kind !== "period")
|
|
4495
|
+
return "";
|
|
4496
|
+
const w = wallClock(now, owner.timeZone);
|
|
4497
|
+
const today = `${w.year}-${String(w.month).padStart(2, "0")}-${String(w.day).padStart(2, "0")}`;
|
|
4498
|
+
if (s.anchor >= today)
|
|
4499
|
+
return s.anchor;
|
|
4500
|
+
return periodDateFrom(s, today, owner) ?? s.anchor;
|
|
4501
|
+
}
|
|
4502
|
+
async function askedAbout(d, task, owner, date) {
|
|
4503
|
+
const old = task.schedule;
|
|
4504
|
+
if (old?.kind !== "period")
|
|
4505
|
+
return false;
|
|
4506
|
+
const fired = (await d.store.listOccurrences({ taskId: task.id })).filter((o) => o.status !== "queued" && !o.dedupeKey.startsWith(SNOOZE_PREFIX));
|
|
4507
|
+
return fired.some((o) => periodDate(old, new Date(o.dueAt), owner.timeZone) === date);
|
|
4508
|
+
}
|
|
4509
|
+
async function editRenewal(d, user, taskId, input) {
|
|
4510
|
+
const task = await owned(d, user, taskId, "renewal");
|
|
4511
|
+
if (typeof task === "string")
|
|
4512
|
+
return { ok: false, error: task };
|
|
4513
|
+
if (task.schedule?.kind !== "period")
|
|
4514
|
+
return { ok: false, error: FINISHED };
|
|
4515
|
+
const old = task.schedule;
|
|
4516
|
+
const now = d.clock.now();
|
|
4517
|
+
const config = task.config;
|
|
4518
|
+
const state = task.state;
|
|
4519
|
+
const carried = state && typeof state.amount === "number" ? state.amount : null;
|
|
4520
|
+
const note = input.note ?? config.note;
|
|
4521
|
+
const plan = renewalPlan(d, user, {
|
|
4522
|
+
name: blank(input.name) ? task.title : input.name,
|
|
4523
|
+
amount: input.amount ?? carried ?? config.amount,
|
|
4524
|
+
currency: blank(input.currency) ? config.currency : input.currency,
|
|
4525
|
+
renews: blank(input.renews) ? renewalDate(task, user, now) : input.renews,
|
|
4526
|
+
every: input.every ?? old.every,
|
|
4527
|
+
unit: input.unit ?? old.unit,
|
|
4528
|
+
lead: input.lead ?? old.leadDays ?? 0,
|
|
4529
|
+
...note !== undefined ? { note } : {}
|
|
4530
|
+
});
|
|
4531
|
+
if (!plan.ok)
|
|
4532
|
+
return plan;
|
|
4533
|
+
const sameShape = plan.schedule.every === old.every && plan.schedule.unit === old.unit && plan.schedule.leadDays === (old.leadDays ?? 0);
|
|
4534
|
+
const schedule = sameShape && periodDateFrom(old, plan.schedule.anchor, user) === plan.schedule.anchor ? old : plan.schedule;
|
|
4535
|
+
const problems = scheduleProblems(schedule);
|
|
4536
|
+
if (problems.length > 0)
|
|
4537
|
+
return { ok: false, error: `That schedule does not work: ${problems.join("; ")}.` };
|
|
4538
|
+
const patch = {};
|
|
4539
|
+
if (plan.name !== task.title)
|
|
4540
|
+
patch.title = plan.name;
|
|
4541
|
+
const newAmount = plan.amount !== (carried ?? config.amount);
|
|
4542
|
+
const nextConfig = { amount: newAmount ? plan.amount : config.amount, currency: plan.currency, ...plan.note ? { note: plan.note } : {} };
|
|
4543
|
+
if (!same(nextConfig, config))
|
|
4544
|
+
patch.config = nextConfig;
|
|
4545
|
+
if (newAmount && carried !== null)
|
|
4546
|
+
patch.state = { ...state, amount: plan.amount };
|
|
4547
|
+
let updated = await applyEdit(d, task, user, patch);
|
|
4548
|
+
let next = await nextRun(d, updated);
|
|
4549
|
+
if (!same(schedule, old)) {
|
|
4550
|
+
const asked = await askedAbout(d, task, user, schedule.anchor);
|
|
4551
|
+
updated = await rescheduleKeepingSnoozes(d, updated, user, schedule, user.id);
|
|
4552
|
+
next = (asked ? null : await firstAskIfDue(d, updated, user, schedule)) ?? await nextRun(d, updated);
|
|
4553
|
+
}
|
|
4554
|
+
const ask = updated.status === "paused" ? "paused" : askText(next, plan.days, user, now);
|
|
4555
|
+
const cadence = describeSchedule(schedule, user, user.timeZone, now);
|
|
4556
|
+
return { ok: true, task: updated, text: clip(`Saved \`${updated.id}\` (renewal): ${cadence}. Next ask: ${ask}.`) };
|
|
4557
|
+
}
|
|
4558
|
+
async function editPrice(d, user, taskId, input) {
|
|
4559
|
+
const task = await owned(d, user, taskId, "price");
|
|
4560
|
+
if (typeof task === "string")
|
|
4561
|
+
return { ok: false, error: task };
|
|
4562
|
+
const config = task.config;
|
|
4563
|
+
const schedule = task.schedule;
|
|
4564
|
+
const plan = priceSettingsPlan({
|
|
4565
|
+
name: input.name ?? task.title,
|
|
4566
|
+
hours: input.hours ?? (schedule?.kind === "poll" ? schedule.every : undefined),
|
|
4567
|
+
drop: input.drop ?? config.dropPercent ?? DEFAULT_DROP_PERCENT,
|
|
4568
|
+
baseline: input.baseline ?? config.baseline ?? DEFAULT_BASELINE
|
|
4569
|
+
});
|
|
4570
|
+
if (!plan.ok)
|
|
4571
|
+
return plan;
|
|
4572
|
+
const patch = {};
|
|
4573
|
+
const title = plan.name || titleFor(config.url);
|
|
4574
|
+
if (title !== task.title)
|
|
4575
|
+
patch.title = title;
|
|
4576
|
+
const nextConfig = { ...config, dropPercent: plan.drop, baseline: plan.baseline };
|
|
4577
|
+
if (!same(nextConfig, config))
|
|
4578
|
+
patch.config = nextConfig;
|
|
4579
|
+
let updated = await applyEdit(d, task, user, patch);
|
|
4580
|
+
if (schedule?.kind === "poll" && (schedule.every !== plan.hours || schedule.unit !== "hour")) {
|
|
4581
|
+
const next = { ...schedule, every: plan.hours, unit: "hour" };
|
|
4582
|
+
updated = await rescheduleKeepingSnoozes(d, updated, user, next, user.id);
|
|
4583
|
+
}
|
|
4584
|
+
return { ok: true, task: updated, text: savedText(updated, user, await nextRun(d, updated), d.clock.now(), "(price)") };
|
|
4585
|
+
}
|
|
4586
|
+
|
|
4240
4587
|
// node_modules/@rackbops/styles/rackbops-noir/bundle.css
|
|
4241
4588
|
var bundle_default = `:where([data-rb-style], [data-rb-style] *), :where([data-rb-style], [data-rb-style] *):before, :where([data-rb-style], [data-rb-style] *):after {
|
|
4242
4589
|
box-sizing: border-box;
|
|
@@ -5405,6 +5752,8 @@ var LAYOUT_CSS = `
|
|
|
5405
5752
|
.tr-main { max-width: 60rem; margin: 0 auto; padding: var(--rb-space-4); }
|
|
5406
5753
|
.tr-main > section { margin-bottom: var(--rb-space-5); }
|
|
5407
5754
|
.tr-stack { display: grid; gap: var(--rb-space-3); max-width: 28rem; }
|
|
5755
|
+
.tr-row { display: flex; flex-wrap: wrap; align-items: center; gap: var(--rb-space-2); }
|
|
5756
|
+
.tr-row form { margin: 0; }
|
|
5408
5757
|
.tr-foot { margin-top: var(--rb-space-5); }
|
|
5409
5758
|
`;
|
|
5410
5759
|
var STYLESHEET = `${THEME_CSS}
|
|
@@ -5506,6 +5855,28 @@ function readCookie(request, name) {
|
|
|
5506
5855
|
function cookie(name, value, o) {
|
|
5507
5856
|
return `${name}=${value}; Path=${o.base}/; Max-Age=${o.maxAgeSeconds}; HttpOnly; Secure; SameSite=${o.sameSite}`;
|
|
5508
5857
|
}
|
|
5858
|
+
async function readBody(request, max) {
|
|
5859
|
+
const declared = request.headers.get("content-length");
|
|
5860
|
+
if (declared !== null && !(/^[0-9]{1,12}$/.test(declared) && Number(declared) <= max))
|
|
5861
|
+
return null;
|
|
5862
|
+
if (!request.body)
|
|
5863
|
+
return "";
|
|
5864
|
+
const reader = request.body.getReader();
|
|
5865
|
+
const chunks = [];
|
|
5866
|
+
let size = 0;
|
|
5867
|
+
for (;; ) {
|
|
5868
|
+
const { done, value } = await reader.read();
|
|
5869
|
+
if (done)
|
|
5870
|
+
break;
|
|
5871
|
+
size += value.byteLength;
|
|
5872
|
+
if (size > max) {
|
|
5873
|
+
await reader.cancel().catch(() => {});
|
|
5874
|
+
return null;
|
|
5875
|
+
}
|
|
5876
|
+
chunks.push(value);
|
|
5877
|
+
}
|
|
5878
|
+
return Buffer.concat(chunks).toString("utf8");
|
|
5879
|
+
}
|
|
5509
5880
|
|
|
5510
5881
|
// plugins/tracker/src/web/pages.ts
|
|
5511
5882
|
function name3(u) {
|
|
@@ -5551,30 +5922,39 @@ function cadence(entry, viewer, now) {
|
|
|
5551
5922
|
return "once";
|
|
5552
5923
|
return describeSchedule(s, entry.from ?? viewer, viewer.timeZone, now);
|
|
5553
5924
|
}
|
|
5554
|
-
function
|
|
5925
|
+
function newLinks(v) {
|
|
5926
|
+
return html`<p class="tr-row">
|
|
5927
|
+
<a class="rb-btn rb-btn--primary rb-btn--sm" href="${v.base}/new/reminder">New reminder</a>
|
|
5928
|
+
<a class="rb-btn rb-btn--ghost rb-btn--sm" href="${v.base}/new/renewal">New renewal</a>
|
|
5929
|
+
<a class="rb-btn rb-btn--ghost rb-btn--sm" href="${v.base}/new/price">New price tracker</a>
|
|
5930
|
+
</p>`;
|
|
5931
|
+
}
|
|
5932
|
+
function tasksPage(v, data, now, flash = null) {
|
|
5555
5933
|
const rows = data.entries.map((e) => html`<tr>
|
|
5556
5934
|
<td><a class="rb-link" href="${v.base}/tasks/${e.task.id}">${e.task.title}</a></td>
|
|
5557
5935
|
<td>${e.from ? name3(e.from) : "you"}</td>
|
|
5558
5936
|
<td>${when2(e, v.user, now)}</td>
|
|
5559
5937
|
<td>${cadence(e, v.user, now)}</td>
|
|
5560
5938
|
</tr>`);
|
|
5561
|
-
const active = data.entries.length === 0 ? html`<p class="rb-muted">You have no active tasks
|
|
5939
|
+
const active = data.entries.length === 0 ? html`<p class="rb-muted">You have no active tasks.</p>` : html`<div class="rb-table-scroll"><table class="rb-table">
|
|
5562
5940
|
<thead><tr><th scope="col">Task</th><th scope="col">From</th><th scope="col">Next</th><th scope="col">Repeats</th></tr></thead>
|
|
5563
5941
|
<tbody>${rows}</tbody>
|
|
5564
5942
|
</table></div>`;
|
|
5565
5943
|
const paused = data.paused.length === 0 ? null : html`<section>
|
|
5566
5944
|
<h2>Paused</h2>
|
|
5567
|
-
<ul>${data.paused.map((p) => html`<li><a class="rb-link" href="${v.base}/tasks/${p.task.id}">${p.task.title}</a>${p.held.length > 0 ? html` -- I could not DM ${p.held.join(", ")};
|
|
5945
|
+
<ul>${data.paused.map((p) => html`<li><a class="rb-link" href="${v.base}/tasks/${p.task.id}">${p.task.title}</a>${p.held.length > 0 ? html` -- I could not DM ${p.held.join(", ")}; Resume on its page goes on without them.` : null}</li>`)}</ul>
|
|
5568
5946
|
</section>`;
|
|
5569
5947
|
return framed(v, "My tasks", html`<section>
|
|
5570
5948
|
<h1>My tasks</h1>
|
|
5949
|
+
${flash}
|
|
5571
5950
|
<p class="rb-muted">Times are in ${v.user.timeZone}.</p>
|
|
5951
|
+
${newLinks(v)}
|
|
5572
5952
|
${active}
|
|
5573
5953
|
</section>
|
|
5574
5954
|
${paused}
|
|
5575
5955
|
<p class="rb-muted tr-foot">${ADMIN_DISCLOSURE}</p>`);
|
|
5576
5956
|
}
|
|
5577
|
-
function historyPage(v, h) {
|
|
5957
|
+
function historyPage(v, h, extra = {}) {
|
|
5578
5958
|
const runs = h.runs.length === 0 ? html`<p class="rb-muted">No runs yet.</p>` : html`<div class="rb-table-scroll"><table class="rb-table">
|
|
5579
5959
|
<thead><tr><th scope="col">Due</th><th scope="col">Status</th><th scope="col">Answers</th></tr></thead>
|
|
5580
5960
|
<tbody>${h.runs.map((r) => html`<tr>
|
|
@@ -5586,7 +5966,9 @@ function historyPage(v, h) {
|
|
|
5586
5966
|
return framed(v, h.task.title, html`<section>
|
|
5587
5967
|
<p><a class="rb-link" href="${v.base}/">My tasks</a></p>
|
|
5588
5968
|
<h1>${h.task.title}</h1>
|
|
5589
|
-
|
|
5969
|
+
${extra.flash ?? null}
|
|
5970
|
+
<p>${h.task.type}, ${h.task.status === "archived" ? "deleted" : h.task.status}, ${h.cadence}. Next: ${h.task.status === "active" ? h.next ?? "nothing scheduled" : "nothing, while it is not active"}.</p>
|
|
5971
|
+
${extra.controls ?? null}
|
|
5590
5972
|
</section>
|
|
5591
5973
|
<section>
|
|
5592
5974
|
<h2>Runs</h2>
|
|
@@ -5614,7 +5996,7 @@ ${form.error ? html`<div class="rb-alert rb-alert--danger" role="alert"><p class
|
|
|
5614
5996
|
</div>
|
|
5615
5997
|
<div class="rb-field">
|
|
5616
5998
|
<label class="rb-label" for="zone">Time zone</label>
|
|
5617
|
-
<input class="rb-input" id="zone" name="zone" value="${form.zone}" maxlength="
|
|
5999
|
+
<input class="rb-input" id="zone" name="zone" value="${form.zone}" maxlength="${MAX_ZONE}" required${form.error ? html` aria-invalid="true"` : null}>
|
|
5618
6000
|
<p class="rb-field__help">An IANA name, such as America/New_York or Europe/London.</p>
|
|
5619
6001
|
</div>
|
|
5620
6002
|
<div><button class="rb-btn rb-btn--primary" type="submit">Save</button></div>
|
|
@@ -5629,6 +6011,390 @@ function errorPage(base, title, message) {
|
|
|
5629
6011
|
return page({ base, title, body: html`<section class="rb-card"><h1>${title}</h1><p>${message}</p></section>` });
|
|
5630
6012
|
}
|
|
5631
6013
|
|
|
6014
|
+
// plugins/tracker/src/web/editor-pages.ts
|
|
6015
|
+
var EDITOR_TYPES = ["reminder", "renewal", "price"];
|
|
6016
|
+
var REPEAT_OPTIONS = [["none", "once"], ["day", "daily"], ["week", "weekly"], ["month", "monthly"]];
|
|
6017
|
+
var UNIT_OPTIONS = [["year", "yearly"], ["month", "monthly"], ["week", "weekly"], ["day", "daily"]];
|
|
6018
|
+
var BASELINE_OPTIONS = [["last", "the last price seen"], ["first", "the first price seen"], ["peak", "the highest price seen"]];
|
|
6019
|
+
var PRICE_SETTINGS = [
|
|
6020
|
+
{ name: "name", label: "Name", kind: "text", maxlength: MAX_TITLE, help: "Leave it empty to use the page's address." },
|
|
6021
|
+
{ name: "hours", label: "Hours between checks", kind: "number", min: 1, max: MAX_POLL_HOURS, help: `1 to ${MAX_POLL_HOURS}; 12 when empty.` },
|
|
6022
|
+
{ name: "drop", label: "Alert on a drop of at least (percent)", kind: "text", decimal: true, help: "1 to 90; 10 when empty." },
|
|
6023
|
+
{ name: "baseline", label: "Measure the drop from", kind: "select", options: BASELINE_OPTIONS }
|
|
6024
|
+
];
|
|
6025
|
+
function fieldsFor(type, mode) {
|
|
6026
|
+
switch (type) {
|
|
6027
|
+
case "reminder":
|
|
6028
|
+
return [
|
|
6029
|
+
{ name: "text", label: "Remind me to", kind: "text", required: true, maxlength: MAX_REMINDER_TEXT },
|
|
6030
|
+
{
|
|
6031
|
+
name: "when",
|
|
6032
|
+
label: "When",
|
|
6033
|
+
kind: "text",
|
|
6034
|
+
maxlength: MAX_WHEN,
|
|
6035
|
+
help: mode === "new" ? 'For example "in 20 minutes", "tomorrow 9am", "fri at 17:30". A repeating one with none starts today at your preferred hour.' : "Leave it empty to keep the time it has, unless you change how it repeats."
|
|
6036
|
+
},
|
|
6037
|
+
{ name: "repeat", label: "Repeat", kind: "select", options: REPEAT_OPTIONS }
|
|
6038
|
+
];
|
|
6039
|
+
case "renewal":
|
|
6040
|
+
return [
|
|
6041
|
+
{ name: "name", label: "What renews", kind: "text", required: true, maxlength: MAX_TITLE, help: "A subscription, a domain, a warranty." },
|
|
6042
|
+
{ name: "amount", label: "What one period costs", kind: "text", required: true, decimal: true },
|
|
6043
|
+
{ name: "currency", label: "Currency", kind: "text", required: true, maxlength: CURRENCY_LENGTH, help: "A three-letter code, such as USD or EUR." },
|
|
6044
|
+
{ name: "renews", label: "Next renewal or expiry date", kind: "date", required: true },
|
|
6045
|
+
{ name: "unit", label: "Renews", kind: "select", options: UNIT_OPTIONS },
|
|
6046
|
+
{ name: "every", label: "Every how many of those", kind: "number", min: 1, max: MAX_EVERY, help: "1 when empty." },
|
|
6047
|
+
{ name: "lead", label: "Days before the date to ask", kind: "number", min: 0, max: MAX_LEAD_DAYS, help: "7 when empty." },
|
|
6048
|
+
{ name: "note", label: "Note", kind: "text", maxlength: MAX_NOTE, help: "Carried on every ask: where to cancel, which account." }
|
|
6049
|
+
];
|
|
6050
|
+
case "price":
|
|
6051
|
+
return mode === "edit" ? PRICE_SETTINGS : [
|
|
6052
|
+
{ name: "url", label: "Product page", kind: "url", required: true, maxlength: MAX_URL },
|
|
6053
|
+
...PRICE_SETTINGS,
|
|
6054
|
+
{ name: "near", label: "Words just before the price", kind: "text", maxlength: MAX_NEAR, help: "Only if I cannot find the price on my own." }
|
|
6055
|
+
];
|
|
6056
|
+
}
|
|
6057
|
+
}
|
|
6058
|
+
function prose(text) {
|
|
6059
|
+
return html`${text.split("`").map((part, i) => i % 2 === 1 ? html`<code>${part}</code>` : part)}`;
|
|
6060
|
+
}
|
|
6061
|
+
function field(f, values) {
|
|
6062
|
+
const value = values[f.name] ?? "";
|
|
6063
|
+
const help = f.help ? html`<p class="rb-field__help">${f.help}</p>` : null;
|
|
6064
|
+
if (f.kind === "select") {
|
|
6065
|
+
return html`<div class="rb-field">
|
|
6066
|
+
<label class="rb-label" for="${f.name}">${f.label}</label>
|
|
6067
|
+
<select class="rb-select" id="${f.name}" name="${f.name}">${(f.options ?? []).map(([v, label]) => html`<option value="${v}"${v === value ? html` selected` : null}>${label}</option>`)}</select>
|
|
6068
|
+
${help}
|
|
6069
|
+
</div>`;
|
|
6070
|
+
}
|
|
6071
|
+
const type = f.kind === "number" ? "number" : f.kind === "date" ? "date" : f.kind === "url" ? "url" : "text";
|
|
6072
|
+
return html`<div class="rb-field">
|
|
6073
|
+
<label class="rb-label" for="${f.name}">${f.label}</label>
|
|
6074
|
+
<input class="rb-input" id="${f.name}" name="${f.name}" type="${type}" value="${value}"${f.maxlength ? html` maxlength="${f.maxlength}"` : null}${f.min !== undefined ? html` min="${f.min}"` : null}${f.max !== undefined ? html` max="${f.max}"` : null}${f.decimal ? html` inputmode="decimal"` : null}${f.required ? html` required` : null}>
|
|
6075
|
+
${help}
|
|
6076
|
+
</div>`;
|
|
6077
|
+
}
|
|
6078
|
+
function alert(error) {
|
|
6079
|
+
return error ? html`<div class="rb-alert rb-alert--danger" role="alert"><p class="rb-alert__title">Not saved</p><p>${prose(error)}</p></div>` : null;
|
|
6080
|
+
}
|
|
6081
|
+
function taskHref(v, id, action = "") {
|
|
6082
|
+
return `${v.base}/tasks/${encodeURIComponent(id)}${action ? `/${action}` : ""}`;
|
|
6083
|
+
}
|
|
6084
|
+
var NOUN = { reminder: "reminder", renewal: "renewal", price: "price tracker" };
|
|
6085
|
+
function newTaskPage(v, type, values, error) {
|
|
6086
|
+
const intro = type === "price" ? html`<p class="rb-muted">I read the page once now; nothing is made unless I find a price in it.</p>` : null;
|
|
6087
|
+
return framed(v, `New ${NOUN[type]}`, html`<section>
|
|
6088
|
+
<p><a class="rb-link" href="${v.base}/">My tasks</a></p>
|
|
6089
|
+
<h1>New ${NOUN[type]}</h1>
|
|
6090
|
+
${intro}
|
|
6091
|
+
${alert(error)}
|
|
6092
|
+
<form method="post" action="${v.base}/new/${type}" class="tr-stack">
|
|
6093
|
+
<input type="hidden" name="csrf" value="${v.csrf}">
|
|
6094
|
+
${fieldsFor(type, "new").map((f) => field(f, values))}
|
|
6095
|
+
<div><button class="rb-btn rb-btn--primary" type="submit">Create</button></div>
|
|
6096
|
+
</form>
|
|
6097
|
+
</section>`);
|
|
6098
|
+
}
|
|
6099
|
+
function editTaskPage(v, task, values, o) {
|
|
6100
|
+
const type = task.type;
|
|
6101
|
+
return framed(v, `Edit ${task.title}`, html`<section>
|
|
6102
|
+
<p><a class="rb-link" href="${taskHref(v, task.id)}">Back to the task</a></p>
|
|
6103
|
+
<h1>Edit ${task.title}</h1>
|
|
6104
|
+
<p class="rb-muted">Now: ${o.now}.${task.status === "paused" ? " Paused." : ""}</p>
|
|
6105
|
+
${o.page ? html`<p class="rb-muted">Page: ${o.page}. To track another page, make a new price tracker.</p>` : null}
|
|
6106
|
+
${alert(o.error)}
|
|
6107
|
+
<form method="post" action="${taskHref(v, task.id, "edit")}" class="tr-stack">
|
|
6108
|
+
<input type="hidden" name="csrf" value="${v.csrf}">
|
|
6109
|
+
${fieldsFor(type, "edit").map((f) => field(f, values))}
|
|
6110
|
+
<div><button class="rb-btn rb-btn--primary" type="submit">Save</button></div>
|
|
6111
|
+
</form>
|
|
6112
|
+
</section>`);
|
|
6113
|
+
}
|
|
6114
|
+
function confirmDeletePage(v, task) {
|
|
6115
|
+
return framed(v, `Delete ${task.title}`, html`<section class="rb-card">
|
|
6116
|
+
<h1>Delete ${task.title}?</h1>
|
|
6117
|
+
<p>It stops at once and leaves your lists; nothing more is sent about it. Its history is kept, and an admin of this tracker can still see it.</p>
|
|
6118
|
+
<form method="post" action="${taskHref(v, task.id, "delete")}" class="tr-row">
|
|
6119
|
+
<input type="hidden" name="csrf" value="${v.csrf}">
|
|
6120
|
+
<input type="hidden" name="confirm" value="yes">
|
|
6121
|
+
<button class="rb-btn rb-btn--danger" type="submit">Delete</button>
|
|
6122
|
+
<a class="rb-btn rb-btn--ghost" href="${taskHref(v, task.id)}">Keep it</a>
|
|
6123
|
+
</form>
|
|
6124
|
+
</section>`);
|
|
6125
|
+
}
|
|
6126
|
+
function post(v, task, action, label, style) {
|
|
6127
|
+
return html`<form method="post" action="${taskHref(v, task.id, action)}"><input type="hidden" name="csrf" value="${v.csrf}"><button class="rb-btn ${style} rb-btn--sm" type="submit">${label}</button></form>`;
|
|
6128
|
+
}
|
|
6129
|
+
function ownerControls(v, task) {
|
|
6130
|
+
if (task.status === "archived")
|
|
6131
|
+
return null;
|
|
6132
|
+
const live = task.status === "active" || task.status === "paused";
|
|
6133
|
+
const editor = EDITOR_TYPES.includes(task.type);
|
|
6134
|
+
return html`<div class="tr-row">
|
|
6135
|
+
${live && editor ? html`<a class="rb-btn rb-btn--primary rb-btn--sm" href="${taskHref(v, task.id, "edit")}">Edit</a>` : null}
|
|
6136
|
+
${task.status === "active" ? post(v, task, "pause", "Pause", "rb-btn--ghost") : null}
|
|
6137
|
+
${task.status === "paused" ? post(v, task, "resume", "Resume", "rb-btn--accent") : null}
|
|
6138
|
+
${post(v, task, "delete", "Delete", "rb-btn--danger")}
|
|
6139
|
+
</div>`;
|
|
6140
|
+
}
|
|
6141
|
+
var NOTICES = {
|
|
6142
|
+
created: "Created.",
|
|
6143
|
+
saved: "Saved.",
|
|
6144
|
+
paused: "Paused: nothing is sent until you resume it.",
|
|
6145
|
+
resumed: "Resumed.",
|
|
6146
|
+
deleted: "Deleted. Its history is kept."
|
|
6147
|
+
};
|
|
6148
|
+
function notice(key, error) {
|
|
6149
|
+
if (error)
|
|
6150
|
+
return html`<div class="rb-alert rb-alert--danger" role="alert"><p>${prose(error)}</p></div>`;
|
|
6151
|
+
const text = key !== null && Object.hasOwn(NOTICES, key) ? NOTICES[key] : undefined;
|
|
6152
|
+
return text ? html`<div class="rb-alert rb-alert--success" role="status"><p class="rb-alert__title">${text}</p></div>` : null;
|
|
6153
|
+
}
|
|
6154
|
+
|
|
6155
|
+
// plugins/tracker/src/web/editor.ts
|
|
6156
|
+
var READING = "I am still reading the page of your last new price tracker; try again when it is done.";
|
|
6157
|
+
var GONE = "You are no longer on this tracker's list.";
|
|
6158
|
+
function asViewer(e, fn) {
|
|
6159
|
+
return e.queue(async () => {
|
|
6160
|
+
const user = await e.d.store.getUser(e.v.user.id);
|
|
6161
|
+
return user ? fn(user) : { ok: false, error: GONE };
|
|
6162
|
+
});
|
|
6163
|
+
}
|
|
6164
|
+
var str2 = (form, name) => form.get(name) ?? "";
|
|
6165
|
+
var opt = (form, name) => str2(form, name).trim() === "" ? undefined : str2(form, name);
|
|
6166
|
+
function int(form, name) {
|
|
6167
|
+
const raw = str2(form, name).trim();
|
|
6168
|
+
if (raw === "")
|
|
6169
|
+
return;
|
|
6170
|
+
return /^[0-9]{1,9}$/.test(raw) ? Number(raw) : Number.NaN;
|
|
6171
|
+
}
|
|
6172
|
+
function decimal(form, name) {
|
|
6173
|
+
const raw = str2(form, name).trim();
|
|
6174
|
+
if (raw === "")
|
|
6175
|
+
return;
|
|
6176
|
+
return /^[0-9]{1,12}(\.[0-9]{1,6})?$/.test(raw) ? Number(raw) : Number.NaN;
|
|
6177
|
+
}
|
|
6178
|
+
function typed(type, mode, form) {
|
|
6179
|
+
return Object.fromEntries(fieldsFor(type, mode).map((f) => [f.name, str2(form, f.name)]));
|
|
6180
|
+
}
|
|
6181
|
+
function reminderInput(form) {
|
|
6182
|
+
const when = opt(form, "when");
|
|
6183
|
+
return { text: str2(form, "text"), ...when !== undefined ? { when } : {}, repeat: str2(form, "repeat") || "none" };
|
|
6184
|
+
}
|
|
6185
|
+
function renewalInput(form) {
|
|
6186
|
+
const every = int(form, "every");
|
|
6187
|
+
const lead = int(form, "lead");
|
|
6188
|
+
const unit = opt(form, "unit");
|
|
6189
|
+
const note = opt(form, "note");
|
|
6190
|
+
return {
|
|
6191
|
+
name: str2(form, "name"),
|
|
6192
|
+
amount: decimal(form, "amount") ?? Number.NaN,
|
|
6193
|
+
currency: str2(form, "currency"),
|
|
6194
|
+
renews: str2(form, "renews"),
|
|
6195
|
+
...every !== undefined ? { every } : {},
|
|
6196
|
+
...lead !== undefined ? { lead } : {},
|
|
6197
|
+
...unit !== undefined ? { unit } : {},
|
|
6198
|
+
...note !== undefined ? { note } : {}
|
|
6199
|
+
};
|
|
6200
|
+
}
|
|
6201
|
+
function priceSettings(form) {
|
|
6202
|
+
const name = opt(form, "name");
|
|
6203
|
+
const hours = int(form, "hours");
|
|
6204
|
+
const drop = decimal(form, "drop");
|
|
6205
|
+
const baseline = opt(form, "baseline");
|
|
6206
|
+
return {
|
|
6207
|
+
...name !== undefined ? { name } : {},
|
|
6208
|
+
...hours !== undefined ? { hours } : {},
|
|
6209
|
+
...drop !== undefined ? { drop } : {},
|
|
6210
|
+
...baseline !== undefined ? { baseline } : {}
|
|
6211
|
+
};
|
|
6212
|
+
}
|
|
6213
|
+
function reminderEdit(form) {
|
|
6214
|
+
const text = opt(form, "text");
|
|
6215
|
+
const when = opt(form, "when");
|
|
6216
|
+
const repeat = opt(form, "repeat");
|
|
6217
|
+
return { ...text !== undefined ? { text } : {}, ...when !== undefined ? { when } : {}, ...repeat !== undefined ? { repeat } : {} };
|
|
6218
|
+
}
|
|
6219
|
+
function renewalEdit(form) {
|
|
6220
|
+
const name = opt(form, "name");
|
|
6221
|
+
const amount = decimal(form, "amount");
|
|
6222
|
+
const currency = opt(form, "currency");
|
|
6223
|
+
const renews = opt(form, "renews");
|
|
6224
|
+
const every = int(form, "every");
|
|
6225
|
+
const lead = int(form, "lead");
|
|
6226
|
+
const unit = opt(form, "unit");
|
|
6227
|
+
return {
|
|
6228
|
+
...name !== undefined ? { name } : {},
|
|
6229
|
+
...amount !== undefined ? { amount } : {},
|
|
6230
|
+
...currency !== undefined ? { currency } : {},
|
|
6231
|
+
...renews !== undefined ? { renews } : {},
|
|
6232
|
+
...every !== undefined ? { every } : {},
|
|
6233
|
+
...lead !== undefined ? { lead } : {},
|
|
6234
|
+
...unit !== undefined ? { unit } : {},
|
|
6235
|
+
...form.has("note") ? { note: str2(form, "note") } : {}
|
|
6236
|
+
};
|
|
6237
|
+
}
|
|
6238
|
+
function priceEdit(form) {
|
|
6239
|
+
const { name: _name, ...rest } = priceSettings(form);
|
|
6240
|
+
return { ...rest, ...form.has("name") ? { name: str2(form, "name") } : {} };
|
|
6241
|
+
}
|
|
6242
|
+
var DEFAULTS = {
|
|
6243
|
+
reminder: { repeat: "none" },
|
|
6244
|
+
renewal: { unit: "year", every: "1", lead: "7" },
|
|
6245
|
+
price: { hours: "12", drop: "10", baseline: "last" }
|
|
6246
|
+
};
|
|
6247
|
+
function newGet(e, type) {
|
|
6248
|
+
return htmlResponse(newTaskPage(e.v, type, type === "renewal" ? { ...DEFAULTS.renewal, currency: "USD" } : DEFAULTS[type]));
|
|
6249
|
+
}
|
|
6250
|
+
async function newPost(e, type, form) {
|
|
6251
|
+
let made;
|
|
6252
|
+
if (type === "reminder")
|
|
6253
|
+
made = await asViewer(e, (u) => createReminder(e.d, u, reminderInput(form)));
|
|
6254
|
+
else if (type === "renewal")
|
|
6255
|
+
made = await asViewer(e, (u) => createRenewal(e.d, u, renewalInput(form)));
|
|
6256
|
+
else {
|
|
6257
|
+
const near = opt(form, "near");
|
|
6258
|
+
const input = { url: str2(form, "url"), ...priceSettings(form), ...near !== undefined ? { near } : {} };
|
|
6259
|
+
const who = e.v.user.id;
|
|
6260
|
+
if (e.reading.has(who))
|
|
6261
|
+
made = { ok: false, error: READING };
|
|
6262
|
+
else {
|
|
6263
|
+
e.reading.add(who);
|
|
6264
|
+
try {
|
|
6265
|
+
const started = await asViewer(e, (u) => startPrice(e.d, u, input));
|
|
6266
|
+
if (!started.ok)
|
|
6267
|
+
made = started;
|
|
6268
|
+
else {
|
|
6269
|
+
const { pending } = started;
|
|
6270
|
+
const seen = await previewPrice(e.d, pending);
|
|
6271
|
+
made = await asViewer(e, (u) => finishPrice(e.d, u, pending, seen));
|
|
6272
|
+
}
|
|
6273
|
+
} finally {
|
|
6274
|
+
e.reading.delete(who);
|
|
6275
|
+
}
|
|
6276
|
+
}
|
|
6277
|
+
}
|
|
6278
|
+
if (!made.ok)
|
|
6279
|
+
return htmlResponse(newTaskPage(e.v, type, typed(type, "new", form), made.error), 400);
|
|
6280
|
+
return redirect(`${taskHref(e.v, made.task.id)}?done=created`);
|
|
6281
|
+
}
|
|
6282
|
+
async function taskPage(e, id, done, error) {
|
|
6283
|
+
const view = await loadHistory(e.d, e.v.user, id);
|
|
6284
|
+
if (!view)
|
|
6285
|
+
return htmlResponse(notFoundPage(e.v.base, e.v), 404);
|
|
6286
|
+
const controls = view.task.ownerId === e.v.user.id ? ownerControls(e.v, view.task) : null;
|
|
6287
|
+
return htmlResponse(historyPage(e.v, view, { controls, flash: notice(done, error) }), error ? 400 : 200);
|
|
6288
|
+
}
|
|
6289
|
+
function current(d, user, task) {
|
|
6290
|
+
const now = d.clock.now();
|
|
6291
|
+
if (task.type === "reminder")
|
|
6292
|
+
return { text: String(task.config?.text ?? ""), when: "", repeat: repeatOf(task.schedule) };
|
|
6293
|
+
if (task.type === "renewal" && task.schedule?.kind === "period") {
|
|
6294
|
+
const s = task.schedule;
|
|
6295
|
+
const c = task.config;
|
|
6296
|
+
const carried = task.state?.amount;
|
|
6297
|
+
const renews = renewalDate(task, user, now);
|
|
6298
|
+
return {
|
|
6299
|
+
name: task.title,
|
|
6300
|
+
amount: String(typeof carried === "number" ? carried : c.amount),
|
|
6301
|
+
currency: c.currency,
|
|
6302
|
+
renews,
|
|
6303
|
+
unit: s.unit,
|
|
6304
|
+
every: String(s.every),
|
|
6305
|
+
lead: String(s.leadDays ?? 0),
|
|
6306
|
+
note: c.note ?? ""
|
|
6307
|
+
};
|
|
6308
|
+
}
|
|
6309
|
+
if (task.type !== "price")
|
|
6310
|
+
return {};
|
|
6311
|
+
const c = task.config;
|
|
6312
|
+
const every = task.schedule?.kind === "poll" ? task.schedule.every : 12;
|
|
6313
|
+
return { name: task.title, hours: String(every), drop: String(c.dropPercent ?? 10), baseline: c.baseline ?? "last" };
|
|
6314
|
+
}
|
|
6315
|
+
function editPage(e, task, values, error) {
|
|
6316
|
+
const now = task.schedule ? describeSchedule(task.schedule, e.v.user, e.v.user.timeZone, e.d.clock.now()) : "no schedule";
|
|
6317
|
+
const page = task.type === "price" ? String(task.config.url) : undefined;
|
|
6318
|
+
return editTaskPage(e.v, task, values, { now, ...page !== undefined ? { page } : {}, ...error !== undefined ? { error } : {} });
|
|
6319
|
+
}
|
|
6320
|
+
async function editableTask(e, id) {
|
|
6321
|
+
const task = await ownLiveTask(e.d, e.v.user, id);
|
|
6322
|
+
return task && (task.type === "reminder" || task.type === "renewal" || task.type === "price") ? task : null;
|
|
6323
|
+
}
|
|
6324
|
+
async function editGet(e, id) {
|
|
6325
|
+
const task = await editableTask(e, id);
|
|
6326
|
+
if (!task)
|
|
6327
|
+
return htmlResponse(notFoundPage(e.v.base, e.v), 404);
|
|
6328
|
+
if (task.status !== "active" && task.status !== "paused")
|
|
6329
|
+
return redirect(taskHref(e.v, task.id));
|
|
6330
|
+
return htmlResponse(editPage(e, task, current(e.d, e.v.user, task)));
|
|
6331
|
+
}
|
|
6332
|
+
async function editPost(e, id, form) {
|
|
6333
|
+
const task = await editableTask(e, id);
|
|
6334
|
+
if (!task)
|
|
6335
|
+
return htmlResponse(notFoundPage(e.v.base, e.v), 404);
|
|
6336
|
+
const saved = task.type === "reminder" ? await asViewer(e, (u) => editReminder(e.d, u, task.id, reminderEdit(form))) : task.type === "renewal" ? await asViewer(e, (u) => editRenewal(e.d, u, task.id, renewalEdit(form))) : await asViewer(e, (u) => editPrice(e.d, u, task.id, priceEdit(form)));
|
|
6337
|
+
if (saved.ok)
|
|
6338
|
+
return redirect(`${taskHref(e.v, task.id)}?done=saved`);
|
|
6339
|
+
if (saved.error === NO_SUCH_TASK)
|
|
6340
|
+
return htmlResponse(notFoundPage(e.v.base, e.v), 404);
|
|
6341
|
+
return htmlResponse(editPage(e, task, typed(task.type, "edit", form), saved.error), 400);
|
|
6342
|
+
}
|
|
6343
|
+
async function actionPost(e, id, action, form) {
|
|
6344
|
+
const task = await ownLiveTask(e.d, e.v.user, id);
|
|
6345
|
+
if (!task)
|
|
6346
|
+
return htmlResponse(notFoundPage(e.v.base, e.v), 404);
|
|
6347
|
+
if (action === "delete" && str2(form, "confirm") !== "yes")
|
|
6348
|
+
return htmlResponse(confirmDeletePage(e.v, task));
|
|
6349
|
+
const act = action === "pause" ? pauseTask : action === "resume" ? resumeTask : deleteTask;
|
|
6350
|
+
const done = await asViewer(e, (u) => act(e.d, u, task.id));
|
|
6351
|
+
if (!done.ok)
|
|
6352
|
+
return done.error === NO_SUCH_TASK ? htmlResponse(notFoundPage(e.v.base, e.v), 404) : taskPage(e, task.id, null, done.error);
|
|
6353
|
+
if (action === "delete")
|
|
6354
|
+
return redirect(`${e.v.base}/?done=deleted`);
|
|
6355
|
+
return redirect(`${taskHref(e.v, task.id)}?done=${action === "pause" ? "paused" : "resumed"}`);
|
|
6356
|
+
}
|
|
6357
|
+
|
|
6358
|
+
// plugins/tracker/src/web/routes.ts
|
|
6359
|
+
var TASK_PATH = /^\/tasks\/([^/]+)$/;
|
|
6360
|
+
var TASK_ACTION = /^\/tasks\/([^/]+)\/(edit|pause|resume|delete)$/;
|
|
6361
|
+
var NEW_PATH = /^\/new\/([a-z]+)$/;
|
|
6362
|
+
function route(path) {
|
|
6363
|
+
if (path === "/")
|
|
6364
|
+
return { kind: "tasks" };
|
|
6365
|
+
if (path === "/settings")
|
|
6366
|
+
return { kind: "settings" };
|
|
6367
|
+
if (path === "/logout")
|
|
6368
|
+
return { kind: "logout" };
|
|
6369
|
+
const created = NEW_PATH.exec(path);
|
|
6370
|
+
if (created) {
|
|
6371
|
+
const type = created[1];
|
|
6372
|
+
return EDITOR_TYPES.includes(type) ? { kind: "new", type } : null;
|
|
6373
|
+
}
|
|
6374
|
+
const one = TASK_PATH.exec(path) ?? TASK_ACTION.exec(path);
|
|
6375
|
+
if (!one)
|
|
6376
|
+
return null;
|
|
6377
|
+
let id;
|
|
6378
|
+
try {
|
|
6379
|
+
id = decodeURIComponent(one[1] ?? "");
|
|
6380
|
+
} catch {
|
|
6381
|
+
id = "";
|
|
6382
|
+
}
|
|
6383
|
+
const action = one[2];
|
|
6384
|
+
if (action === undefined)
|
|
6385
|
+
return { kind: "task", id };
|
|
6386
|
+
if (action === "edit")
|
|
6387
|
+
return { kind: "edit", id };
|
|
6388
|
+
return { kind: "act", id, action };
|
|
6389
|
+
}
|
|
6390
|
+
function methodsOf(r) {
|
|
6391
|
+
if (r.kind === "logout" || r.kind === "act")
|
|
6392
|
+
return "POST";
|
|
6393
|
+
if (r.kind === "settings" || r.kind === "edit" || r.kind === "new")
|
|
6394
|
+
return "GET, POST";
|
|
6395
|
+
return "GET";
|
|
6396
|
+
}
|
|
6397
|
+
|
|
5632
6398
|
// plugins/tracker/src/web/sessions.ts
|
|
5633
6399
|
var SESSION_TTL_MS = 7 * 24 * 60 * 60 * 1000;
|
|
5634
6400
|
|
|
@@ -5670,8 +6436,7 @@ var MEMBER_CHECK_TIMEOUT_MS = 3000;
|
|
|
5670
6436
|
var MEMBER_RETRY_MS = 60 * 1000;
|
|
5671
6437
|
var LEFT_SERVER = `You are signed out. ${NOT_MEMBER}`;
|
|
5672
6438
|
var RECHECK_FAILED = "You are signed out: I could not check that you are still a member of this tracker's server.";
|
|
5673
|
-
var MAX_FORM_BYTES =
|
|
5674
|
-
var TASK_PATH = /^\/tasks\/([^/]+)$/;
|
|
6439
|
+
var MAX_FORM_BYTES = 32 * 1024;
|
|
5675
6440
|
function plain(status, text, extra = {}) {
|
|
5676
6441
|
return new Response(text, {
|
|
5677
6442
|
status,
|
|
@@ -5696,6 +6461,7 @@ function createWebHandler(w) {
|
|
|
5696
6461
|
const clear = (name) => cookie(name, "", { base, maxAgeSeconds: 0, sameSite: "Lax" });
|
|
5697
6462
|
const inFlight = new Map;
|
|
5698
6463
|
const failedAt = new Map;
|
|
6464
|
+
const reading = new Set;
|
|
5699
6465
|
async function authenticate(d, request) {
|
|
5700
6466
|
const id = readCookie(request, SESSION_COOKIE);
|
|
5701
6467
|
if (!id)
|
|
@@ -5765,10 +6531,8 @@ function createWebHandler(w) {
|
|
|
5765
6531
|
const type = request.headers.get("content-type") ?? "";
|
|
5766
6532
|
if (!type.toLowerCase().startsWith("application/x-www-form-urlencoded"))
|
|
5767
6533
|
return null;
|
|
5768
|
-
const body = await request
|
|
5769
|
-
|
|
5770
|
-
return null;
|
|
5771
|
-
return new URLSearchParams(body);
|
|
6534
|
+
const body = await readBody(request, MAX_FORM_BYTES);
|
|
6535
|
+
return body === null ? null : new URLSearchParams(body);
|
|
5772
6536
|
}
|
|
5773
6537
|
function forbidden() {
|
|
5774
6538
|
return htmlResponse(errorPage(base, "Refused", "That form was stale or came from somewhere else. Go back, reload the page and try again."), 403);
|
|
@@ -5806,7 +6570,7 @@ function createWebHandler(w) {
|
|
|
5806
6570
|
let error = null;
|
|
5807
6571
|
if (!/^([0-9]|1[0-9]|2[0-3])$/.test(hour))
|
|
5808
6572
|
error = "The preferred hour must be a whole hour from 0 to 23.";
|
|
5809
|
-
else if (zone === "" || zone.length >
|
|
6573
|
+
else if (zone === "" || zone.length > MAX_ZONE)
|
|
5810
6574
|
error = "Give a time zone, such as America/New_York.";
|
|
5811
6575
|
else {
|
|
5812
6576
|
error = await w.queue(async () => {
|
|
@@ -5844,45 +6608,52 @@ function createWebHandler(w) {
|
|
|
5844
6608
|
return only("GET") ?? htmlResponse(signInHelpPage(base, url.searchParams.get("out") === "1" ? "You are signed out." : undefined));
|
|
5845
6609
|
if (path === "/login")
|
|
5846
6610
|
return method === "GET" ? loginGet(d, url) : loginPost(d, request);
|
|
5847
|
-
const
|
|
5848
|
-
|
|
5849
|
-
if (route === null)
|
|
6611
|
+
const r = route(path);
|
|
6612
|
+
if (r === null)
|
|
5850
6613
|
return htmlResponse(notFoundPage(base), 404);
|
|
5851
|
-
const
|
|
5852
|
-
if (
|
|
5853
|
-
return
|
|
6614
|
+
const allowed = methodsOf(r);
|
|
6615
|
+
if (!allowed.split(", ").includes(method))
|
|
6616
|
+
return plain(405, "Method not allowed", { Allow: allowed });
|
|
5854
6617
|
const auth = await authenticate(d, request);
|
|
5855
6618
|
if (auth.kind === "signed-out")
|
|
5856
6619
|
return htmlResponse(signInHelpPage(base, auth.note), 403, { "Set-Cookie": clear(SESSION_COOKIE) });
|
|
5857
6620
|
if (auth.kind !== "ok")
|
|
5858
6621
|
return redirect(`${base}/signin`, auth.kind === "stale" ? [clear(SESSION_COOKIE)] : []);
|
|
5859
6622
|
const v = { base, user: auth.user, csrf: auth.session.csrf };
|
|
6623
|
+
const e = { d, v, queue: w.queue, reading };
|
|
5860
6624
|
if (method === "POST") {
|
|
5861
6625
|
const form = await readForm(request);
|
|
5862
6626
|
if (!form)
|
|
5863
6627
|
return plain(400, "Bad request");
|
|
5864
6628
|
if (!safeEqual(auth.session.csrf, form.get("csrf") ?? ""))
|
|
5865
6629
|
return forbidden();
|
|
5866
|
-
|
|
5867
|
-
|
|
5868
|
-
|
|
6630
|
+
switch (r.kind) {
|
|
6631
|
+
case "logout":
|
|
6632
|
+
d.sessions.delete(auth.id);
|
|
6633
|
+
return redirect(`${base}/signin?out=1`, [clear(SESSION_COOKIE)]);
|
|
6634
|
+
case "settings":
|
|
6635
|
+
return settingsPost(d, v, form);
|
|
6636
|
+
case "new":
|
|
6637
|
+
return newPost(e, r.type, form);
|
|
6638
|
+
case "edit":
|
|
6639
|
+
return editPost(e, r.id, form);
|
|
6640
|
+
case "act":
|
|
6641
|
+
return actionPost(e, r.id, r.action, form);
|
|
6642
|
+
default:
|
|
6643
|
+
return plain(405, "Method not allowed", { Allow: allowed });
|
|
5869
6644
|
}
|
|
5870
|
-
return settingsPost(d, v, form);
|
|
5871
6645
|
}
|
|
5872
6646
|
const now = d.clock.now();
|
|
5873
|
-
|
|
6647
|
+
const done = url.searchParams.get("done");
|
|
6648
|
+
switch (r.kind) {
|
|
5874
6649
|
case "tasks":
|
|
5875
|
-
return htmlResponse(tasksPage(v, await loadTaskList(d, auth.user), now));
|
|
5876
|
-
case "
|
|
5877
|
-
|
|
5878
|
-
|
|
5879
|
-
|
|
5880
|
-
|
|
5881
|
-
|
|
5882
|
-
}
|
|
5883
|
-
const view = await loadHistory(d, auth.user, id);
|
|
5884
|
-
return view ? htmlResponse(historyPage(v, view)) : htmlResponse(notFoundPage(base, v), 404);
|
|
5885
|
-
}
|
|
6650
|
+
return htmlResponse(tasksPage(v, await loadTaskList(d, auth.user), now, notice(done)));
|
|
6651
|
+
case "task":
|
|
6652
|
+
return taskPage(e, r.id, done);
|
|
6653
|
+
case "edit":
|
|
6654
|
+
return editGet(e, r.id);
|
|
6655
|
+
case "new":
|
|
6656
|
+
return newGet(e, r.type);
|
|
5886
6657
|
default:
|
|
5887
6658
|
return htmlResponse(settingsPage(v, { hour: String(auth.user.preferredHour), zone: auth.user.timeZone, saved: url.searchParams.get("saved") === "1" }));
|
|
5888
6659
|
}
|
|
@@ -6059,7 +6830,8 @@ function createPlugin(host, options = {}) {
|
|
|
6059
6830
|
log: host.log,
|
|
6060
6831
|
notifier: dm ? createDmNotifier({ store: openedStore, claims: openedClaims, dm, clock, log: host.log, health: delivery }) : NO_DM,
|
|
6061
6832
|
logins: new LoginLinks(opened),
|
|
6062
|
-
sessions: new Sessions(opened)
|
|
6833
|
+
sessions: new Sessions(opened),
|
|
6834
|
+
webEditor: webOrigin !== null
|
|
6063
6835
|
};
|
|
6064
6836
|
if (guildId === null)
|
|
6065
6837
|
host.log.warn("TRACKER_GUILD_ID is unset: no membership gate, only the admission list");
|