From b87bf6f77e0babc0b36436bf75b03d719cc0dee5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=B8=A1=E5=8F=A3=E6=B5=AA=E4=BA=BA?= Date: Sat, 17 Oct 2026 01:39:23 +0800 Subject: [PATCH] Release v0.1.9: enforce Photos API boundary and finalize Server migration --- .github/workflows/verify.yml | 59 +---- deploy/docker-compose.photos.yml | 14 +- docs/releases/v0.1.9.md | 19 ++ docs/research/KOKONNA_LOCAL_CONTROL.md | 84 +++++++ pigway_frame_flow/models.py | 5 +- pigway_frame_flow/providers/photo_sources.py | 12 + pigway_frame_flow/scheduler/engine.py | 4 + pigway_frame_flow/web/app.js | 33 +-- pigway_frame_flow/web/extensions.js | 2 +- pigway_frame_flow/web/features.js | 14 +- pigway_frame_flow/web/i18n.js | 36 +-- pigway_frame_flow/web/index.html | 2 +- pigway_frame_flow/web/style.css | 13 + scripts/production-deploy.py | 34 +-- scripts/server-release.py | 244 +++++++++++++++++++ scripts/ui-history-localization.cjs | 17 ++ scripts/ui-interval-schedule.cjs | 28 +++ scripts/ui-login-layout.cjs | 17 ++ scripts/ui-photos-api-form.cjs | 23 ++ tests/conftest.py | 14 ++ tests/test_external_photo_sources.py | 4 + tests/test_interval_schedule.py | 30 +++ tests/test_local_cloud_library.py | 5 + tests/test_photo_canvas.py | 4 + tests/test_photo_captions.py | 4 + tests/test_photo_failures.py | 5 + tests/test_photo_orientation.py | 10 +- tests/test_photo_sources.py | 7 + tests/test_photos_api_boundary.py | 23 ++ tests/test_production_bootstrap.py | 8 +- tests/test_production_deploy.py | 34 ++- tests/test_publication_security.py | 8 +- tests/test_retention.py | 4 + tests/test_server_release.py | 55 +++++ tests/test_synology_photos.py | 4 + 35 files changed, 724 insertions(+), 155 deletions(-) create mode 100644 docs/releases/v0.1.9.md create mode 100644 docs/research/KOKONNA_LOCAL_CONTROL.md create mode 100644 scripts/server-release.py create mode 100644 scripts/ui-history-localization.cjs create mode 100644 scripts/ui-interval-schedule.cjs create mode 100644 scripts/ui-login-layout.cjs create mode 100644 scripts/ui-photos-api-form.cjs create mode 100644 tests/test_interval_schedule.py create mode 100644 tests/test_photos_api_boundary.py create mode 100644 tests/test_server_release.py diff --git a/.github/workflows/verify.yml b/.github/workflows/verify.yml index e24f9fd..c5035e2 100644 --- a/.github/workflows/verify.yml +++ b/.github/workflows/verify.yml @@ -5,12 +5,6 @@ on: branches: ["**"] pull_request: workflow_dispatch: - inputs: - deploy_action: - description: Production action (deploy requires explicit enabled Variable) - type: choice - options: [plan, bootstrap, deploy, rollback] - default: plan jobs: verify: # The NAS Docker runner must never execute code from external forks. @@ -47,54 +41,5 @@ jobs: python scripts/ci-compose-verify.py --cleanup fi - deploy: - needs: verify - if: success() && github.ref == 'refs/heads/main' && (github.event_name == 'push' || github.event_name == 'workflow_dispatch') - runs-on: ubuntu-latest - concurrency: - group: frame-flow-production - cancel-in-progress: false - env: - CD_ACTION: ${{ github.event.inputs.deploy_action }} - PROD_DEPLOY_ENABLED: ${{ vars.PROD_DEPLOY_ENABLED }} - PROD_PROJECT: ${{ vars.PROD_PROJECT }} - PROD_CONTAINER: ${{ vars.PROD_CONTAINER }} - PROD_IMAGE_REPO: ${{ vars.PROD_IMAGE_REPO }} - PROD_DATA_DIR: ${{ secrets.PROD_DATA_DIR }} - PROD_CONFIG_DIR: ${{ secrets.PROD_CONFIG_DIR }} - PROD_PHOTOS_DIR: ${{ secrets.PROD_PHOTOS_DIR }} - SYNOLOGY_PHOTOS_URL: ${{ secrets.SYNOLOGY_PHOTOS_URL }} - SYNOLOGY_PHOTOS_CERT_SHA256: ${{ secrets.SYNOLOGY_PHOTOS_CERT_SHA256 }} - PROD_WEB_BIND: ${{ secrets.PROD_WEB_BIND }} - PROD_WEB_PORT: ${{ vars.PROD_WEB_PORT }} - FRAME_FLOW_STORAGE_NAME: ${{ vars.FRAME_FLOW_STORAGE_NAME }} - PROD_STATE_VOLUME: ${{ vars.PROD_STATE_VOLUME }} - PROD_MOCK: ${{ vars.PROD_MOCK }} - PROD_WRITE_ENABLED: ${{ vars.PROD_WRITE_ENABLED }} - DOCKER_PIP_INDEX_URL: ${{ vars.DOCKER_PIP_INDEX_URL }} - DOCKER_PIP_FALLBACK_INDEX_URL: ${{ vars.DOCKER_PIP_FALLBACK_INDEX_URL }} - CI_PIP_INDEX_URL: ${{ vars.CI_PIP_INDEX_URL }} - CI_PIP_FALLBACK_INDEX_URL: ${{ vars.CI_PIP_FALLBACK_INDEX_URL }} - DEBIAN_MIRROR: ${{ vars.DEBIAN_MIRROR }} - DEBIAN_SECURITY_MIRROR: ${{ vars.DEBIAN_SECURITY_MIRROR }} - steps: - - uses: https://github.com/actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 - - uses: https://github.com/actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 - with: - python-version: '3.14' - - name: Install deploy tooling - run: python scripts/ci-install.py - - name: Plan or deploy production (default is plan) - env: - PRODUCTION_ENV: ${{ secrets.PRODUCTION_ENV }} - run: | - . ./deploy/build-cn.env - python scripts/production-deploy.py - - name: Recover interrupted production deployment - if: always() - env: - CD_ACTION: recover - run: | - if [ -n "${FRAME_FLOW_DEPLOY_OWNER:-}" ]; then - python scripts/production-deploy.py - fi +# Production moved to Ubuntu Server. Release manually with scripts/server-release.py; +# the shared NAS runner performs isolated verification only. diff --git a/deploy/docker-compose.photos.yml b/deploy/docker-compose.photos.yml index 3bbdf41..6735267 100644 --- a/deploy/docker-compose.photos.yml +++ b/deploy/docker-compose.photos.yml @@ -1,14 +1,6 @@ -# Opt-in read-only photo mount. Set NAS_PHOTOS_DIR to the actual NAS folder. +# Retired: filesystem photo mounts are forbidden. Use Photos API sources. +# This compatibility filename intentionally grants no additional host access. services: pigway-frame-flow: environment: - PHOTO_ROOT: /photos - group_add: - - "${NAS_PHOTOS_GID:-10333}" - volumes: - - type: bind - source: ${NAS_PHOTOS_DIR:?Set NAS_PHOTOS_DIR to your existing photo directory} - target: /photos - read_only: true - bind: - create_host_path: false + SYNOLOGY_PHOTOS_CACHE_BYTES: ${SYNOLOGY_PHOTOS_CACHE_BYTES:-268435456} diff --git a/docs/releases/v0.1.9.md b/docs/releases/v0.1.9.md new file mode 100644 index 0000000..10852c9 --- /dev/null +++ b/docs/releases/v0.1.9.md @@ -0,0 +1,19 @@ +# v0.1.9 + +照片来源通过 Synology Photos API 按账号权限读取,共享/个人空间、人物 any/all 筛选和有界 XL 预览缓存可用。生产拒绝文件系统照片来源及 /photos 挂载;本地上传、生成图片和 Server 本地图库继续保留。NAS 原图下载接口曾被拒绝,本版不宣称支持原图下载。 + +自动化新增 1~10080 分钟的真实间隔,跨整点与跨天不按 Cron 字段重置,既有 Cron 任务保持原样。显示时长明确为到期恢复期限。统一来源快捷创建任务编辑器,改善登录及来源表单响应式布局、设备显示名与历史记录中英文;诊断详情保留原始标识。 + +## 生产部署 + +生产已迁至 Ubuntu Server,由 systemd 管理 Docker;业务、Docker 数据和恢复资料位于 T7,并有 UUID 与挂载依赖保护。NAS 旧 Frame 专属资源已清理且独立复核 PASS。CI 保留隔离验证,移除 NAS 自动生产部署任务,不再向 NAS 发布。 + +正式发布使用 scripts/server-release.py:仅在既有 Ubuntu Server 环境运行,从已提交源码包构建,校验依赖锁、源码哈希、T7 UUID、容器安全约束和真实写入设置;备份 SQLite、配置及 Compose 后切换镜像,失败恢复旧 Compose,不自动回写旧数据库。它不执行上传、显示、删除或恢复图片等设备操作。成功后记录正式提交号、保存源码包及审计回执,删除本次暂存目录;白名单内的 Frame 旧迁移输入归档到本次 recovery,清除无恢复引用的临时镜像别名,共享 Console 资料不动。旧镜像及历史恢复资料保留供回滚,不执行全局 prune。 + +脚本要求由 git archive 生成的源码放在 /srv/pigway/migration-inbox 的独立子目录;目录内同时提供 RELEASE_COMMIT、RELEASE_HASHES.json 和原始 source.tar.gz。哈希清单涵盖源码与源码包,不含自身。执行 sudo python3 <暂存目录>/scripts/server-release.py;成功后读取 /srv/pigway/apps/pigway-frame-flow/release-verification.json 并独立检查线上接口。 + +## 验收范围 + +完整后端测试、静态检查与桌面/平板/手机的中英文浏览器回归纳入发布检查。线上历史补丁已核验,真实写入保持开启;本轮正式发布不重复真实设备写入测试。隔离浏览器验证与真实设备写入证据分别记录于 docs/verification/2026-10-17-final-audit.md。 + +局域网 HTTPS、独立加密备份及恢复演练仍是后续工作;T7 recovery 不属于独立备份。没有执行 USB 断连、服务器重启或压力测试。 diff --git a/docs/research/KOKONNA_LOCAL_CONTROL.md b/docs/research/KOKONNA_LOCAL_CONTROL.md new file mode 100644 index 0000000..a746023 --- /dev/null +++ b/docs/research/KOKONNA_LOCAL_CONTROL.md @@ -0,0 +1,84 @@ +# KoKonna local-control investigation + +Reviewed 2026-10-06. No frame writes, network changes, or firmware changes. + +## Sources and limits + +- Official FAQ: https://kokonna.art/pages/faq explicitly describes Bluetooth + upload and switching. This is a product-level claim, not verified B101 behavior. +- Public OpenAPI: https://kokonna.art/pages/openapi documents cloud HTTPS calls. +- Existing official Android nightly 1.6.5 APK from the prior download: + SHA256 `8f611bf74bbda70e7e7a5d087e4065dc6000849dcd2f12538d5ff5017c91e1c0`. + Static inspection of its packaged app-service.js; no vendor code is copied here. +- Searches found community API clients, but no verified B101 firmware source or + documented bring-your-own-server configuration. Absence from search is not + proof that firmware source or another configuration path does not exist. + +## Observed client behavior + +The account client uses `https://robot.galaxyguide.cn/api/rpc`. Its Bluetooth +upload flow calls `appFrame.uploadImage` with base64 and `synced: true`, receives +an image ID, downloads the resulting image from the vendor server, and sends +the bytes to the frame over BLE. Therefore this observed flow still exposes +the original image to the vendor cloud. + +BLE service UUID: `4fafc201-1fb5-459e-8fcc-c5c9c331914b`. +Characteristic UUID: `beb5483e-36e1-4688-b7f5-ea07361b26a7`. + +Observed JSON commands include `get_info`, `refresh_image`, and `set_image`. +Image transfer uses start metadata (ID, rotation, type, size, chunk count), +numbered binary chunks, sync acknowledgements/missing-chunk information, and +an end command. The client chooses `y4` for firmware starting with B103 and +`jpg` otherwise. That makes local JPEG transmission a plausible B101 research +path, not a proven implementation: cloud-independent IDs, processing, firmware +acceptance, pairing and completion semantics still require validation. + +The observed `set_config` payload contains `slideType`, `slideMinute`, and +`nickname`; `set_wifi` contains SSID and password. No server-host setter was +found in these paths or common host-setting symbol searches. + +## Host replacement assessment + +Changing Frame Flow's API base URL changes where Frame Flow sends requests, +not where the frame connects. Replacing the frame's cloud server requires its +device-side protocol and a supported address-setting mechanism or firmware +change. DNS redirection alone may fail HTTPS certificate validation and still +requires a compatible server implementation. TLS/pinning and actual device +polling transport have not been established from this APK. + +Next read-only step: inspect BLE `get_info` and installed firmware compatibility. +Any subsequent local image write needs a separate controlled test. No BLE driver +is enabled by this investigation. + +## Wi-Fi-only follow-up + +The user excluded Bluetooth and additional gateways. The target is NAS-to-frame +Wi-Fi control with the original hardware. + +GitHub's public repository search for `kokonna` returned three repositories: +`patrickchen914/kokonna`, `nixihz/kokonna-skill`, and +`zhengweiran66-eng/kokonna-product.html`. These provide a Home Assistant cloud +integration, an OpenAPI CLI, and a product page, respectively; no verified B101 +device firmware or Wi-Fi local-server implementation was identified. + +Static APK checks found no hard-coded private IPv4 target, WebSocket URL, +custom image-URL setter, or common server-host setter symbols in app-service.js. +This does not exclude hidden firmware features or native-library code paths. +`app.getFirmwares` is called with `robotId`. Upgrade submission passes a firmware +name, locale and distributor ID through `app.setUpgradeFirmware`; the inspected +UI does not supply a user-selected firmware download URL. Neither method was +called during this investigation. Existing account RPC allowlists remain intact. + +Candidate paths, none yet verified: + +1. A device-local HTTP/TCP service: requires a confirmed frame LAN address and + bounded service inspection while the frame is awake. +2. A firmware-supported custom server or image URL: requires configuration or + firmware evidence, not simply replacing the application's API host. +3. A NAS implementation of the device's cloud protocol: requires the actual + polling/download protocol and address/TLS behavior. Passive observation at + the existing gateway may provide evidence; a normal NAS on a switched LAN + cannot assume it sees another device's outbound packets. + +No DNS redirection, router interception, certificate replacement, firmware +upgrade, reset, or automatic display change was performed. diff --git a/pigway_frame_flow/models.py b/pigway_frame_flow/models.py index e2a8404..74ed9b3 100644 --- a/pigway_frame_flow/models.py +++ b/pigway_frame_flow/models.py @@ -265,7 +265,10 @@ class GalleryImage(BaseModel): class Schedule(StrictModel): - type: Literal["cron", "daily", "weekdays", "weekends", "date", "temporary"] + type: Literal[ + "cron", "daily", "weekdays", "weekends", "date", "temporary", "interval" + ] + interval_minutes: int | None = Field(default=None, ge=1, le=10080) cron: str | None = None time: str | None = Field(default=None, pattern=r"^([01]\d|2[0-3]):[0-5]\d$") at: datetime | None = None diff --git a/pigway_frame_flow/providers/photo_sources.py b/pigway_frame_flow/providers/photo_sources.py index b397a97..9b6f259 100644 --- a/pigway_frame_flow/providers/photo_sources.py +++ b/pigway_frame_flow/providers/photo_sources.py @@ -109,7 +109,18 @@ class PhotoSources: raise FlowError(E.NOT_FOUND, "Photo source not found", 404) return PhotoSourceInput.model_validate_json(row["payload"]) + @staticmethod + def require_api_source(source): + # Block persisted legacy sources as well as new requests, before any disk read. + if source.kind == "local": + raise FlowError( + E.FORBIDDEN, + "Filesystem photo sources are disabled; use the Synology Photos API", + 403, + ) + def save(self, source_id, source): + self.require_api_source(source) if source.minimum_brightness > source.maximum_brightness: raise FlowError(E.INVALID_REQUEST, "Brightness range is reversed") if source.kind not in ("local", "synology-photos") and ( @@ -284,6 +295,7 @@ class PhotoSources: def catalog(self, source_id, refresh=False, *, target_size=(1200, 1600)): source = self.get(source_id) + self.require_api_source(source) if not source.enabled: raise FlowError(E.CONFLICT, "Photo source is disabled", 409) session_identity = ( diff --git a/pigway_frame_flow/scheduler/engine.py b/pigway_frame_flow/scheduler/engine.py index 39cec8a..ed5f17c 100644 --- a/pigway_frame_flow/scheduler/engine.py +++ b/pigway_frame_flow/scheduler/engine.py @@ -23,6 +23,10 @@ from pigway_frame_flow.storage.database import encode, now_iso def next_run(schedule, after=None): after = after or datetime.now(UTC) + if schedule.type == "interval": + if schedule.interval_minutes is None: + raise FlowError(E.INVALID_REQUEST, "Interval requires interval_minutes") + return (after + timedelta(minutes=schedule.interval_minutes)).isoformat() if schedule.type in ("date", "temporary"): if not schedule.at or schedule.at.tzinfo is None: raise FlowError(E.INVALID_REQUEST, "One-time date must include timezone") diff --git a/pigway_frame_flow/web/app.js b/pigway_frame_flow/web/app.js index bb091de..3bd8d25 100644 --- a/pigway_frame_flow/web/app.js +++ b/pigway_frame_flow/web/app.js @@ -13,10 +13,10 @@ const t=k=>window.MESSAGES[state.lang][k]||k; const esc=v=>String(v??'—').replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c])); const fmt=v=>v?new Date(v).toLocaleString(state.lang==='zh'?'zh-CN':'en-GB',{timeZone:'Asia/Shanghai'}):'—'; const padTime=(hour,minute)=>`${String(hour).padStart(2,'0')}:${String(minute).padStart(2,'0')}`; -function normaliseSchedule(schedule){const copy={...schedule};if(copy.type!=='cron')return copy;const match=/^(\d{1,2})\s+(\d{1,2})\s+\*\s+\*\s+(\*|1-5|6,0|0,6)$/.exec(copy.cron||'');if(!match)return copy;const type=match[3]==='*'?'daily':match[3]==='1-5'?'weekdays':'weekends';return {type,time:padTime(match[2],match[1])};} -function scheduleTypeLabel(type){if(type!=='cron')return t(type);return state.lang==='zh'?'高级规则(Cron)':'Advanced rule (Cron)';} -function scheduleSummary(schedule){const value=normaliseSchedule(schedule);if(['daily','weekdays','weekends'].includes(value.type))return `${scheduleTypeLabel(value.type)} · ${value.time}(${state.lang==='zh'?'上海时间':'Shanghai time'})`;if(value.type==='cron')return `${scheduleTypeLabel('cron')} · ${value.cron}`;return `${scheduleTypeLabel(value.type)} · ${fmt(value.at)}`;} -function scheduleHint(type){if(type==='cron')return state.lang==='zh'?'仅在需要特殊规则时使用;例如“每天 08:00”请选择“每天”。':'Use only for special rules; choose Daily for a regular time.';if(['date','temporary'].includes(type))return state.lang==='zh'?'选择一次性执行的日期和时间。':'Choose the date and time for this one-time task.';return state.lang==='zh'?'选择执行时间(上海时区)。':'Choose a time in Shanghai time.';} +function normaliseSchedule(schedule){const copy={...schedule};if(copy.type==='interval')return {type:copy.interval_minutes%60===0?'hours':'minutes',every:copy.interval_minutes%60===0?copy.interval_minutes/60:copy.interval_minutes};if(copy.type!=='cron')return copy;const match=/^(\d{1,2})\s+(\d{1,2})\s+\*\s+\*\s+(\*|1-5|6,0|0,6)$/.exec(copy.cron||'');if(!match)return copy;const type=match[3]==='*'?'daily':match[3]==='1-5'?'weekdays':'weekends';return {type,time:padTime(match[2],match[1])};} +function scheduleTypeLabel(type){if(type==='hours')return state.lang==='zh'?'每隔若干小时':'Every N hours';if(type==='minutes')return state.lang==='zh'?'每隔若干分钟':'Every N minutes';if(type!=='cron')return t(type);return state.lang==='zh'?'高级规则(Cron)':'Advanced rule (Cron)';} +function scheduleSummary(schedule){const value=normaliseSchedule(schedule);if(['hours','minutes'].includes(value.type))return state.lang==='zh'?`每 ${value.every} ${value.type==='hours'?'小时':'分钟'}`:`Every ${value.every} ${value.type}`;if(['daily','weekdays','weekends'].includes(value.type))return `${scheduleTypeLabel(value.type)} · ${value.time}(${state.lang==='zh'?'上海时间':'Shanghai time'})`;if(value.type==='cron')return `${scheduleTypeLabel('cron')} · ${value.cron}`;return `${scheduleTypeLabel(value.type)} · ${fmt(value.at)}`;} +function scheduleHint(type){if(['hours','minutes'].includes(type))return state.lang==='zh'?'从保存起等待一个间隔后首次执行,此后按间隔重复;不是图片显示时长。':'First runs one interval after saving, then repeats. This is separate from display duration.';if(type==='cron')return state.lang==='zh'?'仅在需要特殊规则时使用;例如“每天 08:00”请选择“每天”。':'Use only for special rules; choose Daily for a regular time.';if(['date','temporary'].includes(type))return state.lang==='zh'?'选择一次性执行的日期和时间。':'Choose the date and time for this one-time task.';return state.lang==='zh'?'选择执行时间(上海时区)。':'Choose a time in Shanghai time.';} const buttons=(id,text,primary=false)=>``; let toastTimer; function toast(message){$('#toast').textContent=message;$('#toast').style.display='block';clearTimeout(toastTimer);toastTimer=setTimeout(()=>$('#toast').style.display='none',6000);} @@ -34,7 +34,12 @@ function busy(fn){return async event=>{try{await fn(event);}catch(e){toast(e.mes function applyTheme(){const dark=state.theme==='dark'||(state.theme==='system'&&matchMedia('(prefers-color-scheme:dark)').matches);document.documentElement.dataset.theme=dark?'dark':'light';} function revealCurrentNavigation(){const active=$('#nav .active'),sidebar=$('.sidebar');if(active&&matchMedia('(max-width:720px)').matches)sidebar.scrollLeft=active.offsetLeft-(sidebar.clientWidth-active.offsetWidth)/2;} window.addEventListener('resize',revealCurrentNavigation); -function shell(){document.documentElement.lang=state.lang==='zh'?'zh-CN':'en';$('#lang').textContent=state.lang==='zh'?'EN':'中文';$('#theme').title=t('theme');$('#nav').innerHTML=Object.entries(icons).map(([id,icon])=>``).join('');document.querySelectorAll('[data-page]').forEach(b=>b.onclick=()=>navigate(b.dataset.page));$('#page-title').textContent=t(state.page);$('#device').innerHTML=(state.devices.length?state.devices:[{id:state.device,name:state.device}]).map(d=>``).join('');revealCurrentNavigation();} +function deviceNameForId(id){return deviceDisplayName(state.devices.find(d=>d.id===id)||{id,name:id});} +function operationActionLabel(action){const labels={'render':['生成预览','Generate preview'],'render-ai':['AI 生成图片','Generate AI image'],'official-widget':['更新官方订阅','Update official widget'],'delete':['删除图片','Delete image'],'display':['显示图片','Display image'],'restore':['恢复图片','Restore image'],'local-upload':['保存到图库','Save to gallery'],'reconcile':['核对状态','Reconcile']};return labels[action]?.[state.lang==='zh'?0:1]||t('operation');} +function operationClientLabel(client){return client==='admin'?tr('管理员','Administrator'):client==='scheduler'?tr('自动调度','Scheduler'):client;} +function operationErrorLabel(error){if(!error?.code)return '';const label=t(error.code);return label===error.code?t('error'):label;} +function deviceDisplayName(device){const name=device.name||device.id;return device.id==='living-room'&&['living-room','Living room'].includes(name)?t('livingRoom'):name;} +function shell(){document.documentElement.lang=state.lang==='zh'?'zh-CN':'en';$('#lang').textContent=state.lang==='zh'?'EN':'中文';$('#theme').title=t('theme');$('#nav').innerHTML=Object.entries(icons).map(([id,icon])=>``).join('');document.querySelectorAll('[data-page]').forEach(b=>b.onclick=()=>navigate(b.dataset.page));$('#page-title').textContent=t(state.page);$('#device').innerHTML=(state.devices.length?state.devices:[{id:state.device,name:state.device}]).map(d=>``).join('');revealCurrentNavigation();} // A destination appears only when its connection and read/write adapter are available. // Future cloud-drive destinations must implement their adapter before registration here. const galleryDestinationAdapters=new Set(['kokonna','local-assets']); @@ -67,13 +72,13 @@ async function blobImage(img,path){ } function hydrateImages(){const observer=new IntersectionObserver(entries=>entries.forEach(entry=>{if(entry.isIntersecting){observer.unobserve(entry.target);blobImage(entry.target,entry.target.dataset.src);}}),{rootMargin:'100px'});document.querySelectorAll('img[data-src]').forEach(img=>{imageLoading(img);observer.observe(img);});} function notice(){return state.settings?.mock?`
${t('mock')}
`:'';} -async function navigate(page=state.page){const previousPage=state.page;state.page=icons[page]?page:'dashboard';location.hash=state.page;shell();state.epoch++;const epoch=state.epoch; +async function navigate(page=state.page){document.body.classList.toggle('login-mode',!state.token);const previousPage=state.page;state.page=icons[page]?page:'dashboard';location.hash=state.page;shell();state.epoch++;const epoch=state.epoch; if(!state.token){login();return;} if(previousPage!==state.page)$('#main').replaceChildren(); const previousBlobs=state.blobs;state.blobs=[]; try{await views[state.page](epoch);for(const url of previousBlobs)URL.revokeObjectURL(url);if(epoch!==state.epoch)return;const enhance={dashboard:enhanceDashboard,templates:enhanceTemplates,settings:enhanceSettings,upload:enhanceUpload,history:enhanceHistory,automation:enhanceAutomation}[state.page];if(enhance)await enhance();guardDeviceButtons();}catch(e){if(epoch!==state.epoch)return;if(!state.token){shell();login();toast(e.message);return;}$('#main').innerHTML=`
${t('apiError')}

${esc(e.message)}

${buttons('retry','refresh')}
`;bind('retry',()=>navigate());} } -function login(){ $('#main').innerHTML=`
FRAME FLOW

${t('connect')}

${t('tokenHint')}

`;$('#login-form').onsubmit=busy(async e=>{e.preventDefault();try{const account=await api('/api/v1/auth/login',{method:'POST',body:{username:$('#username').value.trim(),password:$('#password').value,remember:$('#remember-login').checked}});state.token=account.username;state.csrf=account.csrf_token;await loadConnection();await navigate();}catch(error){forgetLogin();shell();login();toast(error.message);}});} +function login(){document.body.classList.add('login-mode');$('#page-title').textContent=t('signIn');$('#main').innerHTML=`
FRAME FLOW

${t('connect')}

${t('tokenHint')}

`;$('#login-form').onsubmit=busy(async e=>{e.preventDefault();try{const account=await api('/api/v1/auth/login',{method:'POST',body:{username:$('#username').value.trim(),password:$('#password').value,remember:$('#remember-login').checked}});state.token=account.username;state.csrf=account.csrf_token;await loadConnection();await navigate();}catch(error){forgetLogin();shell();login();toast(error.message);}});} // LAN HTTP is not a secure context; getRandomValues remains available there. function clientUUID(){if(typeof crypto.randomUUID==='function')return crypto.randomUUID();const bytes=crypto.getRandomValues(new Uint8Array(16));bytes[6]=(bytes[6]&15)|64;bytes[8]=(bytes[8]&63)|128;const hex=Array.from(bytes,b=>b.toString(16).padStart(2,'0')).join('');return `${hex.slice(0,8)}-${hex.slice(8,12)}-${hex.slice(12,16)}-${hex.slice(16,20)}-${hex.slice(20)}`;} async function accepted(path,payload){const key='web-'+clientUUID();const result=await api(path,{method:'POST',body:payload,headers:{'Idempotency-Key':key}});toast(t('accepted'));return result.operation_id;} @@ -136,13 +141,11 @@ const views={ }, 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')}

${t('templateSteps')}

${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')}

${t('templateGuideTitle')}

${t({'photo-text':'guidePhotoText','daily-dashboard':'guideDashboard',weather:'guideWeather',calendar:'guideCalendar',quote:'guideQuote','daily-poetry':'guidePoetry','system-alert':'guideAlert'}[selected.id]||'templateContentHelp')}

${['weather','daily-dashboard'].includes(selected.id)?`

${t('templateWeatherHelp')}

`:''}

${t('helpProfile')}

${t('templateActionHelp')}

${t('templateAutomationHelp')}

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

${t('preview')}

${t('templatePreviewHint')}
`;document.querySelectorAll('[data-template]').forEach(b=>{b.onclick=()=>{state.selectedTemplate=b.dataset.template;navigate(['collage','poster'].includes(b.dataset.template)?'studio':undefined);};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();});}));}, + 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(operationActionLabel(o.action))}
${esc(deviceNameForId(o.device_id))}
${t(o.status)}${o.status==='UNKNOWN'?`
`:''}
${esc(o.result?.image_id||o.result?.asset_id||operationErrorLabel(o.error)||'—')}${esc(operationClientLabel(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();});}));}, async settings(){state.settings=await api('/api/v1/settings');const s=state.settings;$('#main').innerHTML=notice()+`

${t('settings')}

${t('tokenHint')}

${buttons('logout','logout')}

${t('auth')}

${detail('version',s.version)}${detail('writeProtection',t(!s.write_enabled?'yes':'no'))}${detail('keyConfigured',t(s.api_key_configured?'yes':'no'))}${detail('timezone',s.timezone)}

${t('refreshInterval')}

${t('apiDocs')} →

GET /openapi.json · /api/v1

`;$('#theme-select').onchange=()=>{state.theme=$('#theme-select').value;localStorage.setItem('ff-theme',state.theme);applyTheme();};$('#language-select').onchange=()=>{state.lang=$('#language-select').value;localStorage.setItem('ff-language',state.lang);navigate();};bind('logout',async()=>{await api('/api/v1/auth/logout',{method:'POST'});forgetLogin();shell();login();});accountSettings();} }; function jobPayload(j,changes={}){return {name:j.name,enabled:j.enabled,device_id:j.device_id,schedule:j.schedule,source:j.source,display_duration:j.display_duration,restore_previous:j.restore_previous,priority:j.priority,cleanup_unstarred:j.cleanup_unstarred||false,...changes};} -async function jobEditor(job){const j=job||{name:'',enabled:true,device_id:state.device,schedule:{type:'daily',time:'07:00'},source:{type:'template',template:'daily-dashboard',data:{}},display_duration:5400,restore_previous:true,priority:50};showModal(`

${t(job?'edit':'newJob')}

`);const source=$('#job-source');const choose=document.createElement('div');choose.className='field';choose.innerHTML=``;source.parentElement.before(choose);$('#job-template').onchange=()=>{const id=$('#job-template').value;if(id)source.value=JSON.stringify({type:'template',template:id,data:{}},null,2);};function update(){const type=$('#job-schedule').value;$('#schedule-label').textContent=t(type==='cron'?'cron':['date','temporary'].includes(type)?'at':'time');$('#schedule-value').type=type==='cron'?'text':['date','temporary'].includes(type)?'datetime-local':'time';}$('#job-schedule').onchange=()=>{update();$('#schedule-value').value='';};update();$('#job-form').onsubmit=busy(async e=>{e.preventDefault();const type=$('#job-schedule').value,value=$('#schedule-value').value;const schedule={type};if(type==='cron')schedule.cron=value;else if(['date','temporary'].includes(type))schedule.at=value+':00+08:00';else schedule.time=value;const payload=jobPayload(j,{name:$('#job-name').value,schedule,source:JSON.parse($('#job-source').value),display_duration:Number($('#job-duration').value),priority:Number($('#job-priority').value),restore_previous:$('#job-restore').checked,cleanup_unstarred:false,enabled:$('#job-enabled').checked});await api('/api/v1/jobs'+(job?'/'+job.id:''),{method:job?'PUT':'POST',body:payload});$('#modal').close();await navigate();});await jobSourceEditor(j);} async function playlistEditor(p){p=p||{name:'',image_ids:[],interval_minutes:60,mode:'queue',enabled:false};showModal(`

${t('playlists')}

`);$('#playlist-form').onsubmit=busy(async e=>{e.preventDefault();await api('/api/v1/playlists'+(p.id?'/'+p.id:''),{method:p.id?'PUT':'POST',body:{name:$('#playlist-name').value,device_id:p.device_id||state.device,image_ids:$('#playlist-images').value.split(',').map(x=>Number(x.trim())),interval_minutes:Number($('#playlist-interval').value),mode:$('#playlist-mode').value,enabled:$('#playlist-enabled').checked}});$('#modal').close();await navigate();});await playlistImagePicker(p);} $('#lang').onclick=()=>{state.lang=state.lang==='zh'?'en':'zh';localStorage.setItem('ff-language',state.lang);navigate();};$('#theme').onclick=()=>{state.theme=document.documentElement.dataset.theme==='dark'?'light':'dark';localStorage.setItem('ff-theme',state.theme);applyTheme();};$('#device').onchange=()=>{state.device=$('#device').value;navigate();};window.addEventListener('hashchange',()=>{const page=location.hash.slice(1);if(page!==state.page)navigate(page);});matchMedia('(prefers-color-scheme:dark)').addEventListener('change',applyTheme); applyTheme();shell();localStorage.removeItem('ff-login');(async()=>{try{const account=await api('/api/v1/auth/me');state.token=account.username;state.csrf=account.csrf_token;await loadConnection();}catch{forgetLogin();}await navigate();})();api('/api/v1/version').then(v=>$('#footer').innerHTML=`Pigway Frame Flow · ${t('footer')}v${esc(v.version)} · ${esc(v.commit.slice(0,8))} · ${esc(v.build_time)}`).catch(()=>{}); @@ -153,25 +156,25 @@ async function jobEditor(job){ const base={name:'',enabled:true,device_id:state.device,schedule:{type:'daily',time:'07:00'},source:{type:'template',template:'daily-dashboard',data:{}},display_duration:5400,restore_previous:true,priority:50}; const original=job||base; const j={...base,...original,schedule:normaliseSchedule(original.schedule||base.schedule)}; - const scheduleValue=j.schedule.time||j.schedule.cron||(j.schedule.at?j.schedule.at.slice(0,16):'07:00'); - showModal(`

${t(job?'edit':'newJob')}

${t('cloudKeepLatest')}

`); + const scheduleValue=j.schedule.every||j.schedule.time||j.schedule.cron||(j.schedule.at?j.schedule.at.slice(0,16):'07:00'); + showModal(`

${t(job?.id?'edit':'newJob')}

${t('temporaryOptionsHint')}

${t('cloudKeepLatest')}

`); const source=$('#job-source'); const choose=document.createElement('div'); choose.className='field'; choose.innerHTML=``; source.parentElement.before(choose); $('#job-template').onchange=()=>{const id=$('#job-template').value;if(id)source.value=JSON.stringify({type:'template',template:id,data:{}},null,2);}; - function updateScheduleField(){const type=$('#job-schedule').value;$('#schedule-label').textContent=type==='cron'?scheduleTypeLabel(type):t(['date','temporary'].includes(type)?'at':'time');$('#schedule-value').type=type==='cron'?'text':['date','temporary'].includes(type)?'datetime-local':'time';$('#schedule-hint').textContent=scheduleHint(type);} + function updateScheduleField(){const type=$('#job-schedule').value;const interval=['hours','minutes'].includes(type);$('#schedule-value').min=interval?'1':'';$('#schedule-value').max=type==='hours'?'168':type==='minutes'?'10080':'';$('#schedule-value').step=interval?'1':'';$('#schedule-label').textContent=interval?tr('间隔数值','Interval'):type==='cron'?scheduleTypeLabel(type):t(['date','temporary'].includes(type)?'at':'time');$('#schedule-value').type=interval?'number':type==='cron'?'text':['date','temporary'].includes(type)?'datetime-local':'time';$('#schedule-hint').textContent=scheduleHint(type);} $('#job-schedule').onchange=()=>{updateScheduleField();$('#schedule-value').value='';}; updateScheduleField(); - $('#job-form').onsubmit=busy(async event=>{event.preventDefault();const type=$('#job-schedule').value;const value=$('#schedule-value').value;const schedule={type};if(type==='cron')schedule.cron=value;else if(['date','temporary'].includes(type))schedule.at=value+':00+08:00';else schedule.time=value;const payload=jobPayload(j,{name:$('#job-name').value,schedule,source:JSON.parse(source.value),display_duration:Number($('#job-duration').value),priority:Number($('#job-priority').value),restore_previous:$('#job-restore').checked,cleanup_unstarred:false,enabled:$('#job-enabled').checked});await api('/api/v1/jobs'+(job?'/'+job.id:''),{method:job?'PUT':'POST',body:payload});$('#modal').close();await navigate();}); + $('#job-form').onsubmit=busy(async event=>{event.preventDefault();const type=$('#job-schedule').value;const value=$('#schedule-value').value;const schedule=['hours','minutes'].includes(type)?{type:'interval',interval_minutes:Number(value)*(type==='hours'?60:1)}:{type};if(['hours','minutes'].includes(type)){}else if(type==='cron')schedule.cron=value;else if(['date','temporary'].includes(type))schedule.at=value+':00+08:00';else schedule.time=value;const payload=jobPayload(j,{name:$('#job-name').value,schedule,source:JSON.parse(source.value),display_duration:Number($('#job-duration').value),priority:Number($('#job-priority').value),restore_previous:$('#job-restore').checked,cleanup_unstarred:false,enabled:$('#job-enabled').checked});await api('/api/v1/jobs'+(job?.id?'/'+job.id:''),{method:job?.id?'PUT':'POST',body:payload});$('#modal').close();await navigate();}); await jobSourceEditor(j); } views.automation=async 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(key=>``).join('')}${jobs.map((job,index)=>``).join('')}
${t(key)}
${esc(job.name)}
${esc(job.device_id)}
${esc(scheduleSummary(job.schedule))}${t(job.enabled?'enabled':'disabled')}${fmt(job.next_run)}${fmt(job.last_run)}${esc(job.last_result)}
`:`
${t('noTasks')}
`}
`; + $('#main').innerHTML=notice()+`

${state.lang==='zh'?'上海时间':'Shanghai time'} · ${t('automation')}

${buttons('new-job','newJob',true)}
${jobs.length?`
${['name','schedule','status','nextRun','lastRun','result','action'].map(key=>``).join('')}${jobs.map((job,index)=>``).join('')}
${t(key)}
${esc(job.name)}
${esc(deviceDisplayName(state.devices.find(d=>d.id===job.device_id)||{id:job.device_id,name:job.device_id}))}
${esc(scheduleSummary(job.schedule))}${t(job.enabled?'enabled':'disabled')}${fmt(job.next_run)}${fmt(job.last_run)}${esc(job.last_result)}
`:`
${t('noTasks')}
`}
`; bind('new-job',()=>jobEditor()); document.querySelectorAll('[data-job-edit]').forEach(button=>button.onclick=()=>jobEditor(jobs[button.dataset.jobEdit])); document.querySelectorAll('[data-job-toggle]').forEach(button=>button.onclick=busy(async()=>{const job=jobs[button.dataset.jobToggle];await api('/api/v1/jobs/'+job.id,{method:'PUT',body:jobPayload(job,{enabled:!job.enabled})});await navigate();})); diff --git a/pigway_frame_flow/web/extensions.js b/pigway_frame_flow/web/extensions.js index 3776e24..9167fb5 100644 --- a/pigway_frame_flow/web/extensions.js +++ b/pigway_frame_flow/web/extensions.js @@ -49,7 +49,7 @@ async function officialWidgetControls(info,data){ const send=cover_id=>confirmDialog(tr('此操作会改变官方订阅和画框内容,是否继续?','This changes the official subscription and frame content. Continue?'),async()=>{const args=cover_id?Object.fromEntries([...document.querySelectorAll('[data-widget-arg]')].filter(x=>x.value).map(x=>[x.dataset.widgetArg,x.value])):{};const r=await api('/api/v1/devices/'+state.device+'/widgets/selection',{method:'POST',body:{cover_id,args},headers:{'Idempotency-Key':'widget-'+clientUUID()}});await waitOperation(r.operation_id);await navigate('templates');});if(!disabled){$('#official-widget-use').onclick=busy(()=>send(Number($('#official-widget-choice').value)));$('#official-widget-cancel').onclick=busy(()=>send(null));} } function addPhotoQualityFields(source){ - const box=document.createElement('section');box.innerHTML=`

${tr('照片筛选','Photo filtering')}

${tr('按小图清晰度、亮度和相似度筛选,不改动或删除 NAS 原照片;无法保证识别闭眼。建议先用低阈值。本地目录来源仍需挂载 /photos;Synology Photos API 来源通过网络读取,无需文件挂载。','Filters thumbnail sharpness, brightness and similarity without modifying or deleting originals. Closed-eye detection is not guaranteed. Start with low thresholds. Local-folder sources still require /photos; Synology Photos API sources read over the network without file mounts.')}

`;$('#photo-source-form .modal-actions').before(box);$('#source-people-space').value=source?.people_space||'shared'; + const box=document.createElement('section');box.innerHTML=`

${tr('照片筛选','Photo filtering')}

${tr('对 API 获取的预览图按清晰度、亮度和相似度筛选,不修改或删除 NAS 原照片;无法保证识别闭眼。建议先使用较低阈值。','Filters API previews by sharpness, brightness and similarity without modifying or deleting NAS originals. Closed-eye detection is not guaranteed. Start with low thresholds.')}

`;$('#photo-source-form .modal-actions').before(box);$('#source-people-space').value=source?.people_space||'shared'; } async function aiImagePanel(){ diff --git a/pigway_frame_flow/web/features.js b/pigway_frame_flow/web/features.js index 699db78..0f4b291 100644 --- a/pigway_frame_flow/web/features.js +++ b/pigway_frame_flow/web/features.js @@ -45,7 +45,7 @@ async function weatherSettings(){ result(weather);$('#weather-form').onsubmit=busy(async e=>{e.preventDefault();await api('/api/v1/settings/weather',{method:'PUT',body:{provider:$('#weather-provider').value,location:$('#weather-location').value,latitude:Number($('#weather-latitude').value),longitude:Number($('#weather-longitude').value)}});result(await api('/api/v1/providers/weather'));toast(t('done'));}); } async function enhanceHistory(){ - const table=document.querySelector('#main table');if(!table)return;table.classList.add('history-table');const heading=document.createElement('th');heading.textContent=t('operation');table.querySelector('thead tr').append(heading);const rows=[...table.querySelectorAll('tbody tr')];const ops=await api('/api/v1/operations');rows.forEach((row,i)=>{const op=ops[i];if(!op)return;const button=document.createElement('button');button.textContent=t('details');const actions=document.createElement('td');actions.className='history-actions';actions.append(button);row.append(actions);button.onclick=busy(async()=>{const current=await api('/api/v1/operations/'+op.operation_id);showModal(`

${t('details')}

${t(current.status)} · ${esc(current.action)}

${esc(current.error?.message||'')}

${esc(JSON.stringify({operation_id:current.operation_id,request_id:current.request_id,created_at:current.created_at,updated_at:current.updated_at,result:current.result},null,2))}
${current.result?.url?'Preview':''}`);if(current.result?.url)await blobImage($('#operation-preview'),current.result.url);});});} + const table=document.querySelector('#main table');if(!table)return;table.classList.add('history-table');const heading=document.createElement('th');heading.textContent=t('operation');table.querySelector('thead tr').append(heading);const rows=[...table.querySelectorAll('tbody tr')];const ops=await api('/api/v1/operations');rows.forEach((row,i)=>{const op=ops[i];if(!op)return;const button=document.createElement('button');button.textContent=t('details');const actions=document.createElement('td');actions.className='history-actions';actions.append(button);row.append(actions);button.onclick=busy(async()=>{const current=await api('/api/v1/operations/'+op.operation_id);showModal(`

${t('details')}

${t(current.status)} · ${esc(operationActionLabel(current.action))}

${esc(operationErrorLabel(current.error)||current.error?.message||'')}

${esc(JSON.stringify({action:current.action,device_id:current.device_id,error_code:current.error?.code,operation_id:current.operation_id,request_id:current.request_id,created_at:current.created_at,updated_at:current.updated_at,result:current.result},null,2))}
${current.result?.url?'Preview':''}`);if(current.result?.url)await blobImage($('#operation-preview'),current.result.url);});});} async function enhanceAutomation(){ const jobs=await api('/api/v1/jobs');const rows=[...document.querySelectorAll('tbody tr')];rows.forEach((row,i)=>{const job=jobs[i];if(!job)return;const actions=row.querySelector('.row-actions');const run=document.createElement('button');run.textContent=t('runNow');run.disabled=!liveWriteAllowed();run.title=run.disabled?t('readOnlyWarning'):'';actions.append(run);run.onclick=()=>confirmDialog(t('runConfirm'),async()=>{await accepted('/api/v1/jobs/'+job.id+'/run',{});await navigate('history');});if(['template','auto_collage'].includes(job.source.type)){const preview=document.createElement('button');preview.textContent=t('jobPreview');actions.append(preview);preview.onclick=busy(async()=>{const id=await accepted('/api/v1/jobs/'+job.id+'/preview',{});const op=await waitOperation(id);showModal(`

${t('jobPreview')}

Preview`);await blobImage($('#job-preview-image'),op.result.url);});}}); } @@ -140,7 +140,7 @@ function photosLoginDialog(){ async function photoSourcesPanel(){ const section=document.createElement('section');section.className='card photo-sources-panel';$('#main').append(section); const sources=await api('/api/v1/photo-sources');if(!section.isConnected)return; - section.innerHTML=`

${t('photoSources')}

${t('sourceHint')}

${buttons('add-photo-source','addSource')}
${sources.length?sources.map(s=>`

${esc(s.name)}

${t('sourceIdLabel')}: ${esc(s.id)} · ${t('sourceLocation')}: ${esc(s.location)}

${s.kind==='synology-photos'?t('sourcePhotosAPI'):s.people_filter?t('sourceSharedPhotos'):s.kind==='local'?t('sourceLocalOnly'):t('sourceOwnCredentials')}

${esc(s.kind)} · ${t(s.enabled?'enabled':'disabled')}${s.people_filter?' · '+t('peopleFilter'):''}${s.orientation?' · '+t({auto:'orientationAuto',landscape:'orientationLandscape',portrait:'orientationPortrait'}[s.orientation]):''}

`).join(''):`

${t('noSources')}

`}
`; + section.innerHTML=`

${t('photoSources')}

${t('sourceHint')}

${buttons('add-photo-source','addSource')}
${sources.length?sources.map(s=>`

${esc(s.name)}

${t('sourceIdLabel')}: ${esc(s.id)} · ${t('sourceLocation')}: ${esc(s.location)}

${s.kind==='synology-photos'?t('sourcePhotosAPI'):t('sourceOwnCredentials')}

${esc(s.kind==='synology-photos'?'Synology Photos':s.kind)} · ${t(s.enabled?'enabled':'disabled')}${s.people_filter?' · '+t('peopleFilter'):''}${s.orientation?' · '+t({auto:'orientationAuto',landscape:'orientationLandscape',portrait:'orientationPortrait'}[s.orientation]):''}

`).join(''):`

${t('noSources')}

`}
`; await photosConnectionPanel(); bind('add-photo-source',()=>photoSourceForm()); section.querySelectorAll('[data-source-edit]').forEach(b=>b.onclick=()=>photoSourceForm(sources.find(s=>s.id===b.dataset.sourceEdit))); @@ -150,9 +150,9 @@ async function photoSourcesPanel(){ section.querySelectorAll('[data-source-job]').forEach(b=>b.onclick=()=>sourceJobForm(b.dataset.sourceJob)); } async function photoSourceForm(source=null){ - showModal(`

${t(source?'edit':'addSource')}

${t('sourceIndependentHelp')}

${t('sourceLocationHint')}

${t('sourceCredentialHint')}

${t('orientationHint')}

${t('folderCaptionHint')}

${t('peopleHint')}

${t('peopleSelected')}${(source?.person_ids||[]).length}

`); - $('#source-orientation').value=source?(source.orientation||''):'auto';const selectedPeople=new Set(source?.person_ids||[]);$('#source-person-match').value=source?.person_match||'any';$('#source-people-load').onclick=busy(async()=>{await photosPeoplePicker(selectedPeople);});$('#source-kind').value=source?.kind||'local';const update=()=>{const dav=!['local','synology-photos'].includes($('#source-kind').value);$('#source-orientation').disabled=dav;$('#source-credential').disabled=!dav;$('#source-credential').closest('.field').hidden=$('#source-kind').value!=='webdav';const oauth=$('#source-oauth');if(oauth)oauth.closest('.field').hidden=!['google-drive','alipan','baidu'].includes($('#source-kind').value);const quality=$('#source-local-quality');if(quality)quality.hidden=dav;$('#source-recursive').disabled=$('#source-kind').value==='webdav';$('#source-synology-previews').disabled=$('#source-kind').value!=='local';$('#source-synology-previews').closest('.check-row').hidden=$('#source-kind').value==='synology-photos';$('#source-organized').disabled=dav;$('#source-caption').disabled=dav;$('#source-people-filter').disabled=dav;$('#source-people-load').disabled=dav;};addPhotoQualityFields(source);update();$('#source-kind').onchange=update;$('#source-people-space').onchange=()=>{selectedPeople.clear();$('#source-people-panel').innerHTML='';}; - $('#photo-source-form').onsubmit=busy(async e=>{e.preventDefault();const oauth=$('#source-oauth').value;const sourceId=$('#source-id').value;const native=['google-drive','alipan','baidu'].includes($('#source-kind').value);if(oauth&&native)JSON.parse(oauth);const dav=!['local','synology-photos'].includes($('#source-kind').value);await api('/api/v1/photo-sources/'+$('#source-id').value,{method:'PUT',body:{name:$('#source-name').value,kind:$('#source-kind').value,location:$('#source-location').value,recursive:$('#source-kind').value!=='webdav'&&$('#source-recursive').checked,synology_previews:$('#source-kind').value==='local'&&$('#source-synology-previews').checked,catalog_limit:$('#source-organized').checked?100000:(source?.catalog_limit||10000),organized_only:!dav&&$('#source-organized').checked,folder_caption:!dav&&$('#source-caption').checked,orientation:dav?null:($('#source-orientation').value||null),people_filter:!dav&&$('#source-people-filter').checked,person_ids:[...selectedPeople],person_match:$('#source-person-match').value,people_space:$('#source-people-space').value,minimum_sharpness:Number($('#source-sharpness').value),minimum_brightness:Number($('#source-bright-min').value),maximum_brightness:Number($('#source-bright-max').value),avoid_duplicates:$('#source-dedup').checked,enabled:$('#source-enabled').checked,credential_env:dav?($('#source-credential').value||null):null}});if(oauth&&native){$('#source-oauth').value='';await api('/api/v1/photo-sources/'+sourceId+'/credentials',{method:'PUT',body:{key:oauth}});}$('#modal').close();await navigate('photo-sources');}); + showModal(`

${t(source?'edit':'addSource')}

${t('sourceIndependentHelp')}

${t('sourceLocationHint')}

${t('sourceCredentialHint')}

${t('orientationHint')}

${t('folderCaptionHint')}

${t('peopleHint')}

${t('peopleSelected')}${(source?.person_ids||[]).length}

`); + $('#source-orientation').value=source?(source.orientation||''):'auto';const selectedPeople=new Set(source?.person_ids||[]);$('#source-person-match').value=source?.person_match||'any';$('#source-people-load').onclick=busy(async()=>{await photosPeoplePicker(selectedPeople);});$('#source-kind').value=source?.kind||'synology-photos';const update=()=>{const photosAPI=$('#source-kind').value==='synology-photos';$('#source-location').labels[0].textContent=photosAPI?tr('照片目录','Photo folder'):t('sourceLocation');$('#source-location').nextElementSibling.textContent=photosAPI?tr('填写所选空间内的目录路径,. 表示整个空间。按需获取 XL 预览图,不下载原图。','Folder path within the selected space; . means the whole space. Downloads XL previews on demand, not originals.'):t('sourceLocationHint');const dav=!['local','synology-photos'].includes($('#source-kind').value);$('#source-orientation').disabled=dav;$('#source-credential').disabled=!dav;$('#source-credential').closest('.field').hidden=$('#source-kind').value!=='webdav';const oauth=$('#source-oauth');if(oauth)oauth.closest('.field').hidden=!['google-drive','alipan','baidu'].includes($('#source-kind').value);const quality=$('#source-local-quality');if(quality)quality.hidden=dav;$('#source-recursive').disabled=$('#source-kind').value==='webdav';$('#source-synology-previews').disabled=$('#source-kind').value!=='local';$('#source-organized').disabled=dav;$('#source-caption').disabled=dav;$('#source-people-filter').disabled=dav;$('#source-people-load').disabled=dav;};addPhotoQualityFields(source);$('#source-location').closest('.field').before($('#source-people-space').closest('.field'));update();$('#source-kind').onchange=update;$('#source-people-space').onchange=()=>{selectedPeople.clear();$('#source-people-panel').innerHTML='';}; + $('#photo-source-form').onsubmit=busy(async e=>{e.preventDefault();const oauth=$('#source-oauth').value;const sourceId=$('#source-id').value;const native=['google-drive','alipan','baidu'].includes($('#source-kind').value);if(oauth&&native)JSON.parse(oauth);const dav=!['local','synology-photos'].includes($('#source-kind').value);await api('/api/v1/photo-sources/'+$('#source-id').value,{method:'PUT',body:{name:$('#source-name').value,kind:$('#source-kind').value,location:$('#source-location').value,recursive:$('#source-kind').value!=='webdav'&&$('#source-recursive').checked,synology_previews:false,catalog_limit:$('#source-organized').checked?100000:(source?.catalog_limit||10000),organized_only:!dav&&$('#source-organized').checked,folder_caption:!dav&&$('#source-caption').checked,orientation:dav?null:($('#source-orientation').value||null),people_filter:!dav&&$('#source-people-filter').checked,person_ids:[...selectedPeople],person_match:$('#source-person-match').value,people_space:$('#source-people-space').value,minimum_sharpness:Number($('#source-sharpness').value),minimum_brightness:Number($('#source-bright-min').value),maximum_brightness:Number($('#source-bright-max').value),avoid_duplicates:$('#source-dedup').checked,enabled:$('#source-enabled').checked,credential_env:dav?($('#source-credential').value||null):null}});if(oauth&&native){$('#source-oauth').value='';await api('/api/v1/photo-sources/'+sourceId+'/credentials',{method:'PUT',body:{key:oauth}});}$('#modal').close();await navigate('photo-sources');}); } async function browseSource(id,offset=0,refresh=false){ const result=await api('/api/v1/photo-sources/'+id+'/images?device_id='+encodeURIComponent(state.device)+'&offset='+offset+'&limit=50&refresh='+refresh);const box=$('#source-browser');if(!box)return; @@ -163,6 +163,6 @@ async function previewSource(id,item_id=null){ const acceptedResult=await api('/api/v1/devices/'+state.device+'/upload',{method:'POST',body:{type:'photo_source',source_id:id,item_id,fit:'contain',profile:'color'},headers:{'Idempotency-Key':clientUUID()}});const op=await waitOperation(acceptedResult.operation_id); showModal(`

${t('preview')}

${t('preview')}

${t('sourcePreviewHint')}

`);await blobImage($('#source-preview'),op.result.url);$('#source-display').disabled=!liveWriteAllowed();bind('source-display',()=>displayDialog({type:'image',asset_id:op.result.asset_id})); } -function sourceJobForm(id){ - showModal(`

${t('sourceJob')}

${t('sourceJobHint')}

${t('cloudKeepLatest')}

`);$('#source-job-form').onsubmit=busy(async e=>{e.preventDefault();await api('/api/v1/jobs',{method:'POST',body:{name:$('#source-job-name').value,device_id:state.device,enabled:$('#source-job-enabled').checked,cleanup_unstarred:false,schedule:{type:'cron',cron:$('#source-job-cron').value},source:{type:'photo_source',source_id:id,fit:'contain',profile:'color'}}});$('#modal').close();await navigate('automation');}); +async function sourceJobForm(id){ + await jobEditor({name:t('randomPhoto'),device_id:state.device,enabled:false,schedule:{type:'interval',interval_minutes:120},source:{type:'photo_source',source_id:id,fit:'contain',profile:'color'},display_duration:0,restore_previous:false,priority:50}); } diff --git a/pigway_frame_flow/web/i18n.js b/pigway_frame_flow/web/i18n.js index 3484e47..1204642 100644 --- a/pigway_frame_flow/web/i18n.js +++ b/pigway_frame_flow/web/i18n.js @@ -17,16 +17,16 @@ Object.assign(window.MESSAGES.zh,{poetryNotice:'Frame Flow 每日诗词:内置 Object.assign(MESSAGES.zh,{officialContentTitle:'KoKonna 官方小组件与订阅',officialContentDescription:'官方产品资料列出的内容类型;下列功能与 Frame Flow 自建模板分开管理。',officialCalendarWeather:'日历与当地天气',officialWordAlmanacTravel:'每日一词、传统黄历、全球旅行推荐',officialNews:'每日新闻',officialCreative:'AI 语音 / 文字 / 涂鸦 / 照片转艺术;照片轮播',officialSubscriptionUnavailable:'公开 OpenAPI 尚未列出订阅目录、当前订阅、开通或取消订阅接口。官方订阅请继续在官方小程序中管理;Frame Flow 不会读取或修改这些设置。你使用的每日诗词属于官方小程序内容,当前无法通过公开 API 确认完整类型列表。',officialProductDocs:'官方内容介绍 ↗',officialAPIDocs:'官方 OpenAPI ↗'}); Object.assign(MESSAGES.en,{officialContentTitle:'KoKonna official widgets and subscriptions',officialContentDescription:'Content types listed in official product materials; managed separately from Frame Flow templates.',officialCalendarWeather:'Calendar and local weather',officialWordAlmanacTravel:'Word of the day, traditional almanac and global travel recommendations',officialNews:'Daily news',officialCreative:'AI voice / text / sketch / photo-to-art; photo rotation',officialSubscriptionUnavailable:'Public OpenAPI does not list subscription discovery, current subscription, subscribe or unsubscribe endpoints. Manage official subscriptions in the official mini-program. Frame Flow cannot read or modify these settings. Your daily-poetry subscription is mini-program content; its complete catalog cannot currently be verified through public API.',officialProductDocs:'Official content overview ↗',officialAPIDocs:'Official OpenAPI ↗'}); -Object.assign(MESSAGES.zh,{photoSources:'照片数据源',sourceHint:'从只读 NAS 目录或 WebDAV 选图;预览不会改变画框。',addSource:'添加数据源',browse:'浏览照片',randomPreview:'随机选图预览',sourceJob:'定时随机照片',noSources:'暂无照片,请添加数据源或检查目录。',sourceKind:'数据源类型',nasDirectory:'NAS 本地目录',sourceLocation:'目录 / WebDAV URL',sourceLocationHint:'本地填写挂载照片根目录下的相对路径,如 . 或 family。WebDAV 填写 HTTPS 目录 URL,并先在设置中允许该域名。',sourceCredential:'WebDAV 凭据环境变量名',sourceCredentialHint:'仅填写变量名,禁止填写密码。管理员通过环境变量或 Secret 配置 JSON username/password。',sourceRecursive:'包含子目录(本地目录)',sourceImages:'可用照片',sourceBrowseHint:'按需读取目录,点击文件名才生成预览;不会下载全部原图。',previousPage:'上一页',nextPage:'下一页',sourcePreviewHint:'这是已选中照片的预览,显示时使用同一张照片。',randomPhoto:'随机照片',sourceJobHint:'每次执行重新随机选图。默认暂停;启用后会按时改变画框显示。'}); -Object.assign(MESSAGES.en,{photoSources:'Photo sources',sourceHint:'Choose photos from a read-only NAS folder or WebDAV. Preview does not change the frame.',addSource:'Add source',browse:'Browse photos',randomPreview:'Preview random photo',sourceJob:'Schedule random photos',noSources:'No photos yet. Add a source or check the folder.',sourceKind:'Source type',nasDirectory:'NAS local folder',sourceLocation:'Folder / WebDAV URL',sourceLocationHint:'Local: relative folder under the mounted photo root, e.g. . or family. WebDAV: HTTPS directory URL; allow its hostname in Settings first.',sourceCredential:'WebDAV credential environment name',sourceCredentialHint:'Enter a variable name, never a password. Admin configures JSON username/password via environment or Secret.',sourceRecursive:'Include subfolders (local)',sourceImages:'Available photos',sourceBrowseHint:'Catalog loaded on demand. Click a filename to preview; originals are not downloaded in bulk.',previousPage:'Previous page',nextPage:'Next page',sourcePreviewHint:'Display uses this exact previewed photo.',randomPhoto:'Random photo',sourceJobHint:'Choose a fresh random photo each run. Paused by default; enabling changes the frame on schedule.'}); +Object.assign(MESSAGES.zh,{photoSources:'照片数据源',sourceHint:'通过 Synology Photos API 管理照片来源、人物和筛选规则。浏览与预览不会改变画框。',addSource:'添加数据源',browse:'浏览照片',randomPreview:'随机选图预览',sourceJob:'定时随机照片',noSources:'暂无照片,请添加数据源或检查目录。',sourceKind:'数据源类型',sourceLocation:'目录 / WebDAV URL',sourceLocationHint:'Synology Photos:所选空间内的目录路径,. 表示整个空间。按需获取 XL 预览图,不下载原图。其他云盘使用对应目录地址或 ID。',sourceCredential:'WebDAV 凭据环境变量名',sourceCredentialHint:'仅填写变量名,禁止填写密码。管理员通过环境变量或 Secret 配置 JSON username/password。',sourceRecursive:'包含子目录',sourceImages:'可用照片',sourceBrowseHint:'按需读取目录,点击文件名才生成预览;不会下载全部原图。',previousPage:'上一页',nextPage:'下一页',sourcePreviewHint:'这是已选中照片的预览,显示时使用同一张照片。',randomPhoto:'随机照片',sourceJobHint:'每次执行重新随机选图。默认暂停;启用后会按时改变画框显示。'}); +Object.assign(MESSAGES.en,{photoSources:'Photo sources',sourceHint:'Manage photo sources, people and filters through the Synology Photos API. Browsing and previewing do not change the frame.',addSource:'Add source',browse:'Browse photos',randomPreview:'Preview random photo',sourceJob:'Schedule random photos',noSources:'No photos yet. Add a source or check the folder.',sourceKind:'Source type',sourceLocation:'Folder / WebDAV URL',sourceLocationHint:'Synology Photos: folder path within the selected space; . means the whole space. Downloads XL previews on demand, not originals. Other drives use their folder URL or ID.',sourceCredential:'WebDAV credential environment name',sourceCredentialHint:'Enter a variable name, never a password. Admin configures JSON username/password via environment or Secret.',sourceRecursive:'Include subfolders',sourceImages:'Available photos',sourceBrowseHint:'Catalog loaded on demand. Click a filename to preview; originals are not downloaded in bulk.',previousPage:'Previous page',nextPage:'Next page',sourcePreviewHint:'Display uses this exact previewed photo.',randomPhoto:'Random photo',sourceJobHint:'Choose a fresh random photo each run. Paused by default; enabling changes the frame on schedule.'}); Object.assign(MESSAGES.zh,{organizedPhotos:'仅日期开头文件夹中的照片',folderCaption:'照片下方显示文件夹文字',folderCaptionHint:'第一行:去掉日期和地点后的主题;第二行:日期;第三行:@ 后的地点(没有地点则省略)。子目录沿用上级日期文件夹。'}); Object.assign(MESSAGES.en,{organizedPhotos:'Only photos in date-prefixed folders',folderCaption:'Show folder caption below the photo',folderCaptionHint:'Line 1: title without date/place. Line 2: date. Line 3: place after @, omitted when absent. Subfolders inherit their nearest dated folder.'}); -Object.assign(MESSAGES.zh,{synologyPreviews:'超大图片使用 Synology Photos 的现有预览图'});Object.assign(MESSAGES.en,{synologyPreviews:'Use existing Synology Photos previews for oversized images'}); +Object.assign(MESSAGES.zh,{});Object.assign(MESSAGES.en,{}); -Object.assign(MESSAGES.zh,{peopleFilter:'按 Synology Photos 人物筛选',peopleHint:'只从当前目录中选择符合人物条件的照片。所选空间的人物相册需要当前 Photos 账号有权访问。',choosePeople:'连接 / 选择人物',personMatch:'人物匹配方式',personAny:'包含任意所选人物',personAll:'同时包含所有所选人物',photosConfigure:'管理员需配置 SYNOLOGY_PHOTOS_URL 和 SYNOLOGY_PHOTOS_CERT_SHA256,并将共享 photo 目录挂载为照片根目录。',photosSessionHint:'仅保存受保护的登录会话,不保存 NAS 密码。会话失效后需要重新连接。',photosConnect:'连接 Synology Photos',photosDisconnect:'断开连接',peopleSearch:'搜索人物名称或 ID',peopleUnnamed:'显示未命名人物分组',peopleSelected:'已选择:'}); -Object.assign(MESSAGES.en,{peopleFilter:'Filter by Synology Photos people',peopleHint:'Choose matching people photos only within this folder. The Photos account needs access to People albums in the selected space.',choosePeople:'Connect / choose people',personMatch:'Person matching',personAny:'Any selected person',personAll:'All selected people together',photosConfigure:'Admin must configure SYNOLOGY_PHOTOS_URL and SYNOLOGY_PHOTOS_CERT_SHA256, and mount the shared photo folder as the photo root.',photosSessionHint:'Only a protected login session is stored, never your NAS password. Reconnect when the session expires.',photosConnect:'Connect Synology Photos',photosDisconnect:'Disconnect',peopleSearch:'Search person name or ID',peopleUnnamed:'Show unnamed person groups',peopleSelected:'Selected: '}); +Object.assign(MESSAGES.zh,{peopleFilter:'按 Synology Photos 人物筛选',peopleHint:'只从当前目录中选择符合人物条件的照片。所选空间的人物相册需要当前 Photos 账号有权访问。',choosePeople:'连接 / 选择人物',personMatch:'人物匹配方式',personAny:'包含任意所选人物',personAll:'同时包含所有所选人物',photosConfigure:'管理员需配置 SYNOLOGY_PHOTOS_URL 和 SYNOLOGY_PHOTOS_CERT_SHA256。照片和人物均通过 API 读取。',photosSessionHint:'仅保存受保护的登录会话,不保存 NAS 密码。会话失效后需要重新连接。',photosConnect:'连接 Synology Photos',photosDisconnect:'断开连接',peopleSearch:'搜索人物名称或 ID',peopleUnnamed:'显示未命名人物分组',peopleSelected:'已选择:'}); +Object.assign(MESSAGES.en,{peopleFilter:'Filter by Synology Photos people',peopleHint:'Choose matching people photos only within this folder. The Photos account needs access to People albums in the selected space.',choosePeople:'Connect / choose people',personMatch:'Person matching',personAny:'Any selected person',personAll:'All selected people together',photosConfigure:'Configure SYNOLOGY_PHOTOS_URL and SYNOLOGY_PHOTOS_CERT_SHA256. Photos and people are read through the API.',photosSessionHint:'Only a protected login session is stored, never your NAS password. Reconnect when the session expires.',photosConnect:'Connect Synology Photos',photosDisconnect:'Disconnect',peopleSearch:'Search person name or ID',peopleUnnamed:'Show unnamed person groups',peopleSelected:'Selected: '}); Object.assign(MESSAGES.zh,{'photo-sources':'照片数据源'}); Object.assign(MESSAGES.en,{'photo-sources':'Photo sources'}); @@ -117,22 +117,22 @@ Object.assign(window.MESSAGES.en,{ Object.assign(window.MESSAGES.zh,{studio:'拼版与海报',connections:'连接中心'}); Object.assign(window.MESSAGES.en,{studio:'Layout Studio',connections:'Connections'}); -Object.assign(MESSAGES.zh,{photosConnectionHelp:'NAS 照片登录与文件夹设置分开管理。人物筛选和 Photos 预览需要保持连接。',photosConnected:'已连接',photosDisconnected:'未连接',photosReconnect:'重新登录',photosReconnectHelp:'尚未登录或会话已失效。依赖 Synology Photos 的自动化会失败,请先登录 NAS 账号。无需填写配置变量。',photosConnectedHelp:'连接已保存。相关自动化将在下次计划时间使用照片,不会立即切换画框。',photosSetupHelp:'服务尚未配置 Photos 地址和证书。请管理员完成部署设置后再登录;这里不需要填写网盘变量。',photosLoginHelp:'使用登录群晖 NAS / Synology Photos 的账号密码,不是 Frame Flow 账号,也不是 KoKonna Key。',nasUsername:'NAS 用户名',nasPassword:'NAS 密码'}); -Object.assign(MESSAGES.en,{photosConnectionHelp:'Manage NAS sign-in separately from photo folders. People filtering and Photos previews require a connection.',photosConnected:'Connected',photosDisconnected:'Not connected',photosReconnect:'Sign in again',photosReconnectHelp:'Not signed in or the session expired. Automation using Synology Photos will fail until you sign in. No configuration variable is required here.',photosConnectedHelp:'Connection saved. Automation will use photos at its next scheduled time; the frame is not changed now.',photosSetupHelp:'The Photos address and certificate are not configured. Ask the administrator to finish deployment setup. Drive variables are not needed here.',photosLoginHelp:'Use your Synology NAS / Photos account, not your Frame Flow account or KoKonna key.',nasUsername:'NAS username',nasPassword:'NAS password'}); -Object.assign(MESSAGES.zh,{sourceHint:'管理 NAS 照片目录、WebDAV 和网盘来源。浏览与生成预览不会改变画框。'}); -Object.assign(MESSAGES.en,{sourceHint:'Manage NAS folders, WebDAV and cloud drives. Browsing and generating previews do not change the frame.'}); +Object.assign(MESSAGES.zh,{photosConnectionHelp:'此账号通过 Synology Photos API 授权读取照片和人物。连接成功后,请添加照片来源并设置空间和人物;更换账号会影响所有 Photos 来源。',photosConnected:'已连接',photosDisconnected:'未连接',photosReconnect:'重新登录',photosReconnectHelp:'尚未登录或会话已失效。依赖 Synology Photos 的自动化会失败,请先登录 NAS 账号。无需填写配置变量。',photosConnectedHelp:'连接已保存。相关自动化将在下次计划时间使用照片,不会立即切换画框。',photosSetupHelp:'服务尚未配置 Photos 地址和证书。请管理员完成部署设置后再登录;这里不需要填写网盘变量。',photosLoginHelp:'使用登录群晖 NAS / Synology Photos 的账号密码,不是 Frame Flow 账号,也不是 KoKonna Key。',nasUsername:'NAS 用户名',nasPassword:'NAS 密码'}); +Object.assign(MESSAGES.en,{photosConnectionHelp:'This account authorizes photo and people access through the Synology Photos API. After connecting, add a photo source and choose its space and people. Changing accounts affects all Photos sources.',photosConnected:'Connected',photosDisconnected:'Not connected',photosReconnect:'Sign in again',photosReconnectHelp:'Not signed in or the session expired. Automation using Synology Photos will fail until you sign in. No configuration variable is required here.',photosConnectedHelp:'Connection saved. Automation will use photos at its next scheduled time; the frame is not changed now.',photosSetupHelp:'The Photos address and certificate are not configured. Ask the administrator to finish deployment setup. Drive variables are not needed here.',photosLoginHelp:'Use your Synology NAS / Photos account, not your Frame Flow account or KoKonna key.',nasUsername:'NAS username',nasPassword:'NAS password'}); +Object.assign(MESSAGES.zh,{sourceHint:'通过 Synology Photos API 管理照片来源、人物和筛选规则。浏览与预览不会改变画框。'}); +Object.assign(MESSAGES.en,{sourceHint:'Manage photo sources, people and filters through the Synology Photos API. Browsing and previewing do not change the frame.'}); Object.assign(MESSAGES.zh,{choosePeople:'选择人物'}); Object.assign(MESSAGES.en,{choosePeople:'Choose people'}); -Object.assign(MESSAGES.zh,{photosSharedConnection:'共享连接',photosConnectionHelp:'当前仅支持一台 NAS、一个 Photos 登录账号。下方文件夹分别保存设置;此登录不是任何一张数据源卡片的专属连接。人物筛选使用此共享连接,普通目录读取和本地缩略图不依赖此账号。',photosLoginHelp:'使用群晖 NAS / Photos 账号,不是 Frame Flow 账号或 KoKonna Key。更换账号会影响所有启用人物筛选的数据源;个人空间的人物可能不同,更换后请逐个重新核对人物选择。',sourceIndependentHelp:'这里只保存当前数据源的目录、人物与筛选规则,不会修改其他数据源,也不会更换顶部共享 NAS 账号。任务通过数据源 ID 引用这些设置。',sourceIdLabel:'数据源 ID',sourceSharedPhotos:'关联:顶部 Synology Photos 共享连接(人物筛选)',sourceLocalOnly:'关联:NAS 挂载目录;读取文件无需顶部 Photos 登录',sourceOwnCredentials:'关联:此数据源自己的网盘/WebDAV 凭据;不使用顶部 NAS 登录'}); -Object.assign(MESSAGES.en,{photosSharedConnection:'Shared connection',photosConnectionHelp:'Currently one NAS and one Photos account are supported. Each folder stores its own settings; this sign-in is not owned by any source card. People filtering uses this shared session. Ordinary folder reads and local thumbnails do not use this account.',photosLoginHelp:'Use a Synology NAS / Photos account, not a Frame Flow account or KoKonna key. Changing accounts affects all people-filtered sources. Personal-space people may differ; recheck each source after switching accounts.',sourceIndependentHelp:'These folder, people and filter settings apply only to this source. Other sources and the shared NAS account are unchanged. Jobs refer to these settings by source ID.',sourceIdLabel:'Source ID',sourceSharedPhotos:'Connection: shared Synology Photos session above (people filtering)',sourceLocalOnly:'Connection: mounted NAS folder; file reads do not require Photos sign-in',sourceOwnCredentials:'Connection: this source’s drive/WebDAV credentials; independent of NAS sign-in'}); +Object.assign(MESSAGES.zh,{photosSharedConnection:'共享连接',photosConnectionHelp:'此账号通过 Synology Photos API 授权读取照片和人物。连接成功后,请添加照片来源并设置空间和人物;更换账号会影响所有 Photos 来源。',photosLoginHelp:'使用群晖 NAS / Photos 账号,不是 Frame Flow 账号或 KoKonna Key。更换账号会影响所有启用人物筛选的数据源;个人空间的人物可能不同,更换后请逐个重新核对人物选择。',sourceIndependentHelp:'这里只保存当前数据源的目录、人物与筛选规则,不会修改其他数据源,也不会更换顶部共享 NAS 账号。任务通过数据源 ID 引用这些设置。',sourceIdLabel:'数据源 ID',sourceSharedPhotos:'关联:Synology Photos API 共享账号',sourceOwnCredentials:'关联:此数据源自己的网盘/WebDAV 凭据;不使用顶部 NAS 登录'}); +Object.assign(MESSAGES.en,{photosSharedConnection:'Shared connection',photosConnectionHelp:'This account authorizes photo and people access through the Synology Photos API. After connecting, add a photo source and choose its space and people. Changing accounts affects all Photos sources.',photosLoginHelp:'Use a Synology NAS / Photos account, not a Frame Flow account or KoKonna key. Changing accounts affects all people-filtered sources. Personal-space people may differ; recheck each source after switching accounts.',sourceIndependentHelp:'These folder, people and filter settings apply only to this source. Other sources and the shared NAS account are unchanged. Jobs refer to these settings by source ID.',sourceIdLabel:'Source ID',sourceSharedPhotos:'Connection: shared Synology Photos API account',sourceOwnCredentials:'Connection: this source’s drive/WebDAV credentials; independent of NAS sign-in'}); Object.assign(MESSAGES.zh,{photosDisconnect:'断开共享连接',photosSharedDisconnectWarning:'这将断开所有人物筛选数据源共用的 Photos 登录,相关任务会无法读取人物照片。文件夹设置和网盘连接不变。确定断开吗?'}); Object.assign(MESSAGES.en,{photosDisconnect:'Disconnect shared session',photosSharedDisconnectWarning:'This disconnects the Photos session used by all people-filtered sources. Their tasks will fail to read people photos. Folder settings and drive connections remain unchanged. Disconnect?'}); -Object.assign(MESSAGES.zh,{weather:'天气',connected:'已连接',disconnected:'未连接',sortOrder:'排序方式',playbackMode:'播放顺序',apiKeyConfigured:'API 密钥已配置',templateSourceUnavailable:'这张图片没有可编辑的模板来源。可编辑模板仅适用于保留了模板配置的生成图片。',localDeleteConfirm:'确认删除本地图库中的这张图片?不会删除 NAS 原始照片或云端副本,也不会切换画框。此操作不可撤销。',cloudDeleteConfirm:'确认删除这张图片的云端副本?不会删除 NAS 原始照片或本地图库副本。此操作不可撤销。',gallerySelectionHelp:'全选和反选只作用于当前页可见图片;批量操作处理当前页全部已勾选图片,包括搜索后隐藏的勾选项。',starHelp:'星标保存在服务端,用于防止自动清理本地图及其最近一次成功上传的云端副本。星标本身不会上传或切换画框;手动删除仍有效。',sourceLocationHint:'NAS 目录:填写挂载照片根目录下的相对路径,如 . 或 family。WebDAV:填写 HTTPS 目录地址,并在设置中允许其域名。Google Drive / 阿里云盘:目录 ID;百度网盘:目录路径。',sourceCredential:'WebDAV / 网盘凭据环境变量名',sourceCredentialHint:'填写变量名,不要填写密码。管理员可通过环境变量配置凭据;原生网盘也可使用下方 OAuth 凭据。'}); -Object.assign(MESSAGES.en,{weather:'Weather',connected:'Connected',disconnected:'Not connected',sortOrder:'Sort order',playbackMode:'Playback order',apiKeyConfigured:'API key configured',templateSourceUnavailable:'No editable template source is available for this image. Template editing requires a generated image with saved template settings.',localDeleteConfirm:'Delete this local gallery image? NAS originals and cloud copies remain intact, and the frame display is unchanged. This cannot be undone.',cloudDeleteConfirm:'Delete this cloud image? NAS originals and local gallery copies remain intact. This cannot be undone.',gallerySelectionHelp:'Select all and invert affect visible images on this page. Batch actions include every checked image on this page, including items hidden by search.',starHelp:'Stars are saved on the server and protect the local image and its latest successfully uploaded cloud copy from automatic cleanup. Starring does not upload or change the frame; manual deletion still applies.',sourceLocationHint:'NAS: a relative folder under the mounted photo root, such as . or family. WebDAV: an HTTPS folder URL whose hostname is allowed in Settings. Google Drive / AliPan: folder ID. Baidu: folder path.',sourceCredential:'WebDAV / drive credential environment variable',sourceCredentialHint:'Enter a variable name, never a password. An administrator can configure credentials through environment variables; native drives can also use the OAuth credentials below.'}); +Object.assign(MESSAGES.zh,{weather:'天气',connected:'已连接',disconnected:'未连接',sortOrder:'排序方式',playbackMode:'播放顺序',apiKeyConfigured:'API 密钥已配置',templateSourceUnavailable:'这张图片没有可编辑的模板来源。可编辑模板仅适用于保留了模板配置的生成图片。',localDeleteConfirm:'确认删除本地图库中的这张图片?不会删除 NAS 原始照片或云端副本,也不会切换画框。此操作不可撤销。',cloudDeleteConfirm:'确认删除这张图片的云端副本?不会删除 NAS 原始照片或本地图库副本。此操作不可撤销。',gallerySelectionHelp:'全选和反选只作用于当前页可见图片;批量操作处理当前页全部已勾选图片,包括搜索后隐藏的勾选项。',starHelp:'星标保存在服务端,用于防止自动清理本地图及其最近一次成功上传的云端副本。星标本身不会上传或切换画框;手动删除仍有效。',sourceLocationHint:'Synology Photos:所选空间内的目录路径,. 表示整个空间。按需获取 XL 预览图,不下载原图。其他云盘使用对应目录地址或 ID。',sourceCredential:'WebDAV / 网盘凭据环境变量名',sourceCredentialHint:'填写变量名,不要填写密码。管理员可通过环境变量配置凭据;原生网盘也可使用下方 OAuth 凭据。'}); +Object.assign(MESSAGES.en,{weather:'Weather',connected:'Connected',disconnected:'Not connected',sortOrder:'Sort order',playbackMode:'Playback order',apiKeyConfigured:'API key configured',templateSourceUnavailable:'No editable template source is available for this image. Template editing requires a generated image with saved template settings.',localDeleteConfirm:'Delete this local gallery image? NAS originals and cloud copies remain intact, and the frame display is unchanged. This cannot be undone.',cloudDeleteConfirm:'Delete this cloud image? NAS originals and local gallery copies remain intact. This cannot be undone.',gallerySelectionHelp:'Select all and invert affect visible images on this page. Batch actions include every checked image on this page, including items hidden by search.',starHelp:'Stars are saved on the server and protect the local image and its latest successfully uploaded cloud copy from automatic cleanup. Starring does not upload or change the frame; manual deletion still applies.',sourceLocationHint:'Synology Photos: folder path within the selected space; . means the whole space. Downloads XL previews on demand, not originals. Other drives use their folder URL or ID.',sourceCredential:'WebDAV / drive credential environment variable',sourceCredentialHint:'Enter a variable name, never a password. An administrator can configure credentials through environment variables; native drives can also use the OAuth credentials below.'}); Object.assign(MESSAGES.zh,{currentImageId:'当前图片 ID',modelSource:'型号信息来源',modelVerified:'设备 API 返回',modelConfigured:'设备配置文件',switchInterval:'切换间隔',sync:'云端同步状态',synced:'云端已确认',syncing:'等待云端确认'}); Object.assign(MESSAGES.en,{currentImageId:'Current image ID',modelSource:'Model information source',modelVerified:'Device API',modelConfigured:'Device configuration',switchInterval:'Switch interval',sync:'Cloud sync status',synced:'Cloud confirmed',syncing:'Awaiting cloud confirmation'}); @@ -140,7 +140,9 @@ Object.assign(MESSAGES.en,{currentImageId:'Current image ID',modelSource:'Model Object.assign(MESSAGES.zh,{noSources:'尚未配置照片数据源,请先添加。',noSourcePhotos:'当前目录或筛选条件下没有可用照片。',sourceLocation:'目录路径 / 地址 / ID',temporaryOptionsHint:'勾选到期恢复时,显示时长和优先级用于临时显示;未勾选时持续显示,直到其他操作切换图片。'}); Object.assign(MESSAGES.en,{noSources:'No photo sources configured. Add a source first.',noSourcePhotos:'No photos match this folder and its filters.',sourceLocation:'Folder path / URL / ID',temporaryOptionsHint:'With restore enabled, duration and priority apply to the temporary display. Otherwise, the image stays until another action changes it.'}); -Object.assign(MESSAGES.zh,{synologyAPISource:'Synology Photos API(无需挂载)',sourcePhotosAPI:'关联:共享 Photos 登录;按需下载 XL 预览图,无需 /photos 挂载',sourceLocationHint:'本地目录:挂载根目录下的相对路径。Photos API:所选空间的目录路径,. 表示整个空间;按需取 XL 预览图,不下载原图。WebDAV:HTTPS 目录地址。Google Drive / 阿里云盘:目录 ID;百度网盘:目录路径。',photosConnectionHelp:'当前共享一个 NAS Photos 登录。Photos API 来源通过此会话获取照片和人物,不需要挂载;本地目录来源仍从 /photos 读取。更换账号影响所有 Photos API 来源和人物筛选。',sourceRecursive:'包含子目录(本地 / Photos API)'}); -Object.assign(MESSAGES.en,{synologyAPISource:'Synology Photos API (no mount)',sourcePhotosAPI:'Connection: shared Photos session; downloads XL previews on demand, without /photos',sourceLocationHint:'Local: relative folder under the mounted root. Photos API: folder path in the selected space; . means the whole space. Downloads XL previews, not originals. WebDAV: HTTPS folder URL. Google Drive / AliPan: folder ID. Baidu: folder path.',photosConnectionHelp:'One NAS Photos session is shared. Photos API sources use it for photos and people without a mount; local-folder sources still read /photos. Changing accounts affects all Photos API sources and people filtering.',sourceRecursive:'Include subfolders (local / Photos API)'}); -Object.assign(MESSAGES.zh,{photosReconnectHelp:'尚未连接或会话已失效。Photos API 来源及依赖人物筛选的任务需要重新登录后才能读取照片。',photosSharedDisconnectWarning:'这将断开所有 Photos API 来源和人物筛选共用的登录,相关任务将无法读取照片。目录配置和独立网盘连接不会删除。确定断开吗?',photosConfigure:'管理员需配置 SYNOLOGY_PHOTOS_URL 和 SYNOLOGY_PHOTOS_CERT_SHA256。Photos API 来源无需 /photos 挂载;本地目录来源仍需挂载。'}); -Object.assign(MESSAGES.en,{photosReconnectHelp:'Not connected or session expired. Photos API sources and people-filtered tasks need a new sign-in before reading photos.',photosSharedDisconnectWarning:'Disconnect the shared session used by all Photos API sources and people filters? Their tasks cannot read photos until reconnected. Folder settings and separate drive connections are retained.',photosConfigure:'Configure SYNOLOGY_PHOTOS_URL and SYNOLOGY_PHOTOS_CERT_SHA256. Photos API sources need no /photos mount; local-folder sources still require it.'}); +Object.assign(MESSAGES.zh,{synologyAPISource:'Synology Photos API',sourcePhotosAPI:'通过共享 Photos 账号读取 XL 预览图与人物,遵循账号访问权限。',sourceLocationHint:'Synology Photos:所选空间内的目录路径,. 表示整个空间。按需获取 XL 预览图,不下载原图。其他云盘使用对应目录地址或 ID。',photosConnectionHelp:'此账号通过 Synology Photos API 授权读取照片和人物。连接成功后,请添加照片来源并设置空间和人物;更换账号会影响所有 Photos 来源。',sourceRecursive:'包含子目录'}); +Object.assign(MESSAGES.en,{synologyAPISource:'Synology Photos API',sourcePhotosAPI:'Reads XL previews and people through the shared Photos account, respecting its access permissions.',sourceLocationHint:'Synology Photos: folder path within the selected space; . means the whole space. Downloads XL previews on demand, not originals. Other drives use their folder URL or ID.',photosConnectionHelp:'This account authorizes photo and people access through the Synology Photos API. After connecting, add a photo source and choose its space and people. Changing accounts affects all Photos sources.',sourceRecursive:'Include subfolders'}); +Object.assign(MESSAGES.zh,{photosReconnectHelp:'尚未连接或会话已失效。Photos API 来源及依赖人物筛选的任务需要重新登录后才能读取照片。',photosSharedDisconnectWarning:'这将断开所有 Photos API 来源和人物筛选共用的登录,相关任务将无法读取照片。目录配置和独立网盘连接不会删除。确定断开吗?',photosConfigure:'管理员需配置 SYNOLOGY_PHOTOS_URL 和 SYNOLOGY_PHOTOS_CERT_SHA256。照片和人物均通过 API 读取。'}); +Object.assign(MESSAGES.en,{photosReconnectHelp:'Not connected or session expired. Photos API sources and people-filtered tasks need a new sign-in before reading photos.',photosSharedDisconnectWarning:'Disconnect the shared session used by all Photos API sources and people filters? Their tasks cannot read photos until reconnected. Folder settings and separate drive connections are retained.',photosConfigure:'Configure SYNOLOGY_PHOTOS_URL and SYNOLOGY_PHOTOS_CERT_SHA256. Photos and people are read through the API.'}); + +Object.assign(window.MESSAGES.zh,{signIn:'登录',livingRoom:'客厅'});Object.assign(window.MESSAGES.en,{signIn:'Sign in',livingRoom:'Living room'}); diff --git a/pigway_frame_flow/web/index.html b/pigway_frame_flow/web/index.html index 9d6ed9b..b23b8d2 100644 --- a/pigway_frame_flow/web/index.html +++ b/pigway_frame_flow/web/index.html @@ -1,3 +1,3 @@ Pigway Frame Flow -
PIGWAY FRAME FLOW

+
PIGWAY FRAME FLOW

diff --git a/pigway_frame_flow/web/style.css b/pigway_frame_flow/web/style.css index 0e7c556..bf7f6ba 100644 --- a/pigway_frame_flow/web/style.css +++ b/pigway_frame_flow/web/style.css @@ -153,3 +153,16 @@ form>.modal-actions{margin-top:8px} .gallery-tabs button{min-width:0;white-space:normal;overflow-wrap:anywhere} .gallery:empty{display:none} + +/* The signed-out page needs its own form rhythm and a viewport-sized content area. */ +body.login-mode .workspace{min-height:100dvh;display:flex;flex-direction:column} +body.login-mode #main{flex:1;display:grid;place-items:center;padding:24px 0 40px} +body.login-mode header,body.login-mode footer{flex-shrink:0} +.login-card{width:min(100%,480px);padding:32px} +.login-card h2{margin:20px 0 8px;font-size:24px;line-height:1.35} +.login-card>.hint{margin:0 0 24px;line-height:1.7} +#login-form{display:flex;flex-direction:column;gap:20px} +#login-form .check-row{align-items:center;gap:10px;font-weight:400;line-height:1.5} +#login-form .check-row input{margin:0;width:18px;height:18px} +#login-form>.primary{width:100%;min-height:46px;margin-top:4px} +@media(max-width:720px){body.login-mode .workspace{min-height:calc(100dvh - 92px)}body.login-mode #main{padding:16px 0 24px}.login-card{padding:24px}} diff --git a/scripts/production-deploy.py b/scripts/production-deploy.py index 2947a14..0c934bf 100644 --- a/scripts/production-deploy.py +++ b/scripts/production-deploy.py @@ -70,6 +70,13 @@ class Deploy: return json.loads(self.run("inspect", name).stdout)[0] def settings(self): + if any( + self.env.get(key, "").startswith("/volume2/docker/pigway-frame-flow") + for key in ("PROD_DATA_DIR", "PROD_CONFIG_DIR") + ): + raise ValueError( + "NAS Frame deployment is retired; target the Ubuntu Server" + ) missing = [key for key in REQUIRED if not self.env.get(key)] if missing: raise ValueError("Missing Variables: " + ", ".join(missing)) @@ -118,20 +125,9 @@ class Deploy: or not m.get("RW") ): raise ValueError("Production mount mismatch: " + key) - photos = self.env.get("PROD_PHOTOS_DIR") - if "/photos" in mounts and ( - not photos - or mounts["/photos"]["Source"] != photos - or mounts["/photos"]["RW"] - ): - raise ValueError( - "Confirm PROD_PHOTOS_DIR for the existing read-only photo mount" - ) - if photos and "/photos" not in mounts: - raise ValueError( - "Configured photos directory does not match current container" - ) - if set(mounts) != {"/data", "/config"} | ({"/photos"} if photos else set()): + if self.env.get("PROD_PHOTOS_DIR") or "/photos" in mounts: + raise ValueError("Filesystem photo mounts are retired; use the Photos API") + if set(mounts) != {"/data", "/config"}: raise ValueError("Unrecognized persistent mount; review before adopting CD") bindings = old["HostConfig"].get("PortBindings", {}).get("18787/tcp") if bindings != [ @@ -180,16 +176,6 @@ class Deploy: ) ] service["group_add"] = old["HostConfig"].get("GroupAdd") or [] - if photos: - service["volumes"].append( - { - "type": "bind", - "source": photos, - "target": "/photos", - "read_only": True, - "bind": {"create_host_path": False}, - } - ) # Adoption must preserve runtime constraints, not silently change them. if ( old["Config"]["User"] != service["user"] diff --git a/scripts/server-release.py b/scripts/server-release.py new file mode 100644 index 0000000..2d34e38 --- /dev/null +++ b/scripts/server-release.py @@ -0,0 +1,244 @@ +"""Release a verified source bundle on the Ubuntu T7 host; never write to frames.""" + +import datetime +import fcntl +import hashlib +import json +import os +import re +import shutil +import sqlite3 +import subprocess +import time +import urllib.request +from pathlib import Path + +APP = Path("/srv/pigway/apps/pigway-frame-flow") +RECOVERY = Path("/srv/pigway/recovery/frame-flow") +CONTAINER = "pigway-frame-flow" + + +def run(*args): + return subprocess.check_output(args, text=True, timeout=600).strip() + + +def inspect(): + return json.loads(run("docker", "inspect", CONTAINER))[0] + + +def archive_staging(backup, ctx): + # These are retired Frame-only inputs, never shared console migration files. + names = [ + "frame-schedule-ui", + "frame-photos-ui", + "frame-final-status.sh", + "frame-compose.candidate.private.json", + "frame-fresh-compose.private.json", + "frame-image.tar", + "frame-prepare.sh", + "frame-nfs-setup.sh", + "frame.unit", + "compose.ubuntu-photos-api.yml", + ] + archive = backup / "retired-frame-staging" + archive.mkdir(mode=0o700) + moved = [] + for name in names: + source = ctx.parent / name + if source.exists(): + assert not source.is_symlink(), name + source.rename(archive / name) + moved.append(name) + for p in archive.rglob("*"): + if not p.is_symlink(): + p.chmod(0o700 if p.is_dir() else 0o600) + # Remove only disposable aliases, preserving every recovery reference and image. + references = "\n".join( + p.read_text(errors="replace") + for p in RECOVERY.rglob("*") + if p.is_file() and p.suffix in {".json", ".txt"} + ) + removed = [] + for tag in [ + "pigway-frame-flow:history-ui-local-base", + "pigway-frame-flow:final-audit-local-base", + ]: + tags = run( + "docker", "image", "ls", "--format", "{{.Repository}}:{{.Tag}}" + ).splitlines() + if tag in tags and tag not in references: + run("docker", "image", "rm", tag) + removed.append(tag) + return { + "archived_frame_inputs": moved, + "removed_temporary_aliases": removed, + "shared_inputs_and_recovery_preserved": True, + } + + +def verify_runtime(commit): + c = inspect() + env = dict(s.split("=", 1) for s in c["Config"]["Env"]) + assert env["KOKONNA_MOCK"] == "false" + assert env["KOKONNA_WRITE_ENABLED"] == "true" + assert env["BUILD_COMMIT"] == commit + assert c["State"]["Running"] + assert c["Config"]["User"] == "10333:10333" + assert c["HostConfig"]["ReadonlyRootfs"] + assert set(c["HostConfig"]["CapDrop"]) == {"ALL"} + assert {m["Destination"] for m in c["Mounts"]} == {"/data", "/config"} + for mount in c["Mounts"]: + assert Path(mount["Source"]).resolve() == APP / mount["Destination"][1:] + return c + + +def main(): + assert os.geteuid() == 0, "Run with sudo on the Ubuntu Server" + os.umask(0o077) + run("/usr/local/libexec/pigway-guard-mount") + assert run("findmnt", "-n", "-o", "UUID", "/srv/pigway") == ( + "00000000-0000-4000-8000-000000000001" + ) + with (RECOVERY / ".release.lock").open("a") as lock: + fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB) + release(lock) + + +def release(lock): + ctx = Path(__file__).resolve().parent.parent + assert ctx.parent == Path("/srv/pigway/migration-inbox") + commit = (ctx / "RELEASE_COMMIT").read_text().strip() + assert re.fullmatch("[0-9a-f]{40}", commit) + hashes = json.loads((ctx / "RELEASE_HASHES.json").read_text()) + for name, expected in hashes.items(): + p = (ctx / name).resolve() + assert p.is_relative_to(ctx) + assert hashlib.sha256(p.read_bytes()).hexdigest() == expected, name + before = inspect() + old_env = dict(s.split("=", 1) for s in before["Config"]["Env"]) + verify_runtime(old_env["BUILD_COMMIT"]) + assert run("docker", "exec", CONTAINER, "cat", "/app/requirements.lock") == ( + (ctx / "requirements.lock").read_text().strip() + ), "Dependencies differ; full build required" + stamp = datetime.datetime.now(datetime.timezone.utc).strftime("%Y%m%dT%H%M%SZ") + backup = RECOVERY / ("release-" + stamp + "-" + commit[:12]) + backup.mkdir(mode=0o700) + shutil.copy2(APP / "compose.json", backup / "compose.before.json") + shutil.copytree(APP / "config", backup / "config") + with sqlite3.connect( + "file:" + str(APP / "data/frameflow.db") + "?mode=ro", uri=True + ) as src: + with sqlite3.connect(backup / "frameflow.before.db") as dst: + src.backup(dst) + assert dst.execute("pragma quick_check").fetchone()[0] == "ok" + base = "pigway-frame-flow:release-base-" + commit[:12] + run("docker", "image", "tag", before["Image"], base) + image = "pigway-frame-flow:0.1.9-" + commit[:12] + try: + run( + "docker", + "build", + "--pull=false", + "--network=none", + "-f", + str(ctx / "deploy/Dockerfile.runtime-update"), + "--build-arg", + "BASE_IMAGE=" + base, + "--build-arg", + "BUILD_COMMIT=" + commit, + "--build-arg", + "BUILD_TIME=" + stamp, + "-t", + image, + str(ctx), + ) + finally: + run("docker", "image", "rm", base) + compose = json.loads((APP / "compose.json").read_text()) + service = compose["services"][CONTAINER] + service["image"] = image + service["environment"]["BUILD_COMMIT"] = commit + service["environment"]["BUILD_TIME"] = stamp + changed = False + try: + changed = True + (APP / "compose.json").write_text(json.dumps(compose, indent=2) + "\n") + run("systemctl", "restart", "pigway-frame-flow.service") + for attempt in range(30): + try: + with urllib.request.urlopen( + "http://127.0.0.1:81/ready", timeout=4 + ) as r: + assert json.load(r)["status"] == "ready" + break + except Exception: + if attempt == 29: + raise + time.sleep(2) + current = verify_runtime(commit) + assert current["Image"] == run( + "docker", "image", "inspect", "--format", "{{.Id}}", image + ) + # Verify all deployed project files, not just a version string or an image tag. + project_hashes = { + n: h + for n, h in hashes.items() + if n.startswith(("pigway_frame_flow/", "vendor/")) + or n == "requirements.lock" + } + code = 'import hashlib,json,pathlib,sys; h=json.load(sys.stdin); assert all(hashlib.sha256((pathlib.Path("/app")/n).read_bytes()).hexdigest()==v for n,v in h.items())' + subprocess.run( + ["docker", "exec", "-i", CONTAINER, "python", "-c", code], + input=json.dumps(project_hashes), + text=True, + check=True, + timeout=60, + ) + for name in [ + "app.js", + "features.js", + "extensions.js", + "i18n.js", + "index.html", + "style.css", + ]: + url = "http://127.0.0.1:81/" + ( + "" if name == "index.html" else "ui/" + name + ) + with urllib.request.urlopen(url, timeout=10) as response: + assert ( + response.read() + == (ctx / "pigway_frame_flow/web" / name).read_bytes() + ) + run("systemctl", "is-active", "pigway-frame-flow.service") + except BaseException: + if changed: + shutil.copy2(backup / "compose.before.json", APP / "compose.json") + run("systemctl", "restart", "pigway-frame-flow.service") + raise + shutil.copy2(APP / "compose.json", RECOVERY / "compose.json") + (RECOVERY / "image.txt").write_text(image + "\n") + shutil.copy2(ctx / "source.tar.gz", backup / "source.tar.gz") + cleanup = archive_staging(backup, ctx) + receipt = { + "result": "PASS", + "commit": commit, + "image": image, + "image_id": current["Image"], + "backup": str(backup), + "mock": False, + "write_enabled": True, + "checked_at": stamp, + "cleanup": cleanup, + } + output = APP / "release-verification.json" + output.write_text(json.dumps(receipt, indent=2) + "\n") + owner = ctx.stat() + os.chown(output, owner.st_uid, owner.st_gid) + shutil.copy2(output, backup / "release-verification.json") + shutil.rmtree(ctx) + print(json.dumps(receipt, indent=2)) + + +if __name__ == "__main__": + main() diff --git a/scripts/ui-history-localization.cjs b/scripts/ui-history-localization.cjs new file mode 100644 index 0000000..1475907 --- /dev/null +++ b/scripts/ui-history-localization.cjs @@ -0,0 +1,17 @@ +const {chromium}=require('playwright');const fs=require('fs'),path=require('path'),assert=require('assert'); +const root=path.join(__dirname,'../pigway_frame_flow/web'); +(async()=>{const b=await chromium.launch({headless:true,executablePath:'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'});try{for(const width of [1440,768,390])for(const lang of ['zh','en']){ + const p=await b.newPage({viewport:{width,height:900}});let errors=[];p.on('pageerror',e=>errors.push(e.message));let saved; + await p.addInitScript(lang=>localStorage.setItem('ff-language',lang),lang); + await p.route('**/*',async r=>{const u=new URL(r.request().url());if(u.pathname.startsWith('/api/')){ + if(['PUT','POST'].includes(r.request().method())){saved=r.request().postDataJSON();return r.fulfill({json:{}})} + if(['/api/v1/jobs','/api/v1/assets','/api/v1/devices/living-room/images'].includes(u.pathname))return r.fulfill({json:[]}); + if(u.pathname==='/api/v1/photo-sources')return r.fulfill({json:[]}); + if(u.pathname==='/api/v1/integrations/synology-photos')return r.fulfill({json:{configured:true,connected:true}}); + if(u.pathname.endsWith('/people'))return r.fulfill({json:[{id:1,name:'Test Person',count:5}]}); + return r.fulfill({status:401,json:{error:{code:'UNAUTHORIZED',message:'Sign in required'}}});} + const f=u.pathname==='/'?'index.html':u.pathname.replace(/^\/ui\//,'');return r.fulfill({body:fs.readFileSync(path.join(root,f)),contentType:f.endsWith('.js')?'application/javascript':f.endsWith('.css')?'text/css':'text/html'});}); + await p.goto('http://frame.test/');await p.locator('#login-form').waitFor(); + await p.evaluate(async()=>{const previous=api;api=async()=>[{operation_id:'fixture',action:'display',device_id:'living-room',status:'FAILED',client_id:'scheduler',error:{code:'WRITE_DISABLED'},request_id:'fixture'}];state.devices=[{id:'living-room',name:'living-room'}];try{await views.history(state.epoch);}finally{api=previous;}}); + const text=await p.locator('#main').innerText();assert(!text.includes('living-room'));assert(!text.includes('WRITE_DISABLED'));assert(text.includes(lang==='zh'?'客厅':'Living room'));assert(text.includes(lang==='zh'?'显示图片':'Display image'));assert(text.includes(lang==='zh'?'自动调度':'Scheduler'));assert.equal(errors.length,0);console.log('PASS history',width,lang);await p.close(); + }}finally{await b.close()}})().catch(e=>{console.error(e);process.exit(1)}); diff --git a/scripts/ui-interval-schedule.cjs b/scripts/ui-interval-schedule.cjs new file mode 100644 index 0000000..3823560 --- /dev/null +++ b/scripts/ui-interval-schedule.cjs @@ -0,0 +1,28 @@ +const {chromium}=require('playwright');const fs=require('fs'),path=require('path'),assert=require('assert'); +const root=path.join(__dirname,'../pigway_frame_flow/web'); +(async()=>{const b=await chromium.launch({headless:true,executablePath:'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'});try{for(const width of [1440,768,390])for(const lang of ['zh','en']){ + const p=await b.newPage({viewport:{width,height:900}});let errors=[];p.on('pageerror',e=>errors.push(e.message));let saved; + await p.addInitScript(lang=>localStorage.setItem('ff-language',lang),lang); + await p.route('**/*',async r=>{const u=new URL(r.request().url());if(u.pathname.startsWith('/api/')){ + if(['PUT','POST'].includes(r.request().method())){saved=r.request().postDataJSON();return r.fulfill({json:{}})} + if(['/api/v1/jobs','/api/v1/assets','/api/v1/devices/living-room/images'].includes(u.pathname))return r.fulfill({json:[]}); + if(u.pathname==='/api/v1/photo-sources')return r.fulfill({json:[]}); + if(u.pathname==='/api/v1/integrations/synology-photos')return r.fulfill({json:{configured:true,connected:true}}); + if(u.pathname.endsWith('/people'))return r.fulfill({json:[{id:1,name:'Test Person',count:5}]}); + return r.fulfill({status:401,json:{error:{code:'UNAUTHORIZED',message:'Sign in required'}}});} + const f=u.pathname==='/'?'index.html':u.pathname.replace(/^\/ui\//,'');return r.fulfill({body:fs.readFileSync(path.join(root,f)),contentType:f.endsWith('.js')?'application/javascript':f.endsWith('.css')?'text/css':'text/html'});}); + await p.goto('http://frame.test/');await p.locator('#login-form').waitFor(); + await p.evaluate(async()=>{state.templates=[{id:'daily-dashboard',name:'Dashboard',defaults:{}}];await jobEditor();}); + for(const unit of ['hours','minutes']){ + await p.locator('#job-schedule').selectOption(unit);await p.locator('#schedule-value').fill('2'); + assert.equal(await p.locator('#schedule-value').getAttribute('type'),'number'); + assert(await p.locator('#schedule-hint').innerText()); + } + assert(!await p.evaluate(()=>document.documentElement.scrollWidth>innerWidth)); + await p.locator('#job-name').fill('Test interval'); + await p.locator('#job-form button[type=submit]').click();await p.waitForTimeout(100); + assert(saved);assert.deepEqual(saved.schedule,{type:'interval',interval_minutes:2}); + await p.evaluate(async()=>{await jobEditor({id:'existing',name:'Existing',schedule:{type:'interval',interval_minutes:120},source:{type:'template',template:'daily-dashboard',data:{}},enabled:false,display_duration:0,restore_previous:false,priority:50});}); + assert.equal(await p.locator('#job-schedule').inputValue(),'hours');assert.equal(await p.locator('#schedule-value').inputValue(),'2'); + await p.evaluate(()=>{state.devices=[{id:'living-room',name:'我的相框'}];});assert.equal(await p.evaluate(()=>deviceDisplayName(state.devices[0])),'我的相框');assert.equal(await p.evaluate(()=>deviceDisplayName({id:'living-room',name:'living-room'})),lang==='zh'?'客厅':'Living room');await p.locator('#modal-cancel').click();await p.evaluate(async()=>{const previous=api;api=async()=>[{id:'fixture',name:'Family',device_id:'living-room',schedule:{type:'interval',interval_minutes:120},enabled:false}];try{await views.automation(state.epoch);}finally{api=previous;}});assert.equal(await p.locator('tbody td small').first().innerText(),'我的相框');assert.equal(errors.length,0,errors.join('\n'));console.log('PASS',width,lang);await p.close(); + }}finally{await b.close()}})().catch(e=>{console.error(e);process.exit(1)}); diff --git a/scripts/ui-login-layout.cjs b/scripts/ui-login-layout.cjs new file mode 100644 index 0000000..5fcc799 --- /dev/null +++ b/scripts/ui-login-layout.cjs @@ -0,0 +1,17 @@ +const {chromium}=require('playwright');const fs=require('fs');const path=require('path');const assert=require('assert'); +const root=path.resolve(__dirname,'../pigway_frame_flow/web');const out=process.env.FRAME_FLOW_SCREENSHOT_DIR;if(out)fs.mkdirSync(out,{recursive:true}); +(async()=>{const b=await chromium.launch({headless:true,executablePath:'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'});let count=0; +try{for(const width of [1440,768,390,320])for(const lang of ['zh','en'])for(const theme of ['dark','light']){ +const p=await b.newPage({viewport:{width,height:900}});const errors=[];p.on('pageerror',e=>errors.push(e.message)); +await p.addInitScript(({lang,theme})=>{localStorage.setItem('ff-language',lang);localStorage.setItem('ff-theme',theme)},{lang,theme}); +await p.route('**/*',async r=>{const u=new URL(r.request().url());if(u.pathname.startsWith('/api/')){if(u.pathname==='/api/v1/version')return r.fulfill({json:{version:'0.1.9',commit:'ba9424a-ui',build_time:'validation'}});return r.fulfill({status:401,json:{error:{code:'UNAUTHORIZED',message:'Sign in required'}}});}let f=u.pathname==='/'?'index.html':u.pathname.replace(/^\/ui\//,'');const file=path.join(root,f);if(!file.startsWith(root)||!fs.existsSync(file))return r.fulfill({status:404,body:''});return r.fulfill({body:fs.readFileSync(file),contentType:f.endsWith('.css')?'text/css':f.endsWith('.js')?'application/javascript':'text/html'});}); +await p.goto('http://frame.test/');await p.locator('#login-form').waitFor();await p.waitForTimeout(60); +assert.equal(await p.locator('#device option:checked').innerText(),lang==='zh'?'客厅':'Living room'); +const layout=await p.evaluate(()=>{const q=s=>document.querySelector(s).getBoundingClientRect();return {overflow:document.documentElement.scrollWidth>innerWidth,card:q('.login-card').toJSON(),form:q('#login-form').toJSON(),user:q('#username').toJSON(),password:q('#password').toJSON(),remember:document.querySelector('#remember-login').closest('label').getBoundingClientRect().toJSON(),button:q('#login-form button').toJSON(),label:document.querySelector('#remember-login').labels[0].innerText}}); +assert(!layout.overflow,JSON.stringify({width,lang,theme,layout}));assert(layout.password.top-layout.user.bottom>=24);assert(layout.button.top-layout.remember.bottom>=20);assert(layout.button.width>=layout.form.width-1);assert.equal(errors.length,0,errors.join('\n')); +// Language switching and custom names must not change the internal device ID. +await p.evaluate(()=>{state.devices=[{id:'living-room',name:'我的画框'}];shell()});assert.equal(await p.locator('#device option:checked').innerText(),'我的画框'); +await p.evaluate(()=>{state.devices=[];shell();login()}); +if(out&&lang==='zh'&&theme==='dark'&&[1440,390].includes(width))await p.screenshot({path:path.join(out,`login-${width}.png`),fullPage:true}); +console.log('PASS',width,lang,theme);count++;await p.close();} +console.log('TOTAL',count,'profiles');}finally{await b.close()}})().catch(e=>{console.error(e);process.exit(1)}); diff --git a/scripts/ui-photos-api-form.cjs b/scripts/ui-photos-api-form.cjs new file mode 100644 index 0000000..57021a6 --- /dev/null +++ b/scripts/ui-photos-api-form.cjs @@ -0,0 +1,23 @@ +const {chromium}=require('playwright');const fs=require('fs'),path=require('path'),assert=require('assert'); +const root=path.join(__dirname,'../pigway_frame_flow/web'); +(async()=>{const b=await chromium.launch({headless:true,executablePath:'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'});try{for(const width of [1440,768,390])for(const lang of ['zh','en']){ + const p=await b.newPage({viewport:{width,height:900}});let errors=[];p.on('pageerror',e=>errors.push(e.message));let saved; + await p.addInitScript(lang=>localStorage.setItem('ff-language',lang),lang); + await p.route('**/*',async r=>{const u=new URL(r.request().url());if(u.pathname.startsWith('/api/')){ + if(r.request().method()==='PUT'){saved=r.request().postDataJSON();return r.fulfill({json:{}})} + if(u.pathname==='/api/v1/photo-sources')return r.fulfill({json:[]}); + if(u.pathname==='/api/v1/integrations/synology-photos')return r.fulfill({json:{configured:true,connected:true}}); + if(u.pathname.endsWith('/people'))return r.fulfill({json:[{id:1,name:'Test Person',count:5}]}); + return r.fulfill({status:401,json:{error:{code:'UNAUTHORIZED',message:'Sign in required'}}});} + const f=u.pathname==='/'?'index.html':u.pathname.replace(/^\/ui\//,'');return r.fulfill({body:fs.readFileSync(path.join(root,f)),contentType:f.endsWith('.js')?'application/javascript':f.endsWith('.css')?'text/css':'text/html'});}); + await p.goto('http://frame.test/');await p.locator('#login-form').waitFor();await p.evaluate(()=>photoSourceForm()); + assert(!(await p.locator('#source-kind').isVisible()));assert(!(await p.locator('#source-id').isVisible()));assert(!(await p.locator('#source-synology-previews').isVisible())); + assert.equal(await p.locator('#source-kind').inputValue(),'synology-photos');assert.match(await p.locator('#source-id').inputValue(),/^photos-[a-z0-9-]+$/); + const text=await p.locator('#modal').innerText();assert(!/\/photos|本地目录|local.folder|mounted/i.test(text)); + assert(await p.locator('#source-people-space').isVisible());await p.locator('#source-people-load').click();await p.locator('[data-person-id], #source-people-panel input[type=checkbox]').last().waitFor(); + assert(!await p.evaluate(()=>document.documentElement.scrollWidth>innerWidth)); + await p.locator('#source-name').fill('Family'); + await p.locator('#photo-source-form button[type=submit]').click();await p.waitForTimeout(100); + assert(saved);assert.equal(saved.kind,'synology-photos');assert.equal(saved.synology_previews,false); + await p.evaluate(()=>photoSourceForm({id:'family-photos',name:'Family',kind:'synology-photos',location:'.',people_filter:true,person_ids:[1],people_space:'personal',person_match:'all'}));assert.equal(await p.locator('#source-id').inputValue(),'family-photos');assert.equal(await p.locator('#source-people-space').inputValue(),'personal');assert.equal(await p.locator('#source-person-match').inputValue(),'all');assert(await p.locator('#source-people-filter').isChecked());if(lang==='zh')await p.screenshot({path:'/tmp/frame-source-'+width+'.png',fullPage:true});assert.equal(errors.length,0,errors.join('\n'));console.log('PASS',width,lang);await p.close(); + }}finally{await b.close()}})().catch(e=>{console.error(e);process.exit(1)}); diff --git a/tests/conftest.py b/tests/conftest.py index 8b15826..ad9efd8 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -30,3 +30,17 @@ def client(app): @pytest.fixture def service(app): return app.state.service + + +@pytest.fixture +def legacy_photo_engine(monkeypatch): + """Exercise retained legacy internals without weakening production policy. + + These fixtures cover rendering, retention and migration algorithms that used + filesystem inputs historically. API boundary tests never use this override. + """ + from pigway_frame_flow.providers.photo_sources import PhotoSources + + monkeypatch.setattr( + PhotoSources, "require_api_source", staticmethod(lambda source: None) + ) diff --git a/tests/test_external_photo_sources.py b/tests/test_external_photo_sources.py index 3ec65e1..39ae1f8 100644 --- a/tests/test_external_photo_sources.py +++ b/tests/test_external_photo_sources.py @@ -222,3 +222,7 @@ def test_unreadable_source_is_forbidden_not_empty( response = client.get("/api/v1/photo-sources/family/images") assert response.status_code == 403 assert response.json()["error"]["code"] == "FORBIDDEN" + + +# Legacy algorithms remain covered; production filesystem access is tested separately. +pytestmark = pytest.mark.usefixtures("legacy_photo_engine") diff --git a/tests/test_interval_schedule.py b/tests/test_interval_schedule.py new file mode 100644 index 0000000..6afc23e --- /dev/null +++ b/tests/test_interval_schedule.py @@ -0,0 +1,30 @@ +from datetime import UTC, datetime, timedelta + +import pytest +from pydantic import ValidationError + +from pigway_frame_flow.errors import FlowError +from pigway_frame_flow.models import Schedule +from pigway_frame_flow.scheduler.engine import next_run + + +@pytest.mark.parametrize("minutes", [1, 7, 60, 120, 420, 10080]) +def test_interval_crosses_calendar_boundaries(minutes): + start = datetime(2026, 10, 16, 23, 58, tzinfo=UTC) + schedule = Schedule(type="interval", interval_minutes=minutes) + first = datetime.fromisoformat(next_run(schedule, start)) + assert first == start + timedelta(minutes=minutes) + assert datetime.fromisoformat(next_run(schedule, first)) == first + timedelta( + minutes=minutes + ) + + +@pytest.mark.parametrize("minutes", [0, -1, 10081]) +def test_invalid_interval(minutes): + with pytest.raises(ValidationError): + Schedule(type="interval", interval_minutes=minutes) + + +def test_missing_interval_fails(): + with pytest.raises(FlowError): + next_run(Schedule(type="interval")) diff --git a/tests/test_local_cloud_library.py b/tests/test_local_cloud_library.py index e2be58d..1e4cc78 100644 --- a/tests/test_local_cloud_library.py +++ b/tests/test_local_cloud_library.py @@ -1,5 +1,6 @@ from io import BytesIO +import pytest from PIL import Image from pigway_frame_flow.providers.photo_sources import PhotoSourceInput @@ -168,3 +169,7 @@ def test_manual_display_does_not_replace_task_head(client, service, settings): "SELECT * FROM managed_cloud_uploads WHERE image_id=?", (second,) ) ) + + +# Legacy algorithms remain covered; production filesystem access is tested separately. +pytestmark = pytest.mark.usefixtures("legacy_photo_engine") diff --git a/tests/test_photo_canvas.py b/tests/test_photo_canvas.py index f33abb2..2416a5d 100644 --- a/tests/test_photo_canvas.py +++ b/tests/test_photo_canvas.py @@ -138,3 +138,7 @@ def test_auto_preview_uses_requested_device_not_default( assert op["status"] == "COMPLETED" with Image.open(BytesIO(client.get(op["result"]["url"]).content)) as image: assert image.size == (1200, 1600) + + +# Legacy algorithms remain covered; production filesystem access is tested separately. +pytestmark = pytest.mark.usefixtures("legacy_photo_engine") diff --git a/tests/test_photo_captions.py b/tests/test_photo_captions.py index 7c2ba24..84115e2 100644 --- a/tests/test_photo_captions.py +++ b/tests/test_photo_captions.py @@ -159,3 +159,7 @@ def test_oversized_synology_photo_uses_read_only_cached_preview(service, setting "synology", source.model_copy(update={"synology_previews": False}) ) assert service.photo_sources.catalog("synology") == [] + + +# Legacy algorithms remain covered; production filesystem access is tested separately. +pytestmark = pytest.mark.usefixtures("legacy_photo_engine") diff --git a/tests/test_photo_failures.py b/tests/test_photo_failures.py index a8ef885..c6799a1 100644 --- a/tests/test_photo_failures.py +++ b/tests/test_photo_failures.py @@ -1,3 +1,4 @@ +import pytest from PIL import Image from pigway_frame_flow.providers.photo_sources import PhotoSourceInput @@ -101,3 +102,7 @@ def test_random_bad_images_bounded_to_five(client, service, settings, monkeypatc "SELECT write_started FROM operations WHERE id=?", (op["operation_id"],) ) assert row["write_started"] == 0 + + +# Legacy algorithms remain covered; production filesystem access is tested separately. +pytestmark = pytest.mark.usefixtures("legacy_photo_engine") diff --git a/tests/test_photo_orientation.py b/tests/test_photo_orientation.py index 5fd7088..5e55032 100644 --- a/tests/test_photo_orientation.py +++ b/tests/test_photo_orientation.py @@ -62,12 +62,16 @@ def test_cached_dimensions_invalidation_and_no_fallback( ), ) assert len(oriented.catalog("family")) == 1 + from pigway_frame_flow.providers.photo_sources import dimensions + monkeypatch.setattr( "pigway_frame_flow.providers.photo_sources.dimensions", lambda *a: pytest.fail("unchanged dimensions should be cached"), ) assert len(oriented.catalog("family", refresh=True)) == 1 - monkeypatch.undo() + monkeypatch.setattr( + "pigway_frame_flow.providers.photo_sources.dimensions", dimensions + ) Image.new("RGB", (50, 100)).save(settings.photo_root / "20240101 Day" / "wide.jpg") assert oriented.catalog("family", refresh=True) == [] with pytest.raises(FlowError): @@ -110,3 +114,7 @@ def test_orientation_validation_and_api(client, oriented): ).json()["error"]["code"] == "UNSUPPORTED_CAPABILITY" ) + + +# Legacy algorithms remain covered; production filesystem access is tested separately. +pytestmark = pytest.mark.usefixtures("legacy_photo_engine") diff --git a/tests/test_photo_sources.py b/tests/test_photo_sources.py index bfd8d53..8ca5b31 100644 --- a/tests/test_photo_sources.py +++ b/tests/test_photo_sources.py @@ -1,3 +1,6 @@ +import pytest + + def test_uploaded_photos_exclude_generated_templates_before_limit(client, service): photo = service.assets.put(b"fixture", "family.jpg", "images") for _ in range(5): @@ -66,3 +69,7 @@ def test_multiple_sources_keep_separate_people_and_folder_settings( assert service.photo_sources.get("travel").model_dump() == before assert service.photo_sources.get("family").person_ids == [3] assert service.photo_sources.get("travel").person_ids == [2] + + +# Legacy algorithms remain covered; production filesystem access is tested separately. +pytestmark = pytest.mark.usefixtures("legacy_photo_engine") diff --git a/tests/test_photos_api_boundary.py b/tests/test_photos_api_boundary.py new file mode 100644 index 0000000..b1d9727 --- /dev/null +++ b/tests/test_photos_api_boundary.py @@ -0,0 +1,23 @@ +import json + + +def test_filesystem_source_cannot_be_created(client): + response = client.put( + "/api/v1/photo-sources/legacy", + json={"name": "NAS directory", "kind": "local", "location": "."}, + ) + assert response.status_code == 403 + assert "Filesystem photo sources are disabled" in response.text + + +def test_persisted_filesystem_source_cannot_be_read(client, service): + service.db.execute( + "INSERT INTO app_config(key,payload) VALUES (?,?)", + ( + "photo_source:legacy", + json.dumps({"name": "Legacy", "kind": "local", "location": "."}), + ), + ) + response = client.get("/api/v1/photo-sources/legacy/images") + assert response.status_code == 403 + assert "Filesystem photo sources are disabled" in response.text diff --git a/tests/test_production_bootstrap.py b/tests/test_production_bootstrap.py index 2ad602a..c149132 100644 --- a/tests/test_production_bootstrap.py +++ b/tests/test_production_bootstrap.py @@ -248,18 +248,14 @@ def test_actual_verify_rejects_ready_smoke_and_commit_failure( tool.verify(config) -def test_workflow_bootstrap_manual_only_and_ci_namespace_separate(tool): +def test_retired_workflow_has_no_bootstrap_and_ci_namespace_separate(tool): from pathlib import Path import yaml root = Path(__file__).parents[1] workflow = yaml.safe_load((root / ".github/workflows/verify.yml").read_text()) - inputs = workflow[True]["workflow_dispatch"]["inputs"]["deploy_action"] - assert inputs["options"] == ["plan", "bootstrap", "deploy", "rollback"] - assert inputs["default"] == "plan" - assert workflow["jobs"]["deploy"]["needs"] == "verify" - assert "success()" in workflow["jobs"]["deploy"]["if"] + assert set(workflow["jobs"]) == {"verify"} tool.env["PROD_PROJECT"] = "ffci-invalid" with pytest.raises(ValueError): tool.settings() diff --git a/tests/test_production_deploy.py b/tests/test_production_deploy.py index 8926a5c..ef191a3 100644 --- a/tests/test_production_deploy.py +++ b/tests/test_production_deploy.py @@ -164,21 +164,14 @@ def test_activate_escapes_dollar_secrets_and_never_down(tool, monkeypatch): assert len(calls) == 2 and "up" in calls[1] and "--no-build" in calls[1] -def test_workflow_deploy_is_verify_gated_and_manual_plan_default(tool): +def test_workflow_verifies_without_legacy_production_deploy(tool): import yaml workflow = yaml.safe_load( (Path(__file__).parents[1] / ".github/workflows/verify.yml").read_text() ) - job = workflow["jobs"]["deploy"] - assert job["needs"] == "verify" and "success()" in job["if"] - assert "refs/heads/main" in job["if"] and "pull_request" not in job["if"] - assert job["env"]["PROD_DEPLOY_ENABLED"] == "${{ vars.PROD_DEPLOY_ENABLED }}" - assert ( - workflow[True]["workflow_dispatch"]["inputs"]["deploy_action"]["default"] - == "plan" - ) - assert job["steps"][-1]["if"] == "always()" + assert set(workflow["jobs"]) == {"verify"} + assert workflow[True]["workflow_dispatch"] is None def test_recovery_handles_missing_or_crashed_production_container(tool, monkeypatch): @@ -311,3 +304,24 @@ def test_image_retention_failure_does_not_fail_verified_deploy( output = capsys.readouterr().out assert "cleanup deferred" in output assert "private diagnostic" not in output + + +def test_retired_nas_target_is_rejected(tool): + tool.env["PROD_DATA_DIR"] = "/volume2/docker/pigway-frame-flow/data" + with pytest.raises(ValueError, match="retired"): + tool.settings() + + +def test_preflight_rejects_legacy_photo_mount(tool, monkeypatch): + old = existing(tool) + old["Mounts"].append( + { + "Type": "bind", + "Source": "/volume1/photo", + "Destination": "/photos", + "RW": False, + } + ) + monkeypatch.setattr(tool, "inspect", lambda _: old) + with pytest.raises(ValueError, match="retired"): + tool.preflight() diff --git a/tests/test_publication_security.py b/tests/test_publication_security.py index 01fd088..5db71e1 100644 --- a/tests/test_publication_security.py +++ b/tests/test_publication_security.py @@ -6,10 +6,10 @@ def test_external_forks_do_not_execute_on_nas_runner(): assert ( "github.event.pull_request.head.repo.full_name == github.repository" in workflow ) - assert "needs: verify" in workflow + assert "python scripts/production-deploy.py" not in workflow -def test_private_production_configuration_uses_secrets(): +def test_verification_runner_receives_no_production_configuration(): workflow = Path(".github/workflows/verify.yml").read_text() for key in ( "PROD_DATA_DIR", @@ -19,7 +19,7 @@ def test_private_production_configuration_uses_secrets(): "SYNOLOGY_PHOTOS_URL", "SYNOLOGY_PHOTOS_CERT_SHA256", ): - assert "${{ secrets." + key + " }}" in workflow + assert "${{ secrets." + key + " }}" not in workflow assert "${{ vars." + key + " }}" not in workflow @@ -28,7 +28,7 @@ def test_actions_downloads_do_not_depend_on_personal_server(): workflow = Path(".github/workflows/verify.yml").read_text() actions = re.findall(r"uses:\s*(\S+)", workflow) - assert len(actions) == 4 + assert len(actions) == 2 for action in actions: assert re.fullmatch( r"https://github\.com/actions/(checkout|setup-python)@[0-9a-f]{40}", diff --git a/tests/test_retention.py b/tests/test_retention.py index 7c06337..e88e1ae 100644 --- a/tests/test_retention.py +++ b/tests/test_retention.py @@ -172,3 +172,7 @@ def test_first_cleanup_runs_after_recent_host_boot( ] == 1 ) + + +# Legacy algorithms remain covered; production filesystem access is tested separately. +pytestmark = pytest.mark.usefixtures("legacy_photo_engine") diff --git a/tests/test_server_release.py b/tests/test_server_release.py new file mode 100644 index 0000000..651284a --- /dev/null +++ b/tests/test_server_release.py @@ -0,0 +1,55 @@ +"""Production release preflight must reject unsafe inherited container settings.""" + +import importlib.util +from pathlib import Path + +import pytest + + +@pytest.fixture +def release_tool(): + spec = importlib.util.spec_from_file_location( + "server_release", Path(__file__).parents[1] / "scripts/server-release.py" + ) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +@pytest.mark.parametrize( + "unsafe", ["mock", "writes", "photos", "system_disk", "root", "caps"] +) +def test_release_rejects_unsafe_runtime(release_tool, monkeypatch, unsafe): + container = { + "Config": { + "User": "10333:10333", + "Env": [ + "KOKONNA_MOCK=false", + "KOKONNA_WRITE_ENABLED=true", + "BUILD_COMMIT=test", + ], + }, + "State": {"Running": True}, + "HostConfig": {"ReadonlyRootfs": True, "CapDrop": ["ALL"]}, + "Mounts": [ + {"Destination": "/data", "Source": str(release_tool.APP / "data")}, + {"Destination": "/config", "Source": str(release_tool.APP / "config")}, + ], + } + if unsafe == "mock": + container["Config"]["Env"][0] = "KOKONNA_MOCK=true" + elif unsafe == "writes": + container["Config"]["Env"][1] = "KOKONNA_WRITE_ENABLED=false" + elif unsafe == "photos": + container["Mounts"].append( + {"Destination": "/photos", "Source": "/volume1/photo"} + ) + elif unsafe == "system_disk": + container["Mounts"][0]["Source"] = "/var/lib/frame-flow" + elif unsafe == "root": + container["Config"]["User"] = "root" + else: + container["HostConfig"]["CapDrop"] = [] + monkeypatch.setattr(release_tool, "inspect", lambda: container) + with pytest.raises(AssertionError): + release_tool.verify_runtime("test") diff --git a/tests/test_synology_photos.py b/tests/test_synology_photos.py index cd05521..6cdde07 100644 --- a/tests/test_synology_photos.py +++ b/tests/test_synology_photos.py @@ -152,3 +152,7 @@ def test_people_api_auth_and_secret_response(client, service, monkeypatch): assert r.status_code == 422 and "input" not in r.text client.headers.pop("Authorization") assert client.get("/api/v1/integrations/synology-photos/people").status_code == 401 + + +# Legacy algorithms remain covered; production filesystem access is tested separately. +pytestmark = pytest.mark.usefixtures("legacy_photo_engine")