export interface DraftCleanupLocator { incarnation: string; version: number; revision: string } export interface PrivateNoteReference { operationId: string; route: string; firstAt: number; cleanup?: DraftCleanupLocator; } export interface PrivateNoteRecoveryOwner { author: string; route: string } const PREFIX = 'private-note-create:v1:'; const uuid = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; const digest = async (value: string): Promise => Array.from(new Uint8Array( await crypto.subtle.digest('SHA-256', new TextEncoder().encode(value)), )).map((b) => b.toString(16).padStart(2, '0')).join(''); export async function privateNoteRecoveryOwner(author: string, scope: string, patient: string): Promise { if (!author || !patient) throw new Error('A current note author and patient are required.'); return { author: await digest(JSON.stringify(['author', author])), route: await digest(JSON.stringify(['private-note-create/v1', author, scope, patient])) }; } function read(owner: PrivateNoteRecoveryOwner): PrivateNoteReference[] { const raw = localStorage.getItem(PREFIX + owner.author); if (raw === null) return []; const list: unknown = JSON.parse(raw); if (!Array.isArray(list) || list.length > 20 || !list.every((r: PrivateNoteReference) => r && Object.keys(r).every(k => ['operationId', 'route', 'firstAt', 'cleanup'].includes(k)) && uuid.test(r.operationId) && /^[0-9a-f]{64}$/.test(r.route) && Number.isFinite(r.firstAt) && (!r.cleanup || (typeof r.cleanup.incarnation === 'string' && !!r.cleanup.incarnation && Number.isSafeInteger(r.cleanup.version) && r.cleanup.version > 0 && typeof r.cleanup.revision === 'string')))) { throw new Error('Private-note recovery references could not be read.'); } return list; } function write(owner: PrivateNoteRecoveryOwner, list: PrivateNoteReference[]): void { const raw = JSON.stringify(list); localStorage.setItem(PREFIX + owner.author, raw); if (localStorage.getItem(PREFIX + owner.author) !== raw) throw new Error('Private-note recovery could not be kept.'); window.dispatchEvent(new CustomEvent('private-note-recovery-changed', { detail: owner.author })); } export function heldPrivateNote(owner: PrivateNoteRecoveryOwner): PrivateNoteReference | undefined { return read(owner).find((r) => r.route === owner.route); } const exclusive = (owner: PrivateNoteRecoveryOwner, work: () => T): Promise => { if (!navigator.locks?.request) return Promise.reject(new Error('Private-note recovery coordination is unavailable.')); return navigator.locks.request(PREFIX + owner.author, work); }; export function reservePrivateNote(owner: PrivateNoteRecoveryOwner, cleanup?: DraftCleanupLocator, admit: () => void = () => {}): Promise { return exclusive(owner, () => { admit(); const list = read(owner); if (list.some((r) => r.route === owner.route)) throw new Error('Check the earlier private-note save before starting another.'); if (list.length >= 20) throw new Error('Check earlier private-note saves before starting another.'); const ref = { operationId: crypto.randomUUID(), route: owner.route, firstAt: Date.now(), ...(cleanup ? { cleanup } : {}) }; write(owner, [...list, ref]); return ref; }); } export function releasePrivateNote(owner: PrivateNoteRecoveryOwner, operationId: string): Promise { return exclusive(owner, () => { const list = read(owner); write(owner, list.filter((r) => !(r.route === owner.route && r.operationId === operationId))); }); } export function onPrivateNoteRecoveryChange(owner: PrivateNoteRecoveryOwner, changed: () => void): () => void { const local = (event: Event) => { if ((event as CustomEvent).detail === owner.author) changed(); }; const remote = (event: StorageEvent) => { if (event.key === null || event.key === PREFIX + owner.author) changed(); }; window.addEventListener('private-note-recovery-changed', local); window.addEventListener('storage', remote); return () => { window.removeEventListener('private-note-recovery-changed', local); window.removeEventListener('storage', remote); }; }