mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-10-15 19:32:21 +02:00
refactor(notes): decompose Notes into feature-owned modules (#6713)
* refactor(notes): decompose Notes into feature-owned modules * fix(notes): avoid HTML interpolation in reminder picker * docs(notes): drop checkpoint archives, session logs and publication drafts The N0-N6 tarballs and raw pytest/node logs are not read by any script or test, and the logs carry absolute home-directory paths. Once squashed into dev they would stay in history for good. notes-pr.md and notes-issue.md were the drafts used to open #6712 and #6713, not documentation. notes-validation.md keeps the counts, the checkpoint hashes and implementation.sha256 as the record; local interpreter paths and the local Docker image name are replaced with portable placeholders. * fix(notes): keep the state store raw and stop masonry re-observing after close - state: writes built from views (filter, spread, splice) stored nested read-only views in the store, so structuredClone on a stored note threw DataCloneError. Store writes now unwrap nested views. - layout: a save that resolves after closePanel re-renders the leaving pane and re-created the masonry ResizeObserver that teardown had disposed. Skip creating it once Notes is closed. - index.js: drop 30 imported or declared names that were never used and group the imports at the top. Import order is unchanged. - Revert unrelated punctuation edits in .gitattributes and keep the header comment and docstring first in compare/vote.js and test_notes_dom_xss_helpers.py. Both fixes have a node test that fails on the previous head. * test(notes): make script rejection assertion case-insensitive Address CodeQL js/bad-tag-filter alert #1150 in test-only assertion and refresh Notes evidence manifest checksum. --------- Co-authored-by: Léo <leograndcontact@gmail.com>
This commit is contained in:
co-authored by
Léo
parent
9f8eae72fd
commit
f1a47f3812
@@ -0,0 +1,182 @@
|
||||
/** Page-owned reminder scheduler and notification delivery workflow. */
|
||||
import { _hasTimeComponent, _isDueTodayOrOverdue, _advanceRecurring } from '../domain/recurrence.js';
|
||||
export function createNotesReminders({
|
||||
app,
|
||||
api,
|
||||
persistence,
|
||||
notifications,
|
||||
clock,
|
||||
scheduler,
|
||||
effects,
|
||||
fetchNotes,
|
||||
isNoteFullyDone,
|
||||
hasItems,
|
||||
dismissedKey
|
||||
}) {
|
||||
const state = app.state;
|
||||
const { _loadFiredReminders, _saveFiredReminders } = persistence;
|
||||
const _patchNote = api.patchNote;
|
||||
const _fetchNotes = fetchNotes;
|
||||
const _isNoteFullyDone = isNoteFullyDone;
|
||||
const _hasItems = hasItems;
|
||||
const REMINDER_DISMISSED_AT_KEY = dismissedKey;
|
||||
const _updateRailBadge = effects.badge;
|
||||
let _reminderTimer = null;
|
||||
let startupTimer = null;
|
||||
let started = false;
|
||||
let disposed = false;
|
||||
function _checkReminders() {
|
||||
if (!state.notes.length) return;
|
||||
const now = clock.now();
|
||||
const fired = _loadFiredReminders();
|
||||
let changed = false;
|
||||
for (const note of state.notes) {
|
||||
if (!note.due_date || note.archived) continue;
|
||||
if (!_hasTimeComponent(note.due_date)) continue;
|
||||
if (fired.has(note.id)) continue;
|
||||
const due = new Date(note.due_date).getTime();
|
||||
if (isNaN(due)) continue;
|
||||
if (due <= now && due > now - 60000) {
|
||||
_fireReminder(note);
|
||||
// Recurring? advance the due_date instead of marking as fired
|
||||
if (note.repeat && note.repeat !== 'none') {
|
||||
const next = _advanceRecurring(note.due_date, note.repeat, clock);
|
||||
if (next) {
|
||||
app.mergeNote(note, { due_date: next });
|
||||
_patchNote(note.id, { due_date: next }).catch(() => {});
|
||||
// Don't add to fired : new due_date is in the future
|
||||
continue;
|
||||
}
|
||||
}
|
||||
fired.add(note.id);
|
||||
changed = true;
|
||||
} else if (due <= now - 60000) {
|
||||
// Past, never seen : silently advance recurring or mark fired
|
||||
if (note.repeat && note.repeat !== 'none') {
|
||||
const next = _advanceRecurring(note.due_date, note.repeat, clock);
|
||||
if (next) {
|
||||
app.mergeNote(note, { due_date: next });
|
||||
_patchNote(note.id, { due_date: next }).catch(() => {});
|
||||
continue;
|
||||
}
|
||||
}
|
||||
fired.add(note.id);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
if (changed) _saveFiredReminders(fired);
|
||||
// Always refresh badge : fired state may have changed visually without note mutation
|
||||
_updateRailBadge();
|
||||
}
|
||||
|
||||
function _fireReminder(note) {
|
||||
const title = note.title || 'Note reminder';
|
||||
// Include the verbatim note content so the email/notification actually
|
||||
// shows what to do, not just a count. Cap the per-item lines (8 max) and
|
||||
// total length so the body stays inbox-friendly.
|
||||
let rawBody;
|
||||
if (_hasItems(note)) {
|
||||
const pending = (note.items || [])
|
||||
.filter(i => !i.done && !i.checked)
|
||||
.map(i => (i.text || '').trim())
|
||||
.filter(Boolean);
|
||||
if (pending.length) {
|
||||
const shown = pending.slice(0, 8).map(t => `- ${t}`).join('\n');
|
||||
const extra = pending.length > 8 ? `\n…and ${pending.length - 8} more` : '';
|
||||
rawBody = `Pending (${pending.length}):\n${shown}${extra}`;
|
||||
} else {
|
||||
rawBody = `${(note.items || []).length} item${(note.items || []).length === 1 ? '' : 's'}`;
|
||||
}
|
||||
} else {
|
||||
rawBody = (note.content || '').slice(0, 400);
|
||||
}
|
||||
|
||||
// Ask the server to dispatch according to user settings. The server may
|
||||
// return an LLM-written synthesis line and/or send an email. We still show
|
||||
// a local browser notification so the user gets immediate feedback even if
|
||||
// the server path is disabled or slow.
|
||||
const showLocal = body => notifications.show(title, body, note.id);
|
||||
|
||||
// Fire-and-forget server dispatch. If synthesis comes back quickly enough,
|
||||
// use it as the notification body; otherwise the local notification has
|
||||
// already shown with the raw body.
|
||||
let shown = false;
|
||||
const timer = scheduler.setTimeout(() => { if (!shown) { shown = true; showLocal(rawBody); } }, 1500);
|
||||
|
||||
api.fireReminder({ note_id: note.id, title, body: rawBody })
|
||||
.then(r => r.ok ? r.json() : null)
|
||||
.then(data => {
|
||||
scheduler.clearTimeout(timer);
|
||||
if (shown) return;
|
||||
shown = true;
|
||||
const body = (data && data.synthesis) ? data.synthesis : rawBody;
|
||||
showLocal(body);
|
||||
})
|
||||
.catch(() => {
|
||||
scheduler.clearTimeout(timer);
|
||||
if (!shown) { shown = true; showLocal(rawBody); }
|
||||
});
|
||||
|
||||
// Pulse the card if visible; otherwise queue it so the next time the user
|
||||
// opens the notes panel the card gets a brief glow.
|
||||
effects.highlight(note.id);
|
||||
}
|
||||
|
||||
function _startReminderLoop() {
|
||||
if (disposed || _reminderTimer) return;
|
||||
_reminderTimer = scheduler.setInterval(_checkReminders, 30000);
|
||||
_checkReminders(); // run once immediately
|
||||
}
|
||||
|
||||
function _countDueReminders() {
|
||||
return state.notes.filter(n => !n.archived && _isDueTodayOrOverdue(n.due_date, clock) && !_isNoteFullyDone(n)).length;
|
||||
}
|
||||
|
||||
function _countFiredReminders() {
|
||||
// Reminders whose time has actually passed (not just date-today),
|
||||
// and which fired after the last user dismissal.
|
||||
const now = clock.now();
|
||||
return state.notes.filter(n => {
|
||||
if (n.archived || _isNoteFullyDone(n)) return false;
|
||||
if (!n.due_date || !_hasTimeComponent(n.due_date)) return false;
|
||||
const t = new Date(n.due_date).getTime();
|
||||
if (isNaN(t) || t > now) return false;
|
||||
return t > state.firedDotDismissedAt;
|
||||
}).length;
|
||||
}
|
||||
|
||||
function dismissFiredReminderDot() {
|
||||
app.setFiredDotDismissedAt(clock.now());
|
||||
try { persistence.setItem(REMINDER_DISMISSED_AT_KEY, String(state.firedDotDismissedAt)); } catch {}
|
||||
_updateRailBadge();
|
||||
}
|
||||
|
||||
async function refreshDueBadge(opts = {}) {
|
||||
// Usually lightweight, but callers that just created a note reminder can
|
||||
// force a refresh so the background reminder loop sees it immediately.
|
||||
if (opts.force || state.notes.length === 0) {
|
||||
try {
|
||||
const wasArchived = state.showingArchived;
|
||||
app.setShowingArchived(false);
|
||||
await _fetchNotes();
|
||||
app.setShowingArchived(wasArchived);
|
||||
} catch {}
|
||||
}
|
||||
_updateRailBadge();
|
||||
}
|
||||
|
||||
async function _initReminders() {
|
||||
try {
|
||||
const res = await api.listNotes(false);
|
||||
if (res.ok && !disposed) {
|
||||
const data = await res.json();
|
||||
app.replaceNotes(data.notes || data || []);
|
||||
_startReminderLoop();
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
function start() { if (started || disposed) return; started = true; startupTimer = scheduler.setTimeout(_initReminders, 3000); }
|
||||
function dispose() { disposed = true; scheduler.clearTimeout(startupTimer); scheduler.clearInterval(_reminderTimer); _reminderTimer = null; }
|
||||
return { start, dispose, _checkReminders, _fireReminder, _startReminderLoop, _countDueReminders, _countFiredReminders, dismissFiredReminderDot, refreshDueBadge, _initReminders };
|
||||
}
|
||||
Reference in New Issue
Block a user