feat(productivity): add draft recovery and memory search

This commit is contained in:
Codex committed 2026-09-24 08:18:31 +03:00
1 parent 667ab4b4b8
commit 75c81ce498
3 files changed
+78 -1

No files matched your search

+1
View File
@@ -14,6 +14,7 @@
- **System**:深浅主题、中文/English 切换、JSON 导入导出与本地存储说明。
- **Bing 每日一图**:桌面壁纸每日自动更新;接口不可用时保留内置背景。
- **Windows 式操作**:桌面端单击选择、双击打开;右键桌面或 App 图标可使用快捷菜单。
- **快捷键与草稿恢复**:`Ctrl / ⌘ + K` 打开启动器,`Ctrl / ⌘ + F` 搜索记忆,`Ctrl / ⌘ + S` 归档 Today;当天未归档文字会自动恢复。
- **移动端**:App 以全屏方式打开,保留任务栏恢复入口。
## 数据与备份
+76
View File
@@ -0,0 +1,76 @@
/* Life OS reliability and productivity add-ons. */
(() => {
const DRAFT_KEY = 'lifeos_today_draft_v1';
const appIds = ['today', 'timeline', 'museum', 'insights', 'profile', 'system'];
const zh = () => (typeof lang === 'function' ? lang() : 'en') === 'zh';
const copy = (en, cn) => (zh() ? cn : en);
function readDraft() {
try { return JSON.parse(localStorage.getItem(DRAFT_KEY)); } catch { return null; }
}
function saveDraft(text) {
localStorage.setItem(DRAFT_KEY, JSON.stringify({ date: new Date().toISOString().slice(0, 10), text, updatedAt: new Date().toISOString() }));
}
function restoreDraft(entry) {
const draft = readDraft();
const today = new Date().toISOString().slice(0, 10);
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(copy('DRAFT RESTORED', '草稿已恢复'));
}
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 clearDraftAfterSave() {
setTimeout(() => {
const today = new Date().toISOString().slice(0, 10);
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>${copy('SEARCH MEMORY ARCHIVE', '搜索记忆档案')}</span><button aria-label="Close">×</button></div><input autofocus placeholder="${copy('Search titles, notes, moods…', '搜索标题、记录或情绪…')}"><div class="lifeos-search-results"></div><p class="lifeos-search-hint">${copy('ESC to close · click a result to open it', 'ESC 关闭 · 点击结果即可打开')}</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.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}"><small>${m.date} · ${(m.mood || '').toUpperCase()}</small><strong>${m.filmTitle || copy('Untitled memory', '未命名记忆')}</strong><span>${(m.text || copy('No written note.', '未写下记录。')).replace(/[&<>]/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;'}[c]))}</span></button>`).join('') : `<div class="lifeos-search-empty">${copy('No memories found.', '没有找到记忆。')}</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}`;
document.head.append(style);
new MutationObserver(wireToday).observe(document.body, { childList: true, subtree: true });
wireToday();
document.addEventListener('click', e => { if (e.target.closest('#content-today .primary')) clearDraftAfterSave(); });
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 && /^[1-6]$/.test(e.key)) { e.preventDefault(); openApp(appIds[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(); }
});
})();
+1 -1
View File
@@ -23,4 +23,4 @@ function museumApp(c){let a=museum(),ms=mems();c.innerHTML=`<div class="eyebrow"
function streak(a){let s=0,d=new Date();while(a.some(m=>m.date===d.toISOString().slice(0,10))){s++;d.setDate(d.getDate()-1)}return s}function longest(a){let ds=a.map(x=>x.date).sort(),best=0,run=0,last='';ds.forEach(d=>{run=last&&((new Date(d)-new Date(last))/864e5===1)?run+1:1;best=Math.max(best,run);last=d});return best}function insights(c){let a=mems(),last=a.filter(m=>(Date.now()-new Date(m.date+'T12:00:00'))<2592e6),cnt={clear:0,light:0,cloudy:0,rain:0,storm:0};last.forEach(x=>cnt[x.mood]++);let avg=a.length?(a.reduce((s,x)=>s+x.energy,0)/a.length).toFixed(1):'—',days={};a.forEach(x=>{let d=new Date(x.date+'T12:00:00').toLocaleDateString('en-US',{weekday:'long'});days[d]=(days[d]||0)+1});let active=Object.entries(days).sort((x,y)=>y[1]-x[1])[0]?.[0]||'—';c.innerHTML=`<div class="eyebrow">LAST 30 DAYS</div><h1>Insights, quietly.</h1><div class="insights"><section class="metric"><div class="eyebrow">WEATHER</div>${Object.entries(cnt).map(([k,v])=>`<div class="sub">${cap(k)} <b style="float:right;color:var(--ink)">${v} DAYS</b></div>`).join('')}</section><section class="metric"><div class="eyebrow">AVERAGE ENERGY</div><div class="big">${avg}<span style="font-size:16px"> / 5</span></div><div class="bars">${a.slice(0,10).reverse().map(x=>`<i class="bar" style="height:${x.energy*18}%"></i>`).join('')||'<span class="sub">No entries yet</span>'}</div></section><section class="metric"><div class="eyebrow">MOST ACTIVE DAY</div><div class="big" style="font-size:26px">${active}</div></section><section class="metric"><div class="eyebrow">CURRENT STREAK</div><div class="big">${streak(a)}<span style="font-size:16px"> DAYS</span></div><div class="sub">Longest streak: ${longest(a)} days</div></section></div>`}
function profileApp(c){let p=profile(),a=mems(),common=Object.entries(a.reduce((o,m)=>(o[m.mood]=(o[m.mood]||0)+1,o),{})).sort((x,y)=>y[1]-x[1])[0]?.[0]||'—',avg=a.length?(a.reduce((x,m)=>x+m.energy,0)/a.length).toFixed(1):'—',started=Math.max(1,Math.floor((Date.now()-new Date(p.startDate))/864e5));c.innerHTML=`<div class="eyebrow">SYSTEM PROFILE</div><h1>${p.name?esc(p.name)+' · ':''}LIFE OS ONLINE</h1><div class="profile-grid"><div class="stat"><b>${started}</b><span>DAYS ONLINE</span></div><div class="stat"><b>${a.length}</b><span>MEMORIES</span></div><div class="stat"><b>${museum().length}</b><span>MUSEUM ITEMS</span></div><div class="stat"><b>${streak(a)}</b><span>CURRENT STREAK</span></div><div class="stat"><b>${longest(a)}</b><span>LONGEST STREAK</span></div><div class="stat"><b>${cap(common)}</b><span>COMMON WEATHER</span></div></div><div class="eyebrow">PERSONAL SYSTEM</div><div class="formrow"><label>Display Name</label><input id="pname" value="${esc(p.name)}"></div><div class="formrow"><label>Birthday (optional)</label><input id="pbday" type="date" value="${esc(p.birthday)}"></div><div class="formrow"><label>Life OS Start Date</label><input id="pstart" type="date" value="${esc(p.startDate)}"></div><div class="formrow"><label>Personal Motto</label><input id="pmotto" value="${esc(p.motto)}"></div><div class="actions"><button class="primary" onclick="saveProfile()">SAVE PROFILE</button><span class="status">AVERAGE ENERGY ${avg} / 5</span></div>`}function saveProfile(){save('lifeos_profile',{name:pname.value,birthday:pbday.value,startDate:pstart.value||day(),motto:pmotto.value});renderAll();toast('PROFILE SAVED')}
function systemApp(c){let s=settings(),bytes=new Blob([JSON.stringify({mems:mems(),museum:museum(),profile:profile()})]).size;let rec=s.lastBackup&&(Date.now()-new Date(s.lastBackup)>12096e5);c.innerHTML=`<div class="eyebrow">SYSTEM SETTINGS</div><h1>System, local and yours.</h1><div class="settings"><section><div class="eyebrow">APPEARANCE</div><div class="choices">${['dark','light','auto'].map(x=>`<button class="chip ${s.theme===x?'selected':''}" onclick="theme('${x}')">${x.toUpperCase()}</button>`).join('')}</div></section><section><div class="eyebrow">LANGUAGE</div><div class="choices"><button class="chip ${s.language!=='zh'?'selected':''}" onclick="setLanguage('en')">ENGLISH</button><button class="chip ${s.language==='zh'?'selected':''}" onclick="setLanguage('zh')">CHINESE</button></div></section><section><div class="eyebrow">DATA</div><div class="actions"><button class="primary" onclick="exportData()">EXPORT DATA</button><button class="secondary" onclick="document.querySelector('#importer').click()">IMPORT DATA</button><button class="danger" onclick="resetConfirm()">RESET LIFE OS</button><input id="importer" class="hidden" type="file" accept=".json" onchange="readImport(this)"></div></section><section><div class="eyebrow">STORAGE</div><p>Local Storage<br>${mems().length} memories · ${museum().length} museum items · Approx. ${Math.ceil(bytes/1024)} KB</p></section><section><div class="eyebrow">DATA STORAGE</div><p>Your data is stored locally in this browser.<br>Create regular backups to prevent data loss.</p><p>${s.lastBackup?'LAST BACKUP · '+fmtDate(s.lastBackup.slice(0,10)):'LAST BACKUP · NEVER'} ${rec?'<br><b style="color:var(--accent)">BACKUP RECOMMENDED</b>':''}</p></section><section><div class="eyebrow">VERSION</div><p>Life OS<br>Version 0.1 · Schema v1</p></section></div>`}function theme(x){let s=settings();s.theme=x;save('lifeos_settings',s);init();renderAll()}function exportData(){let data={app:'Life OS',version:'0.1',schemaVersion:1,exportedAt:new Date().toISOString(),profile:profile(),memories:mems(),museum:museum(),settings:settings()};let a=document.createElement('a');a.href=URL.createObjectURL(new Blob([JSON.stringify(data,null,2)],{type:'application/json'}));a.download='life-os-backup-'+day()+'.json';a.click();let s=settings();s.lastBackup=new Date().toISOString();save('lifeos_settings',s);renderAll();toast('BACKUP CREATED')}function readImport(el){let f=el.files[0];if(!f)return;let r=new FileReader;r.onload=()=>{try{let d=JSON.parse(r.result);if(d.app!=='Life OS'||d.schemaVersion!==1||!Array.isArray(d.memories)||!Array.isArray(d.museum))throw 0;importData=d;importPreview()}catch{toast('INVALID BACKUP FILE')}};r.readAsText(f)}function importPreview(){let d=importData;modal.innerHTML=`<div class="modal"><div class="modalbox"><div class="eyebrow">LIFE OS BACKUP</div><h2>Ready to restore</h2><p class="sub">Exported: ${fmtDate(d.exportedAt.slice(0,10))}<br>Memories: ${d.memories.length}<br>Museum Items: ${d.museum.length}</p><div class="actions"><button class="primary" onclick="applyImport('merge')">MERGE</button><button class="danger" onclick="replaceConfirm()">REPLACE ALL DATA</button><button class="secondary" onclick="modal.innerHTML=''">CANCEL</button></div></div></div>`}function replaceConfirm(){modal.innerHTML=`<div class="modal"><div class="modalbox"><div class="eyebrow">FINAL CONFIRMATION</div><h2>Replace all Life OS data?</h2><p class="sub">This will replace all current Life OS data.</p><div class="actions"><button class="danger" onclick="applyImport('replace')">REPLACE DATA</button><button class="secondary" onclick="importPreview()">CANCEL</button></div></div></div>`}function applyImport(mode){let d=importData;if(mode==='replace'){save('lifeos_memories',d.memories);save('lifeos_museum',d.museum);save('lifeos_profile',d.profile||{});save('lifeos_settings',d.settings||{})}else{let a=mems(), ids=new Set(a.map(x=>x.id)),dates=new Set(a.map(x=>x.date));save('lifeos_memories',[...a,...d.memories.filter(x=>!ids.has(x.id)&&!dates.has(x.date))]);let mu=museum(),mi=new Set(mu.map(x=>x.id));save('lifeos_museum',[...mu,...d.museum.filter(x=>!mi.has(x.id))])}modal.innerHTML='';toast('RESTORING SYSTEM...');setTimeout(()=>{renderAll();toast('SYSTEM RESTORED')},500)}function resetConfirm(){modal.innerHTML=`<div class="modal"><div class="modalbox"><div class="eyebrow">RESET LIFE OS</div><h2>Reset all local data?</h2><p class="sub">Export a backup first if you want to keep it.</p><div class="actions"><button class="danger" onclick="['lifeos_memories','lifeos_museum','lifeos_profile','lifeos_settings','lifeos_system'].forEach(k=>localStorage.removeItem(k));location.reload()">RESET DATA</button><button class="secondary" onclick="modal.innerHTML=''">CANCEL</button></div></div></div>`}function toast(t){let x=document.createElement('div');x.className='toast';x.textContent=t;document.body.append(x);setTimeout(()=>x.remove(),2000)}init();
</script></body></html>
</script><script src="addons.js"></script></body></html>