Repository navigation
Expand file tree
/
Copy pathbackground.js
More file actions
327 lines (291 loc) · 11 KB
/
Copy pathbackground.js
File metadata and controls
327 lines (291 loc) · 11 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
// Background service worker: per-todo daily reminders so a missed check-in
// doesn't slip by. Each unfinished todo fires at its own reminderTime, or at the
// global daily time when it has none.
// Share the repeat-cycle date logic with the popup so "明天重置" stays identical
// whether the popup is open or not.
importScripts('i18n.js', 'todo-core.js');
const { nextRepeatResetTime, rolloverRepeatTodos } = globalThis.TodoCore;
chrome.runtime.onInstalled.addListener(reschedule);
chrome.runtime.onStartup.addListener(reschedule);
const recentNotifications = new Map();
const NOTIFICATION_DEDUPE_MS = 60000;
// popup tells us to reschedule whenever todos or reminder settings change.
chrome.runtime.onMessage.addListener((msg) => {
if (!msg) return;
if (msg.type === 'updateReminder') {
reschedule((scheduled) => {
console.log('[Sisyphus] scheduled reminders', scheduled);
});
}
});
chrome.alarms.onAlarm.addListener((alarm) => {
const parsed = parseAlarmName(alarm.name);
if (!parsed) return;
// Repeat 真循环: a reset alarm flips a completed repeat todo back to active for
// its next period, then reschedules so the normal reminder fires again.
if (parsed.kind === 'reset') {
chrome.storage.local.get(['todos'], (res) => {
const todos = Array.isArray(res.todos) ? res.todos : [];
const result = rolloverRepeatTodos(todos, Date.now());
if (result.changed) {
chrome.storage.local.set({ todos: result.todos }, () => {
if (chrome.runtime.lastError) {
console.warn('[Sisyphus] rollover save failed:', chrome.runtime.lastError.message);
}
reschedule();
});
} else {
reschedule();
}
});
return;
}
chrome.storage.local.get(['todos', 'reminderEnabled', 'snoozeMinutes'], (res) => {
if (res.reminderEnabled === false) return;
const todos = Array.isArray(res.todos) ? res.todos : [];
const todo = todos.find((t) => String(t.id) === parsed.id);
if (!todo || todo.completed) return; // gone or already done — stay quiet
if (parsed.kind === 'snooze') {
delete todo.snoozedUntil;
chrome.storage.local.set({ todos }, () => {
if (chrome.runtime.lastError) {
console.warn('[Sisyphus] snooze-clear save failed:', chrome.runtime.lastError.message);
}
});
}
if (!shouldNotifyTodo(todo.id)) return;
if (parsed.kind === 'todo') chrome.alarms.clear('snooze_' + todo.id);
const snoozeMinutes = clampMinutes(res.snoozeMinutes, [5, 10, 15, 30], 10);
notifyTodo(todo, snoozeMinutes);
const nagMinutes = clampMinutes(todo.nagMinutes, [5, 10, 15, 30], 0);
if (parsed.kind === 'todo' && nagMinutes > 0) {
chrome.alarms.create('snooze_' + todo.id, {
when: Date.now() + nagMinutes * 60 * 1000
});
}
});
});
// If the OS toast hides action buttons, clicking the notification itself is the
// fallback "snooze" action. Closing/dismissing the toast still only dismisses it.
chrome.notifications.onClicked.addListener((id) => {
const todoId = notificationTodoId(id);
if (!todoId) {
chrome.notifications.clear(id);
return;
}
snoozeTodo(todoId, id);
});
chrome.notifications.onButtonClicked.addListener((notificationId, buttonIndex) => {
const todoId = notificationTodoId(notificationId);
if (!todoId) {
chrome.notifications.clear(notificationId);
return;
}
if (buttonIndex === 0) {
snoozeTodo(todoId, notificationId);
return;
}
if (buttonIndex === 1) {
completeTodo(todoId, notificationId);
}
});
function reschedule(done) {
chrome.storage.local.get(['reminderEnabled', 'reminderTime', 'todos'], (res) => {
const enabled = res.reminderEnabled !== false; // default ON
const globalTime = res.reminderTime || '20:00';
const todos = Array.isArray(res.todos) ? res.todos : [];
const scheduled = [];
chrome.alarms.getAll((alarms) => {
// drop every previous reminder/reset alarm (and the legacy global one)
alarms.forEach((a) => {
if (a.name === 'dailyReminder' || a.name.startsWith('todo_') || a.name.startsWith('reset_')) {
chrome.alarms.clear(a.name);
}
});
// Repeat 真循环: schedule each completed repeat todo to come back at the
// start of its next period. Runs even when reminders are off, since this is
// about the todo reappearing, not about notifying.
todos.forEach((t) => {
if (!t.completed) return;
const when = nextRepeatResetTime(t.completedAt, t.repeat);
if (when != null) chrome.alarms.create('reset_' + t.id, { when });
});
if (!enabled) {
if (typeof done === 'function') done(scheduled);
return;
}
// one alarm per unfinished todo, at its own time or the global default
todos
.filter((t) => !t.completed)
.forEach((t) => {
const time = t.reminderTime || globalTime;
const alarmOptions = reminderAlarmOptions(t, time);
if (!alarmOptions) return;
chrome.alarms.create('todo_' + t.id, alarmOptions);
scheduled.push({
id: t.id,
text: t.text,
time,
whenMs: alarmOptions.when,
when: new Date(alarmOptions.when).toLocaleString()
});
});
if (typeof done === 'function') done(scheduled);
});
});
}
function notifyTodo(todo, snoozeMinutes) {
const id = 'todo_' + todo.id;
// Reminders fire while the popup is closed, so resolve the UI language straight
// from storage here; buttons stay English (shared technical labels).
chrome.storage.local.get(['lang'], (res) => {
if (typeof I18N !== 'undefined' && res && res.lang) I18N.setLocale(res.lang);
const tr = (key, vars) => (typeof I18N !== 'undefined' ? I18N.t(key, vars) : null);
const options = {
type: 'basic',
iconUrl: 'icon.png',
title: todo.text || tr('notify.titleFallback') || 'Reminder',
message: tr('notify.clickToSnooze', { min: snoozeMinutes }) || `Click to snooze ${snoozeMinutes} min`,
priority: 2,
requireInteraction: true,
buttons: [
{ title: `${snoozeMinutes} min later` },
{ title: 'Done' }
]
};
// Stable ids make repeated daily/snooze alarms replace the same toast instead
// of stacking duplicate Sisyphus notifications.
clearOldTodoNotifications(todo.id, id, () => {
chrome.notifications.create(id, options);
});
});
}
function shouldNotifyTodo(todoId) {
const key = String(todoId);
const now = Date.now();
const last = recentNotifications.get(key) || 0;
if (now - last < NOTIFICATION_DEDUPE_MS) return false;
recentNotifications.set(key, now);
return true;
}
function clearOldTodoNotifications(todoId, stableId, done) {
chrome.notifications.getAll((items) => {
const stalePrefix = stableId + '_';
const ids = Object.keys(items || {}).filter(id => id === stableId || id.startsWith(stalePrefix));
if (ids.length === 0) {
done();
return;
}
let left = ids.length;
ids.forEach((id) => {
chrome.notifications.clear(id, () => {
left -= 1;
if (left === 0) done();
});
});
});
}
function parseAlarmName(name) {
if (name.startsWith('todo_')) return { kind: 'todo', id: name.slice('todo_'.length) };
if (name.startsWith('snooze_')) return { kind: 'snooze', id: name.slice('snooze_'.length) };
if (name.startsWith('reset_')) return { kind: 'reset', id: name.slice('reset_'.length) };
return null;
}
function notificationTodoId(notificationId) {
if (!notificationId.startsWith('todo_')) return null;
return notificationId.slice('todo_'.length);
}
function clampMinutes(value, allowed, fallback) {
const n = parseInt(value, 10);
return allowed.includes(n) ? n : fallback;
}
function snoozeTodo(todoId, notificationId) {
chrome.storage.local.get(['snoozeMinutes', 'todos'], (res) => {
const todos = Array.isArray(res.todos) ? res.todos : [];
const todo = todos.find((t) => String(t.id) === String(todoId));
// Deleted meanwhile: writing the whole (possibly stale) array back would
// widen the lost-update window for zero benefit, and a snooze alarm for a
// missing todo would fire into silence anyway.
if (!todo) {
chrome.notifications.clear(notificationId);
return;
}
const minutes = clampMinutes(res.snoozeMinutes, [5, 10, 15, 30], 10);
const when = Date.now() + minutes * 60 * 1000;
chrome.alarms.create('snooze_' + todoId, {
when
});
todo.snoozedUntil = when;
chrome.storage.local.set({ todos }, () => {
if (chrome.runtime.lastError) {
console.warn('[Sisyphus] snooze save failed:', chrome.runtime.lastError.message);
}
chrome.notifications.clear(notificationId);
});
});
}
function completeTodo(todoId, notificationId) {
chrome.storage.local.get(['todos'], (res) => {
const todos = Array.isArray(res.todos) ? res.todos : [];
const todo = todos.find((t) => String(t.id) === String(todoId));
if (todo) {
todo.completed = true;
todo.completedAt = Date.now();
chrome.storage.local.set({ todos }, () => {
if (chrome.runtime.lastError) {
console.warn('[Sisyphus] complete save failed:', chrome.runtime.lastError.message);
}
reschedule();
});
}
chrome.alarms.clear('snooze_' + todoId);
chrome.notifications.clear(notificationId);
});
}
function reminderAlarmOptions(todo, hhmm) {
// A dated one-shot either fires once at its due moment or not at all. Without
// this explicit branch an EXPIRED one-shot (dueDate in the past) would fall
// through to the daily branch and nag every day forever.
if (todo && todo.oneShotReminder && /^\d{4}-\d{2}-\d{2}$/.test(String(todo.dueDate || ''))) {
const oneShotWhen = oneShotReminderTime(todo, hhmm);
return oneShotWhen != null ? { when: oneShotWhen } : null;
}
return {
when: nextOccurrence(hhmm),
periodInMinutes: 1440 // every 24h, keeps the same clock time
};
}
function oneShotReminderTime(todo, hhmm) {
if (!todo || !todo.oneShotReminder || !todo.dueDate) return null;
const dateMatch = String(todo.dueDate).match(/^(\d{4})-(\d{2})-(\d{2})$/);
const time = parseTimeParts(hhmm);
if (!dateMatch || !time) return null;
const when = new Date(
Number(dateMatch[1]),
Number(dateMatch[2]) - 1,
Number(dateMatch[3]),
time.h,
time.m,
0,
0
).getTime();
if (!Number.isFinite(when) || when <= Date.now()) return null;
return when;
}
function parseTimeParts(hhmm) {
const match = String(hhmm || '20:00').match(/^(\d{1,2}):(\d{1,2})$/);
if (!match) return { h: 20, m: 0 };
const h = Number(match[1]);
const m = Number(match[2]);
if (!Number.isFinite(h) || !Number.isFinite(m) || h < 0 || h > 23 || m < 0 || m > 59) {
return { h: 20, m: 0 };
}
return { h, m };
}
function nextOccurrence(hhmm) {
const { h, m } = parseTimeParts(hhmm);
const now = new Date();
const next = new Date(now.getFullYear(), now.getMonth(), now.getDate(), h, m, 0, 0);
if (next.getTime() <= now.getTime()) next.setDate(next.getDate() + 1);
return next.getTime();
}