Fix page spacing and add daily poetry with manual write testing
This commit is contained in:
@@ -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
|
results, UI and weather/dashboard output. Public API usage is intended for this
|
||||||
non-commercial household deployment; commercial deployments must review provider
|
non-commercial household deployment; commercial deployments must review provider
|
||||||
terms. This service is not affiliated with Open-Meteo.
|
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.
|
||||||
|
|||||||
@@ -207,3 +207,9 @@ Additional v1 endpoints: `/devices/{id}/current-content`, `/assets/{id}/source`,
|
|||||||
`/providers/weather`, `/settings/weather`, `/settings/image-url`, `/jobs/{id}`,
|
`/providers/weather`, `/settings/weather`, `/settings/image-url`, `/jobs/{id}`,
|
||||||
`/jobs/{id}/preview`, `/jobs/{id}/run` (all under `/api/v1`). Existing contracts remain
|
`/jobs/{id}/preview`, `/jobs/{id}/run` (all under `/api/v1`). Existing contracts remain
|
||||||
compatible; new endpoints are included in generated OpenAPI and contract tests.
|
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 查询或修改。
|
||||||
|
|||||||
@@ -889,6 +889,22 @@ def create_app(settings=None, start_worker=True):
|
|||||||
)
|
)
|
||||||
return payload
|
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)
|
@app.get("/docs", include_in_schema=False)
|
||||||
def api_docs():
|
def api_docs():
|
||||||
response = get_swagger_ui_html(
|
response = get_swagger_ui_html(
|
||||||
|
|||||||
@@ -422,6 +422,8 @@ class DisplayService:
|
|||||||
)
|
)
|
||||||
|
|
||||||
def _start_write(self, id):
|
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(
|
self.db.execute(
|
||||||
"UPDATE operations SET write_started=1,status='UPLOADING',updated_at=? WHERE id=?",
|
"UPDATE operations SET write_started=1,status='UPLOADING',updated_at=? WHERE id=?",
|
||||||
(now_iso(), id),
|
(now_iso(), id),
|
||||||
@@ -456,6 +458,8 @@ class DisplayService:
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
def expire(self):
|
def expire(self):
|
||||||
|
if not self.settings.mock and not self.settings.write_enabled:
|
||||||
|
return
|
||||||
for device_id in self.registry.drivers:
|
for device_id in self.registry.drivers:
|
||||||
if self.blocked(device_id):
|
if self.blocked(device_id):
|
||||||
continue
|
continue
|
||||||
|
|||||||
@@ -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()}
|
||||||
@@ -69,7 +69,26 @@ class TemplateRenderer:
|
|||||||
"星期日 / Sunday",
|
"星期日 / Sunday",
|
||||||
][current.weekday()],
|
][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()
|
values["weather"] = self.weather.get_current()
|
||||||
if (
|
if (
|
||||||
any(b.get("field") == "forecast" for b in spec["blocks"])
|
any(b.get("field") == "forecast" for b in spec["blocks"])
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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 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 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 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 invalidate(){state.templatePreview=null;$('#display-template').disabled=true;$('#template-preview').className='empty';$('#template-preview').textContent=t('previewHint');}
|
||||||
function build(){panel.innerHTML=`<h3>${t('formEditor')}</h3>`+fields.map(k=>k==='photo_id'?`<div class="field"><label for="field-photo_id">${t('photo')}</label><select id="field-photo_id" data-field="photo_id"><option value="">${t('noPhoto')}</option>${assets.map(a=>`<option value="${esc(a.asset_id)}" ${a.asset_id===values.photo_id?'selected':''}>${esc(a.name)}</option>`).join('')}</select></div>`:`<div class="field"><label for="field-${k}">${esc(labels[k]||k)}</label><textarea id="field-${k}" data-field="${k}" rows="${['events','message','weather','quote'].includes(k)?3:1}">${esc(k==='events'?(values[k]||[]).map(v=>typeof v==='object'?v.title:v).join('\n'):['weather','forecast'].includes(k)?(values[k]?JSON.stringify(values[k],null,2):''):values[k]||'')}</textarea></div>`).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=`<h3>${t('formEditor')}</h3>`+fields.map(k=>k==='photo_id'?`<div class="field"><label for="field-photo_id">${t('photo')}</label><select id="field-photo_id" data-field="photo_id"><option value="">${t('noPhoto')}</option>${assets.map(a=>`<option value="${esc(a.asset_id)}" ${a.asset_id===values.photo_id?'selected':''}>${esc(a.name)}</option>`).join('')}</select></div>`:`<div class="field"><label for="field-${k}">${esc(labels[k]||t(k))}</label><textarea id="field-${k}" data-field="${k}" rows="${['events','message','weather','quote'].includes(k)?3:1}">${esc(k==='events'?(values[k]||[]).map(v=>typeof v==='object'?v.title:v).join('\n'):['weather','forecast'].includes(k)?(values[k]?JSON.stringify(values[k],null,2):''):values[k]||'')}</textarea></div>`).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=`<summary>${t('advanced')}</summary>`;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'));}};
|
build();const advanced=document.createElement('details');advanced.innerHTML=`<summary>${t('advanced')}</summary>`;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 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);};
|
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?`<div class="notice warning">${t('lowBattery')}</div>`:''}<section class="card hero"><div class="hero-info"><span class="badge ${d.status==='offline'?'bad':''}">● ${t(d.status).toUpperCase()}</span><h2>${esc(d.name)}</h2><p>${esc(d.model)} · ${esc(d.model_code)} · ${d.display.width} × ${d.display.height}</p><div class="actions">${buttons('go-upload','upload',true)}${buttons('restore','restore')}${buttons('refresh-device','refresh')}</div><p class="hint" style="margin-top:28px">${t('refreshInterval')}</p></div><div class="frame-demo"><div class="mini-art"><div class="sun"></div></div><span class="frame-label">${t('testLabel')}</span></div></section><div class="stats">${[['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])=>`<div class="card stat"><div class="label">${t(l)}</div><div class="value">${esc(v)}</div></div>`).join('')}</div><div class="columns"><section class="card"><h3>${t('device')}</h3><div class="detail-grid">${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'))}</div></section><section class="card"><h3>${t('cloudRotation')}</h3><div class="detail-grid">${detail('mode',s.mode?t(s.mode):'—')}${detail('interval',s.interval_minutes==null?'—':s.interval_minutes+' '+t('minutes'))}</div><p class="hint">${t('rotationNotice')}</p><a href="#playlists" id="go-playlists">${t('playlists')} →</a></section></div>`;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 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?`<div class="notice warning">${t('lowBattery')}</div>`:''}<section class="card hero"><div class="hero-info"><span class="badge ${d.status==='offline'?'bad':''}">● ${t(d.status).toUpperCase()}</span><h2>${esc(d.name)}</h2><p>${esc(d.model)} · ${esc(d.model_code)} · ${d.display.width} × ${d.display.height}</p><div class="actions">${buttons('go-upload','upload',true)}${buttons('restore','restore')}${buttons('refresh-device','refresh')}</div><p class="hint" style="margin-top:28px">${t('refreshInterval')}</p></div><div class="frame-demo"><div class="mini-art"><div class="sun"></div></div><span class="frame-label">${t('testLabel')}</span></div></section><div class="stats">${[['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])=>`<div class="card stat"><div class="label">${t(l)}</div><div class="value">${esc(v)}</div></div>`).join('')}</div><div class="columns"><section class="card"><h3>${t('device')}</h3><div class="detail-grid">${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'))}</div></section><section class="card"><h3>${t('cloudRotation')}</h3><div class="detail-grid">${detail('mode',s.mode?t(s.mode):'—')}${detail('interval',s.interval_minutes==null?'—':s.interval_minutes+' '+t('minutes'))}</div><p class="hint">${t('rotationNotice')}</p><a href="#playlists" id="go-playlists">${t('playlists')} →</a></section></div>`;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()+`<div class="section-heading"><div class="tabs"><button id="cloud-tab" class="${state.tab==='cloud'?'active':''}">${t('cloud')}</button><button id="local-tab" class="${state.tab==='local'?'active':''}">${t('local')}</button></div><div class="actions">${buttons('gallery-refresh','refresh')}${state.tab==='cloud'?buttons('show-name','displayByName'):''}${buttons('gallery-upload','upload',true)}</div></div>${state.tab==='local'?`<div class="notice">${t('uploadNotice')}</div>`:''}${items.length?`<div class="gallery">${items.map((x,i)=>`<article class="card image-card"><img class="thumb" alt="${esc(x.name)}" data-src="${esc(x.url+(state.tab==='cloud'?'?thumbnail=true':''))}"><h3 title="${esc(x.name)}">${esc(x.name)}</h3><small>${state.tab==='cloud'?'#'+x.image_id:'LOCAL'} · ${esc(fmt(x.created_at))}</small>${x.current?`<div><span class="badge">${t('current')}</span></div>`:''}<div class="actions"><button class="primary" data-show="${i}">${t('display')}</button><button data-original="${i}">${t('original')}</button><button class="danger" data-delete="${i}">${t('delete')}</button></div></article>`).join('')}</div>`:`<div class="card empty"><strong>${t('noImages')}</strong>${t('emptyHint')}</div>`}`;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(`<h2>${t('displayByName')}</h2><div class="field"><label for="image-name">${t('name')}</label><input id="image-name" maxlength="120"></div><div class="modal-actions">${buttons('modal-cancel','cancel')}${buttons('show-name-confirm','display',true)}</div>`);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(`<h2>${esc(x.name)}</h2><img id="original-image" class="preview" alt="${esc(x.name)}"><div class="modal-actions">${buttons('modal-cancel','cancel')}</div>`);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 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()+`<div class="section-heading"><div class="tabs"><button id="cloud-tab" class="${state.tab==='cloud'?'active':''}">${t('cloud')}</button><button id="local-tab" class="${state.tab==='local'?'active':''}">${t('local')}</button></div><div class="actions">${buttons('gallery-refresh','refresh')}${state.tab==='cloud'?buttons('show-name','displayByName'):''}${buttons('gallery-upload','upload',true)}</div></div>${state.tab==='local'?`<div class="notice">${t('uploadNotice')}</div>`:''}${items.length?`<div class="gallery">${items.map((x,i)=>`<article class="card image-card"><img class="thumb" alt="${esc(x.name)}" data-src="${esc(x.url+(state.tab==='cloud'?'?thumbnail=true':''))}"><h3 title="${esc(x.name)}">${esc(x.name)}</h3><small>${state.tab==='cloud'?'#'+x.image_id:'LOCAL'} · ${esc(fmt(x.created_at))}</small>${x.current?`<div><span class="badge">${t('current')}</span></div>`:''}<div class="actions"><button class="primary" data-show="${i}">${t('display')}</button><button data-original="${i}">${t('original')}</button><button class="danger" data-delete="${i}">${t('delete')}</button></div></article>`).join('')}</div>`:`<div class="card empty"><strong>${t('noImages')}</strong>${t('emptyHint')}</div>`}`;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(`<h2>${t('displayByName')}</h2><div class="field"><label for="image-name">${t('name')}</label><input id="image-name" maxlength="120"></div><div class="modal-actions">${buttons('modal-cancel','cancel')}${buttons('show-name-confirm','display',true)}</div>`);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(`<h2>${esc(x.name)}</h2><img id="original-image" class="preview" alt="${esc(x.name)}"><div class="modal-actions">${buttons('modal-cancel','cancel')}</div>`);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()+`<div class="notice">${t('uploadNotice')}</div><div class="columns"><section class="card"><h2>${t('upload')}</h2><div class="dropzone"><strong>${t('selectFile')}</strong><p class="hint">${t('uploadHint')}</p><input id="file" type="file" accept="image/jpeg,image/png,image/webp" aria-label="${t('selectFile')}"></div><div class="form-grid" style="margin-top:24px"><div class="field"><label for="fit">${t('mode')}</label><select id="fit">${['contain','cover','smart'].map(x=>`<option value="${x}">${t(x)}</option>`).join('')}</select></div><div class="field"><label for="profile">${t('profile')}</label><select id="profile">${['color','grayscale'].map(x=>`<option value="${x}">${t(x)}</option>`).join('')}</select></div></div>${buttons('make-preview','preview',true)}<p class="hint">${t('previewHint')}</p></section><section class="card"><h3>${t('preview')}</h3><div id="preview-box" class="empty">${t('previewHint')}</div><div class="actions" style="margin-top:18px"><button id="save-local" disabled>${t('staging')}</button><button id="display-upload" class="primary" disabled>${t('display')}</button></div></section></div>`;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='<img id="upload-preview" class="preview" alt="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 upload(){state.preview=null;$('#main').innerHTML=notice()+`<div class="notice">${t('uploadNotice')}</div><div class="columns"><section class="card"><h2>${t('upload')}</h2><div class="dropzone"><strong>${t('selectFile')}</strong><p class="hint">${t('uploadHint')}</p><input id="file" type="file" accept="image/jpeg,image/png,image/webp" aria-label="${t('selectFile')}"></div><div class="form-grid" style="margin-top:24px"><div class="field"><label for="fit">${t('mode')}</label><select id="fit">${['contain','cover','smart'].map(x=>`<option value="${x}">${t(x)}</option>`).join('')}</select></div><div class="field"><label for="profile">${t('profile')}</label><select id="profile">${['color','grayscale'].map(x=>`<option value="${x}">${t(x)}</option>`).join('')}</select></div></div>${buttons('make-preview','preview',true)}<p class="hint">${t('previewHint')}</p></section><section class="card"><h3>${t('preview')}</h3><div id="preview-box" class="empty">${t('previewHint')}</div><div class="actions" style="margin-top:18px"><button id="save-local" disabled>${t('staging')}</button><button id="display-upload" class="primary" disabled>${t('display')}</button></div></section></div>`;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='<img id="upload-preview" class="preview" alt="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()+`<div class="notice">${t('templateHint')}</div><div class="template-grid">${state.templates.map((x,i)=>`<div class="card template-card ${x.id===selected.id?'selected':''}" role="button" tabindex="0" data-template="${x.id}"><span>${['☀','▦','◒','▣','❝','☁'][i]}</span><div><h3>${esc(x.name.split(' / ')[state.lang==='zh'?1:0]||x.name)}</h3><small>1200 × 1600 · ${esc(x.id)}</small></div></div>`).join('')}</div><div class="columns"><section class="card"><h3>${t('templateData')}</h3><textarea id="template-data" aria-label="${t('templateData')}">${esc(JSON.stringify(selected.defaults,null,2))}</textarea><div class="field" style="margin-top:16px"><label for="template-profile">${t('profile')}</label><select id="template-profile"><option value="color">${t('color')}</option><option value="grayscale">${t('grayscale')}</option></select></div><div class="actions">${buttons('render-template','render',true)}${buttons('save-template','saveTemplate')}</div></section><section class="card"><h3>${t('preview')}</h3><div id="template-preview" class="empty">${t('previewHint')}</div><button id="display-template" class="primary" style="margin-top:18px" disabled>${t('display')}</button></section></div>`;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='<img id="template-image" class="preview" alt="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()+`<div class="notice">${t('templateHint')}</div><div class="template-grid">${state.templates.map((x,i)=>`<div class="card template-card ${x.id===selected.id?'selected':''}" role="button" tabindex="0" data-template="${x.id}"><span>${{'daily-poetry':'詩','calendar':'▦','daily-dashboard':'☀','photo-text':'▣','quote':'❝','system-alert':'◒','weather':'☁'}[x.id]||'▦'}</span><div><h3>${esc(x.name.split(' / ')[state.lang==='zh'?1:0]||x.name)}</h3><small>1200 × 1600 · ${esc(x.id)}</small></div></div>`).join('')}</div><div class="columns"><section class="card"><h3>${t('templateData')}</h3><textarea id="template-data" aria-label="${t('templateData')}">${esc(JSON.stringify(selected.defaults,null,2))}</textarea><div class="field" style="margin-top:16px"><label for="template-profile">${t('profile')}</label><select id="template-profile"><option value="color">${t('color')}</option><option value="grayscale">${t('grayscale')}</option></select></div><div class="actions">${buttons('render-template','render',true)}${buttons('save-template','saveTemplate')}</div></section><section class="card"><h3>${t('preview')}</h3><div id="template-preview" class="empty">${t('previewHint')}</div><button id="display-template" class="primary" style="margin-top:18px" disabled>${t('display')}</button></section></div>`;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='<img id="template-image" class="preview" alt="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()+`<div class="section-heading"><p class="hint">Asia/Shanghai · ${t('automation')}</p>${buttons('new-job','newJob',true)}</div><div class="card">${jobs.length?`<div class="table-wrap"><table><thead><tr>${['name','schedule','status','nextRun','lastRun','result','action'].map(x=>`<th>${t(x)}</th>`).join('')}</tr></thead><tbody>${jobs.map((j,i)=>`<tr><td><strong>${esc(j.name)}</strong><br><small>${esc(j.device_id)}</small></td><td>${t(j.schedule.type)}<br>${esc(j.schedule.time||j.schedule.cron||fmt(j.schedule.at))}</td><td><span class="badge">${t(j.enabled?'enabled':'disabled')}</span></td><td>${fmt(j.next_run)}</td><td>${fmt(j.last_run)}</td><td><code>${esc(j.last_result)}</code></td><td><div class="row-actions"><button data-job-edit="${i}">${t('edit')}</button><button data-job-toggle="${i}">${t(j.enabled?'disabled':'enabled')}</button><button class="danger" data-job-delete="${i}">${t('remove')}</button></div></td></tr>`).join('')}</tbody></table></div>`:`<div class="empty"><strong>${t('noTasks')}</strong></div>`}</div>`;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 automation(epoch){const jobs=await api('/api/v1/jobs');if(epoch!==state.epoch)return;$('#main').innerHTML=notice()+`<div class="section-heading"><p class="hint">Asia/Shanghai · ${t('automation')}</p>${buttons('new-job','newJob',true)}</div><div class="card">${jobs.length?`<div class="table-wrap"><table><thead><tr>${['name','schedule','status','nextRun','lastRun','result','action'].map(x=>`<th>${t(x)}</th>`).join('')}</tr></thead><tbody>${jobs.map((j,i)=>`<tr><td><strong>${esc(j.name)}</strong><br><small>${esc(j.device_id)}</small></td><td>${t(j.schedule.type)}<br>${esc(j.schedule.time||j.schedule.cron||fmt(j.schedule.at))}</td><td><span class="badge">${t(j.enabled?'enabled':'disabled')}</span></td><td>${fmt(j.next_run)}</td><td>${fmt(j.last_run)}</td><td><code>${esc(j.last_result)}</code></td><td><div class="row-actions"><button data-job-edit="${i}">${t('edit')}</button><button data-job-toggle="${i}">${t(j.enabled?'disabled':'enabled')}</button><button class="danger" data-job-delete="${i}">${t('remove')}</button></div></td></tr>`).join('')}</tbody></table></div>`:`<div class="empty"><strong>${t('noTasks')}</strong></div>`}</div>`;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()+`<div class="notice">${t('playlistNotice')}</div><div class="section-heading"><h2>${t('playlists')}</h2>${buttons('new-playlist','newPlaylist',true)}</div><div class="gallery">${playlists.map((p,i)=>`<section class="card"><span class="badge">${t(p.enabled?'enabled':'disabled')}</span><h3 style="margin-top:16px">${esc(p.name)}</h3><p class="hint">${p.image_ids.length} ${t('count')} · ${p.interval_minutes} ${t('minutes')} · ${t(p.mode)}</p><p><code>${esc(p.image_ids.join(' → '))}</code></p><small>${t('lastScheduled')}: ${p.position>=0?esc(p.image_ids[p.position]):'—'}</small><div class="actions" style="margin-top:16px"><button data-pl-edit="${i}">${t('edit')}</button><button class="danger" data-pl-delete="${i}">${t('remove')}</button></div></section>`).join('')||`<div class="card empty"><strong>${t('noTasks')}</strong></div>`}</div>`;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 playlists(epoch){const playlists=await api('/api/v1/playlists');if(epoch!==state.epoch)return;$('#main').innerHTML=notice()+`<div class="notice">${t('playlistNotice')}</div><div class="section-heading"><h2>${t('playlists')}</h2>${buttons('new-playlist','newPlaylist',true)}</div><div class="gallery">${playlists.map((p,i)=>`<section class="card"><span class="badge">${t(p.enabled?'enabled':'disabled')}</span><h3 style="margin-top:16px">${esc(p.name)}</h3><p class="hint">${p.image_ids.length} ${t('count')} · ${p.interval_minutes} ${t('minutes')} · ${t(p.mode)}</p><p><code>${esc(p.image_ids.join(' → '))}</code></p><small>${t('lastScheduled')}: ${p.position>=0?esc(p.image_ids[p.position]):'—'}</small><div class="actions" style="margin-top:16px"><button data-pl-edit="${i}">${t('edit')}</button><button class="danger" data-pl-delete="${i}">${t('remove')}</button></div></section>`).join('')||`<div class="card empty"><strong>${t('noTasks')}</strong></div>`}</div>`;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=`<div class="section-heading"><h2>${t('history')}</h2>${buttons('history-refresh','refresh')}</div>${ops.some(x=>x.status==='UNKNOWN')?`<div class="notice warning">${t('unknown')}</div>`:''}<div class="card table-wrap">${ops.length?`<table><thead><tr>${['created','action','status','result','client','requestId'].map(x=>`<th>${t(x)}</th>`).join('')}</tr></thead><tbody>${ops.map((o,i)=>`<tr><td>${fmt(o.created_at)}</td><td>${esc(o.action)}<br><small>${esc(o.device_id)}</small></td><td><span class="badge ${['FAILED','UNKNOWN'].includes(o.status)?'bad':''}">${t(o.status)}</span>${o.status==='UNKNOWN'?`<div><button data-reconcile="${i}">${t('reconcile')}</button></div>`:''}</td><td>${esc(o.result?.image_id||o.result?.asset_id||o.error?.code||'—')}</td><td>${esc(o.client_id)}</td><td><code>${esc(o.request_id)}<br>${esc(o.operation_id)}</code></td></tr>`).join('')}</tbody></table>`:`<div class="empty">${t('noHistory')}</div>`}</div>`;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(`<h2>${t('reconcile')}</h2><p>${t('current')}: ${esc(r.current_image_id)}</p><p>${t('result')}: ${esc(r.expected_image_id)}</p><p class="hint">${t('unknown')}</p><div class="modal-actions">${buttons('modal-cancel','cancel')}${r.resolved?'':buttons('acknowledge','acknowledge')}</div>`);bind('acknowledge',async()=>{await api('/api/v1/operations/'+o.operation_id+'/reconcile',{method:'POST',body:{acknowledge:true}});$('#modal').close();await navigate();});}));},
|
async history(epoch){const ops=await api('/api/v1/operations');if(epoch!==state.epoch)return;$('#main').innerHTML=`<div class="section-heading"><h2>${t('history')}</h2>${buttons('history-refresh','refresh')}</div>${ops.some(x=>x.status==='UNKNOWN')?`<div class="notice warning">${t('unknown')}</div>`:''}<div class="card table-wrap">${ops.length?`<table><thead><tr>${['created','action','status','result','client','requestId'].map(x=>`<th>${t(x)}</th>`).join('')}</tr></thead><tbody>${ops.map((o,i)=>`<tr><td>${fmt(o.created_at)}</td><td>${esc(o.action)}<br><small>${esc(o.device_id)}</small></td><td><span class="badge ${['FAILED','UNKNOWN'].includes(o.status)?'bad':''}">${t(o.status)}</span>${o.status==='UNKNOWN'?`<div><button data-reconcile="${i}">${t('reconcile')}</button></div>`:''}</td><td>${esc(o.result?.image_id||o.result?.asset_id||o.error?.code||'—')}</td><td>${esc(o.client_id)}</td><td><code>${esc(o.request_id)}<br>${esc(o.operation_id)}</code></td></tr>`).join('')}</tbody></table>`:`<div class="empty">${t('noHistory')}</div>`}</div>`;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(`<h2>${t('reconcile')}</h2><p>${t('current')}: ${esc(r.current_image_id)}</p><p>${t('result')}: ${esc(r.expected_image_id)}</p><p class="hint">${t('unknown')}</p><div class="modal-actions">${buttons('modal-cancel','cancel')}${r.resolved?'':buttons('acknowledge','acknowledge')}</div>`);bind('acknowledge',async()=>{await api('/api/v1/operations/'+o.operation_id+'/reconcile',{method:'POST',body:{acknowledge:true}});$('#modal').close();await navigate();});}));},
|
||||||
|
|||||||
@@ -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 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);
|
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');});
|
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'));});}
|
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);}
|
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=`<h3>${t('urlSettings')}</h3><form id="image-host-form"><div class="field"><label for="image-hosts">${t('allowedHosts')}</label><textarea id="image-hosts" rows="3">${esc(config.allowed_hosts.join('\n'))}</textarea><p class="hint">${t('urlHint')}</p></div><button type="submit">${t('save')}</button></form>`;$('#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'));});}
|
async function enhanceSettings(){await weatherSettings();const config=await api('/api/v1/settings/image-url');const box=document.createElement('section');box.className='card';box.innerHTML=`<h3>${t('urlSettings')}</h3><form id="image-host-form"><div class="field"><label for="image-hosts">${t('allowedHosts')}</label><textarea id="image-hosts" rows="3">${esc(config.allowed_hosts.join('\n'))}</textarea><p class="hint">${t('urlHint')}</p></div><button type="submit">${t('save')}</button></form>`;$('#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=`<h3>${t('uploadURL')}</h3><div class="field"><label for="upload-url">${t('imageURL')}</label><input id="upload-url" type="url" placeholder="https://…"><p class="hint">${t('urlHint')}</p></div>${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='<img id="upload-preview" class="preview" alt="Preview">';await blobImage($('#upload-preview'),state.preview.url);$('#save-local').disabled=false;$('#display-upload').disabled=!liveWriteAllowed();});}
|
function enhanceUpload(){const box=document.createElement('div');box.innerHTML=`<h3>${t('uploadURL')}</h3><div class="field"><label for="upload-url">${t('imageURL')}</label><input id="upload-url" type="url" placeholder="https://…"><p class="hint">${t('urlHint')}</p></div>${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='<img id="upload-preview" class="preview" alt="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=`<div class="notice">${t('poetryNotice')}</div><div class="field"><label for="poem-choice">${t('poemChoice')}</label><select id="poem-choice"><option value="">${t('dailyAutomatic')}</option>${provider.catalog.map(p=>`<option value="${p.id}">${esc(p.title)} · ${esc(p.author)}</option>`).join('')}</select></div><p id="poem-description" class="hint"></p>${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(`<h2>${t('dailyPoetryJob')}</h2><form id="poetry-job-form"><div class="field"><label for="poetry-job-time">${t('time')}</label><input id="poetry-job-time" type="time" required value="07:00"></div><label class="check-row"><input id="poetry-job-enabled" type="checkbox">${t('enabled')}</label><p class="hint">${t('poetryScheduleHint')}</p><div class="modal-actions">${buttons('modal-cancel','cancel')}<button type="submit" class="primary">${t('save')}</button></div></form>`);$('#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');});});
|
||||||
|
}
|
||||||
|
|||||||
@@ -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.';
|
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,{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'});
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -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)});
|
||||||
@@ -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('#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 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 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('#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('#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++;
|
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++;
|
||||||
|
|||||||
@@ -750,5 +750,21 @@
|
|||||||
"502",
|
"502",
|
||||||
"503"
|
"503"
|
||||||
]
|
]
|
||||||
|
},
|
||||||
|
"/api/v1/providers/poetry": {
|
||||||
|
"get": [
|
||||||
|
"200",
|
||||||
|
"400",
|
||||||
|
"401",
|
||||||
|
"403",
|
||||||
|
"404",
|
||||||
|
"409",
|
||||||
|
"413",
|
||||||
|
"422",
|
||||||
|
"429",
|
||||||
|
"500",
|
||||||
|
"502",
|
||||||
|
"503"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -21,7 +21,7 @@ def test_health_ready(client):
|
|||||||
def test_without_external_project(client):
|
def test_without_external_project(client):
|
||||||
assert client.get("/").status_code == 200
|
assert client.get("/").status_code == 200
|
||||||
assert client.get("/api/v1/devices").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
|
assert client.get("/api/v1/jobs").status_code == 200
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
Reference in New Issue
Block a user