Files
Life-OS/public/addons.js
T

271 lines
18 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* 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 => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[char]));
const APP_VERSION = '1.13.0';
window.LifeOSVersion = APP_VERSION;
const appIds = ['today', 'timeline', 'museum', 'insights', 'profile', 'tasks', 'calendar', 'system', 'browser', 'terminal'];
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 => ({'&':'&amp;','<':'&lt;','>':'&gt;'}[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(); }
});
})();