diff --git a/CHANGELOG.md b/CHANGELOG.md index 78d70dc..91770cf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,12 @@ 本项目按 `v主版本.次版本.修订版本` 标记发布。下列内容以对应 Git tag 和已提交功能为准。 +## v1.15.0 — 2026-09-28 + +- 系统设置新增浏览器通知:今日未写记忆、到期未完成待办、进行中目标倒计时,可分别开启并选择本地通知时间。 +- 通知权限由用户主动申请,提供权限状态和测试通知;每天每类提醒最多发送一次,点击通知打开对应 App。 +- 通知设置仅保存在当前设备;网页关闭后浏览器无法保证定时提醒,界面明确说明这一限制。 + ## v1.14.2 — 2026-09-28 - 修复数据库管理器首次设置密码时 PBKDF2 迭代次数超过托管环境上限,导致服务器返回错误页面的问题。 diff --git a/README.md b/README.md index 1f2ca90..9ba5ce6 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ 以桌面操作系统为交互方式的个人记录网站。用 App 记录日常、管理目标与待办,并沿时间线回看已经留下的内容。 -在线站点:[Life OS](https://life-os-personal-desktop.biss0.chatgpt.site) · 当前应用版本:**v1.14.2** · [更新日志](CHANGELOG.md) +在线站点:[Life OS](https://life-os-personal-desktop.biss0.chatgpt.site) · 当前应用版本:**v1.15.0** · [更新日志](CHANGELOG.md) ## 功能 @@ -18,7 +18,9 @@ | 档案 | 查看记录概况并维护个人资料。 | | 浏览器 | 在桌面窗口里打开网页或使用 Google 搜索,支持后退、前进、刷新和外部打开。默认打开 Google;第三方网站可能禁止 iframe 嵌入。 | | 终端 | 逐项创建记忆、目标与待办;选择并确认删除记录,管理完成状态,查询数据和打开其他 App。 | -| 系统设置 | 选择语言、主题、壁纸、桌面快捷方式和开机 App,管理备份。 | +| 系统设置 | 选择语言、主题、壁纸、桌面快捷方式和开机 App,管理备份;分别设置今日记忆、本日待办和目标倒计时的系统通知与时间。 | + +通知权限需手动授予,开关和时间仅保存在当前浏览器。网页打开时检查提醒;关闭网站后无法保证定时送达,如需关闭网页后的可靠提醒,需要另行接入推送服务。 桌面端支持单击选择、双击打开 App、多窗口、任务栏恢复、图标拖动排列和窗口布局记忆。拖动图标或小部件时会显示吸附网格;目标倒计时、时钟、今日概览与快捷操作小部件可移动、调整大小和分别开关。移动端以横排图标和全屏 App 为主,小部件在桌面下方排列。可选必应每日图片、内置背景或自定义 HTTPS 图片链接。中文与英文可自动跟随浏览器语言,也可手动选择。 diff --git a/package.json b/package.json index 159bb1a..13e266d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "life-os", - "version": "1.14.2", + "version": "1.15.0", "private": true, "type": "module", "scripts": { diff --git a/public/addons.js b/public/addons.js index 4843751..e7d9199 100644 --- a/public/addons.js +++ b/public/addons.js @@ -3,7 +3,7 @@ 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.14.2'; + const APP_VERSION = '1.15.0'; 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); diff --git a/public/i18n-renderers.js b/public/i18n-renderers.js index 787f8f8..2189eb9 100644 --- a/public/i18n-renderers.js +++ b/public/i18n-renderers.js @@ -110,7 +110,7 @@ window.saveProfile = () => { save('lifeos_profile', { name: pname.value, birthday: pbday.value, startDate: pstart.value || day(), motto: pmotto.value }); renderAll(); toast(t('profile.saved')); }; window.systemApp = container => { const state = settings(), preference = LifeI18n.preference(), bytes = new Blob([JSON.stringify({ mems: mems(), museum: museum(), profile: profile(), system: sys() })]).size, oldBackup = state.lastBackup && Date.now() - new Date(state.lastBackup) > 12096e5, detected = browserLanguage() === 'zh' ? t('system.detectedZh') : t('system.detectedEn'); - container.innerHTML = `
${t('system.eyebrow')}

${t('system.title')}

${t('system.appearance')}
${['dark','light','auto'].map(item => ``).join('')}
${t('system.language')}

${detected}

${t('system.data')}
${t('system.storage')}

${t('system.local')}
${t('system.storageSummary',{memories:LifeI18n.number(mems().length),museum:LifeI18n.number(museum().length),tasks:LifeI18n.number(sys().tasksBoard?.tasks?.length||0),size:LifeI18n.number(Math.ceil(bytes/1024))})}

${t('system.dataStorage')}

${t('system.stored')}
${t('system.backups')}

${t('system.lastBackup',{value:state.lastBackup ? short(state.lastBackup.slice(0,10)) : t('common.never')})}${oldBackup ? `
${t('system.recommended')}` : ''}

${t('system.version')}

${t('system.release',{version:window.LifeOSVersion||'1.14.2'})}

`; + container.innerHTML = `
${t('system.eyebrow')}

${t('system.title')}

${t('system.appearance')}
${['dark','light','auto'].map(item => ``).join('')}
${t('system.language')}

${detected}

${window.LifeOSNotifications?.markup() || ''}
${t('system.data')}
${t('system.storage')}

${t('system.local')}
${t('system.storageSummary',{memories:LifeI18n.number(mems().length),museum:LifeI18n.number(museum().length),tasks:LifeI18n.number(sys().tasksBoard?.tasks?.length||0),size:LifeI18n.number(Math.ceil(bytes/1024))})}

${t('system.dataStorage')}

${t('system.stored')}
${t('system.backups')}

${t('system.lastBackup',{value:state.lastBackup ? short(state.lastBackup.slice(0,10)) : t('common.never')})}${oldBackup ? `
${t('system.recommended')}` : ''}

${t('system.version')}

${t('system.release',{version:window.LifeOSVersion||'1.15.0'})}

`; }; window.importPreview = () => { const data = importData, board = data.system?.tasksBoard || {}; rootModal().innerHTML = ``; }; window.replaceConfirm = () => { rootModal().innerHTML = ``; }; @@ -134,7 +134,7 @@ } rootModal().innerHTML = ''; toast('feedback.restoring'); setTimeout(() => { renderAll(); toast('feedback.restored'); }, 500); }; - window.resetConfirm = () => { rootModal().innerHTML = ``; }; + window.resetConfirm = () => { rootModal().innerHTML = ``; }; window.toast = key => { const message = key.includes('.') ? t(key) : key; const node = document.createElement('div'); node.className = 'toast'; node.textContent = message; document.body.append(node); setTimeout(() => node.remove(), 2000); }; window.minWin = id => { const windowNode = windows[id]; if (!windowNode) return; windowNode.classList.add('min'); if (activeWindow === id) { activeWindow = Object.keys(windows).find(key => key !== id && !windows[key].classList.contains('min')) || null; if (activeWindow) focus(windows[activeWindow]); } renderTaskbar(); toast('feedback.minimized'); }; const memoryStyle = document.createElement('style'); diff --git a/public/index.html b/public/index.html index cdd5753..d7aea6c 100644 --- a/public/index.html +++ b/public/index.html @@ -14,7 +14,7 @@ function browserLanguage(){let ls=navigator.languages?.length?navigator.language function appName(id){return i18n(APPS[id][1])}function moodName(m){return i18n(m.toUpperCase(),cap(m))}function day(){const d=new Date();return `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')}`}function fmtDate(d){return new Date(d+'T12:00:00').toLocaleDateString(lang()==='zh'?'zh-CN':'en-US',{weekday:'long',month:'long',day:'numeric',year:'numeric'})}function shortDate(d){return new Date(d+'T12:00:00').toLocaleDateString(lang()==='zh'?'zh-CN':'en-US',lang()==='zh'?{month:'numeric',day:'numeric'}:{month:'short',day:'numeric'}).toUpperCase()}function cap(x){return x[0].toUpperCase()+x.slice(1)}function esc(s){return (s||'').replace(/[&<>]/g,x=>({'&':'&','<':'<','>':'>'}[x]))} function applyWallpaper(w){if(!w?.url)return;desktop.style.backgroundImage=`linear-gradient(120deg,color-mix(in srgb,var(--bg) 68%,transparent),color-mix(in srgb,var(--bg) 36%,transparent)),url('${w.url}')`;desktop.style.backgroundSize='cover';desktop.style.backgroundPosition='center';desktop.title=LifeI18n.t('shell.wallpaperTitle',{title:w.title||LifeI18n.t('wallpaper.dailyImage')})}async function loadBingWallpaper(){let s=settings();if(s.wallpaper?.date===day()&&s.wallpaper?.source==='lifeos-proxy'){applyWallpaper(s.wallpaper);return}try{let r=await fetch('/api/bing-wallpaper'),d=await r.json();if(!r.ok||!d.url)throw Error(d.error||'No Bing image');s=settings();if((s.wallpaperSource||'bing')!=='bing')return;s.wallpaper={date:day(),url:'/api/bing-wallpaper-image?date='+day(),title:d.title||'Bing daily image',source:'lifeos-proxy'};save('lifeos_settings',s);applyWallpaper(s.wallpaper)}catch(e){console.info('Bing wallpaper unavailable; using the calm fallback.',e)}}function init(){let s=settings();document.documentElement.dataset.theme=s.theme==='auto'?(matchMedia('(prefers-color-scheme:light)').matches?'light':'dark'):s.theme;renderIcons();renderTaskbar();loadBingWallpaper();setInterval(clock,1000);clock();setTimeout(()=>window.applyLanguage?.(),0);setTimeout(()=>boot.remove(),650)} function desktopIconClick(id,e){if(innerWidth<650){openApp(id);return}selectedIcon=id;document.querySelectorAll('.app-icon').forEach(x=>x.classList.remove('selected'));e.currentTarget.classList.add('selected')}function desktopIconKey(id,e){if(e.key==='Enter'||e.key===' '){e.preventDefault();openApp(id)}}function renderIcons(){icons.innerHTML=Object.entries(APPS).map(([id,[g,n]])=>``).join('')}function taskClick(id){let w=windows[id];if(!w){openApp(id);return}if(w.classList.contains('min')){openApp(id);return}if(activeWindow===id){minWin(id);return}focus(w)}function renderTaskbar(){let ids=innerWidth<650?Object.keys(windows):['today','timeline','museum','insights','tasks','calendar','browser','terminal','database'];tasks.innerHTML=ids.map(id=>{let w=windows[id],min=w?.classList.contains('min'),fg=activeWindow===id&&!min,name=window.appName(id);return``}).join('')} -function clock(){let d=new Date();document.querySelector('#clock').textContent=d.toLocaleTimeString(LifeI18n.locale(), {hour:'2-digit',minute:'2-digit'});document.querySelector('#date').textContent=d.toLocaleDateString(lang()==='zh'?'zh-CN':'en-US',lang()==='zh'?{month:'numeric',day:'numeric'}:{month:'short',day:'numeric'}).toUpperCase()}function toggleLauncher(){launcher.classList.toggle('hidden');calendar.classList.add('hidden');launcher.innerHTML=`
LIFE OS${LifeI18n.t(`shell.${new Date().getHours()<12?'morning':new Date().getHours()<18?'afternoon':'evening'}`)}
${Object.entries(APPS).map(([id,a])=>``).join('')}
${LifeI18n.t('shell.systemOnline',{version:window.LifeOSVersion||'1.14.2'})}
`}function toggleCalendar(){calendar.classList.toggle('hidden');launcher.classList.add('hidden');calendar.innerHTML=`
${LifeI18n.t('shell.systemDate')}

${fmtDate(day())}

${LifeI18n.t('shell.todaySummary',{memories:mems().length,preserved:museum().length})}

`}function hideContext(){document.querySelector('#life-context')?.remove()}function showContext(e,id){e.preventDefault();e.stopPropagation();hideContext();let labels={open:LifeI18n.t('shell.open'),min:LifeI18n.t('shell.minimize'),close:LifeI18n.t('shell.menuCloseWindow'),refresh:LifeI18n.t('shell.refresh'),new:LifeI18n.t('shell.newToday'),system:LifeI18n.t('shell.systemSettings')},actions=id?`${windows[id]?``:''}`:`
`;let m=document.createElement('div');m.id='life-context';m.style.cssText=`position:fixed;z-index:7000;left:${Math.min(e.clientX,innerWidth-190)}px;top:${Math.min(e.clientY,innerHeight-150)}px;width:180px;padding:6px;background:var(--panel);border:1px solid var(--line);border-radius:10px;box-shadow:var(--shadow)`;m.innerHTML=actions;m.querySelectorAll('button').forEach(b=>b.style.cssText='display:block;width:100%;border:0;background:transparent;color:var(--ink);text-align:left;border-radius:6px;padding:9px 10px;font:12px Manrope');m.querySelectorAll('button').forEach(b=>b.onmouseenter=()=>b.style.background='var(--panel2)');document.body.append(m)}document.addEventListener('contextmenu',e=>{if(e.target.closest('.desktop')&&!e.target.closest('.app-icon')&&!e.target.closest('.window'))showContext(e,null)});document.addEventListener('click',hideContext) +function clock(){let d=new Date();document.querySelector('#clock').textContent=d.toLocaleTimeString(LifeI18n.locale(), {hour:'2-digit',minute:'2-digit'});document.querySelector('#date').textContent=d.toLocaleDateString(lang()==='zh'?'zh-CN':'en-US',lang()==='zh'?{month:'numeric',day:'numeric'}:{month:'short',day:'numeric'}).toUpperCase()}function toggleLauncher(){launcher.classList.toggle('hidden');calendar.classList.add('hidden');launcher.innerHTML=`
LIFE OS${LifeI18n.t(`shell.${new Date().getHours()<12?'morning':new Date().getHours()<18?'afternoon':'evening'}`)}
${Object.entries(APPS).map(([id,a])=>``).join('')}
${LifeI18n.t('shell.systemOnline',{version:window.LifeOSVersion||'1.15.0'})}
`}function toggleCalendar(){calendar.classList.toggle('hidden');launcher.classList.add('hidden');calendar.innerHTML=`
${LifeI18n.t('shell.systemDate')}

${fmtDate(day())}

${LifeI18n.t('shell.todaySummary',{memories:mems().length,preserved:museum().length})}

`}function hideContext(){document.querySelector('#life-context')?.remove()}function showContext(e,id){e.preventDefault();e.stopPropagation();hideContext();let labels={open:LifeI18n.t('shell.open'),min:LifeI18n.t('shell.minimize'),close:LifeI18n.t('shell.menuCloseWindow'),refresh:LifeI18n.t('shell.refresh'),new:LifeI18n.t('shell.newToday'),system:LifeI18n.t('shell.systemSettings')},actions=id?`${windows[id]?``:''}`:`
`;let m=document.createElement('div');m.id='life-context';m.style.cssText=`position:fixed;z-index:7000;left:${Math.min(e.clientX,innerWidth-190)}px;top:${Math.min(e.clientY,innerHeight-150)}px;width:180px;padding:6px;background:var(--panel);border:1px solid var(--line);border-radius:10px;box-shadow:var(--shadow)`;m.innerHTML=actions;m.querySelectorAll('button').forEach(b=>b.style.cssText='display:block;width:100%;border:0;background:transparent;color:var(--ink);text-align:left;border-radius:6px;padding:9px 10px;font:12px Manrope');m.querySelectorAll('button').forEach(b=>b.onmouseenter=()=>b.style.background='var(--panel2)');document.body.append(m)}document.addEventListener('contextmenu',e=>{if(e.target.closest('.desktop')&&!e.target.closest('.app-icon')&&!e.target.closest('.window'))showContext(e,null)});document.addEventListener('click',hideContext) function openApp(id){launcher.classList.add('hidden');let w=windows[id];if(w){w.classList.remove('min');focus(w);return}w=document.createElement('section');w.className='window';w.dataset.id=id;w.style.left=(innerWidth<650?0:90+Object.keys(windows).length*36)+'px';w.style.top=(innerWidth<650?0:72+Object.keys(windows).length*28)+'px';w.innerHTML=`
${APPS[id][0]}${appName(id)}
`;windows[id]=w;document.querySelector('#windows').append(w);let controls=w.querySelector('.controls'),bar=w.querySelector('.titlebar');controls.addEventListener('pointerdown',e=>{e.preventDefault();e.stopPropagation()});controls.addEventListener('click',e=>{let action=e.target.closest('button')?.dataset.windowAction;if(!action)return;e.preventDefault();e.stopPropagation();if(action==='min')minWin(id);if(action==='max')maxWin(id);if(action==='close')closeWin(id)});bar.addEventListener('dblclick',e=>{if(!e.target.closest('.controls'))maxWin(id)});w.addEventListener('pointerdown',()=>focus(w));drag(w);renderApp(id);focus(w)}function focus(w){if(!w)return;activeWindow=w.dataset.id;w.style.zIndex=++z;document.querySelectorAll('.window').forEach(x=>x.classList.toggle('active-window',x===w));renderTaskbar()}function closeWin(id){windows[id]?.remove();delete windows[id];if(activeWindow===id){activeWindow=Object.keys(windows).find(k=>!windows[k].classList.contains('min'))||null;if(activeWindow)focus(windows[activeWindow])}renderTaskbar()}function minWin(id){let w=windows[id];if(!w)return;w.classList.add('min');if(activeWindow===id){activeWindow=Object.keys(windows).find(k=>k!==id&&!windows[k].classList.contains('min'))||null;if(activeWindow)focus(windows[activeWindow])}renderTaskbar();toast(i18n('MINIMIZED — TAP THE APP ICON TO RESTORE'))}function maxWin(id){let w=windows[id];if(!w)return;w.classList.toggle('max');focus(w)}function snapWindow(w){if(innerWidth<650||w.classList.contains('max'))return;let r=w.getBoundingClientRect(),edge=22;if(r.topinnerWidth-edge){w.style.left='calc(50vw + 9px)';w.style.top='18px';w.style.width='calc(50vw - 27px)';w.style.height='calc(100vh - 98px)'}}function drag(w){let bar=w.querySelector('.titlebar'),x,y,l,t;bar.onpointerdown=e=>{if(e.target.closest('.controls')||innerWidth<650)return;if(w.classList.contains('max'))w.classList.remove('max');x=e.clientX;y=e.clientY;l=w.offsetLeft;t=w.offsetTop;bar.setPointerCapture(e.pointerId);bar.onpointermove=e=>{w.style.left=Math.max(0,l+e.clientX-x)+'px';w.style.top=Math.max(0,t+e.clientY-y)+'px'};bar.onpointerup=()=>{bar.onpointermove=null;snapWindow(w)}}}document.addEventListener('keydown',e=>{if(e.altKey&&e.key==='F4'&&activeWindow){e.preventDefault();closeWin(activeWindow)}if(e.key==='Escape'&&activeWindow&&!document.querySelector('.modal')&&!document.querySelector('#lifeos-search')){let w=windows[activeWindow];if(w?.classList.contains('max')){e.preventDefault();maxWin(activeWindow)}}}) function renderApp(id){let c=document.querySelector('#content-'+id);if(!c)return;if(id==='today')window.today(c);if(id==='timeline')window.timeline(c);if(id==='museum')window.museumApp(c);if(id==='insights')window.insights(c);if(id==='profile')window.profileApp(c);if(id==='tasks')window.tasksApp(c);if(id==='calendar')window.calendarApp(c);if(id==='system')window.systemApp(c);if(id==='browser')window.browserApp(c);if(id==='terminal')window.terminalApp(c);if(id==='database')window.databaseApp(c);setTimeout(()=>window.applyLanguage?.(),0)}function renderAll(){Object.keys(windows).forEach(renderApp);renderTaskbar();window.renderDesktopGoalCountdowns?.();setTimeout(()=>window.applyLanguage?.(),0)}document.addEventListener('click',e=>{if(e.target.closest('#content-today .chip,#content-today .energy button'))setTimeout(()=>window.applyLanguage?.(),0)}) let todayDraft=null,todayTitleDraft=null,todayTargetDate=null;function today(c){let m=mems().find(x=>x.date===day());if(todayDraft===null){selectedMood=m?.mood||'cloudy';selectedEnergy=m?.energy||3}let f=m||filmFor(selectedMood,selectedEnergy),text=todayDraft===null?(m?.text||''):todayDraft;c.innerHTML=`
${new Date().toLocaleDateString('en-US',{weekday:'long'}).toUpperCase()}

${fmtDate(day()).replace(/^[^,]+, /,'')}

How was your day?

`}function filmFor(m,e){const dict=LifeI18n.dictionaries[lang()==='zh'?'zh':'en'],seed=[...`${day()}|${m}|${e}`].reduce((sum,char,index)=>sum+char.charCodeAt(0)*(index+1),0),n=seed%dict.prompt.films.length;return{filmTitle:dict.prompt.films[n].title,filmSubtitle:dict.prompt.films[n].subtitle,filmLine:dict.prompt.lines[(n*3+e)%dict.prompt.lines.length]}}function rerenderToday(){let old=document.querySelector('#entry');todayDraft=old?old.value:todayDraft||'';window.today(document.querySelector('#content-today'));let entry=document.querySelector('#entry');entry?.focus();entry?.setSelectionRange(entry.value.length,entry.value.length)}function setMood(x){selectedMood=x;rerenderToday()}function setEnergy(x){selectedEnergy=x;rerenderToday()}function archiveToday(){let text=document.querySelector('#entry').value.trim(),a=mems(),old=a.find(x=>x.date===day()),f=old||filmFor(selectedMood,selectedEnergy),now=new Date().toISOString(),m={id:old?.id||'memory_'+crypto.randomUUID(),date:day(),text,mood:selectedMood,energy:selectedEnergy,filmTitle:f.filmTitle,filmSubtitle:f.filmSubtitle,filmLine:f.filmLine,createdAt:old?.createdAt||now,updatedAt:now};let status=document.querySelector('#saveStatus');status.textContent='ARCHIVING MEMORY...';setTimeout(()=>{save('lifeos_memories',[...a.filter(x=>x.date!==day()),m].sort((x,y)=>y.date.localeCompare(x.date)));todayDraft=null;status.textContent='MEMORY ARCHIVED';renderAll()},360)} @@ -23,6 +23,6 @@ function museumApp(c){let a=museum(),ms=mems();c.innerHTML=`
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=`
LAST 30 DAYS

Insights, quietly.

WEATHER
${Object.entries(cnt).map(([k,v])=>`
${cap(k)} ${v} DAYS
`).join('')}
AVERAGE ENERGY
${avg} / 5
${a.slice(0,10).reverse().map(x=>``).join('')||'No entries yet'}
MOST ACTIVE DAY
${active}
CURRENT STREAK
${streak(a)} DAYS
Longest streak: ${longest(a)} days
`} 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=`
SYSTEM PROFILE

${p.name?esc(p.name)+' · ':''}LIFE OS ONLINE

${started}DAYS ONLINE
${a.length}MEMORIES
${museum().length}MUSEUM ITEMS
${streak(a)}CURRENT STREAK
${longest(a)}LONGEST STREAK
${cap(common)}COMMON WEATHER
PERSONAL SYSTEM
AVERAGE ENERGY ${avg} / 5
`}function saveProfile(){save('lifeos_profile',{name:pname.value,birthday:pbday.value,startDate:pstart.value||day(),motto:pmotto.value});renderAll();toast('PROFILE SAVED')} function today(c){let m=mems().find(x=>x.date===day());if(todayDraft===null){selectedMood=m?.mood||'cloudy';selectedEnergy=m?.energy||3}let f=m||filmFor(selectedMood,selectedEnergy),text=todayDraft===null?(m?.text||''):todayDraft,cWeek=new Date().toLocaleDateString(lang()==='zh'?'zh-CN':'en-US',{weekday:'long'}).toUpperCase();c.innerHTML=`
${cWeek}

${fmtDate(day()).replace(/^[^,]+, /,'')}

${i18n('How was your day?')}

`}function rerenderToday(){let old=document.querySelector('#entry');todayDraft=old?old.value:todayDraft||'';window.today(document.querySelector('#content-today'));let entry=document.querySelector('#entry');entry?.focus();entry?.setSelectionRange(entry.value.length,entry.value.length)} -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=`
SYSTEM SETTINGS

System, local and yours.

APPEARANCE
${['dark','light','auto'].map(x=>``).join('')}
LANGUAGE
DATA
STORAGE

Local Storage
${mems().length} memories · ${museum().length} museum items · Approx. ${Math.ceil(bytes/1024)} KB

DATA STORAGE

Your data is stored locally in this browser.
Create regular backups to prevent data loss.

${s.lastBackup?'LAST BACKUP · '+fmtDate(s.lastBackup.slice(0,10)):'LAST BACKUP · NEVER'} ${rec?'
BACKUP RECOMMENDED':''}

VERSION

Life OS
Version ${window.LifeOSVersion||'1.14.2'} · Schema v2

`}function theme(x){let s=settings();s.theme=x;save('lifeos_settings',s);init();renderAll()}function exportData(){let data={app:'Life OS',version:window.LifeOSVersion||'1.14.2',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'||![1,2].includes(d.schemaVersion)||!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=``}function replaceConfirm(){modal.innerHTML=``}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=``}function toast(t){let x=document.createElement('div');x.className='toast';x.textContent=t;document.body.append(x);setTimeout(()=>x.remove(),2000)} +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=`
SYSTEM SETTINGS

System, local and yours.

APPEARANCE
${['dark','light','auto'].map(x=>``).join('')}
LANGUAGE
DATA
STORAGE

Local Storage
${mems().length} memories · ${museum().length} museum items · Approx. ${Math.ceil(bytes/1024)} KB

DATA STORAGE

Your data is stored locally in this browser.
Create regular backups to prevent data loss.

${s.lastBackup?'LAST BACKUP · '+fmtDate(s.lastBackup.slice(0,10)):'LAST BACKUP · NEVER'} ${rec?'
BACKUP RECOMMENDED':''}

VERSION

Life OS
Version ${window.LifeOSVersion||'1.15.0'} · Schema v2

`}function theme(x){let s=settings();s.theme=x;save('lifeos_settings',s);init();renderAll()}function exportData(){let data={app:'Life OS',version:window.LifeOSVersion||'1.15.0',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'||![1,2].includes(d.schemaVersion)||!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=``}function replaceConfirm(){modal.innerHTML=``}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=``}function toast(t){let x=document.createElement('div');x.className='toast';x.textContent=t;document.body.append(x);setTimeout(()=>x.remove(),2000)} window.renderIcons=renderIcons;window.renderAll=renderAll;window.toast=toast; - + diff --git a/public/locales/en.json b/public/locales/en.json index 23fe626..a3e4d8e 100644 --- a/public/locales/en.json +++ b/public/locales/en.json @@ -198,6 +198,23 @@ "saved": "PROFILE SAVED", "average": "AVERAGE ENERGY {value} / 5" }, + "notifications": { + "title": "NOTIFICATIONS", + "allow": "Allow system notifications", + "openOnly": "Reminders run while Life OS is open. Browsers may delay them when a tab sleeps; they cannot arrive after the site is closed.", + "time": "{name} time", + "sendTest": "Send a test notification", + "status": { + "default": "Permission has not been requested. Choose Allow to enable delivery.", + "granted": "System notifications are allowed on this device.", + "denied": "Notifications are blocked. Change this site's permission in your browser settings.", + "unsupported": "System notifications are unavailable in this browser or context." + }, + "memory": { "label": "Today's memory", "description": "Remind me if I have not recorded a memory today.", "title": "A moment to remember", "body": "You have not written today's memory yet." }, + "tasks": { "label": "Today's tasks", "description": "Show the number of unfinished tasks due today or earlier.", "title": "Today's tasks", "body": "{count} unfinished tasks are due today or earlier." }, + "goals": { "label": "Goal countdown", "description": "Show the nearest active goal with a target date.", "title": "Goal countdown", "days": "{name}: {count} days left.", "today": "{name}: target date is today.", "overdue": "{name}: {count} days past the target date." }, + "test": { "title": "Life OS notifications are ready", "body": "Your browser can show reminders from Life OS.", "failed": "Could not show the test notification." } + }, "system": { "eyebrow": "SYSTEM SETTINGS", "title": "System, local and yours.", diff --git a/public/locales/zh-CN.json b/public/locales/zh-CN.json index 5390ca1..5aae3bb 100644 --- a/public/locales/zh-CN.json +++ b/public/locales/zh-CN.json @@ -198,6 +198,23 @@ "saved": "资料已保存", "average": "平均能量 {value} / 5" }, + "notifications": { + "title": "系统通知", + "allow": "允许系统通知", + "openOnly": "Life OS 打开时才会检查提醒。浏览器休眠可能导致延迟;关闭网站后无法准时发送。", + "time": "{name}通知时间", + "sendTest": "发送测试通知", + "status": { + "default": "尚未请求通知权限,点击允许后才可发送。", + "granted": "此设备已允许系统通知。", + "denied": "通知已被阻止,请在浏览器的站点权限中开启。", + "unsupported": "当前浏览器或访问环境不支持系统通知。" + }, + "memory": { "label": "今日记忆", "description": "今天还没有记忆时提醒我编写。", "title": "记录今天的片刻", "body": "今天还没有写下记忆。" }, + "tasks": { "label": "本日待办", "description": "显示今天及此前到期但尚未完成的待办数量。", "title": "本日待办", "body": "有 {count} 项到期或逾期的待办尚未完成。" }, + "goals": { "label": "目标倒计时", "description": "显示最近一个有目标日期的进行中目标。", "title": "目标倒计时", "days": "{name}:还有 {count} 天。", "today": "{name}:目标日期就是今天。", "overdue": "{name}:已超过目标日期 {count} 天。" }, + "test": { "title": "Life OS 通知已就绪", "body": "浏览器可以显示来自 Life OS 的提醒。", "failed": "测试通知发送失败。" } + }, "system": { "eyebrow": "系统设置", "title": "你的个人系统。", diff --git a/public/notifications.js b/public/notifications.js new file mode 100644 index 0000000..2c42edd --- /dev/null +++ b/public/notifications.js @@ -0,0 +1,124 @@ +/* Device-local reminders. Browsers cannot guarantee timed delivery after the site is closed. */ +(() => { + const KEY = 'lifeos_notifications_v1'; + const SENT = 'lifeos_notifications_sent_v1'; + const defaults = { + memory: { enabled: false, time: '21:00' }, + tasks: { enabled: false, time: '09:00' }, + goals: { enabled: false, time: '08:00' } + }; + const kinds = Object.keys(defaults); + const t = (key, values) => LifeI18n.t(`notifications.${key}`, values); + const validTime = value => typeof value === 'string' && /^([01]\d|2[0-3]):[0-5]\d$/.test(value); + const today = now => `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`; + const read = key => { try { return JSON.parse(localStorage.getItem(key) || '{}') || {}; } catch { return {}; } }; + const preferences = () => { + const stored = read(KEY); + return Object.fromEntries(kinds.map(kind => [kind, { + enabled: stored[kind]?.enabled === true, + time: validTime(stored[kind]?.time) ? stored[kind].time : defaults[kind].time + }])); + }; + const supported = () => typeof Notification !== 'undefined' && typeof Notification.requestPermission === 'function' && isSecureContext; + const status = () => !supported() ? 'unsupported' : Notification.permission; + + function markup() { + const current = preferences(), permission = status(); + return `
${t('title')}

${t(`status.${permission}`)}

+ ${permission === 'default' ? `` : ''} +

${t('openOnly')}

${kinds.map(kind => ` + `).join('')}
+ ${permission === 'granted' ? `` : ''}`; + } + + async function show(title, body, app, tag) { + const options = { body, icon: '/life-os-icon.svg', tag, data: { app } }; + if ('serviceWorker' in navigator) { + const registration = await navigator.serviceWorker.getRegistration(); + if (registration?.showNotification) { await registration.showNotification(title, options); return; } + } + const notification = new Notification(title, options); + notification.onclick = () => { window.focus(); openApp(app); notification.close(); }; + } + + function content(kind, date) { + if (kind === 'memory') { + if (mems().some(memory => memory.date === date)) return null; + return { title: t('memory.title'), body: t('memory.body'), app: 'today' }; + } + const board = sys().tasksBoard || {}; + if (kind === 'tasks') { + const pending = (board.tasks || []).filter(task => !task.done && task.dueDate && task.dueDate <= date); + if (!pending.length) return null; + return { title: t('tasks.title'), body: t('tasks.body', { count: pending.length }), app: 'tasks' }; + } + const goals = (board.goals || []).filter(goal => !goal.done && goal.targetDate).sort((a, b) => a.targetDate.localeCompare(b.targetDate)); + if (!goals.length) return null; + const goal = goals.find(item => item.targetDate >= date) || goals.at(-1); + const [year, month, day] = goal.targetDate.split('-').map(Number), [currentYear, currentMonth, currentDay] = date.split('-').map(Number); + const days = Math.round((Date.UTC(year, month - 1, day) - Date.UTC(currentYear, currentMonth - 1, currentDay)) / 864e5); + return { title: t('goals.title'), body: t(days < 0 ? 'goals.overdue' : days === 0 ? 'goals.today' : 'goals.days', { name: goal.title, count: Math.abs(days) }), app: 'tasks' }; + } + + let checking = false; + async function check(now = new Date()) { + if (checking || status() !== 'granted') return; + checking = true; + try { + const date = today(now), minute = now.getHours() * 60 + now.getMinutes(), current = preferences(); + for (const kind of kinds) { + const preference = current[kind]; + if (!preference.enabled) continue; + const [hour, minutes] = preference.time.split(':').map(Number), elapsed = minute - hour * 60 - minutes; + if (elapsed < 0 || elapsed > 60) continue; + const key = `${date}:${kind}`, sent = read(SENT); + if (sent[key]) continue; + const reminder = content(kind, date); + if (!reminder) continue; + try { + await show(reminder.title, reminder.body, reminder.app, `lifeos-${kind}-${date}`); + const latest = read(SENT); + localStorage.setItem(SENT, JSON.stringify({ ...Object.fromEntries(Object.entries(latest).filter(([item]) => item.startsWith(date + ':'))), [key]: true })); + } catch (error) { console.info('Life OS notification unavailable', error); } + } + } finally { checking = false; } + } + + async function request() { + if (!supported()) return; + try { await Notification.requestPermission(); } catch {} + window.renderApp?.('system'); + check(); + } + async function test() { + if (status() !== 'granted') return; + try { await show(t('test.title'), t('test.body'), 'system', 'lifeos-test'); } + catch { window.toast?.(t('test.failed')); } + } + + document.addEventListener('change', event => { + const field = event.target.closest('[data-notification-kind][data-field]'); + if (!field || !kinds.includes(field.dataset.notificationKind)) return; + const current = preferences(), kind = field.dataset.notificationKind; + if (field.dataset.field === 'time' && validTime(field.value)) current[kind].time = field.value; + if (field.dataset.field === 'enabled') current[kind].enabled = field.checked; + localStorage.setItem(KEY, JSON.stringify(current)); + check(); + }); + window.addEventListener('focus', () => check()); + document.addEventListener('visibilitychange', () => { if (!document.hidden) check(); }); + window.addEventListener('load', () => check()); + navigator.serviceWorker?.addEventListener('message', event => { + if (event.data?.type === 'lifeos-open-app' && ['today', 'tasks', 'system'].includes(event.data.app)) openApp(event.data.app); + }); + const requestedApp = new URL(location.href).searchParams.get('app'); + if (['today', 'tasks', 'system'].includes(requestedApp)) { + window.addEventListener('load', () => openApp(requestedApp), { once: true }); + const clean = new URL(location.href); clean.searchParams.delete('app'); history.replaceState(null, '', clean); + } + setInterval(() => check(), 30_000); + window.LifeOSNotifications = { markup, request, test, check }; + const style = document.createElement('style'); + style.textContent = `.notification-settings{grid-column:1/-1}.notification-settings>.sub{line-height:1.5}.notification-list{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:12px;margin:14px 0}.notification-row{display:flex;align-items:flex-start;justify-content:space-between;gap:12px;padding:14px;border:1px solid var(--line);border-radius:10px;background:var(--panel2)}.notification-row span{display:grid;gap:5px}.notification-row strong{font-size:14px}.notification-row small{font-size:12px;line-height:1.45;color:var(--muted)}.notification-row input{flex:none;width:19px;height:19px;accent-color:var(--accent)}.notification-time{display:grid;gap:7px;padding:0 12px;color:var(--muted);font-size:13px}.notification-time input{width:100%;max-width:170px;padding:8px;border:1px solid var(--line);border-radius:7px;color:var(--ink);background:var(--panel2);font:inherit}@media(max-width:760px){.notification-list{grid-template-columns:1fr}.notification-time{margin-bottom:8px}}`; + document.head.append(style); +})(); diff --git a/public/service-worker.js b/public/service-worker.js index e49c9e3..f8901ba 100644 --- a/public/service-worker.js +++ b/public/service-worker.js @@ -1,4 +1,4 @@ -const CACHE = 'life-os-shell-v1.14.2'; +const CACHE = 'life-os-shell-v1.15.0'; const SHELL = [ '/', '/index.html', '/manifest.webmanifest', '/life-os-icon.svg' ]; @@ -29,3 +29,15 @@ self.addEventListener('fetch', event => { return response; }))); }); + +self.addEventListener('notificationclick', event => { + event.notification.close(); + const app = ['today', 'tasks', 'system'].includes(event.notification.data?.app) ? event.notification.data.app : 'system'; + event.waitUntil(self.clients.matchAll({ type: 'window', includeUncontrolled: true }).then(async clients => { + const existing = clients.find(client => new URL(client.url).origin === self.location.origin); + if (existing) { + await existing.focus(); + existing.postMessage({ type: 'lifeos-open-app', app }); + } else await self.clients.openWindow('/?app=' + encodeURIComponent(app)); + })); +}); diff --git a/public/terminal-app.js b/public/terminal-app.js index 8dbb576..5de77bc 100644 --- a/public/terminal-app.js +++ b/public/terminal-app.js @@ -150,7 +150,7 @@ return { message: new Intl.DateTimeFormat(lang() === 'zh' ? 'zh-CN' : 'en-US', options).format(new Date()) }; } if (kind === 'uptime') { if (verb) throw new Error('usage'); const seconds = Math.floor((Date.now() - startedAt) / 1000); return { message: t('uptime', { minutes: Math.floor(seconds / 60), seconds: seconds % 60 }) }; } - if (kind === 'version') { if (verb) throw new Error('usage'); return { message: `Life OS v${window.LifeOSVersion || '1.14.2'}` }; } + if (kind === 'version') { if (verb) throw new Error('usage'); return { message: `Life OS v${window.LifeOSVersion || '1.15.0'}` }; } if (kind === 'status') { if (verb) throw new Error('usage'); const data = board(); return { message: t('status', { memories: mems().length, goals: data.goals.length, tasks: data.tasks.length }) }; } if (kind === 'apps') { if (verb) throw new Error('usage'); return { message: Object.keys(APPS).map(id => `${id} — ${LifeI18n.t(`app.${id}`)}`).join('\n') }; } if (kind === 'history') { if (verb) throw new Error('usage'); return { message: history.slice(-20).map((entry,index) => `${index + 1}. ${entry}`).join('\n') || t('empty') }; } @@ -235,7 +235,7 @@ }; window.terminalApp = node => { if (node.querySelector('.terminal-shell') && node.dataset.terminalLocale === lang()) { output(node); return; } - if (!welcomed) { lines.unshift({ value: t('welcome', { version: window.LifeOSVersion || '1.14.2' }), kind: 'system' }); welcomed = true; } + if (!welcomed) { lines.unshift({ value: t('welcome', { version: window.LifeOSVersion || '1.15.0' }), kind: 'system' }); welcomed = true; } node.dataset.terminalLocale = lang(); node.innerHTML = `
Life OS / ${t('title')}${t('hint')}
`; output(node); composer(node, Boolean(compose)); diff --git a/scripts/build.mjs b/scripts/build.mjs index a8d1100..dd27d9a 100644 --- a/scripts/build.mjs +++ b/scripts/build.mjs @@ -7,21 +7,21 @@ const meta = new URL("../dist/.openai/", import.meta.url); await rm(dist, { recursive: true, force: true }); await Promise.all([mkdir(server, { recursive: true }), mkdir(meta, { recursive: true })]); -const [rawIndexHtml, addonsJs, bootGuardJs, i18nJs, tasksJs, calendarJs, i18nRenderersJs, desktopPersonalizationJs, desktopWidgetsJs, browserAppJs, dataActionsJs, terminalAppJs, dbAdminJs, manifest, serviceWorker, icon, localeEn, localeZh] = await Promise.all([text("index.html"), text("addons.js"), text("boot-guard.js"), text("i18n.js"), text("tasks.js"), text("calendar.js"), text("i18n-renderers.js"), text("desktop-personalization.js"), text("desktop-widgets.js"), text("browser-app.js"), text("data-actions.js"), text("terminal-app.js"), text("db-admin.js"), text("manifest.webmanifest"), text("service-worker.js"), text("life-os-icon.svg"), readFile(new URL("../public/locales/en.json", import.meta.url), "utf8"), readFile(new URL("../public/locales/zh-CN.json", import.meta.url), "utf8")]); -const release = "1.14.2"; +const [rawIndexHtml, addonsJs, bootGuardJs, i18nJs, tasksJs, calendarJs, notificationsJs, i18nRenderersJs, desktopPersonalizationJs, desktopWidgetsJs, browserAppJs, dataActionsJs, terminalAppJs, dbAdminJs, manifest, serviceWorker, icon, localeEn, localeZh] = await Promise.all([text("index.html"), text("addons.js"), text("boot-guard.js"), text("i18n.js"), text("tasks.js"), text("calendar.js"), text("notifications.js"), text("i18n-renderers.js"), text("desktop-personalization.js"), text("desktop-widgets.js"), text("browser-app.js"), text("data-actions.js"), text("terminal-app.js"), text("db-admin.js"), text("manifest.webmanifest"), text("service-worker.js"), text("life-os-icon.svg"), readFile(new URL("../public/locales/en.json", import.meta.url), "utf8"), readFile(new URL("../public/locales/zh-CN.json", import.meta.url), "utf8")]); +const release = "1.15.0"; const localeEnJs = `window.LifeOSLocales=window.LifeOSLocales||{};window.LifeOSLocales.en=${localeEn};`; const localeZhJs = `window.LifeOSLocales=window.LifeOSLocales||{};window.LifeOSLocales['zh-CN']=${localeZh};`; let indexHtml = rawIndexHtml.replace('src="addons.js"', `src="addons.js?v=${release}"`).replace('src="tasks.js"', `src="tasks.js?v=${release}"`).replace('src="calendar.js"', `src="calendar.js?v=${release}"`); for (const [src, code] of [ ['boot-guard.js', bootGuardJs], ['locales/en.js', localeEnJs], ['locales/zh-CN.js', localeZhJs], ['i18n.js', i18nJs], ['tasks.js', tasksJs], ['calendar.js', calendarJs], - ['addons.js', addonsJs], ['i18n-renderers.js', i18nRenderersJs], ['desktop-personalization.js', desktopPersonalizationJs], ['desktop-widgets.js', desktopWidgetsJs], ['browser-app.js', browserAppJs], ['data-actions.js', dataActionsJs], ['terminal-app.js', terminalAppJs], ['db-admin.js', dbAdminJs] + ['addons.js', addonsJs], ['notifications.js', notificationsJs], ['i18n-renderers.js', i18nRenderersJs], ['desktop-personalization.js', desktopPersonalizationJs], ['desktop-widgets.js', desktopWidgetsJs], ['browser-app.js', browserAppJs], ['data-actions.js', dataActionsJs], ['terminal-app.js', terminalAppJs], ['db-admin.js', dbAdminJs] ]) { const tag = ``; if (!indexHtml.includes(tag)) throw new Error(`Missing Life OS script: ${src}`); indexHtml = indexHtml.replace(tag, ``); } -await writeFile(new URL("assets.js", server), `export const indexHtml=${JSON.stringify(indexHtml)};\nexport const addonsJs=${JSON.stringify(addonsJs)};\nexport const bootGuardJs=${JSON.stringify(bootGuardJs)};\nexport const i18nJs=${JSON.stringify(i18nJs)};\nexport const localeEn=JSON.parse(${JSON.stringify(localeEn)});\nexport const localeZh=JSON.parse(${JSON.stringify(localeZh)});\nexport const localeEnJs=${JSON.stringify(localeEnJs)};\nexport const localeZhJs=${JSON.stringify(localeZhJs)};\nexport const tasksJs=${JSON.stringify(tasksJs)};\nexport const calendarJs=${JSON.stringify(calendarJs)};\nexport const i18nRenderersJs=${JSON.stringify(i18nRenderersJs)};\nexport const desktopPersonalizationJs=${JSON.stringify(desktopPersonalizationJs)};\nexport const desktopWidgetsJs=${JSON.stringify(desktopWidgetsJs)};\nexport const browserAppJs=${JSON.stringify(browserAppJs)};\nexport const dataActionsJs=${JSON.stringify(dataActionsJs)};\nexport const terminalAppJs=${JSON.stringify(terminalAppJs)};\nexport const dbAdminJs=${JSON.stringify(dbAdminJs)};\nexport const manifest=${JSON.stringify(manifest)};\nexport const serviceWorker=${JSON.stringify(serviceWorker)};\nexport const icon=${JSON.stringify(icon)};\n`); +await writeFile(new URL("assets.js", server), `export const indexHtml=${JSON.stringify(indexHtml)};\nexport const addonsJs=${JSON.stringify(addonsJs)};\nexport const bootGuardJs=${JSON.stringify(bootGuardJs)};\nexport const i18nJs=${JSON.stringify(i18nJs)};\nexport const localeEn=JSON.parse(${JSON.stringify(localeEn)});\nexport const localeZh=JSON.parse(${JSON.stringify(localeZh)});\nexport const localeEnJs=${JSON.stringify(localeEnJs)};\nexport const localeZhJs=${JSON.stringify(localeZhJs)};\nexport const tasksJs=${JSON.stringify(tasksJs)};\nexport const calendarJs=${JSON.stringify(calendarJs)};\nexport const notificationsJs=${JSON.stringify(notificationsJs)};\nexport const i18nRenderersJs=${JSON.stringify(i18nRenderersJs)};\nexport const desktopPersonalizationJs=${JSON.stringify(desktopPersonalizationJs)};\nexport const desktopWidgetsJs=${JSON.stringify(desktopWidgetsJs)};\nexport const browserAppJs=${JSON.stringify(browserAppJs)};\nexport const dataActionsJs=${JSON.stringify(dataActionsJs)};\nexport const terminalAppJs=${JSON.stringify(terminalAppJs)};\nexport const dbAdminJs=${JSON.stringify(dbAdminJs)};\nexport const manifest=${JSON.stringify(manifest)};\nexport const serviceWorker=${JSON.stringify(serviceWorker)};\nexport const icon=${JSON.stringify(icon)};\n`); await copyFile(new URL("../worker/index.js", import.meta.url), new URL("index.js", server)); await copyFile(new URL("../worker/cloud-state.js", import.meta.url), new URL("cloud-state.js", server)); await copyFile(new URL("../worker/db-admin.js", import.meta.url), new URL("db-admin.js", server)); diff --git a/scripts/validate.mjs b/scripts/validate.mjs index 6bcb51c..568c11e 100644 --- a/scripts/validate.mjs +++ b/scripts/validate.mjs @@ -50,11 +50,28 @@ assert.doesNotMatch(assets.indexHtml, /setTimeout\(applyLanguage\s*,/); assert.doesNotMatch(assets.addonsJs, /(?([\s\S]*?)<\/script>/g)].map(match => match[1]); -assert.equal(shippedScripts.length, 16, "the complete desktop script chain is present in the served HTML"); +assert.equal(shippedScripts.length, 17, "the complete desktop script chain is present in the served HTML"); assert.doesNotMatch(assets.indexHtml, / [kind, { enabled: true, time: '12:00' }])))); +const reminderContext = vm.createContext({ + document: { hidden: false, addEventListener() {}, createElement: () => ({}), head: { append() {} } }, + localStorage: { getItem: key => reminderStorage.get(key) ?? null, setItem: (key, value) => reminderStorage.set(key, value) }, + navigator: { serviceWorker: { getRegistration: async () => ({ showNotification: async (title, options) => delivered.push(options.tag) }), addEventListener() {} } }, + Notification: { permission: 'granted', requestPermission: async () => 'granted' }, isSecureContext: true, + LifeI18n: { t: key => key }, mems: () => [], sys: () => ({ tasksBoard: { tasks: [{ dueDate: '2026-09-28', done: false }], goals: [{ title: 'Study', targetDate: '2026-10-01', done: false }] } }), + URL, location: { href: 'https://life-os.test/' }, history: { replaceState() {} }, setInterval() {}, console, Date +}); +reminderContext.window = reminderContext; +reminderContext.addEventListener = () => {}; +vm.runInContext(assets.notificationsJs, reminderContext); +await reminderContext.LifeOSNotifications.check(new Date(2026, 8, 28, 12, 1)); +assert.deepEqual(delivered, ['lifeos-memory-2026-09-28', 'lifeos-tasks-2026-09-28', 'lifeos-goals-2026-09-28']); +await reminderContext.LifeOSNotifications.check(new Date(2026, 8, 28, 12, 2)); +assert.equal(delivered.length, 3, 'a reminder is sent only once per day'); const adminNode = { innerHTML: "", querySelector: () => ({ addEventListener() {} }) }; const adminContext = vm.createContext({ document: { createElement: () => ({ textContent: "" }), head: { append() {} }, querySelector: () => adminNode }, @@ -186,7 +203,7 @@ assert.ok(command('date').message.length > 8); assert.ok(command('time').message.includes(':')); assert.ok(command('datetime').message.length > 12); assert.equal(command('uptime').message, 'terminal.uptime'); -assert.equal(command('version').message, 'Life OS v1.14.2'); +assert.equal(command('version').message, 'Life OS v1.15.0'); assert.equal(command('status').message, 'terminal.status'); assert.match(command('apps').message, /calendar/); assert.equal(command('echo "hello Life OS"').message, 'hello Life OS'); @@ -284,7 +301,7 @@ const uiContext = vm.createContext({ ...Object.fromEntries(["icons", "tasks", "launcher", "calendar", "desktop", "boot", "modal"].map(id => [id, uiNodes.get(`#${id}`)])) }); uiContext.window = uiContext; -for (const index of [0, 2, 3, 4, 5, 9, 10]) vm.runInContext(shippedScripts[index], uiContext); +for (const index of [0, 2, 3, 4, 5, 10, 11]) vm.runInContext(shippedScripts[index], uiContext); vm.runInContext("windows.system={classList:{contains(){return false}}}", uiContext); uiContext.setLanguage("zh"); assert.equal(uiContext.LifeI18n.language(), "zh"); diff --git a/worker/index.js b/worker/index.js index 78ad427..d83bbc5 100644 --- a/worker/index.js +++ b/worker/index.js @@ -1,4 +1,4 @@ -import { addonsJs, bootGuardJs, i18nJs, localeEn, localeZh, localeEnJs, localeZhJs, tasksJs, calendarJs, i18nRenderersJs, browserAppJs, dataActionsJs, terminalAppJs, dbAdminJs, indexHtml, manifest, serviceWorker, icon } from "./assets.js"; +import { addonsJs, bootGuardJs, i18nJs, localeEn, localeZh, localeEnJs, localeZhJs, tasksJs, calendarJs, notificationsJs, i18nRenderersJs, browserAppJs, dataActionsJs, terminalAppJs, dbAdminJs, indexHtml, manifest, serviceWorker, icon } from "./assets.js"; import { cloudApi } from "./cloud-state.js"; import { dbAdminApi } from "./db-admin.js"; @@ -52,6 +52,7 @@ export default { if (url.pathname === "/data-actions.js") return new Response(dataActionsJs, { headers: headers("application/javascript; charset=utf-8", "no-cache") }); if (url.pathname === "/terminal-app.js") return new Response(terminalAppJs, { headers: headers("application/javascript; charset=utf-8", "no-cache") }); if (url.pathname === "/db-admin.js") return new Response(dbAdminJs, { headers: headers("application/javascript; charset=utf-8", "no-cache") }); + if (url.pathname === "/notifications.js") return new Response(notificationsJs, { headers: headers("application/javascript; charset=utf-8", "no-cache") }); if (url.pathname === "/browser-app.js") return new Response(browserAppJs, { headers: headers("application/javascript; charset=utf-8", "no-cache") }); if (url.pathname === "/i18n-renderers.js") return new Response(i18nRenderersJs, { headers: headers("application/javascript; charset=utf-8", "no-cache") }); if (url.pathname === "/" || url.pathname === "/index.html") {