diff --git a/NOTICE.md b/NOTICE.md index ce7f0f4..2982b12 100644 --- a/NOTICE.md +++ b/NOTICE.md @@ -40,3 +40,7 @@ Weather data attribution: Open-Meteo, CC BY 4.0. Attribution is retained in prov results, UI and weather/dashboard output. Public API usage is intended for this non-commercial household deployment; commercial deployments must review provider terms. This service is not affiliated with Open-Meteo. + +## Classical poetry + +The offline daily-poetry catalog contains original Chinese texts of 20 classical poems by historical authors, in the public domain. No copyrighted modern translations or annotations are included. Selection and rendering are original Frame Flow code and do not reproduce KoKonna subscription content or software. diff --git a/README.md b/README.md index 13a4fa6..324907c 100644 --- a/README.md +++ b/README.md @@ -207,3 +207,9 @@ Additional v1 endpoints: `/devices/{id}/current-content`, `/assets/{id}/source`, `/providers/weather`, `/settings/weather`, `/settings/image-url`, `/jobs/{id}`, `/jobs/{id}/preview`, `/jobs/{id}/run` (all under `/api/v1`). Existing contracts remain compatible; new endpoints are included in generated OpenAPI and contract tests. + +## Daily Poetry / 每日诗词 + +The daily-poetry template includes 20 public-domain classical Chinese poems, selected deterministically by Shanghai date, or manually. Create a daily job from its editor; new poetry jobs are paused by default until you enable them. Custom title, author and verses are editable. This is an independent Frame Flow content provider, not the KoKonna mini-program subscription. Official public API does not expose mini-program subscription/template settings. + +每日诗词支持按上海日期选诗、手选 20 首古典诗词、自定义文本以及每日任务。模板显示操作使用统一上传/显示服务;真实写入须开启 KOKONNA_WRITE_ENABLED,结果未知不会自动重试。官方小程序原有每日诗词订阅不能通过当前公开 API 查询或修改。 diff --git a/pigway_frame_flow/api/app.py b/pigway_frame_flow/api/app.py index ee34795..b216b60 100644 --- a/pigway_frame_flow/api/app.py +++ b/pigway_frame_flow/api/app.py @@ -889,6 +889,22 @@ def create_app(settings=None, start_worker=True): ) return payload + @app.get( + "/api/v1/providers/poetry", + response_model=dict[str, Any], + tags=["Providers"], + dependencies=[Depends(require("templates:read"))], + ) + def daily_poetry(date: str | None = None, poem_id: str | None = None): + from pigway_frame_flow.providers.poetry import catalog, get_poem + + try: + return {"current": get_poem(date, poem_id), "catalog": catalog()} + except ValueError: + raise FlowError( + E.INVALID_REQUEST, "Invalid poetry date or poem selection" + ) from None + @app.get("/docs", include_in_schema=False) def api_docs(): response = get_swagger_ui_html( diff --git a/pigway_frame_flow/display/service.py b/pigway_frame_flow/display/service.py index 2cb5933..44a035e 100644 --- a/pigway_frame_flow/display/service.py +++ b/pigway_frame_flow/display/service.py @@ -422,6 +422,8 @@ class DisplayService: ) def _start_write(self, id): + if not self.settings.mock and not self.settings.write_enabled: + raise FlowError(E.WRITE_DISABLED, "Real device writes are disabled", 403) self.db.execute( "UPDATE operations SET write_started=1,status='UPLOADING',updated_at=? WHERE id=?", (now_iso(), id), @@ -456,6 +458,8 @@ class DisplayService: pass def expire(self): + if not self.settings.mock and not self.settings.write_enabled: + return for device_id in self.registry.drivers: if self.blocked(device_id): continue diff --git a/pigway_frame_flow/providers/poetry.py b/pigway_frame_flow/providers/poetry.py new file mode 100644 index 0000000..85840d4 --- /dev/null +++ b/pigway_frame_flow/providers/poetry.py @@ -0,0 +1,143 @@ +"""A deterministic daily selection of public-domain classical Chinese poetry.""" + +from datetime import datetime +from zoneinfo import ZoneInfo + +# Original classical works; no modern copyrighted translations are included. +POEMS = [ + ( + "jing-ye-si", + "静夜思", + "李白", + "床前明月光,疑是地上霜。\n举头望明月,低头思故乡。", + ), + ( + "chun-xiao", + "春晓", + "孟浩然", + "春眠不觉晓,处处闻啼鸟。\n夜来风雨声,花落知多少。", + ), + ( + "deng-guan-que-lou", + "登鹳雀楼", + "王之涣", + "白日依山尽,黄河入海流。\n欲穷千里目,更上一层楼。", + ), + ("xiang-si", "相思", "王维", "红豆生南国,春来发几枝。\n愿君多采撷,此物最相思。"), + ("lu-zhai", "鹿柴", "王维", "空山不见人,但闻人语响。\n返景入深林,复照青苔上。"), + ( + "zhu-li-guan", + "竹里馆", + "王维", + "独坐幽篁里,弹琴复长啸。\n深林人不知,明月来相照。", + ), + ( + "jiang-xue", + "江雪", + "柳宗元", + "千山鸟飞绝,万径人踪灭。\n孤舟蓑笠翁,独钓寒江雪。", + ), + ( + "xun-yin-zhe", + "寻隐者不遇", + "贾岛", + "松下问童子,言师采药去。\n只在此山中,云深不知处。", + ), + ( + "min-nong", + "悯农·其二", + "李绅", + "锄禾日当午,汗滴禾下土。\n谁知盘中餐,粒粒皆辛苦。", + ), + ("feng", "风", "李峤", "解落三秋叶,能开二月花。\n过江千尺浪,入竹万竿斜。"), + ( + "wang-lu-shan", + "望庐山瀑布", + "李白", + "日照香炉生紫烟,遥看瀑布挂前川。\n飞流直下三千尺,疑是银河落九天。", + ), + ( + "zao-fa-bai-di", + "早发白帝城", + "李白", + "朝辞白帝彩云间,千里江陵一日还。\n两岸猿声啼不住,轻舟已过万重山。", + ), + ( + "wang-tian-men", + "望天门山", + "李白", + "天门中断楚江开,碧水东流至此回。\n两岸青山相对出,孤帆一片日边来。", + ), + ( + "zeng-wang-lun", + "赠汪伦", + "李白", + "李白乘舟将欲行,忽闻岸上踏歌声。\n桃花潭水深千尺,不及汪伦送我情。", + ), + ( + "shan-xing", + "山行", + "杜牧", + "远上寒山石径斜,白云生处有人家。\n停车坐爱枫林晚,霜叶红于二月花。", + ), + ( + "jiang-nan-chun", + "江南春", + "杜牧", + "千里莺啼绿映红,水村山郭酒旗风。\n南朝四百八十寺,多少楼台烟雨中。", + ), + ( + "ti-xi-lin-bi", + "题西林壁", + "苏轼", + "横看成岭侧成峰,远近高低各不同。\n不识庐山真面目,只缘身在此山中。", + ), + ( + "hui-xiang-ou-shu", + "回乡偶书·其一", + "贺知章", + "少小离家老大回,乡音无改鬓毛衰。\n儿童相见不相识,笑问客从何处来。", + ), + ( + "ye-yu-ji-bei", + "夜雨寄北", + "李商隐", + "君问归期未有期,巴山夜雨涨秋池。\n何当共剪西窗烛,却话巴山夜雨时。", + ), + ( + "feng-qiao", + "枫桥夜泊", + "张继", + "月落乌啼霜满天,江枫渔火对愁眠。\n姑苏城外寒山寺,夜半钟声到客船。", + ), +] + + +def catalog(): + return [ + { + "id": p[0], + "title": p[1], + "author": p[2], + "lines": p[3], + "source": "Public-domain classical Chinese poetry", + "edition": "frame-flow-classics-v1", + } + for p in POEMS + ] + + +def get_poem(date=None, poem_id=None): + day = ( + datetime.strptime(date, "%Y-%m-%d").date() + if date + else datetime.now(ZoneInfo("Asia/Shanghai")).date() + ) + poems = catalog() + if poem_id: + selected = next((p for p in poems if p["id"] == poem_id), None) + if selected is None: + raise ValueError("Unknown poem") + else: + selected = poems[day.toordinal() % len(poems)] + return {**selected, "date": day.isoformat()} diff --git a/pigway_frame_flow/renderers/templates.py b/pigway_frame_flow/renderers/templates.py index 2a1cd9a..c5d17ef 100644 --- a/pigway_frame_flow/renderers/templates.py +++ b/pigway_frame_flow/renderers/templates.py @@ -69,7 +69,26 @@ class TemplateRenderer: "星期日 / Sunday", ][current.weekday()], ) - if "weather" not in values: + if template_id == "daily-poetry" and not values.get("poem_lines"): + from pigway_frame_flow.providers.poetry import get_poem + + try: + poem = get_poem(values["date"], values.get("poem_id")) + except ValueError: + raise FlowError( + E.INVALID_REQUEST, "Invalid poetry date or poem selection" + ) from None + values.update( + poem_id=poem["id"], + poem_title=poem["title"], + poem_author=poem["author"], + poem_lines=poem["lines"], + poem_source=poem["source"], + poem_edition=poem["edition"], + ) + if "weather" not in values and any( + b.get("field") in ("weather", "forecast") for b in spec["blocks"] + ): values["weather"] = self.weather.get_current() if ( any(b.get("field") == "forecast" for b in spec["blocks"]) diff --git a/pigway_frame_flow/templates/daily-poetry.json b/pigway_frame_flow/templates/daily-poetry.json new file mode 100644 index 0000000..752fdff --- /dev/null +++ b/pigway_frame_flow/templates/daily-poetry.json @@ -0,0 +1,98 @@ +{ + "id": "daily-poetry", + "name": "Daily Poetry / 每日诗词", + "width": 1200, + "height": 1600, + "fields": [ + "title", + "date", + "poem_id", + "poem_title", + "poem_author", + "poem_lines" + ], + "defaults": { + "title": "每日诗词 / Daily Poetry", + "poem_id": "", + "poem_title": "", + "poem_author": "", + "poem_lines": "" + }, + "blocks": [ + { + "kind": "text", + "field": "title", + "box": [ + 72, + 60, + 1056, + 140 + ], + "size": 60 + }, + { + "kind": "rule", + "box": [ + 72, + 235, + 1056, + 7 + ] + }, + { + "kind": "text", + "field": "poem_title", + "box": [ + 72, + 350, + 1056, + 150 + ], + "size": 80 + }, + { + "kind": "text", + "field": "poem_author", + "box": [ + 72, + 535, + 1056, + 100 + ], + "size": 42 + }, + { + "kind": "text", + "field": "poem_lines", + "box": [ + 72, + 740, + 1056, + 580 + ], + "size": 56 + }, + { + "kind": "text", + "field": "date", + "box": [ + 72, + 1390, + 1056, + 70 + ], + "size": 34 + }, + { + "kind": "text", + "field": "updated", + "box": [ + 72, + 1500, + 1056, + 70 + ], + "size": 28 + } + ] +} diff --git a/pigway_frame_flow/web/app.js b/pigway_frame_flow/web/app.js index 5a47090..e53e588 100644 --- a/pigway_frame_flow/web/app.js +++ b/pigway_frame_flow/web/app.js @@ -46,9 +46,9 @@ async function templateEditor(selected){ const raw=$('#template-data');const assets=await api('/api/v1/assets');let values=JSON.parse(raw.value); const panel=document.createElement('div');panel.id='template-fields';raw.before(panel); const labels={title:state.lang==='zh'?'标题':'Title',message:state.lang==='zh'?'正文':'Message',quote:state.lang==='zh'?'一句话':'Quote',author:state.lang==='zh'?'作者':'Author',events:t('eventsHelp'),date:t('date'),weekday:state.lang==='zh'?'星期(留空自动)':'Weekday (blank for automatic)',weather:state.lang==='zh'?'天气数据(JSON,可留空使用 Provider)':'Weather JSON (blank uses provider)'}; - const fields=[...new Set(['title',...selected.fields.filter(k=>Object.hasOwn(selected.defaults,k)),...(['daily-dashboard','calendar'].includes(selected.id)?['events','date','weekday']:[]),...(selected.id==='weather'||selected.id==='daily-dashboard'?['weather']:[]),...(['photo-text','daily-dashboard'].includes(selected.id)?['photo_id']:[])])]; + const fields=[...new Set(['title',...selected.fields.filter(k=>Object.hasOwn(selected.defaults,k)),...(['daily-dashboard','calendar'].includes(selected.id)?['events','date','weekday']:selected.id==='daily-poetry'?['date']:[]),...(selected.id==='weather'||selected.id==='daily-dashboard'?['weather']:[]),...(['photo-text','daily-dashboard'].includes(selected.id)?['photo_id']:[])])]; function invalidate(){state.templatePreview=null;$('#display-template').disabled=true;$('#template-preview').className='empty';$('#template-preview').textContent=t('previewHint');} - function build(){panel.innerHTML=`

${t('formEditor')}

`+fields.map(k=>k==='photo_id'?`
`:`
`).join('');panel.querySelectorAll('[data-field]').forEach(el=>el.oninput=()=>{try{const k=el.dataset.field;let v=el.value;if(k==='events')v=v.split('\n').filter(x=>x.trim());if(['weather','forecast'].includes(k))v=v.trim()?JSON.parse(v):null;if(v===null||v==='')delete values[k];else values[k]=v;raw.value=JSON.stringify(values,null,2);el.setCustomValidity('');invalidate();}catch{el.setCustomValidity(t('INVALID_REQUEST'));el.reportValidity();}});} + function build(){panel.innerHTML=`

${t('formEditor')}

`+fields.map(k=>k==='photo_id'?`
`:`
`).join('');panel.querySelectorAll('[data-field]').forEach(el=>el.oninput=()=>{try{const k=el.dataset.field;let v=el.value;if(k==='events')v=v.split('\n').filter(x=>x.trim());if(['weather','forecast'].includes(k))v=v.trim()?JSON.parse(v):null;if(v===null||v==='')delete values[k];else values[k]=v;raw.value=JSON.stringify(values,null,2);el.setCustomValidity('');invalidate();}catch{el.setCustomValidity(t('INVALID_REQUEST'));el.reportValidity();}});} build();const advanced=document.createElement('details');advanced.innerHTML=`${t('advanced')}`;raw.before(advanced);advanced.append(raw);raw.oninput=()=>{invalidate();try{values=JSON.parse(raw.value);if(!values||typeof values!=='object'||Array.isArray(values))throw Error();raw.setCustomValidity('');build();}catch{raw.setCustomValidity(t('INVALID_REQUEST'));}}; const reset=document.createElement('button');reset.id='reset-template';reset.textContent=t('resetTemplate');$('#save-template').after(reset);bind('reset-template',()=>confirmDialog(t('resetConfirm'),async()=>{await api('/api/v1/templates/'+selected.id+'/config',{method:'DELETE'});await navigate();})); const save=$('#save-template');const oldSave=save.onclick;save.onclick=async e=>{if([...panel.querySelectorAll('textarea'),raw].some(x=>!x.checkValidity())){toast(t('INVALID_REQUEST'));return;}await oldSave(e);}; @@ -58,7 +58,7 @@ const views={ async dashboard(epoch){const d=await api('/api/v1/devices/'+state.device);if(epoch!==state.epoch)return;const s=d.slideshow;$('#main').innerHTML=notice()+`${d.power.battery_percent!==null&&d.power.battery_percent<20?`
${t('lowBattery')}
`:''}
● ${t(d.status).toUpperCase()}

${esc(d.name)}

${esc(d.model)} · ${esc(d.model_code)} · ${d.display.width} × ${d.display.height}

${buttons('go-upload','upload',true)}${buttons('restore','restore')}${buttons('refresh-device','refresh')}

${t('refreshInterval')}

${t('testLabel')}
${[['battery',d.power.battery_percent===null?'—':d.power.battery_percent+'%'],['current',d.current_content.id??'—'],['sync',t(d.current_content.synced?'synced':'syncing')],['firmware',d.firmware]].map(([l,v])=>`
${t(l)}
${esc(v)}
`).join('')}

${t('device')}

${detail('heartbeat',fmt(d.last_seen_at))}${detail('rotation',d.display.rotation+'°')}${detail('timezone',d.timezone)}${detail('coverId',d.current_content.cover_id??'—')}${detail('storage',d.storage_used_bytes===null?'—':(d.storage_used_bytes/1024/1024).toFixed(1)+' MiB')}${detail('model',t(d.model_verified?'modelVerified':'modelConfigured'))}

${t('cloudRotation')}

${detail('mode',s.mode?t(s.mode):'—')}${detail('interval',s.interval_minutes==null?'—':s.interval_minutes+' '+t('minutes'))}

${t('rotationNotice')}

${t('playlists')} →
`;bind('go-upload',()=>navigate('upload'));bind('refresh-device',async()=>{await api('/api/v1/devices/'+state.device+'?refresh=true');await navigate();});$('#restore').disabled=!d.can_restore;bind('restore',()=>accepted('/api/v1/devices/'+state.device+'/restore',{}));$('#go-playlists').onclick=e=>{e.preventDefault();navigate('playlists');};}, async gallery(epoch){let items=state.tab==='cloud'?await api('/api/v1/devices/'+state.device+'/images'):await api('/api/v1/assets');if(epoch!==state.epoch)return;state.gallery=items;$('#main').innerHTML=notice()+`
${buttons('gallery-refresh','refresh')}${state.tab==='cloud'?buttons('show-name','displayByName'):''}${buttons('gallery-upload','upload',true)}
${state.tab==='local'?`
${t('uploadNotice')}
`:''}${items.length?``:`
${t('noImages')}${t('emptyHint')}
`}`;bind('cloud-tab',()=>{state.tab='cloud';return navigate();});bind('local-tab',()=>{state.tab='local';return navigate();});bind('gallery-refresh',()=>navigate());bind('gallery-upload',()=>navigate('upload'));bind('show-name',()=>{showModal(`

${t('displayByName')}

`);bind('show-name-confirm',async()=>{const name=$('#image-name').value;$('#modal').close();displayDialog({type:'image',image_name:name});});});hydrateImages();document.querySelectorAll('[data-show]').forEach(b=>b.onclick=busy(()=>{const x=items[b.dataset.show];return displayDialog(state.tab==='cloud'?{type:'image',image_id:x.image_id}:{type:'image',asset_id:x.asset_id});}));document.querySelectorAll('[data-original]').forEach(b=>b.onclick=busy(async()=>{const x=items[b.dataset.original];showModal(`

${esc(x.name)}

${esc(x.name)}`);await blobImage($('#original-image'),x.url);}));document.querySelectorAll('[data-delete]').forEach(b=>b.onclick=()=>{const x=items[b.dataset.delete];confirmDialog(x.current?t('deleteCurrent'):t('deleteConfirm'),async()=>{if(state.tab==='local'){await api('/api/v1/assets/'+x.asset_id,{method:'DELETE'});}else{const result=await api(`/api/v1/devices/${state.device}/images/${x.image_id}?confirm_current=${x.current}`,{method:'DELETE',headers:{'Idempotency-Key':'web-'+clientUUID()}});await waitOperation(result.operation_id);}await navigate();});});}, async upload(){state.preview=null;$('#main').innerHTML=notice()+`
${t('uploadNotice')}

${t('upload')}

${t('selectFile')}

${t('uploadHint')}

${buttons('make-preview','preview',true)}

${t('previewHint')}

${t('preview')}

${t('previewHint')}
`;bind('make-preview',async()=>{const file=$('#file').files[0];if(!file)throw new Error(t('selectFile'));const form=new FormData();form.append('file',file);form.append('fit',$('#fit').value);form.append('profile',$('#profile').value);state.preview=await api('/api/v1/preview',{method:'POST',body:form});$('#preview-box').className='';$('#preview-box').innerHTML='Preview';await blobImage($('#upload-preview'),state.preview.url);$('#save-local').disabled=false;$('#display-upload').disabled=!liveWriteAllowed();});for(const id of ['file','fit','profile'])$('#'+id).onchange=()=>{state.preview=null;$('#save-local').disabled=true;$('#display-upload').disabled=true;};bind('save-local',async()=>{const id=await accepted('/api/v1/devices/'+state.device+'/upload',{type:'image',asset_id:state.preview.asset_id});await waitOperation(id);state.tab='local';return navigate('gallery');});bind('display-upload',()=>displayDialog({type:'image',asset_id:state.preview.asset_id}));}, - async templates(){state.templates=await api('/api/v1/templates');const selected=state.templates.find(x=>x.id===state.selectedTemplate)||state.templates[0];state.selectedTemplate=selected.id;if(state.pendingTemplateData?.template===selected.id){selected.defaults=state.pendingTemplateData.data;state.initialProfile=state.pendingTemplateData.profile;state.pendingTemplateData=null;}state.templatePreview=null;$('#main').innerHTML=notice()+`
${t('templateHint')}
${state.templates.map((x,i)=>`
${['☀','▦','◒','▣','❝','☁'][i]}

${esc(x.name.split(' / ')[state.lang==='zh'?1:0]||x.name)}

1200 × 1600 · ${esc(x.id)}
`).join('')}

${t('templateData')}

${buttons('render-template','render',true)}${buttons('save-template','saveTemplate')}

${t('preview')}

${t('previewHint')}
`;document.querySelectorAll('[data-template]').forEach(b=>{b.onclick=()=>{state.selectedTemplate=b.dataset.template;navigate();};b.onkeydown=e=>{if(e.key==='Enter')b.click();};});bind('render-template',async()=>{const content={type:'template',template:selected.id,data:JSON.parse($('#template-data').value),profile:$('#template-profile').value};if([...document.querySelectorAll('#template-fields textarea'),$('#template-data')].some(x=>!x.checkValidity())){toast(t('INVALID_REQUEST'));return;}const id=await accepted('/api/v1/render',content);$('#template-preview').textContent=t('previewLoading');const op=await waitOperation(id);state.templatePreview=op.result.asset_id;$('#template-preview').className='';$('#template-preview').innerHTML='Template preview';await blobImage($('#template-image'),op.result.url);$('#display-template').disabled=!liveWriteAllowed();});bind('save-template',async()=>{await api('/api/v1/templates/'+selected.id+'/config',{method:'PUT',body:{data:JSON.parse($('#template-data').value)}});toast(t('done'));});$('#template-data').oninput=()=>$('#display-template').disabled=true;$('#template-profile').onchange=()=>$('#display-template').disabled=true;bind('display-template',()=>displayDialog({type:'image',asset_id:state.templatePreview}));await templateEditor(selected);if(state.initialProfile){$('#template-profile').value=state.initialProfile;state.initialProfile=null;}}, + async templates(){state.templates=await api('/api/v1/templates');const selected=state.templates.find(x=>x.id===state.selectedTemplate)||state.templates[0];state.selectedTemplate=selected.id;if(state.pendingTemplateData?.template===selected.id){selected.defaults=state.pendingTemplateData.data;state.initialProfile=state.pendingTemplateData.profile;state.pendingTemplateData=null;}state.templatePreview=null;$('#main').innerHTML=notice()+`
${t('templateHint')}
${state.templates.map((x,i)=>`
${{'daily-poetry':'詩','calendar':'▦','daily-dashboard':'☀','photo-text':'▣','quote':'❝','system-alert':'◒','weather':'☁'}[x.id]||'▦'}

${esc(x.name.split(' / ')[state.lang==='zh'?1:0]||x.name)}

1200 × 1600 · ${esc(x.id)}
`).join('')}

${t('templateData')}

${buttons('render-template','render',true)}${buttons('save-template','saveTemplate')}

${t('preview')}

${t('previewHint')}
`;document.querySelectorAll('[data-template]').forEach(b=>{b.onclick=()=>{state.selectedTemplate=b.dataset.template;navigate();};b.onkeydown=e=>{if(e.key==='Enter')b.click();};});bind('render-template',async()=>{const content={type:'template',template:selected.id,data:JSON.parse($('#template-data').value),profile:$('#template-profile').value};if([...document.querySelectorAll('#template-fields textarea'),$('#template-data')].some(x=>!x.checkValidity())){toast(t('INVALID_REQUEST'));return;}const id=await accepted('/api/v1/render',content);$('#template-preview').textContent=t('previewLoading');const op=await waitOperation(id);state.templatePreview=op.result.asset_id;$('#template-preview').className='';$('#template-preview').innerHTML='Template preview';await blobImage($('#template-image'),op.result.url);$('#display-template').disabled=!liveWriteAllowed();});bind('save-template',async()=>{await api('/api/v1/templates/'+selected.id+'/config',{method:'PUT',body:{data:JSON.parse($('#template-data').value)}});toast(t('done'));});$('#template-data').oninput=()=>$('#display-template').disabled=true;$('#template-profile').onchange=()=>$('#display-template').disabled=true;bind('display-template',()=>displayDialog({type:'image',asset_id:state.templatePreview}));await templateEditor(selected);if(state.initialProfile){$('#template-profile').value=state.initialProfile;state.initialProfile=null;}}, async automation(epoch){const jobs=await api('/api/v1/jobs');if(epoch!==state.epoch)return;$('#main').innerHTML=notice()+`

Asia/Shanghai · ${t('automation')}

${buttons('new-job','newJob',true)}
${jobs.length?`
${['name','schedule','status','nextRun','lastRun','result','action'].map(x=>``).join('')}${jobs.map((j,i)=>``).join('')}
${t(x)}
${esc(j.name)}
${esc(j.device_id)}
${t(j.schedule.type)}
${esc(j.schedule.time||j.schedule.cron||fmt(j.schedule.at))}
${t(j.enabled?'enabled':'disabled')}${fmt(j.next_run)}${fmt(j.last_run)}${esc(j.last_result)}
`:`
${t('noTasks')}
`}
`;bind('new-job',()=>jobEditor());document.querySelectorAll('[data-job-edit]').forEach(b=>b.onclick=()=>jobEditor(jobs[b.dataset.jobEdit]));document.querySelectorAll('[data-job-toggle]').forEach(b=>b.onclick=busy(async()=>{const j=jobs[b.dataset.jobToggle];await api('/api/v1/jobs/'+j.id,{method:'PUT',body:jobPayload(j,{enabled:!j.enabled})});await navigate();}));document.querySelectorAll('[data-job-delete]').forEach(b=>b.onclick=()=>confirmDialog(t('removeConfirm'),async()=>{await api('/api/v1/jobs/'+jobs[b.dataset.jobDelete].id,{method:'DELETE'});await navigate();}));}, async playlists(epoch){const playlists=await api('/api/v1/playlists');if(epoch!==state.epoch)return;$('#main').innerHTML=notice()+`
${t('playlistNotice')}

${t('playlists')}

${buttons('new-playlist','newPlaylist',true)}
`;bind('new-playlist',()=>playlistEditor());document.querySelectorAll('[data-pl-edit]').forEach(b=>b.onclick=()=>playlistEditor(playlists[b.dataset.plEdit]));document.querySelectorAll('[data-pl-delete]').forEach(b=>b.onclick=()=>confirmDialog(t('removeConfirm'),async()=>{await api('/api/v1/playlists/'+playlists[b.dataset.plDelete].id,{method:'DELETE'});await navigate();}));}, async history(epoch){const ops=await api('/api/v1/operations');if(epoch!==state.epoch)return;$('#main').innerHTML=`

${t('history')}

${buttons('history-refresh','refresh')}
${ops.some(x=>x.status==='UNKNOWN')?`
${t('unknown')}
`:''}
${ops.length?`${['created','action','status','result','client','requestId'].map(x=>``).join('')}${ops.map((o,i)=>``).join('')}
${t(x)}
${fmt(o.created_at)}${esc(o.action)}
${esc(o.device_id)}
${t(o.status)}${o.status==='UNKNOWN'?`
`:''}
${esc(o.result?.image_id||o.result?.asset_id||o.error?.code||'—')}${esc(o.client_id)}${esc(o.request_id)}
${esc(o.operation_id)}
`:`
${t('noHistory')}
`}
`;bind('history-refresh',()=>navigate());document.querySelectorAll('[data-reconcile]').forEach(b=>b.onclick=busy(async()=>{const o=ops[b.dataset.reconcile];const r=await api('/api/v1/operations/'+o.operation_id+'/reconcile',{method:'POST',body:{acknowledge:false}});showModal(`

${t('reconcile')}

${t('current')}: ${esc(r.current_image_id)}

${t('result')}: ${esc(r.expected_image_id)}

${t('unknown')}

`);bind('acknowledge',async()=>{await api('/api/v1/operations/'+o.operation_id+'/reconcile',{method:'POST',body:{acknowledge:true}});$('#modal').close();await navigate();});}));}, diff --git a/pigway_frame_flow/web/features.js b/pigway_frame_flow/web/features.js index 6c237b8..34bc415 100644 --- a/pigway_frame_flow/web/features.js +++ b/pigway_frame_flow/web/features.js @@ -19,6 +19,7 @@ async function enhanceTemplates(){ const panel=document.createElement('section');panel.className='card';$('.template-grid').before(panel);await currentPanel(panel);if(!panel.isConnected)return; const download=document.createElement('button');download.id='download-template';download.textContent=t('download');$('#display-template').after(download); download.onclick=busy(async()=>{if(!state.templatePreview){toast(t('previewHint'));return;}await downloadImage('/api/v1/assets/'+state.templatePreview,'frame-flow-template.jpg');}); + await poetryControls(); const photo=$('#field-photo_id');if(photo){const file=document.createElement('input');file.type='file';file.id='template-photo-upload';file.accept='image/jpeg,image/png,image/webp';file.setAttribute('aria-label',t('selectFile'));photo.after(file);file.onchange=busy(async()=>{if(!file.files[0])return;const body=new FormData();body.append('file',file.files[0]);body.append('fit','cover');body.append('profile','color');const asset=await api('/api/v1/preview',{method:'POST',body});const accepted=await api('/api/v1/devices/'+state.device+'/upload',{method:'POST',body:{type:'image',asset_id:asset.asset_id},headers:{'Idempotency-Key':'photo-'+clientUUID()}});await waitOperation(accepted.operation_id);const option=document.createElement('option');option.value=asset.asset_id;option.textContent=asset.name;photo.append(option);photo.value=asset.asset_id;photo.oninput();toast(t('done'));});} } async function downloadImage(path,name){const blob=await api(path,{binary:true});const url=URL.createObjectURL(blob);const link=document.createElement('a');link.href=url;link.download=name;document.body.append(link);link.click();link.remove();setTimeout(()=>URL.revokeObjectURL(url),10000);} @@ -61,3 +62,12 @@ async function playlistImagePicker(playlist){ } async function enhanceSettings(){await weatherSettings();const config=await api('/api/v1/settings/image-url');const box=document.createElement('section');box.className='card';box.innerHTML=`

${t('urlSettings')}

${t('urlHint')}

`;$('#main').append(box);$('#image-host-form').onsubmit=busy(async e=>{e.preventDefault();await api('/api/v1/settings/image-url',{method:'PUT',body:{allowed_hosts:$('#image-hosts').value.split('\n').map(x=>x.trim()).filter(Boolean)}});toast(t('done'));});} function enhanceUpload(){const box=document.createElement('div');box.innerHTML=`

${t('uploadURL')}

${t('urlHint')}

${buttons('preview-url','uploadURL')}`;$('#make-preview').parentElement.append(box);bind('preview-url',async()=>{if(!$('#upload-url').checkValidity())return;const id=await accepted('/api/v1/devices/'+state.device+'/upload',{type:'image_url',url:$('#upload-url').value,fit:$('#fit').value,profile:$('#profile').value});const op=await waitOperation(id);state.preview={asset_id:op.result.asset_id,url:op.result.url};$('#preview-box').className='';$('#preview-box').innerHTML='Preview';await blobImage($('#upload-preview'),state.preview.url);$('#save-local').disabled=false;$('#display-upload').disabled=!liveWriteAllowed();});} +async function poetryControls(){ + if(state.selectedTemplate!=='daily-poetry')return; + const provider=await api('/api/v1/providers/poetry');if(!$('#template-fields'))return; + const box=document.createElement('div');box.className='poetry-controls';box.innerHTML=`
${t('poetryNotice')}

${buttons('daily-poetry-job','dailyPoetryJob')}`;$('#template-fields').before(box); + const values=JSON.parse($('#template-data').value);$('#poem-choice').value=values.poem_id||''; + function description(){const selected=provider.catalog.find(p=>p.id===$('#poem-choice').value)||provider.current;$('#poem-description').textContent=selected.title+' · '+selected.author+' · '+t('poetryCatalog')+provider.catalog.length;const raw=$('#field-poem_id');if(raw)raw.parentElement.hidden=true;} + description();$('#poem-choice').onchange=()=>{const data=JSON.parse($('#template-data').value);delete data.poem_title;delete data.poem_author;delete data.poem_lines;delete data.poem_source;delete data.poem_edition;if($('#poem-choice').value)data.poem_id=$('#poem-choice').value;else{delete data.poem_id;delete data.date;}$('#template-data').value=JSON.stringify(data,null,2);$('#template-data').oninput();description();}; + bind('daily-poetry-job',()=>{showModal(`

${t('dailyPoetryJob')}

${t('poetryScheduleHint')}

`);$('#poetry-job-form').onsubmit=busy(async e=>{e.preventDefault();await api('/api/v1/jobs',{method:'POST',body:{device_id:state.device,name:t('dailyPoetryJob'),enabled:$('#poetry-job-enabled').checked,schedule:{type:'daily',time:$('#poetry-job-time').value},source:{type:'template',template:'daily-poetry',data:{},profile:$('#template-profile').value},display_duration:0,restore_previous:false,priority:50}});$('#modal').close();await navigate('automation');});}); +} diff --git a/pigway_frame_flow/web/i18n.js b/pigway_frame_flow/web/i18n.js index 3e51ae6..fd78104 100644 --- a/pigway_frame_flow/web/i18n.js +++ b/pigway_frame_flow/web/i18n.js @@ -11,3 +11,5 @@ Object.assign(window.MESSAGES.en,{currentContent:'Currently displayed content',c window.MESSAGES.zh.templateHint='先生成预览,再选择显示方式。天气来源可在设置中配置。';window.MESSAGES.en.templateHint='Render a preview, then choose display options. Configure weather in Settings.'; Object.assign(window.MESSAGES.zh,{allowedHosts:'允许下载图片的域名(每行一个)',urlHint:'只允许 HTTPS 和设置中明确允许的域名,内网地址始终禁止。',uploadURL:'从链接导入',urlSettings:'图片 URL 安全设置'});Object.assign(window.MESSAGES.en,{allowedHosts:'Allowed image hostnames (one per line)',urlHint:'HTTPS and explicitly allowed hosts only. Private network addresses are blocked.',uploadURL:'Import URL',urlSettings:'Image URL security'}); + +Object.assign(window.MESSAGES.zh,{poetryNotice:'Frame Flow 每日诗词:内置古典诗词,按上海日期每天换一首。独立于官方小程序的订阅,官方订阅设置无法通过公开 API 读取或修改。',poemChoice:'选择诗词',dailyAutomatic:'每日自动选诗',poetryCatalog:'内置篇数:',dailyPoetryJob:'每日一首诗词',poetryScheduleHint:'每天按上海时区生成当天诗词并显示。开启任务后会按时改变画框显示。',poem_title:'诗名',poem_author:'作者',poem_lines:'诗词正文'});Object.assign(window.MESSAGES.en,{poetryNotice:'Frame Flow Daily Poetry selects a classical poem by Shanghai date. It is independent of the official mini-app subscription, whose settings are not exposed by the public API.',poemChoice:'Choose poem',dailyAutomatic:'Automatic daily selection',poetryCatalog:'Catalog size: ',dailyPoetryJob:'A poem every day',poetryScheduleHint:'Generate and display the daily poem in Shanghai time. Enabling this job will change the frame on schedule.',poem_title:'Poem title',poem_author:'Poet',poem_lines:'Poem text'}); diff --git a/pigway_frame_flow/web/style.css b/pigway_frame_flow/web/style.css index cf37e30..9a1e119 100644 --- a/pigway_frame_flow/web/style.css +++ b/pigway_frame_flow/web/style.css @@ -2,3 +2,25 @@ :root[data-theme=dark]{color-scheme:dark;--bg:#111917;--panel:#1b2521;--soft:#24322b;--ink:#edf4ee;--muted:#a1b3a8;--border:#34463c;--accent:#8cddb1;--accent-soft:#254735;--bad:#ff9696;--shadow:0 12px 34px #0002} *{box-sizing:border-box}body{margin:0;background:var(--bg);color:var(--ink);font:15px/1.6 -apple-system,BlinkMacSystemFont,'Segoe UI','PingFang SC',sans-serif}button,input,select,textarea{font:inherit}button,a,input,select,textarea{outline-offset:4px}button{cursor:pointer;border:1px solid var(--border);border-radius:10px;background:var(--panel);color:var(--ink);padding:10px 16px;font-weight:600}button:hover{background:var(--soft)}button:disabled{opacity:.45;cursor:not-allowed}button.primary{background:var(--accent);color:var(--panel);border-color:var(--accent)}button.danger{color:var(--bad)}a{color:var(--accent);text-decoration:none}small{font-size:12px;color:var(--muted)}.sidebar{position:fixed;inset:0 auto 0 0;width:244px;background:var(--panel);border-right:1px solid var(--border);padding:32px 20px;display:flex;flex-direction:column}.brand{display:flex;align-items:center;gap:12px;color:var(--ink);font-size:20px;font-weight:750;letter-spacing:-.6px}.brand small{display:block;font-size:9px;letter-spacing:.2px;font-weight:500}.brand-icon{width:43px;height:43px;background:var(--accent);color:var(--panel);border-radius:13px;display:flex;align-items:center;justify-content:center;position:relative;font-size:25px}.brand-icon span{font-size:15px;margin-top:-18px}nav{margin-top:42px;display:grid;gap:8px}nav button{border:0;display:flex;gap:14px;align-items:center;text-align:left;color:var(--muted);padding:13px 16px;font-weight:500;background:transparent}nav button.active{background:var(--accent-soft);color:var(--accent);font-weight:650}nav .nav-icon{width:22px;text-align:center;font-size:19px}.sidebar-bottom{margin-top:auto;padding:20px 10px;border-top:1px solid var(--border);font-size:12px;color:var(--muted)}.sidebar-bottom small{display:block;margin-top:8px}.live-dot{display:inline-block;width:7px;height:7px;border-radius:100%;background:var(--accent);margin-right:8px}.workspace{margin-left:244px;padding:0 40px}header{padding:33px 0 28px;display:flex;justify-content:space-between;align-items:center;gap:16px}h1{font-size:30px;font-weight:650;letter-spacing:-1px;margin:4px 0 0}h2{font-size:21px;line-height:1.4;margin:0 0 12px;letter-spacing:-.3px}h3{font-size:17px;margin:0 0 12px}.eyebrow{font-size:10px;letter-spacing:2px;color:var(--muted);font-weight:650}.toolbar,.actions{display:flex;align-items:center;gap:10px;flex-wrap:wrap}.icon-button{padding:9px 12px}.card{background:var(--panel);border:1px solid var(--border);border-radius:18px;padding:25px;box-shadow:var(--shadow)}.hero{display:grid;grid-template-columns:1.4fr 1fr;min-height:370px;background:var(--panel);overflow:hidden;padding:0}.hero-info{padding:35px}.hero h2{font-size:31px;margin:22px 0 0}.hero p{color:var(--muted);margin:4px 0 24px}.frame-demo{background:var(--soft);padding:35px;display:flex;align-items:center;justify-content:center;position:relative}.mini-art{width:185px;height:246px;background:#faf8ef;border:10px solid #72523b;box-shadow:0 18px 30px #0002;position:relative;overflow:hidden}.mini-art:before{content:'';position:absolute;bottom:-40px;left:-30px;background:#8cab82;width:180px;height:180px;border-radius:45%;transform:rotate(25deg)}.mini-art:after{content:'';position:absolute;bottom:-65px;right:-70px;background:#456a56;width:180px;height:230px;border-radius:45%;transform:rotate(-20deg)}.mini-art .sun{position:absolute;top:25px;right:25px;width:55px;height:55px;border-radius:50%;background:#dba25e}.frame-label{position:absolute;bottom:9px;font-size:10px;letter-spacing:1px;color:var(--muted)}.badge{display:inline-flex;align-items:center;gap:7px;font-size:11px;letter-spacing:.6px;border-radius:30px;padding:5px 12px;color:var(--accent);background:var(--accent-soft);font-weight:750}.badge.bad{background:#af343415;color:var(--bad)}.stats{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:16px;margin:20px 0}.stat .value{font-size:24px;font-weight:650;letter-spacing:-.5px;margin-top:6px;word-break:break-word}.stat .label{font-size:12px;color:var(--muted)}.columns{display:grid;grid-template-columns:1.25fr 1fr;gap:20px;margin:20px 0}.detail-grid{display:grid;grid-template-columns:1fr 1fr;gap:18px}.detail-grid strong{display:block;font-size:14px;overflow-wrap:anywhere}.detail-grid small{display:block}.hint{font-size:13px;color:var(--muted);margin:12px 0}.notice{background:var(--accent-soft);border:1px solid var(--border);padding:14px 18px;border-radius:12px;margin-bottom:20px;font-size:13px}.notice.warning{color:var(--bad);background:#b4363610}.section-heading{display:flex;align-items:center;justify-content:space-between;gap:15px;margin-bottom:18px}.gallery{display:grid;grid-template-columns:repeat(auto-fill,minmax(210px,1fr));gap:20px}.image-card{padding:12px}.image-card .thumb{width:100%;aspect-ratio:3/4;background:var(--soft);border-radius:10px;object-fit:contain;display:block}.image-card h3{font-size:14px;margin:14px 0 2px;white-space:nowrap;text-overflow:ellipsis;overflow:hidden}.image-card .actions{margin-top:12px}.image-card button{font-size:12px;padding:7px 10px}.empty{text-align:center;padding:64px 24px;color:var(--muted)}.empty strong{display:block;font-size:20px;color:var(--ink);margin-bottom:10px}.form-grid{display:grid;grid-template-columns:1fr 1fr;gap:18px}label{display:block;font-size:13px;font-weight:650;margin:0 0 7px}input,textarea,select{width:100%;border:1px solid var(--border);border-radius:9px;background:var(--panel);color:var(--ink);padding:10px 12px}textarea{min-height:180px;resize:vertical;font:13px/1.7 ui-monospace,monospace}.toolbar select{width:auto;max-width:190px}input[type=checkbox]{width:auto;accent-color:var(--accent)}.field{margin-bottom:18px}.dropzone{border:2px dashed var(--border);border-radius:16px;padding:45px 25px;text-align:center;background:var(--soft)}.dropzone input{margin-top:15px}.preview{width:100%;max-height:620px;object-fit:contain;background:var(--soft);border-radius:12px}.template-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:18px}.template-card{cursor:pointer;min-height:160px;display:flex;flex-direction:column;justify-content:space-between}.template-card:hover,.template-card.selected{border-color:var(--accent);background:var(--accent-soft)}.template-card span{font-size:35px}.table-wrap{overflow:auto}table{border-collapse:collapse;width:100%;font-size:13px}th{text-align:left;font-size:11px;text-transform:uppercase;letter-spacing:.5px;color:var(--muted);padding:10px 14px}td{border-top:1px solid var(--border);padding:14px;vertical-align:top}td code{font-size:11px;overflow-wrap:anywhere}.row-actions{display:flex;gap:7px}.row-actions button{padding:5px 9px;font-size:12px}.tabs{display:flex;gap:6px;background:var(--soft);padding:5px;border-radius:12px;margin-bottom:20px;width:fit-content}.tabs button{border:0;background:transparent}.tabs button.active{background:var(--panel);box-shadow:var(--shadow)}footer{padding:30px 0;font-size:11px;color:var(--muted);display:flex;justify-content:space-between;gap:12px;flex-wrap:wrap}#toast{position:fixed;bottom:28px;right:28px;padding:14px 20px;background:var(--ink);color:var(--panel);border-radius:12px;max-width:430px;z-index:200;display:none;box-shadow:var(--shadow)}dialog{width:min(600px,calc(100vw - 32px));border:1px solid var(--border);border-radius:18px;padding:28px;background:var(--panel);color:var(--ink)}dialog::backdrop{background:#0007}.modal-actions{display:flex;justify-content:flex-end;gap:12px;margin-top:24px}.check-row{display:flex;align-items:center;gap:10px}.spinner{width:25px;height:25px;border:3px solid var(--border);border-top-color:var(--accent);border-radius:50%;animation:spin 1s linear infinite;margin:40px auto}@keyframes spin{to{transform:rotate(360deg)}}.wide{grid-column:1/-1} @media(min-width:1600px){.workspace{max-width:1600px;margin:auto;padding-left:280px}.sidebar{left:max(0px,calc((100vw - 1600px)/2))}}@media(max-width:1050px){.workspace{padding:0 24px}.stats{grid-template-columns:repeat(2,1fr)}.columns{grid-template-columns:1fr}.template-grid{grid-template-columns:repeat(2,1fr)}}@media(max-width:720px){.sidebar{width:100%;height:64px;inset:auto 0 0;padding:0 6px;border-top:1px solid var(--border);z-index:100;overflow:auto}.brand,.sidebar-bottom{display:none}nav{margin:0;display:flex;min-width:660px;gap:0}nav button{display:flex;flex-direction:column;gap:0;padding:6px 11px;font-size:10px;border-radius:0;flex:1}.workspace{margin:0;padding:0 18px 75px}header{padding:22px 0;align-items:flex-start}h1{font-size:25px}.toolbar{gap:6px}.toolbar select{width:120px;font-size:12px;padding:9px}.hero{grid-template-columns:1fr}.hero-info{padding:25px}.hero h2{font-size:26px}.frame-demo{display:none}.stats{gap:10px}.stat{padding:18px}.stat .value{font-size:20px}.card{padding:20px}.form-grid{grid-template-columns:1fr}.template-grid{gap:10px}.gallery{grid-template-columns:repeat(2,minmax(0,1fr));gap:10px}.image-card{padding:10px}.image-card .actions{gap:5px}.image-card button{font-size:11px;padding:6px}#toast{bottom:85px;right:18px;left:18px;max-width:none}.section-heading{align-items:flex-start;flex-wrap:wrap}} + +/* Consistent page rhythm applies to dynamically appended sections as well. */ +#main{display:flex;flex-direction:column;gap:24px;min-width:0} +#main>*{margin-top:0;margin-bottom:0;min-width:0} +#main>.columns,#main>.stats,#main>.notice,#main>.section-heading{margin:0} +.card,.columns>* ,.form-grid>*{min-width:0} +.columns{gap:24px}.gallery,.template-grid{gap:20px} +.card>p:last-child{margin-bottom:0} +.card+.card{scroll-margin-top:24px} +.row-actions{flex-wrap:wrap;gap:8px}.actions{row-gap:10px} +details{margin:18px 0;padding:14px 16px;border:1px solid var(--border);border-radius:12px;background:var(--soft)} +summary{cursor:pointer;font-weight:650;padding:4px 0} +details[open]>summary{margin-bottom:16px} +#template-fields textarea,#job-data-fields textarea{min-height:0;font-family:inherit;font-size:15px} +.check-row{margin:12px 0;gap:12px;align-items:flex-start} +.check-row input{margin-top:4px;flex-shrink:0} +#playlist-image-choices{max-height:320px;overflow:auto;padding:8px 12px;background:var(--soft);border-radius:12px;margin:12px 0 20px} +#current-frame-image{height:300px!important;width:auto!important;max-width:100%;object-fit:contain;border-radius:8px} +#weather-result{margin-top:20px;padding-top:18px;border-top:1px solid var(--border)} +#password-form{margin-top:24px;padding-top:24px;border-top:1px solid var(--border)} +#modal-content>p,#modal-content>pre{margin:16px 0} +@media(max-width:720px){#main{gap:18px}.columns,.gallery,.template-grid{gap:14px}.frame-demo{display:flex;padding:24px}.hero-info{padding:24px}.hero h2{margin-top:16px}.image-card .actions{gap:8px}.modal-actions{flex-wrap:wrap}.check-row{margin:14px 0}#current-frame-image{height:240px!important}} diff --git a/scripts/ui-layout.cjs b/scripts/ui-layout.cjs new file mode 100644 index 0000000..5bc1f39 --- /dev/null +++ b/scripts/ui-layout.cjs @@ -0,0 +1,4 @@ +/* All-page layout and daily-poetry checks; Mock only, no real device writes. */ +const assert=require('assert'),{chromium}=require('playwright'); +(async()=>{const browser=await chromium.launch({headless:true,executablePath:'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'});const page=await browser.newPage();const base='http://127.0.0.1:18787';await page.goto(base);await page.locator('#username').fill(process.env.FRAME_FLOW_TEST_USERNAME);await page.locator('#password').fill(process.env.FRAME_FLOW_TEST_PASSWORD);await page.locator('#login-form button').click();await page.locator('.hero').waitFor();assert(await page.evaluate(()=>state.settings.mock));let checks=0;for(const width of [1440,390])for(const theme of ['light','dark']){await page.setViewportSize({width,height:1000});await page.evaluate(theme=>{localStorage.setItem('ff-theme',theme);document.documentElement.dataset.theme=theme;},theme);for(const name of ['dashboard','gallery','upload','templates','automation','playlists','history','settings']){await page.locator(`[data-page="${name}"]`).click();await page.waitForTimeout(350);if(name==='settings')await page.locator('#image-host-form').waitFor();const result=await page.evaluate(()=>{const children=[...document.querySelector('#main').children].filter(x=>x.getBoundingClientRect().height>0);return {overflow:document.documentElement.scrollWidth>innerWidth,gaps:children.slice(1).map((x,i)=>x.getBoundingClientRect().top-children[i].getBoundingClientRect().bottom)};});assert(!result.overflow,`${name} ${width} overflow`);assert(result.gaps.every(x=>x>=16),`${name} ${width} gaps ${result.gaps}`);checks++;if(name==='templates'||name==='settings')await page.screenshot({path:`docs/screenshots/${name}-${width}-${theme}.png`,fullPage:true});}} +await page.locator('[data-page="templates"]').click();await page.locator('[data-template="daily-poetry"]').click();await page.locator('#poem-choice').waitFor();assert(await page.locator('#poem-choice option').count()===21);checks++;await page.locator('#poem-choice').selectOption('jing-ye-si');await page.locator('#render-template').click();await page.locator('#template-image').waitFor();await page.waitForFunction(()=>document.querySelector('#template-image')?.naturalWidth===1200);checks++;await page.locator('#daily-poetry-job').click();assert(!await page.locator('#poetry-job-enabled').isChecked());checks++;await page.locator('#modal-cancel').click();await browser.close();console.log(`Layout/poetry browser: ${checks} passed (32 page/profile combinations)`);})().catch(e=>{console.error(e.message);process.exit(1)}); diff --git a/scripts/ui-smoke.cjs b/scripts/ui-smoke.cjs index b2bd537..54531e9 100644 --- a/scripts/ui-smoke.cjs +++ b/scripts/ui-smoke.cjs @@ -17,7 +17,7 @@ const fs=require('fs');const path=require('path');const assert=require('assert') await page.locator('#make-preview').click();await page.waitForFunction(()=>!document.querySelector('#save-local').disabled);checks++; await page.locator('#save-local').click();const uploaded=page.locator('.image-card').filter({hasText:uploadName+'.jpg'});await uploaded.waitFor();checks++; await uploaded.locator('[data-delete]').click();await page.locator('#modal-confirm').click();await uploaded.waitFor({state:'hidden'});checks++; - await page.locator('[data-page="templates"]').click();await page.locator('.template-card').first().waitFor();assert(await page.locator('.template-card').count()===6);checks++; + await page.locator('[data-page="templates"]').click();await page.locator('.template-card').first().waitFor();assert(await page.locator('.template-card').count()===7);checks++; await page.locator('#render-template').click();await page.locator('#template-image').waitFor({timeout:30000});assert(await page.locator('#display-template').isEnabled());checks++; await page.locator('#field-title').fill('Edited title');assert(await page.locator('#display-template').isDisabled());checks++;await page.locator('#save-template').click();await page.reload();await page.locator('#field-title').waitFor();assert(await page.locator('#field-title').inputValue()==='Edited title');checks++;await page.locator('#reset-template').click();await page.locator('#modal-confirm').click();await page.waitForFunction(()=>document.querySelector('#field-title')?.value!=='Edited title'&&document.querySelector('#field-title'));assert(await page.locator('#field-title').inputValue()!=='Edited title');checks++;await page.locator('#field-title').fill('Provenance UI title');await page.locator('#render-template').click();await page.locator('#template-image').waitFor();await page.locator('#display-template').click();await page.locator('#confirm-display').click();await page.locator('[data-page="history"]').click();await page.locator('tbody tr').first().waitFor();checks++;await page.locator('tbody tr button').first().click();await page.locator('#modal-cancel').click();checks++;await page.locator('[data-page="dashboard"]').click();await page.locator('#edit-current-template').waitFor({timeout:30000});await page.locator('#edit-current-template').click();await page.locator('#field-title').waitFor();assert(await page.locator('#field-title').inputValue()==='Provenance UI title');checks++; await page.locator('[data-page="automation"]').click();await page.locator('#new-job').click();await page.locator('#job-content-type').waitFor();await page.locator('#job-template').selectOption('quote');checks++;await page.locator('#job-name').fill('UI acceptance');await page.locator('#job-form button[type=submit]').click();await page.locator('td strong').filter({hasText:'UI acceptance'}).waitFor();checks++; diff --git a/tests/contract.json b/tests/contract.json index 7595d40..603babd 100644 --- a/tests/contract.json +++ b/tests/contract.json @@ -750,5 +750,21 @@ "502", "503" ] + }, + "/api/v1/providers/poetry": { + "get": [ + "200", + "400", + "401", + "403", + "404", + "409", + "413", + "422", + "429", + "500", + "502", + "503" + ] } } diff --git a/tests/test_api.py b/tests/test_api.py index cf2a1f7..fb0ba3c 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -21,7 +21,7 @@ def test_health_ready(client): def test_without_external_project(client): assert client.get("/").status_code == 200 assert client.get("/api/v1/devices").status_code == 200 - assert len(client.get("/api/v1/templates").json()) == 6 + assert len(client.get("/api/v1/templates").json()) == 7 assert client.get("/api/v1/jobs").status_code == 200 diff --git a/tests/test_poetry.py b/tests/test_poetry.py new file mode 100644 index 0000000..7ae62c0 --- /dev/null +++ b/tests/test_poetry.py @@ -0,0 +1,53 @@ +from io import BytesIO + +import pytest +from PIL import Image + +from pigway_frame_flow.providers.poetry import catalog, get_poem + + +def test_daily_selection_is_stable_and_changes_next_day(): + today = get_poem("2026-10-02") + assert today == get_poem("2026-10-02") + assert today["id"] != get_poem("2026-10-03")["id"] + assert len({p["id"] for p in catalog()}) == 20 + + +@pytest.mark.parametrize("profile", ["color", "grayscale"]) +def test_poetry_native_size(service, profile): + image = Image.open(BytesIO(service.renderer.render("daily-poetry", {}, profile))) + assert image.size == (1200, 1600) + + +def test_selected_poem_and_custom_poem(service): + selected = service.renderer.resolve("daily-poetry", {"poem_id": "jing-ye-si"}) + assert selected["poem_title"] == "静夜思" and selected["poem_author"] == "李白" + custom = service.renderer.resolve( + "daily-poetry", {"poem_title": "Custom", "poem_lines": "User text"} + ) + assert custom["poem_title"] == "Custom" and custom["poem_lines"] == "User text" + + +def test_poetry_api_validation(client): + assert client.get("/api/v1/providers/poetry").status_code == 200 + assert client.get("/api/v1/providers/poetry?date=wrong").status_code == 400 + assert client.get("/api/v1/providers/poetry?poem_id=missing").status_code == 400 + + +def test_poetry_does_not_fetch_unrelated_weather(service): + def unwanted(): + raise AssertionError("Poetry must not fetch weather") + + service.renderer.weather.get_current = unwanted + assert service.renderer.render("daily-poetry", {}) + + +def test_final_write_gate_blocks_queued_writes(service): + from pigway_frame_flow.errors import FlowError + + service.settings.mock = False + service.settings.write_enabled = False + with pytest.raises(FlowError): + service._start_write("queued-operation") + # Disabled writes leave pending override restoration intact. + service.expire()