271 lines
18 KiB
JavaScript
271 lines
18 KiB
JavaScript
/* Life OS reliability and productivity add-ons. */
|
||
(() => {
|
||
const DRAFT_KEY = 'lifeos_today_draft_v1';
|
||
const localDay = () => { const d = new Date(); return `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')}`; };
|
||
const escapeHtml = value => String(value || '').replace(/[&<>]/g, char => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[char]));
|
||
const APP_VERSION = '1.18.1';
|
||
window.LifeOSVersion = APP_VERSION;
|
||
const appIds = ['today', 'timeline', 'museum', 'insights', 'profile', 'tasks', 'calendar', 'system', 'browser', 'terminal', 'database'];
|
||
const t = (key, values) => LifeI18n.t(key, values);
|
||
|
||
function readDraft() {
|
||
try { return JSON.parse(localStorage.getItem(DRAFT_KEY)); } catch { return null; }
|
||
}
|
||
function saveDraft(text) {
|
||
localStorage.setItem(DRAFT_KEY, JSON.stringify({ date: (typeof todayTargetDate !== 'undefined' && todayTargetDate) || localDay(), text, updatedAt: new Date().toISOString() }));
|
||
}
|
||
function restoreDraft(entry) {
|
||
const draft = readDraft();
|
||
const today = (typeof todayTargetDate !== 'undefined' && todayTargetDate) || localDay();
|
||
if (!draft || draft.date !== today || !draft.text || entry.value) return;
|
||
entry.value = draft.text;
|
||
entry.dispatchEvent(new Event('input', { bubbles: true }));
|
||
if (typeof toast === 'function') toast('addon.draftRestored');
|
||
}
|
||
function wireToday() {
|
||
const entry = document.querySelector('#entry');
|
||
if (!entry || entry.dataset.lifeosDraft === 'ready') return;
|
||
entry.dataset.lifeosDraft = 'ready';
|
||
restoreDraft(entry);
|
||
entry.addEventListener('input', () => saveDraft(entry.value));
|
||
}
|
||
function wireLanguageControl() { /* The System renderer owns this control. */ }
|
||
function clearDraftAfterSave() {
|
||
setTimeout(() => {
|
||
const today = (typeof todayTargetDate !== 'undefined' && todayTargetDate) || localDay();
|
||
try {
|
||
const saved = JSON.parse(localStorage.getItem('lifeos_memories') || '[]').some(m => m.date === today);
|
||
if (saved) localStorage.removeItem(DRAFT_KEY);
|
||
} catch {}
|
||
}, 700);
|
||
}
|
||
|
||
function closeSearch() { document.querySelector('#lifeos-search')?.remove(); }
|
||
function openSearch() {
|
||
closeSearch();
|
||
const box = document.createElement('div');
|
||
box.id = 'lifeos-search';
|
||
box.innerHTML = `<div class="lifeos-search-backdrop"></div><section class="lifeos-search-panel"><div class="lifeos-search-head"><span>${t('addon.searchTitle')}</span><button aria-label="${t('common.close')}">×</button></div><input autofocus aria-label="${t('addon.searchPlaceholder')}" placeholder="${t('addon.searchPlaceholder')}"><div class="lifeos-search-results"></div><p class="lifeos-search-hint">${t('addon.searchHint')}</p></section>`;
|
||
document.body.append(box);
|
||
const input = box.querySelector('input');
|
||
const results = box.querySelector('.lifeos-search-results');
|
||
const render = () => {
|
||
let memories = [];
|
||
try { memories = JSON.parse(localStorage.getItem('lifeos_memories') || '[]'); } catch {}
|
||
const query = input.value.trim().toLowerCase();
|
||
const found = memories.filter(m => !query || [m.title, m.filmTitle, m.text, m.mood, m.date].join(' ').toLowerCase().includes(query)).slice(0, 8);
|
||
results.innerHTML = found.length ? found.map(m => `<button data-id="${m.id}" aria-label="${t('addon.openMemory',{title:escapeHtml(m.title || m.text?.trim().split('\n')[0].slice(0, 64) || t('timeline.untitled'))})}"><small>${LifeI18n.date(m.date,{dateStyle:'medium'})} · ${t(`weather.${m.mood || 'cloudy'}`).toUpperCase()}</small><strong>${escapeHtml(m.title || m.text?.trim().split('\n')[0].slice(0, 64) || t('timeline.untitled'))}</strong><span>${(m.text || t('timeline.noNote')).replace(/[&<>]/g, c => ({'&':'&','<':'<','>':'>'}[c]))}</span></button>`).join('') : `<div class="lifeos-search-empty">${t('addon.noResults')}</div>`;
|
||
results.querySelectorAll('button').forEach(button => button.addEventListener('click', () => { closeSearch(); openApp('timeline'); setTimeout(() => detail(button.dataset.id), 20); }));
|
||
};
|
||
input.addEventListener('input', render);
|
||
box.querySelector('.lifeos-search-head button').addEventListener('click', closeSearch);
|
||
box.querySelector('.lifeos-search-backdrop').addEventListener('click', closeSearch);
|
||
render(); input.focus();
|
||
}
|
||
|
||
const style = document.createElement('style');
|
||
style.textContent = `.lifeos-search-backdrop{position:fixed;inset:0;background:#0008;z-index:8000}.lifeos-search-panel{position:fixed;z-index:8001;top:18vh;left:50%;transform:translateX(-50%);width:min(560px,calc(100vw - 28px));background:var(--panel);border:1px solid var(--line);border-radius:15px;box-shadow:var(--shadow);padding:15px}.lifeos-search-head{display:flex;justify-content:space-between;align-items:center;font:500 11px 'DM Mono';letter-spacing:.12em;color:var(--muted);margin-bottom:13px}.lifeos-search-head button{border:0;background:transparent;color:var(--ink);font-size:20px}.lifeos-search-panel input{width:100%;background:var(--panel2);border:1px solid var(--line);border-radius:9px;padding:12px;outline:none}.lifeos-search-panel input:focus{border-color:var(--accent)}.lifeos-search-results{margin-top:9px;max-height:360px;overflow:auto}.lifeos-search-results button{display:grid;gap:3px;width:100%;text-align:left;border:0;border-radius:9px;padding:11px;background:transparent;color:var(--ink)}.lifeos-search-results button:hover{background:var(--panel2)}.lifeos-search-results small,.lifeos-search-results span,.lifeos-search-hint{color:var(--muted);font-size:12px}.lifeos-search-results strong{font-size:14px}.lifeos-search-results span{white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.lifeos-search-empty{padding:24px;color:var(--muted);text-align:center;font-size:13px}.lifeos-search-hint{margin:11px 0 0;font:10px 'DM Mono';letter-spacing:.04em}.launchitem svg{width:28px;height:28px;display:block;margin:auto;fill:none;stroke:currentColor;stroke-width:1.7;stroke-linecap:round;stroke-linejoin:round}.launchitem:hover svg{color:var(--accent)}`;
|
||
document.head.append(style);
|
||
|
||
function exportData() {
|
||
let desktopLayout = null, widgetLayout = null;
|
||
try { desktopLayout = JSON.parse(localStorage.getItem('lifeos_window_layout_v1') || 'null'); } catch {}
|
||
try { widgetLayout = JSON.parse(localStorage.getItem('lifeos_widget_layout_v1') || 'null'); } catch {}
|
||
const data = { app: 'Life OS', version: APP_VERSION, schemaVersion: 2, exportedAt: new Date().toISOString(), profile: profile(), memories: mems(), museum: museum(), settings: settings(), system: sys(), desktopLayout, widgetLayout };
|
||
const link = document.createElement('a');
|
||
link.href = URL.createObjectURL(new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' }));
|
||
link.download = `life-os-backup-${day()}.json`; link.click();
|
||
const state = settings(); state.lastBackup = new Date().toISOString(); save('lifeos_settings', state); renderAll(); toast('feedback.backup');
|
||
}
|
||
// The System panel uses inline onclick handlers, so expose this complete exporter.
|
||
window.exportData = exportData;
|
||
const CLOUD_KEYS = { profile: 'lifeos_profile', memories: 'lifeos_memories', museum: 'lifeos_museum', settings: 'lifeos_settings', system: 'lifeos_system' };
|
||
let cloudReady = false, applyingCloud = false, syncTimer = null, syncErrorShown = false, syncing = false, syncAgain = false;
|
||
let baseline = null, versions = null;
|
||
const cloudFallback = { profile: {}, memories: [], museum: [], settings: {}, system: { schemaVersion: 2 } };
|
||
function cloudSnapshot() {
|
||
const snapshot = Object.fromEntries(Object.entries(CLOUD_KEYS).map(([field, key]) => {
|
||
try { return [field, JSON.parse(localStorage.getItem(key) || JSON.stringify(cloudFallback[field]))]; } catch { return [field, cloudFallback[field]]; }
|
||
}));
|
||
snapshot.system = { ...snapshot.system, schemaVersion: 2 };
|
||
return snapshot;
|
||
}
|
||
function hasLocalLife(state) { return state.memories.length || state.museum.length || Object.keys(state.profile).length || Object.keys(state.settings).length > 2 || state.system?.tasksBoard?.goals?.length || state.system?.tasksBoard?.tasks?.length; }
|
||
function applyCloudState(state) {
|
||
state = { ...state, system: { ...(state.system || {}), schemaVersion: 2 } };
|
||
applyingCloud = true;
|
||
Object.entries(CLOUD_KEYS).forEach(([field, key]) => localStorage.setItem(key, JSON.stringify(state[field] ?? cloudFallback[field])));
|
||
applyingCloud = false;
|
||
todayDraft = null; window.renderIcons?.(); window.renderAll?.(); window.applyLanguage?.(); window.applyDesktopPreferences?.();
|
||
}
|
||
const clone = value => JSON.parse(JSON.stringify(value));
|
||
const same = (a, b) => JSON.stringify(a) === JSON.stringify(b);
|
||
function parts(state) {
|
||
const { tasksBoard, ...system } = state.system || {};
|
||
return { profile: state.profile || {}, settings: state.settings || {}, system,
|
||
memories: state.memories || [], museum: state.museum || [],
|
||
goals: tasksBoard?.goals || [], tasks: tasksBoard?.tasks || [] };
|
||
}
|
||
function record(state, kind, id) {
|
||
const group = parts(state)[kind];
|
||
return Array.isArray(group) ? group.find(item => item.id === id) || null : group;
|
||
}
|
||
function replaceRecord(state, kind, id, value) {
|
||
const next = clone(state), board = next.system.tasksBoard ||= { goals: [], tasks: [] };
|
||
if (['goals', 'tasks'].includes(kind)) board[kind] = [...board[kind].filter(item => item.id !== id), ...(value ? [value] : [])];
|
||
else if (['memories', 'museum'].includes(kind)) next[kind] = [...next[kind].filter(item => item.id !== id), ...(value ? [value] : [])];
|
||
else if (kind === 'system') next.system = { ...(value || {}), tasksBoard: board };
|
||
else next[kind] = value;
|
||
return next;
|
||
}
|
||
function changes(from, to) {
|
||
const before = parts(from), after = parts(to), operations = [];
|
||
for (const kind of ['profile', 'settings', 'system']) {
|
||
if (!same(before[kind], after[kind])) operations.push({ kind, id: 'value', value: after[kind], baseVersion: versions[kind].value || 0 });
|
||
}
|
||
for (const kind of ['memories', 'goals', 'tasks', 'museum']) {
|
||
const old = new Map(before[kind].map(item => [item.id, item]));
|
||
const current = new Map(after[kind].map(item => [item.id, item]));
|
||
for (const id of new Set([...old.keys(), ...current.keys()])) {
|
||
if (!id || same(old.get(id), current.get(id))) continue;
|
||
operations.push({ kind, id, value: current.get(id) || null, baseVersion: versions[kind][id] || 0 });
|
||
}
|
||
}
|
||
return operations;
|
||
}
|
||
function saveBaseline() {
|
||
localStorage.setItem('lifeos_cloud_baseline_v3', JSON.stringify({ state: baseline, versions }));
|
||
}
|
||
async function post(url, value) {
|
||
return fetch(url, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(value) });
|
||
}
|
||
async function resolveConflict(op) {
|
||
const response = await fetch('/api/lifeos-state', { cache: 'no-store' });
|
||
if (!response.ok) throw new Error('conflict fetch failed');
|
||
const latest = await response.json();
|
||
const remote = record(latest.state, op.kind, op.id);
|
||
localStorage.setItem('lifeos_sync_conflict_backup_v1', JSON.stringify({ exportedAt: new Date().toISOString(), state: cloudSnapshot() }));
|
||
const overwrite = remote !== null && confirm(t('addon.syncConflictConfirm'));
|
||
baseline = replaceRecord(baseline, op.kind, op.id, remote);
|
||
versions[op.kind][op.id] = latest.versions[op.kind][op.id] || 0;
|
||
if (!overwrite) {
|
||
const restored = replaceRecord(cloudSnapshot(), op.kind, op.id, remote);
|
||
applyingCloud = true;
|
||
const field = ['goals', 'tasks'].includes(op.kind) ? 'system' : op.kind;
|
||
localStorage.setItem(CLOUD_KEYS[field], JSON.stringify(restored[field]));
|
||
applyingCloud = false;
|
||
window.renderAll?.();
|
||
}
|
||
saveBaseline();
|
||
}
|
||
async function uploadCloudState() {
|
||
if (!cloudReady || syncing) { syncAgain = true; return; }
|
||
syncing = true;
|
||
try {
|
||
do {
|
||
syncAgain = false;
|
||
for (const op of changes(baseline, cloudSnapshot())) {
|
||
const response = await post('/api/lifeos-sync', op);
|
||
if (response.status === 409) { await resolveConflict(op); syncAgain = true; break; }
|
||
if (!response.ok) throw new Error('sync failed');
|
||
const result = await response.json();
|
||
baseline = replaceRecord(baseline, op.kind, op.id, op.value);
|
||
versions[op.kind][op.id] = result.version;
|
||
saveBaseline();
|
||
}
|
||
} while (syncAgain || changes(baseline, cloudSnapshot()).length);
|
||
syncErrorShown = false;
|
||
} finally { syncing = false; }
|
||
}
|
||
function scheduleCloudSync() {
|
||
if (!cloudReady || applyingCloud) return;
|
||
clearTimeout(syncTimer);
|
||
syncTimer = setTimeout(() => uploadCloudState().catch(() => {
|
||
if (!syncErrorShown) { syncErrorShown = true; toast('addon.syncPaused'); }
|
||
}), 450);
|
||
}
|
||
function migrationPrompt(state) {
|
||
const dialog = document.createElement('div');
|
||
dialog.id = 'lifeos-cloud-migration';
|
||
dialog.innerHTML = `<div class="lifeos-cloud-card"><div class="eyebrow">${t('addon.syncReady')}</div><h2>${t('addon.migrateQuestion')}</h2><p>${t('addon.migrateBody')}</p><div class="actions"><button class="primary" data-cloud="move">${t('addon.migrate')}</button><button class="secondary" data-cloud="empty">${t('addon.fresh')}</button></div></div>`;
|
||
document.body.append(dialog);
|
||
dialog.querySelector('[data-cloud="move"]').addEventListener('click', async () => { dialog.remove(); try { await initializeCloud(state); toast('addon.migrated'); } catch { toast('addon.cloudUnavailable'); } });
|
||
dialog.querySelector('[data-cloud="empty"]').addEventListener('click', async () => { dialog.remove(); applyCloudState(cloudFallback); try { await initializeCloud(cloudFallback); } catch { toast('addon.cloudUnavailable'); } });
|
||
}
|
||
async function initializeCloud(state) {
|
||
const response = await post('/api/lifeos-initialize', { state });
|
||
if (!response.ok) throw new Error('cloud initialization failed');
|
||
const data = await response.json();
|
||
baseline = clone(data.state); versions = data.versions; cloudReady = true; saveBaseline();
|
||
}
|
||
async function startCloudSync() {
|
||
try {
|
||
const response = await fetch('/api/lifeos-state', { cache: 'no-store' });
|
||
if (!response.ok) throw new Error('cloud unavailable');
|
||
const { state, versions: cloudVersions } = await response.json();
|
||
const local = cloudSnapshot();
|
||
if (state) {
|
||
if (hasLocalLife(local)) localStorage.setItem('lifeos_precloud_backup_v1', JSON.stringify({ exportedAt: new Date().toISOString(), state: local }));
|
||
let previous = null;
|
||
try { previous = JSON.parse(localStorage.getItem('lifeos_cloud_baseline_v3') || 'null'); } catch {}
|
||
const pending = previous?.state ? changesFromSaved(previous.state, local) : [];
|
||
baseline = clone(state); versions = cloudVersions;
|
||
applyCloudState(state);
|
||
let hadConflict = false;
|
||
for (const op of pending) {
|
||
if (!same(record(previous.state, op.kind, op.id), record(state, op.kind, op.id))) {
|
||
localStorage.setItem('lifeos_sync_conflict_backup_v1', JSON.stringify({ exportedAt: new Date().toISOString(), state: local }));
|
||
hadConflict = true;
|
||
if (record(state, op.kind, op.id) === null || !confirm(t('addon.syncConflictConfirm'))) continue;
|
||
}
|
||
const restored = replaceRecord(cloudSnapshot(), op.kind, op.id, op.value);
|
||
applyingCloud = true;
|
||
const field = ['goals', 'tasks'].includes(op.kind) ? 'system' : op.kind;
|
||
localStorage.setItem(CLOUD_KEYS[field], JSON.stringify(restored[field]));
|
||
applyingCloud = false;
|
||
}
|
||
cloudReady = true; saveBaseline();
|
||
if (pending.length) await uploadCloudState();
|
||
toast(hadConflict ? 'addon.syncConflict' : 'addon.cloudRestored');
|
||
} else if (hasLocalLife(local)) {
|
||
migrationPrompt(local);
|
||
} else {
|
||
await initializeCloud(cloudFallback);
|
||
}
|
||
} catch {
|
||
if (!syncErrorShown) { syncErrorShown = true; toast('addon.cloudUnavailable'); }
|
||
}
|
||
}
|
||
function changesFromSaved(from, to) {
|
||
const savedVersions = versions;
|
||
versions = { profile: {}, settings: {}, system: {}, memories: {}, goals: {}, tasks: {}, museum: {} };
|
||
const result = changes(from, to);
|
||
versions = savedVersions;
|
||
return result;
|
||
}
|
||
const originalSetItem = Storage.prototype.setItem;
|
||
Storage.prototype.setItem = function(key, value) {
|
||
originalSetItem.call(this, key, value);
|
||
if (this === localStorage && Object.values(CLOUD_KEYS).includes(key)) scheduleCloudSync();
|
||
};
|
||
const cloudStyle = document.createElement('style');
|
||
cloudStyle.textContent = `#lifeos-cloud-migration{position:fixed;inset:0;z-index:9000;background:#071008b8;display:grid;place-items:center;padding:20px}.lifeos-cloud-card{width:min(470px,100%);background:var(--panel);border:1px solid var(--line);border-radius:16px;padding:25px;box-shadow:var(--shadow)}.lifeos-cloud-card h2{margin:10px 0;font-size:25px;letter-spacing:-.04em}.lifeos-cloud-card p{color:var(--muted);line-height:1.65;margin:0 0 20px}`;
|
||
document.head.append(cloudStyle);
|
||
startCloudSync();
|
||
new MutationObserver(() => { wireToday(); wireLanguageControl(); }).observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['title'] });
|
||
wireToday();
|
||
wireLanguageControl();
|
||
document.addEventListener('click', e => { if (e.target.closest('#content-today .primary')) clearDraftAfterSave(); });
|
||
document.addEventListener('click', e => {
|
||
if (e.target.closest('.launchitem')) document.querySelector('#launcher')?.classList.add('hidden');
|
||
});
|
||
document.addEventListener('keydown', e => {
|
||
const mod = e.ctrlKey || e.metaKey;
|
||
if (mod && e.key.toLowerCase() === 'k') { e.preventDefault(); toggleLauncher(); }
|
||
if (mod && e.key.toLowerCase() === 'f') { e.preventDefault(); openSearch(); }
|
||
if (mod && e.key.toLowerCase() === 's' && document.querySelector('#entry')) { e.preventDefault(); archiveToday(); }
|
||
if (e.altKey && /^[0-9]$/.test(e.key)) { e.preventDefault(); openApp(appIds[e.key === '0' ? 9 : Number(e.key) - 1]); }
|
||
if (e.key === 'Escape') { closeSearch(); document.querySelector('#launcher')?.classList.add('hidden'); document.querySelector('#calendar')?.classList.add('hidden'); document.querySelector('#life-context')?.remove(); }
|
||
});
|
||
})();
|