Release v0.1.9: enforce Photos API boundary and finalize Server migration
This commit is contained in:
@@ -5,12 +5,6 @@ on:
|
|||||||
branches: ["**"]
|
branches: ["**"]
|
||||||
pull_request:
|
pull_request:
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
inputs:
|
|
||||||
deploy_action:
|
|
||||||
description: Production action (deploy requires explicit enabled Variable)
|
|
||||||
type: choice
|
|
||||||
options: [plan, bootstrap, deploy, rollback]
|
|
||||||
default: plan
|
|
||||||
jobs:
|
jobs:
|
||||||
verify:
|
verify:
|
||||||
# The NAS Docker runner must never execute code from external forks.
|
# The NAS Docker runner must never execute code from external forks.
|
||||||
@@ -47,54 +41,5 @@ jobs:
|
|||||||
python scripts/ci-compose-verify.py --cleanup
|
python scripts/ci-compose-verify.py --cleanup
|
||||||
fi
|
fi
|
||||||
|
|
||||||
deploy:
|
# Production moved to Ubuntu Server. Release manually with scripts/server-release.py;
|
||||||
needs: verify
|
# the shared NAS runner performs isolated verification only.
|
||||||
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
|
|
||||||
|
|||||||
@@ -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:
|
services:
|
||||||
pigway-frame-flow:
|
pigway-frame-flow:
|
||||||
environment:
|
environment:
|
||||||
PHOTO_ROOT: /photos
|
SYNOLOGY_PHOTOS_CACHE_BYTES: ${SYNOLOGY_PHOTOS_CACHE_BYTES:-268435456}
|
||||||
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
|
|
||||||
|
|||||||
@@ -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 断连、服务器重启或压力测试。
|
||||||
@@ -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.
|
||||||
@@ -265,7 +265,10 @@ class GalleryImage(BaseModel):
|
|||||||
|
|
||||||
|
|
||||||
class Schedule(StrictModel):
|
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
|
cron: str | None = None
|
||||||
time: str | None = Field(default=None, pattern=r"^([01]\d|2[0-3]):[0-5]\d$")
|
time: str | None = Field(default=None, pattern=r"^([01]\d|2[0-3]):[0-5]\d$")
|
||||||
at: datetime | None = None
|
at: datetime | None = None
|
||||||
|
|||||||
@@ -109,7 +109,18 @@ class PhotoSources:
|
|||||||
raise FlowError(E.NOT_FOUND, "Photo source not found", 404)
|
raise FlowError(E.NOT_FOUND, "Photo source not found", 404)
|
||||||
return PhotoSourceInput.model_validate_json(row["payload"])
|
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):
|
def save(self, source_id, source):
|
||||||
|
self.require_api_source(source)
|
||||||
if source.minimum_brightness > source.maximum_brightness:
|
if source.minimum_brightness > source.maximum_brightness:
|
||||||
raise FlowError(E.INVALID_REQUEST, "Brightness range is reversed")
|
raise FlowError(E.INVALID_REQUEST, "Brightness range is reversed")
|
||||||
if source.kind not in ("local", "synology-photos") and (
|
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)):
|
def catalog(self, source_id, refresh=False, *, target_size=(1200, 1600)):
|
||||||
source = self.get(source_id)
|
source = self.get(source_id)
|
||||||
|
self.require_api_source(source)
|
||||||
if not source.enabled:
|
if not source.enabled:
|
||||||
raise FlowError(E.CONFLICT, "Photo source is disabled", 409)
|
raise FlowError(E.CONFLICT, "Photo source is disabled", 409)
|
||||||
session_identity = (
|
session_identity = (
|
||||||
|
|||||||
@@ -23,6 +23,10 @@ from pigway_frame_flow.storage.database import encode, now_iso
|
|||||||
|
|
||||||
def next_run(schedule, after=None):
|
def next_run(schedule, after=None):
|
||||||
after = after or datetime.now(UTC)
|
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 schedule.type in ("date", "temporary"):
|
||||||
if not schedule.at or schedule.at.tzinfo is None:
|
if not schedule.at or schedule.at.tzinfo is None:
|
||||||
raise FlowError(E.INVALID_REQUEST, "One-time date must include timezone")
|
raise FlowError(E.INVALID_REQUEST, "One-time date must include timezone")
|
||||||
|
|||||||
@@ -13,10 +13,10 @@ const t=k=>window.MESSAGES[state.lang][k]||k;
|
|||||||
const esc=v=>String(v??'—').replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
|
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 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')}`;
|
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 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!=='cron')return t(type);return state.lang==='zh'?'高级规则(Cron)':'Advanced rule (Cron)';}
|
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(['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 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(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 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)=>`<button type="button" id="${id}" class="${primary?'primary':''}">${t(text)}</button>`;
|
const buttons=(id,text,primary=false)=>`<button type="button" id="${id}" class="${primary?'primary':''}">${t(text)}</button>`;
|
||||||
let toastTimer;
|
let toastTimer;
|
||||||
function toast(message){$('#toast').textContent=message;$('#toast').style.display='block';clearTimeout(toastTimer);toastTimer=setTimeout(()=>$('#toast').style.display='none',6000);}
|
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 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;}
|
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);
|
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])=>`<button data-page="${id}" class="${id===state.page?'active':''}" ${id===state.page?'aria-current="page"':''}><span class="nav-icon">${icon}</span><span>${t(id)}</span></button>`).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=>`<option value="${esc(d.id)}" ${d.id===state.device?'selected':''}>${esc(d.name)}</option>`).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])=>`<button data-page="${id}" class="${id===state.page?'active':''}" ${id===state.page?'aria-current="page"':''}><span class="nav-icon">${icon}</span><span>${t(id)}</span></button>`).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=>`<option value="${esc(d.id)}" ${d.id===state.device?'selected':''}>${esc(deviceDisplayName(d))}</option>`).join('');revealCurrentNavigation();}
|
||||||
// A destination appears only when its connection and read/write adapter are available.
|
// A destination appears only when its connection and read/write adapter are available.
|
||||||
// Future cloud-drive destinations must implement their adapter before registration here.
|
// Future cloud-drive destinations must implement their adapter before registration here.
|
||||||
const galleryDestinationAdapters=new Set(['kokonna','local-assets']);
|
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 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?`<div class="notice">${t('mock')}</div>`:'';}
|
function notice(){return state.settings?.mock?`<div class="notice">${t('mock')}</div>`:'';}
|
||||||
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(!state.token){login();return;}
|
||||||
if(previousPage!==state.page)$('#main').replaceChildren();
|
if(previousPage!==state.page)$('#main').replaceChildren();
|
||||||
const previousBlobs=state.blobs;state.blobs=[];
|
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=`<div class="card empty"><strong>${t('apiError')}</strong><p>${esc(e.message)}</p>${buttons('retry','refresh')}</div>`;bind('retry',()=>navigate());}
|
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=`<div class="card empty"><strong>${t('apiError')}</strong><p>${esc(e.message)}</p>${buttons('retry','refresh')}</div>`;bind('retry',()=>navigate());}
|
||||||
}
|
}
|
||||||
function login(){ $('#main').innerHTML=`<div class="card" style="max-width:560px;margin:40px auto"><span class="badge">FRAME FLOW</span><h2 style="margin-top:20px">${t('connect')}</h2><p class="hint">${t('tokenHint')}</p><form id="login-form"><div class="field"><label for="username">${t('username')}</label><input id="username" required autocomplete="username"></div><div class="field"><label for="password">${t('password')}</label><input id="password" type="password" required autocomplete="current-password"></div><label class="check-row"><input id="remember-login" type="checkbox" checked>${t('rememberLogin')}</label><button class="primary" type="submit">${t('connect')}</button></form></div>`;$('#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=`<section class="card login-card" aria-labelledby="login-title"><span class="badge">FRAME FLOW</span><h2 id="login-title">${t('connect')}</h2><p class="hint">${t('tokenHint')}</p><form id="login-form"><div class="field"><label for="username">${t('username')}</label><input id="username" required autocomplete="username"></div><div class="field"><label for="password">${t('password')}</label><input id="password" type="password" required autocomplete="current-password"></div><label class="check-row"><input id="remember-login" type="checkbox" checked><span>${t('rememberLogin')}</span></label><button class="primary" type="submit">${t('connect')}</button></form></section>`;$('#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.
|
// 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)}`;}
|
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;}
|
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()+`<div class="notice">${t('uploadNotice')}</div><div class="columns"><section class="card"><h2>${t('upload')}</h2><div class="dropzone"><strong>${t('selectFile')}</strong><p class="hint">${t('uploadHint')}</p><input id="file" type="file" accept="image/jpeg,image/png,image/webp" aria-label="${t('selectFile')}"></div><div class="form-grid" style="margin-top:24px"><div class="field"><label for="fit">${t('mode')}</label><select id="fit">${['contain','cover','smart'].map(x=>`<option value="${x}">${t(x)}</option>`).join('')}</select></div><div class="field"><label for="profile">${t('profile')}</label><select id="profile">${['color','grayscale'].map(x=>`<option value="${x}">${t(x)}</option>`).join('')}</select></div></div>${buttons('make-preview','preview',true)}<p class="hint">${t('previewHint')}</p></section><section class="card"><h3>${t('preview')}</h3><div id="preview-box" class="empty">${t('previewHint')}</div><div class="actions" style="margin-top:18px"><button id="save-local" disabled>${t('staging')}</button><button id="display-upload" class="primary" disabled>${t('display')}</button></div></section></div>`;bind('make-preview',async()=>{const file=$('#file').files[0];if(!file)throw new Error(t('selectFile'));const form=new FormData();form.append('file',file);form.append('fit',$('#fit').value);form.append('profile',$('#profile').value);state.preview=await api('/api/v1/preview',{method:'POST',body:form});$('#preview-box').className='';$('#preview-box').innerHTML='<img id="upload-preview" class="preview" alt="Preview">';await blobImage($('#upload-preview'),state.preview.url);$('#save-local').disabled=false;$('#display-upload').disabled=!liveWriteAllowed();});for(const id of ['file','fit','profile'])$('#'+id).onchange=()=>{state.preview=null;$('#save-local').disabled=true;$('#display-upload').disabled=true;};bind('save-local',async()=>{const id=await accepted('/api/v1/devices/'+state.device+'/upload',{type:'image',asset_id:state.preview.asset_id});await waitOperation(id);state.tab='local';return navigate('gallery');});bind('display-upload',()=>displayDialog({type:'image',asset_id:state.preview.asset_id}));},
|
async upload(){state.preview=null;$('#main').innerHTML=notice()+`<div class="notice">${t('uploadNotice')}</div><div class="columns"><section class="card"><h2>${t('upload')}</h2><div class="dropzone"><strong>${t('selectFile')}</strong><p class="hint">${t('uploadHint')}</p><input id="file" type="file" accept="image/jpeg,image/png,image/webp" aria-label="${t('selectFile')}"></div><div class="form-grid" style="margin-top:24px"><div class="field"><label for="fit">${t('mode')}</label><select id="fit">${['contain','cover','smart'].map(x=>`<option value="${x}">${t(x)}</option>`).join('')}</select></div><div class="field"><label for="profile">${t('profile')}</label><select id="profile">${['color','grayscale'].map(x=>`<option value="${x}">${t(x)}</option>`).join('')}</select></div></div>${buttons('make-preview','preview',true)}<p class="hint">${t('previewHint')}</p></section><section class="card"><h3>${t('preview')}</h3><div id="preview-box" class="empty">${t('previewHint')}</div><div class="actions" style="margin-top:18px"><button id="save-local" disabled>${t('staging')}</button><button id="display-upload" class="primary" disabled>${t('display')}</button></div></section></div>`;bind('make-preview',async()=>{const file=$('#file').files[0];if(!file)throw new Error(t('selectFile'));const form=new FormData();form.append('file',file);form.append('fit',$('#fit').value);form.append('profile',$('#profile').value);state.preview=await api('/api/v1/preview',{method:'POST',body:form});$('#preview-box').className='';$('#preview-box').innerHTML='<img id="upload-preview" class="preview" alt="Preview">';await blobImage($('#upload-preview'),state.preview.url);$('#save-local').disabled=false;$('#display-upload').disabled=!liveWriteAllowed();});for(const id of ['file','fit','profile'])$('#'+id).onchange=()=>{state.preview=null;$('#save-local').disabled=true;$('#display-upload').disabled=true;};bind('save-local',async()=>{const id=await accepted('/api/v1/devices/'+state.device+'/upload',{type:'image',asset_id:state.preview.asset_id});await waitOperation(id);state.tab='local';return navigate('gallery');});bind('display-upload',()=>displayDialog({type:'image',asset_id:state.preview.asset_id}));},
|
||||||
async templates(){state.templates=await api('/api/v1/templates');const selected=state.templates.find(x=>x.id===state.selectedTemplate)||state.templates[0];state.selectedTemplate=selected.id;if(state.pendingTemplateData?.template===selected.id){selected.defaults=state.pendingTemplateData.data;state.initialProfile=state.pendingTemplateData.profile;state.pendingTemplateData=null;}state.templatePreview=null;$('#main').innerHTML=notice()+`<div class="notice">${t('templateHint')}<p>${t('templateSteps')}</p></div><div class="template-grid">${state.templates.map((x,i)=>`<div class="card template-card ${x.id===selected.id?'selected':''}" role="button" tabindex="0" data-template="${x.id}"><span>${{'daily-poetry':'詩','calendar':'▦','daily-dashboard':'☀','photo-text':'▣','quote':'❝','system-alert':'◒','weather':'☁'}[x.id]||'▦'}</span><div><h3>${esc(x.name.split(' / ')[state.lang==='zh'?1:0]||x.name)}</h3><small>1200 × 1600 · ${esc(x.id)}</small></div></div>`).join('')}</div><div class="columns"><section class="card"><h3>${t('templateData')}</h3><p class="hint">${t('templateGuideTitle')}</p><p class="hint">${t({'photo-text':'guidePhotoText','daily-dashboard':'guideDashboard',weather:'guideWeather',calendar:'guideCalendar',quote:'guideQuote','daily-poetry':'guidePoetry','system-alert':'guideAlert'}[selected.id]||'templateContentHelp')}</p>${['weather','daily-dashboard'].includes(selected.id)?`<p class="hint">${t('templateWeatherHelp')}</p>`:''}<textarea id="template-data" aria-label="${t('templateData')}">${esc(JSON.stringify(selected.defaults,null,2))}</textarea><div class="field" style="margin-top:16px"><label for="template-profile">${t('profile')}</label><select id="template-profile"><option value="color">${t('color')}</option><option value="grayscale">${t('grayscale')}</option></select><p class="hint">${t('helpProfile')}</p></div><p class="hint">${t('templateActionHelp')}</p><p class="hint">${t('templateAutomationHelp')}</p><div class="actions">${buttons('render-template','render',true)}${buttons('save-template','saveTemplate')}</div></section><section class="card"><h3>${t('preview')}</h3><div id="template-preview" class="empty">${t('templatePreviewHint')}</div><button id="display-template" class="primary" style="margin-top:18px" disabled>${t('display')}</button></section></div>`;document.querySelectorAll('[data-template]').forEach(b=>{b.onclick=()=>{state.selectedTemplate=b.dataset.template;navigate(['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='<img id="template-image" class="preview" alt="Template preview">';await blobImage($('#template-image'),op.result.url);$('#display-template').disabled=!liveWriteAllowed();});bind('save-template',async()=>{await api('/api/v1/templates/'+selected.id+'/config',{method:'PUT',body:{data:JSON.parse($('#template-data').value)}});toast(t('done'));});$('#template-data').oninput=()=>$('#display-template').disabled=true;$('#template-profile').onchange=()=>$('#display-template').disabled=true;bind('display-template',()=>displayDialog({type:'image',asset_id:state.templatePreview}));await templateEditor(selected);if(state.initialProfile){$('#template-profile').value=state.initialProfile;state.initialProfile=null;}},
|
async templates(){state.templates=await api('/api/v1/templates');const selected=state.templates.find(x=>x.id===state.selectedTemplate)||state.templates[0];state.selectedTemplate=selected.id;if(state.pendingTemplateData?.template===selected.id){selected.defaults=state.pendingTemplateData.data;state.initialProfile=state.pendingTemplateData.profile;state.pendingTemplateData=null;}state.templatePreview=null;$('#main').innerHTML=notice()+`<div class="notice">${t('templateHint')}<p>${t('templateSteps')}</p></div><div class="template-grid">${state.templates.map((x,i)=>`<div class="card template-card ${x.id===selected.id?'selected':''}" role="button" tabindex="0" data-template="${x.id}"><span>${{'daily-poetry':'詩','calendar':'▦','daily-dashboard':'☀','photo-text':'▣','quote':'❝','system-alert':'◒','weather':'☁'}[x.id]||'▦'}</span><div><h3>${esc(x.name.split(' / ')[state.lang==='zh'?1:0]||x.name)}</h3><small>1200 × 1600 · ${esc(x.id)}</small></div></div>`).join('')}</div><div class="columns"><section class="card"><h3>${t('templateData')}</h3><p class="hint">${t('templateGuideTitle')}</p><p class="hint">${t({'photo-text':'guidePhotoText','daily-dashboard':'guideDashboard',weather:'guideWeather',calendar:'guideCalendar',quote:'guideQuote','daily-poetry':'guidePoetry','system-alert':'guideAlert'}[selected.id]||'templateContentHelp')}</p>${['weather','daily-dashboard'].includes(selected.id)?`<p class="hint">${t('templateWeatherHelp')}</p>`:''}<textarea id="template-data" aria-label="${t('templateData')}">${esc(JSON.stringify(selected.defaults,null,2))}</textarea><div class="field" style="margin-top:16px"><label for="template-profile">${t('profile')}</label><select id="template-profile"><option value="color">${t('color')}</option><option value="grayscale">${t('grayscale')}</option></select><p class="hint">${t('helpProfile')}</p></div><p class="hint">${t('templateActionHelp')}</p><p class="hint">${t('templateAutomationHelp')}</p><div class="actions">${buttons('render-template','render',true)}${buttons('save-template','saveTemplate')}</div></section><section class="card"><h3>${t('preview')}</h3><div id="template-preview" class="empty">${t('templatePreviewHint')}</div><button id="display-template" class="primary" style="margin-top:18px" disabled>${t('display')}</button></section></div>`;document.querySelectorAll('[data-template]').forEach(b=>{b.onclick=()=>{state.selectedTemplate=b.dataset.template;navigate(['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='<img id="template-image" class="preview" alt="Template preview">';await blobImage($('#template-image'),op.result.url);$('#display-template').disabled=!liveWriteAllowed();});bind('save-template',async()=>{await api('/api/v1/templates/'+selected.id+'/config',{method:'PUT',body:{data:JSON.parse($('#template-data').value)}});toast(t('done'));});$('#template-data').oninput=()=>$('#display-template').disabled=true;$('#template-profile').onchange=()=>$('#display-template').disabled=true;bind('display-template',()=>displayDialog({type:'image',asset_id:state.templatePreview}));await templateEditor(selected);if(state.initialProfile){$('#template-profile').value=state.initialProfile;state.initialProfile=null;}},
|
||||||
async automation(epoch){const jobs=await api('/api/v1/jobs');if(epoch!==state.epoch)return;$('#main').innerHTML=notice()+`<div class="section-heading"><p class="hint">Asia/Shanghai · ${t('automation')}</p>${buttons('new-job','newJob',true)}</div><div class="card">${jobs.length?`<div class="table-wrap"><table><thead><tr>${['name','schedule','status','nextRun','lastRun','result','action'].map(x=>`<th>${t(x)}</th>`).join('')}</tr></thead><tbody>${jobs.map((j,i)=>`<tr><td><strong>${esc(j.name)}</strong><br><small>${esc(j.device_id)}</small></td><td>${t(j.schedule.type)}<br>${esc(j.schedule.time||j.schedule.cron||fmt(j.schedule.at))}</td><td><span class="badge">${t(j.enabled?'enabled':'disabled')}</span></td><td>${fmt(j.next_run)}</td><td>${fmt(j.last_run)}</td><td><code>${esc(j.last_result)}</code></td><td><div class="row-actions"><button data-job-edit="${i}">${t('edit')}</button><button data-job-toggle="${i}">${t(j.enabled?'disabled':'enabled')}</button><button class="danger" data-job-delete="${i}">${t('remove')}</button></div></td></tr>`).join('')}</tbody></table></div>`:`<div class="empty"><strong>${t('noTasks')}</strong></div>`}</div>`;bind('new-job',()=>jobEditor());document.querySelectorAll('[data-job-edit]').forEach(b=>b.onclick=()=>jobEditor(jobs[b.dataset.jobEdit]));document.querySelectorAll('[data-job-toggle]').forEach(b=>b.onclick=busy(async()=>{const j=jobs[b.dataset.jobToggle];await api('/api/v1/jobs/'+j.id,{method:'PUT',body:jobPayload(j,{enabled:!j.enabled})});await navigate();}));document.querySelectorAll('[data-job-delete]').forEach(b=>b.onclick=()=>confirmDialog(t('removeConfirm'),async()=>{await api('/api/v1/jobs/'+jobs[b.dataset.jobDelete].id,{method:'DELETE'});await navigate();}));},
|
|
||||||
async playlists(epoch){const playlists=await api('/api/v1/playlists');if(epoch!==state.epoch)return;$('#main').innerHTML=notice()+`<div class="notice">${t('playlistNotice')}</div><div class="section-heading"><h2>${t('playlists')}</h2>${buttons('new-playlist','newPlaylist',true)}</div><div class="gallery">${playlists.map((p,i)=>`<section class="card"><span class="badge">${t(p.enabled?'enabled':'disabled')}</span><h3 style="margin-top:16px">${esc(p.name)}</h3><p class="hint">${p.image_ids.length} ${t('count')} · ${p.interval_minutes} ${t('minutes')} · ${t(p.mode)}</p><p><code>${esc(p.image_ids.join(' → '))}</code></p><small>${t('lastScheduled')}: ${p.position>=0?esc(p.image_ids[p.position]):'—'}</small><div class="actions" style="margin-top:16px"><button data-pl-edit="${i}">${t('edit')}</button><button class="danger" data-pl-delete="${i}">${t('remove')}</button></div></section>`).join('')||`<div class="card empty"><strong>${t('noTasks')}</strong></div>`}</div>`;bind('new-playlist',()=>playlistEditor());document.querySelectorAll('[data-pl-edit]').forEach(b=>b.onclick=()=>playlistEditor(playlists[b.dataset.plEdit]));document.querySelectorAll('[data-pl-delete]').forEach(b=>b.onclick=()=>confirmDialog(t('removeConfirm'),async()=>{await api('/api/v1/playlists/'+playlists[b.dataset.plDelete].id,{method:'DELETE'});await navigate();}));},
|
async playlists(epoch){const playlists=await api('/api/v1/playlists');if(epoch!==state.epoch)return;$('#main').innerHTML=notice()+`<div class="notice">${t('playlistNotice')}</div><div class="section-heading"><h2>${t('playlists')}</h2>${buttons('new-playlist','newPlaylist',true)}</div><div class="gallery">${playlists.map((p,i)=>`<section class="card"><span class="badge">${t(p.enabled?'enabled':'disabled')}</span><h3 style="margin-top:16px">${esc(p.name)}</h3><p class="hint">${p.image_ids.length} ${t('count')} · ${p.interval_minutes} ${t('minutes')} · ${t(p.mode)}</p><p><code>${esc(p.image_ids.join(' → '))}</code></p><small>${t('lastScheduled')}: ${p.position>=0?esc(p.image_ids[p.position]):'—'}</small><div class="actions" style="margin-top:16px"><button data-pl-edit="${i}">${t('edit')}</button><button class="danger" data-pl-delete="${i}">${t('remove')}</button></div></section>`).join('')||`<div class="card empty"><strong>${t('noTasks')}</strong></div>`}</div>`;bind('new-playlist',()=>playlistEditor());document.querySelectorAll('[data-pl-edit]').forEach(b=>b.onclick=()=>playlistEditor(playlists[b.dataset.plEdit]));document.querySelectorAll('[data-pl-delete]').forEach(b=>b.onclick=()=>confirmDialog(t('removeConfirm'),async()=>{await api('/api/v1/playlists/'+playlists[b.dataset.plDelete].id,{method:'DELETE'});await navigate();}));},
|
||||||
async history(epoch){const ops=await api('/api/v1/operations');if(epoch!==state.epoch)return;$('#main').innerHTML=`<div class="section-heading"><h2>${t('history')}</h2>${buttons('history-refresh','refresh')}</div>${ops.some(x=>x.status==='UNKNOWN')?`<div class="notice warning">${t('unknown')}</div>`:''}<div class="card table-wrap">${ops.length?`<table><thead><tr>${['created','action','status','result','client','requestId'].map(x=>`<th>${t(x)}</th>`).join('')}</tr></thead><tbody>${ops.map((o,i)=>`<tr><td>${fmt(o.created_at)}</td><td>${esc(o.action)}<br><small>${esc(o.device_id)}</small></td><td><span class="badge ${['FAILED','UNKNOWN'].includes(o.status)?'bad':''}">${t(o.status)}</span>${o.status==='UNKNOWN'?`<div><button data-reconcile="${i}">${t('reconcile')}</button></div>`:''}</td><td>${esc(o.result?.image_id||o.result?.asset_id||o.error?.code||'—')}</td><td>${esc(o.client_id)}</td><td><code>${esc(o.request_id)}<br>${esc(o.operation_id)}</code></td></tr>`).join('')}</tbody></table>`:`<div class="empty">${t('noHistory')}</div>`}</div>`;bind('history-refresh',()=>navigate());document.querySelectorAll('[data-reconcile]').forEach(b=>b.onclick=busy(async()=>{const o=ops[b.dataset.reconcile];const r=await api('/api/v1/operations/'+o.operation_id+'/reconcile',{method:'POST',body:{acknowledge:false}});showModal(`<h2>${t('reconcile')}</h2><p>${t('current')}: ${esc(r.current_image_id)}</p><p>${t('result')}: ${esc(r.expected_image_id)}</p><p class="hint">${t('unknown')}</p><div class="modal-actions">${buttons('modal-cancel','cancel')}${r.resolved?'':buttons('acknowledge','acknowledge')}</div>`);bind('acknowledge',async()=>{await api('/api/v1/operations/'+o.operation_id+'/reconcile',{method:'POST',body:{acknowledge:true}});$('#modal').close();await navigate();});}));},
|
async history(epoch){const ops=await api('/api/v1/operations');if(epoch!==state.epoch)return;$('#main').innerHTML=`<div class="section-heading"><h2>${t('history')}</h2>${buttons('history-refresh','refresh')}</div>${ops.some(x=>x.status==='UNKNOWN')?`<div class="notice warning">${t('unknown')}</div>`:''}<div class="card table-wrap">${ops.length?`<table><thead><tr>${['created','action','status','result','client','requestId'].map(x=>`<th>${t(x)}</th>`).join('')}</tr></thead><tbody>${ops.map((o,i)=>`<tr><td>${fmt(o.created_at)}</td><td>${esc(operationActionLabel(o.action))}<br><small>${esc(deviceNameForId(o.device_id))}</small></td><td><span class="badge ${['FAILED','UNKNOWN'].includes(o.status)?'bad':''}">${t(o.status)}</span>${o.status==='UNKNOWN'?`<div><button data-reconcile="${i}">${t('reconcile')}</button></div>`:''}</td><td>${esc(o.result?.image_id||o.result?.asset_id||operationErrorLabel(o.error)||'—')}</td><td>${esc(operationClientLabel(o.client_id))}</td><td><code>${esc(o.request_id)}<br>${esc(o.operation_id)}</code></td></tr>`).join('')}</tbody></table>`:`<div class="empty">${t('noHistory')}</div>`}</div>`;bind('history-refresh',()=>navigate());document.querySelectorAll('[data-reconcile]').forEach(b=>b.onclick=busy(async()=>{const o=ops[b.dataset.reconcile];const r=await api('/api/v1/operations/'+o.operation_id+'/reconcile',{method:'POST',body:{acknowledge:false}});showModal(`<h2>${t('reconcile')}</h2><p>${t('current')}: ${esc(r.current_image_id)}</p><p>${t('result')}: ${esc(r.expected_image_id)}</p><p class="hint">${t('unknown')}</p><div class="modal-actions">${buttons('modal-cancel','cancel')}${r.resolved?'':buttons('acknowledge','acknowledge')}</div>`);bind('acknowledge',async()=>{await api('/api/v1/operations/'+o.operation_id+'/reconcile',{method:'POST',body:{acknowledge:true}});$('#modal').close();await navigate();});}));},
|
||||||
async settings(){state.settings=await api('/api/v1/settings');const s=state.settings;$('#main').innerHTML=notice()+`<div class="columns"><section class="card"><h3>${t('settings')}</h3><div class="field"><label for="theme-select">${t('theme')}</label><select id="theme-select">${['system','light','dark'].map(x=>`<option value="${x}" ${state.theme===x?'selected':''}>${t(x==='system'?'systemTheme':x)}</option>`).join('')}</select></div><div class="field"><label for="language-select">${t('language')}</label><select id="language-select"><option value="zh" ${state.lang==='zh'?'selected':''}>简体中文</option><option value="en" ${state.lang==='en'?'selected':''}>English</option></select></div><p class="hint">${t('tokenHint')}</p>${buttons('logout','logout')}</section><section class="card"><h3>${t('auth')}</h3><div class="detail-grid">${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)}</div><p class="hint">${t('refreshInterval')}</p><a href="/docs" target="_blank" rel="noopener">${t('apiDocs')} →</a><p class="hint">GET /openapi.json · /api/v1</p></section></div>`;$('#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();}
|
async settings(){state.settings=await api('/api/v1/settings');const s=state.settings;$('#main').innerHTML=notice()+`<div class="columns"><section class="card"><h3>${t('settings')}</h3><div class="field"><label for="theme-select">${t('theme')}</label><select id="theme-select">${['system','light','dark'].map(x=>`<option value="${x}" ${state.theme===x?'selected':''}>${t(x==='system'?'systemTheme':x)}</option>`).join('')}</select></div><div class="field"><label for="language-select">${t('language')}</label><select id="language-select"><option value="zh" ${state.lang==='zh'?'selected':''}>简体中文</option><option value="en" ${state.lang==='en'?'selected':''}>English</option></select></div><p class="hint">${t('tokenHint')}</p>${buttons('logout','logout')}</section><section class="card"><h3>${t('auth')}</h3><div class="detail-grid">${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)}</div><p class="hint">${t('refreshInterval')}</p><a href="/docs" target="_blank" rel="noopener">${t('apiDocs')} →</a><p class="hint">GET /openapi.json · /api/v1</p></section></div>`;$('#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};}
|
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(`<h2>${t(job?'edit':'newJob')}</h2><form id="job-form"><div class="field"><label for="job-name">${t('name')}</label><input id="job-name" required maxlength="120" value="${esc(j.name)}"></div><div class="form-grid"><div class="field"><label for="job-schedule">${t('schedule')}</label><select id="job-schedule">${['daily','weekdays','weekends','cron','date','temporary'].map(x=>`<option ${j.schedule.type===x?'selected':''} value="${x}">${t(x)}</option>`).join('')}</select></div><div class="field"><label id="schedule-label" for="schedule-value">${t('time')}</label><input id="schedule-value" required value="${esc(j.schedule.time||j.schedule.cron||(j.schedule.at?j.schedule.at.slice(0,16):'07:00'))}"></div></div><div class="field"><label for="job-source">${t('source')} (JSON)</label><textarea id="job-source">${esc(JSON.stringify(j.source,null,2))}</textarea></div><div class="form-grid"><div class="field"><label for="job-duration">${t('duration')}</label><input id="job-duration" type="number" min="0" max="604800" value="${j.display_duration}"></div><div class="field"><label for="job-priority">${t('priority')}</label><input id="job-priority" type="number" min="0" max="100" value="${j.priority}"></div></div><label class="check-row"><input type="checkbox" id="job-enabled" ${j.enabled?'checked':''}>${t('enabled')}</label><label class="check-row"><input type="checkbox" id="job-restore" ${j.restore_previous?'checked':''}>${t('restoreAfter')}</label><div class="modal-actions">${buttons('modal-cancel','cancel')}<button type="submit" class="primary">${t('save')}</button></div></form>`);const source=$('#job-source');const choose=document.createElement('div');choose.className='field';choose.innerHTML=`<label for="job-template">${t('template')}</label><select id="job-template"><option value="">${t('advanced')}</option>${state.templates.map(x=>`<option value="${esc(x.id)}" ${j.source.template===x.id?'selected':''}>${esc(x.name)}</option>`).join('')}</select>`;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(`<h2>${t('playlists')}</h2><form id="playlist-form"><div class="field"><label for="playlist-name">${t('name')}</label><input id="playlist-name" required maxlength="120" value="${esc(p.name)}"></div><div class="field"><label for="playlist-images">${t('imageIds')}</label><input id="playlist-images" required value="${esc(p.image_ids.join(','))}" placeholder="1,2,3"></div><div class="form-grid"><div class="field"><label for="playlist-interval">${t('interval')}</label><input id="playlist-interval" type="number" min="5" max="10080" value="${p.interval_minutes}"></div><div class="field"><label for="playlist-mode">${t('playbackMode')}</label><select id="playlist-mode"><option value="queue" ${p.mode==='queue'?'selected':''}>${t('queue')}</option><option value="random" ${p.mode==='random'?'selected':''}>${t('random')}</option></select></div></div><label class="check-row"><input id="playlist-enabled" type="checkbox" ${p.enabled?'checked':''}>${t('enabled')}</label><div class="modal-actions">${buttons('modal-cancel','cancel')}<button type="submit" class="primary">${t('save')}</button></div></form>`);$('#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);}
|
async function playlistEditor(p){p=p||{name:'',image_ids:[],interval_minutes:60,mode:'queue',enabled:false};showModal(`<h2>${t('playlists')}</h2><form id="playlist-form"><div class="field"><label for="playlist-name">${t('name')}</label><input id="playlist-name" required maxlength="120" value="${esc(p.name)}"></div><div class="field"><label for="playlist-images">${t('imageIds')}</label><input id="playlist-images" required value="${esc(p.image_ids.join(','))}" placeholder="1,2,3"></div><div class="form-grid"><div class="field"><label for="playlist-interval">${t('interval')}</label><input id="playlist-interval" type="number" min="5" max="10080" value="${p.interval_minutes}"></div><div class="field"><label for="playlist-mode">${t('playbackMode')}</label><select id="playlist-mode"><option value="queue" ${p.mode==='queue'?'selected':''}>${t('queue')}</option><option value="random" ${p.mode==='random'?'selected':''}>${t('random')}</option></select></div></div><label class="check-row"><input id="playlist-enabled" type="checkbox" ${p.enabled?'checked':''}>${t('enabled')}</label><div class="modal-actions">${buttons('modal-cancel','cancel')}<button type="submit" class="primary">${t('save')}</button></div></form>`);$('#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);
|
$('#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=`<span>Pigway Frame Flow · ${t('footer')}</span><span>v${esc(v.version)} · ${esc(v.commit.slice(0,8))} · ${esc(v.build_time)}</span>`).catch(()=>{});
|
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=`<span>Pigway Frame Flow · ${t('footer')}</span><span>v${esc(v.version)} · ${esc(v.commit.slice(0,8))} · ${esc(v.build_time)}</span>`).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 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 original=job||base;
|
||||||
const j={...base,...original,schedule:normaliseSchedule(original.schedule||base.schedule)};
|
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');
|
const scheduleValue=j.schedule.every||j.schedule.time||j.schedule.cron||(j.schedule.at?j.schedule.at.slice(0,16):'07:00');
|
||||||
showModal(`<h2>${t(job?'edit':'newJob')}</h2><form id="job-form"><div class="field"><label for="job-name">${t('name')}</label><input id="job-name" required maxlength="120" value="${esc(j.name)}"></div><div class="form-grid"><div class="field"><label for="job-schedule">${t('schedule')}</label><select id="job-schedule">${['daily','weekdays','weekends','date','temporary','cron'].map(type=>`<option value="${type}" ${j.schedule.type===type?'selected':''}>${scheduleTypeLabel(type)}</option>`).join('')}</select></div><div class="field"><label id="schedule-label" for="schedule-value">${t('time')}</label><input id="schedule-value" required value="${esc(scheduleValue)}"></div></div><p class="hint" id="schedule-hint"></p><div class="field"><label for="job-source">${t('source')} (JSON)</label><textarea id="job-source">${esc(JSON.stringify(j.source,null,2))}</textarea></div><div class="form-grid"><div class="field"><label for="job-duration">${t('duration')}</label><input id="job-duration" type="number" min="0" max="604800" value="${j.display_duration}"></div><div class="field"><label for="job-priority">${t('priority')}</label><input id="job-priority" type="number" min="0" max="100" value="${j.priority}"></div></div><label class="check-row"><input type="checkbox" id="job-enabled" ${j.enabled?'checked':''}>${t('enabled')}</label><label class="check-row"><input type="checkbox" id="job-restore" ${j.restore_previous?'checked':''}>${t('restoreAfter')}</label><p class="hint">${t('cloudKeepLatest')}</p><div class="modal-actions">${buttons('modal-cancel','cancel')}<button type="submit" class="primary">${t('save')}</button></div></form>`);
|
showModal(`<h2>${t(job?.id?'edit':'newJob')}</h2><form id="job-form"><div class="field"><label for="job-name">${t('name')}</label><input id="job-name" required maxlength="120" value="${esc(j.name)}"></div><div class="form-grid"><div class="field"><label for="job-schedule">${t('schedule')}</label><select id="job-schedule">${['minutes','hours','daily','weekdays','weekends','date','temporary','cron'].map(type=>`<option value="${type}" ${j.schedule.type===type?'selected':''}>${scheduleTypeLabel(type)}</option>`).join('')}</select></div><div class="field"><label id="schedule-label" for="schedule-value">${t('time')}</label><input id="schedule-value" required value="${esc(scheduleValue)}"></div></div><p class="hint" id="schedule-hint"></p><div class="field"><label for="job-source">${t('source')} (JSON)</label><textarea id="job-source">${esc(JSON.stringify(j.source,null,2))}</textarea></div><div class="form-grid"><div class="field"><label for="job-duration">${t('duration')}</label><input id="job-duration" type="number" min="0" max="604800" value="${j.display_duration}"></div><div class="field"><label for="job-priority">${t('priority')}</label><input id="job-priority" type="number" min="0" max="100" value="${j.priority}"></div></div><label class="check-row"><input type="checkbox" id="job-enabled" ${j.enabled?'checked':''}>${t('enabled')}</label><label class="check-row"><input type="checkbox" id="job-restore" ${j.restore_previous?'checked':''}>${t('restoreAfter')}</label><p class="hint">${t('temporaryOptionsHint')}</p><p class="hint">${t('cloudKeepLatest')}</p><div class="modal-actions">${buttons('modal-cancel','cancel')}<button type="submit" class="primary">${t('save')}</button></div></form>`);
|
||||||
const source=$('#job-source');
|
const source=$('#job-source');
|
||||||
const choose=document.createElement('div');
|
const choose=document.createElement('div');
|
||||||
choose.className='field';
|
choose.className='field';
|
||||||
choose.innerHTML=`<label for="job-template">${t('template')}</label><select id="job-template"><option value="">${t('advanced')}</option>${state.templates.map(template=>`<option value="${esc(template.id)}" ${j.source.template===template.id?'selected':''}>${esc(template.name)}</option>`).join('')}</select>`;
|
choose.innerHTML=`<label for="job-template">${t('template')}</label><select id="job-template"><option value="">${t('advanced')}</option>${state.templates.map(template=>`<option value="${esc(template.id)}" ${j.source.template===template.id?'selected':''}>${esc(template.name)}</option>`).join('')}</select>`;
|
||||||
source.parentElement.before(choose);
|
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);};
|
$('#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='';};
|
$('#job-schedule').onchange=()=>{updateScheduleField();$('#schedule-value').value='';};
|
||||||
updateScheduleField();
|
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);
|
await jobSourceEditor(j);
|
||||||
}
|
}
|
||||||
|
|
||||||
views.automation=async epoch=>{
|
views.automation=async epoch=>{
|
||||||
const jobs=await api('/api/v1/jobs');
|
const jobs=await api('/api/v1/jobs');
|
||||||
if(epoch!==state.epoch)return;
|
if(epoch!==state.epoch)return;
|
||||||
$('#main').innerHTML=notice()+`<div class="section-heading"><p class="hint">Asia/Shanghai · ${t('automation')}</p>${buttons('new-job','newJob',true)}</div><div class="card">${jobs.length?`<div class="table-wrap"><table><thead><tr>${['name','schedule','status','nextRun','lastRun','result','action'].map(key=>`<th>${t(key)}</th>`).join('')}</tr></thead><tbody>${jobs.map((job,index)=>`<tr><td><strong>${esc(job.name)}</strong><br><small>${esc(job.device_id)}</small></td><td>${esc(scheduleSummary(job.schedule))}</td><td><span class="badge">${t(job.enabled?'enabled':'disabled')}</span></td><td>${fmt(job.next_run)}</td><td>${fmt(job.last_run)}</td><td><code>${esc(job.last_result)}</code></td><td><div class="row-actions"><button data-job-edit="${index}">${t('edit')}</button><button data-job-toggle="${index}">${t(job.enabled?'disabled':'enabled')}</button><button class="danger" data-job-delete="${index}">${t('remove')}</button></div></td></tr>`).join('')}</tbody></table></div>`:`<div class="empty"><strong>${t('noTasks')}</strong></div>`}</div>`;
|
$('#main').innerHTML=notice()+`<div class="section-heading"><p class="hint">${state.lang==='zh'?'上海时间':'Shanghai time'} · ${t('automation')}</p>${buttons('new-job','newJob',true)}</div><div class="card">${jobs.length?`<div class="table-wrap"><table><thead><tr>${['name','schedule','status','nextRun','lastRun','result','action'].map(key=>`<th>${t(key)}</th>`).join('')}</tr></thead><tbody>${jobs.map((job,index)=>`<tr><td><strong>${esc(job.name)}</strong><br><small>${esc(deviceDisplayName(state.devices.find(d=>d.id===job.device_id)||{id:job.device_id,name:job.device_id}))}</small></td><td>${esc(scheduleSummary(job.schedule))}</td><td><span class="badge">${t(job.enabled?'enabled':'disabled')}</span></td><td>${fmt(job.next_run)}</td><td>${fmt(job.last_run)}</td><td><code>${esc(job.last_result)}</code></td><td><div class="row-actions"><button data-job-edit="${index}">${t('edit')}</button><button data-job-toggle="${index}">${t(job.enabled?'disabled':'enabled')}</button><button class="danger" data-job-delete="${index}">${t('remove')}</button></div></td></tr>`).join('')}</tbody></table></div>`:`<div class="empty"><strong>${t('noTasks')}</strong></div>`}</div>`;
|
||||||
bind('new-job',()=>jobEditor());
|
bind('new-job',()=>jobEditor());
|
||||||
document.querySelectorAll('[data-job-edit]').forEach(button=>button.onclick=()=>jobEditor(jobs[button.dataset.jobEdit]));
|
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();}));
|
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();}));
|
||||||
|
|||||||
@@ -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));}
|
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){
|
function addPhotoQualityFields(source){
|
||||||
const box=document.createElement('section');box.innerHTML=`<div id="source-local-quality"><h3>${tr('照片筛选','Photo filtering')}</h3><p class="hint">${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.')}</p><div class="field"><label for="source-people-space">${tr('人物空间','People space')}</label><select id="source-people-space"><option value="shared">${tr('共享空间','Shared space')}</option><option value="personal">${tr('个人空间','Personal space')}</option></select></div><div class="form-grid"><div class="field"><label for="source-sharpness">${tr('最低清晰度(0 不筛选)','Minimum sharpness (0 disables)')}</label><input id="source-sharpness" type="number" min="0" max="10000" value="${source?.minimum_sharpness||0}"></div><div class="field"><label for="source-bright-min">${tr('最低亮度','Minimum brightness')}</label><input id="source-bright-min" type="number" min="0" max="255" value="${source?.minimum_brightness||0}"></div><div class="field"><label for="source-bright-max">${tr('最高亮度','Maximum brightness')}</label><input id="source-bright-max" type="number" min="0" max="255" value="${source?.maximum_brightness??255}"></div></div><label class="check-row"><input id="source-dedup" type="checkbox" ${source?.avoid_duplicates?'checked':''}>${tr('避开最近 200 张的相似照片','Avoid photos similar to the last 200 selections')}</label></div><div class="field"><label for="source-oauth">${tr('网盘 OAuth 凭据 JSON(可选;仅原生网盘)','Drive OAuth credentials JSON (optional; native drives only)')}</label><input id="source-oauth" type="password" autocomplete="off" placeholder='{"access_token":"…","drive_id":"…"}'></div>`;$('#photo-source-form .modal-actions').before(box);$('#source-people-space').value=source?.people_space||'shared';
|
const box=document.createElement('section');box.innerHTML=`<div id="source-local-quality"><h3>${tr('照片筛选','Photo filtering')}</h3><p class="hint">${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.')}</p><div class="field"><label for="source-people-space">${tr('照片空间','Photo space')}</label><select id="source-people-space"><option value="shared">${tr('共享空间','Shared space')}</option><option value="personal">${tr('个人空间','Personal space')}</option></select></div><div class="form-grid"><div class="field"><label for="source-sharpness">${tr('最低清晰度(0 不筛选)','Minimum sharpness (0 disables)')}</label><input id="source-sharpness" type="number" min="0" max="10000" value="${source?.minimum_sharpness||0}"></div><div class="field"><label for="source-bright-min">${tr('最低亮度','Minimum brightness')}</label><input id="source-bright-min" type="number" min="0" max="255" value="${source?.minimum_brightness||0}"></div><div class="field"><label for="source-bright-max">${tr('最高亮度','Maximum brightness')}</label><input id="source-bright-max" type="number" min="0" max="255" value="${source?.maximum_brightness??255}"></div></div><label class="check-row"><input id="source-dedup" type="checkbox" ${source?.avoid_duplicates?'checked':''}>${tr('避开最近 200 张的相似照片','Avoid photos similar to the last 200 selections')}</label></div><div class="field"><label for="source-oauth">${tr('网盘 OAuth 凭据 JSON(可选;仅原生网盘)','Drive OAuth credentials JSON (optional; native drives only)')}</label><input id="source-oauth" type="password" autocomplete="off" placeholder='{"access_token":"…","drive_id":"…"}'></div>`;$('#photo-source-form .modal-actions').before(box);$('#source-people-space').value=source?.people_space||'shared';
|
||||||
|
|
||||||
}
|
}
|
||||||
async function aiImagePanel(){
|
async function aiImagePanel(){
|
||||||
|
|||||||
@@ -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'));});
|
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(){
|
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(`<h2>${t('details')}</h2><p>${t(current.status)} · ${esc(current.action)}</p><p>${esc(current.error?.message||'')}</p><pre style="white-space:pre-wrap;overflow-wrap:anywhere">${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))}</pre>${current.result?.url?'<img id="operation-preview" class="preview" alt="Preview">':''}<div class="modal-actions">${buttons('modal-cancel','cancel')}</div>`);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(`<h2>${t('details')}</h2><p>${t(current.status)} · ${esc(operationActionLabel(current.action))}</p><p>${esc(operationErrorLabel(current.error)||current.error?.message||'')}</p><pre style="white-space:pre-wrap;overflow-wrap:anywhere">${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))}</pre>${current.result?.url?'<img id="operation-preview" class="preview" alt="Preview">':''}<div class="modal-actions">${buttons('modal-cancel','cancel')}</div>`);if(current.result?.url)await blobImage($('#operation-preview'),current.result.url);});});}
|
||||||
async function enhanceAutomation(){
|
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(`<h2>${t('jobPreview')}</h2><img id="job-preview-image" class="preview" alt="Preview"><div class="modal-actions">${buttons('modal-cancel','cancel')}</div>`);await blobImage($('#job-preview-image'),op.result.url);});}});
|
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(`<h2>${t('jobPreview')}</h2><img id="job-preview-image" class="preview" alt="Preview"><div class="modal-actions">${buttons('modal-cancel','cancel')}</div>`);await blobImage($('#job-preview-image'),op.result.url);});}});
|
||||||
}
|
}
|
||||||
@@ -140,7 +140,7 @@ function photosLoginDialog(){
|
|||||||
async function photoSourcesPanel(){
|
async function photoSourcesPanel(){
|
||||||
const section=document.createElement('section');section.className='card photo-sources-panel';$('#main').append(section);
|
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;
|
const sources=await api('/api/v1/photo-sources');if(!section.isConnected)return;
|
||||||
section.innerHTML=`<div class="section-heading"><div><h3>${t('photoSources')}</h3><p class="hint">${t('sourceHint')}</p></div>${buttons('add-photo-source','addSource')}</div><section id="photos-connection" class="photos-connection" aria-label="Synology Photos"></section><div class="source-list">${sources.length?sources.map(s=>`<article class="source-card"><h4>${esc(s.name)}</h4><p class="hint source-identity">${t('sourceIdLabel')}: ${esc(s.id)} · ${t('sourceLocation')}: ${esc(s.location)}</p><p class="hint">${s.kind==='synology-photos'?t('sourcePhotosAPI'):s.people_filter?t('sourceSharedPhotos'):s.kind==='local'?t('sourceLocalOnly'):t('sourceOwnCredentials')}</p><p class="hint">${esc(s.kind)} · ${t(s.enabled?'enabled':'disabled')}${s.people_filter?' · '+t('peopleFilter'):''}${s.orientation?' · '+t({auto:'orientationAuto',landscape:'orientationLandscape',portrait:'orientationPortrait'}[s.orientation]):''}</p><div class="actions"><button data-source-browse="${esc(s.id)}">${t('browse')}</button><button data-source-random="${esc(s.id)}" ${s.enabled?'':'disabled'}>${t('randomPreview')}</button><button data-source-job="${esc(s.id)}">${t('sourceJob')}</button><button data-source-edit="${esc(s.id)}">${t('edit')}</button><button class="danger" data-source-delete="${esc(s.id)}">${t('remove')}</button></div></article>`).join(''):`<p class="hint">${t('noSources')}</p>`}</div><div id="source-browser"></div>`;
|
section.innerHTML=`<div class="section-heading"><div><h3>${t('photoSources')}</h3><p class="hint">${t('sourceHint')}</p></div>${buttons('add-photo-source','addSource')}</div><section id="photos-connection" class="photos-connection" aria-label="Synology Photos"></section><div class="source-list">${sources.length?sources.map(s=>`<article class="source-card"><h4>${esc(s.name)}</h4><p class="hint source-identity">${t('sourceIdLabel')}: ${esc(s.id)} · ${t('sourceLocation')}: ${esc(s.location)}</p><p class="hint">${s.kind==='synology-photos'?t('sourcePhotosAPI'):t('sourceOwnCredentials')}</p><p class="hint">${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]):''}</p><div class="actions"><button data-source-browse="${esc(s.id)}">${t('browse')}</button><button data-source-random="${esc(s.id)}" ${s.enabled?'':'disabled'}>${t('randomPreview')}</button><button data-source-job="${esc(s.id)}">${t('sourceJob')}</button><button data-source-edit="${esc(s.id)}">${t('edit')}</button><button class="danger" data-source-delete="${esc(s.id)}">${t('remove')}</button></div></article>`).join(''):`<p class="hint">${t('noSources')}</p>`}</div><div id="source-browser"></div>`;
|
||||||
await photosConnectionPanel();
|
await photosConnectionPanel();
|
||||||
bind('add-photo-source',()=>photoSourceForm());
|
bind('add-photo-source',()=>photoSourceForm());
|
||||||
section.querySelectorAll('[data-source-edit]').forEach(b=>b.onclick=()=>photoSourceForm(sources.find(s=>s.id===b.dataset.sourceEdit)));
|
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));
|
section.querySelectorAll('[data-source-job]').forEach(b=>b.onclick=()=>sourceJobForm(b.dataset.sourceJob));
|
||||||
}
|
}
|
||||||
async function photoSourceForm(source=null){
|
async function photoSourceForm(source=null){
|
||||||
showModal(`<h2>${t(source?'edit':'addSource')}</h2><p class="notice">${t('sourceIndependentHelp')}</p><form id="photo-source-form"><div class="field"><label for="source-id">ID</label><input id="source-id" pattern="[a-z0-9-]{1,60}" required value="${esc(source?.id||'')}" ${source?'readonly':''}></div><div class="field"><label for="source-name">${t('name')}</label><input id="source-name" required maxlength="120" value="${esc(source?.name||'')}"></div><div class="field"><label for="source-kind">${t('sourceKind')}</label><select id="source-kind"><option value="local">${t('nasDirectory')}</option><option value="synology-photos">${t('synologyAPISource')}</option><option value="webdav">WebDAV</option><option value="google-drive">Google Drive</option><option value="alipan">阿里云盘 / AliPan</option><option value="baidu">百度网盘 / Baidu</option></select></div><div class="field"><label for="source-location">${t('sourceLocation')}</label><input id="source-location" required maxlength="2048" value="${esc(source?.location||'.')}"><p class="hint">${t('sourceLocationHint')}</p></div><div class="field"><label for="source-credential">${t('sourceCredential')}</label><input id="source-credential" pattern="FRAME_FLOW_SOURCE_[A-Z0-9_]+" value="${esc(source?.credential_env||'')}" placeholder="FRAME_FLOW_SOURCE_CLOUD"><p class="hint">${t('sourceCredentialHint')}</p></div><div class="field"><label for="source-orientation">${t('photoOrientation')}</label><select id="source-orientation">${source&&!source.orientation?`<option value="">${t('orientationLegacy')}</option>`:''}<option value="auto">${t('orientationAuto')}</option><option value="landscape">${t('orientationLandscape')}</option><option value="portrait">${t('orientationPortrait')}</option></select><p class="hint">${t('orientationHint')}</p></div><div class="field"><label class="check-row"><input id="source-organized" type="checkbox" ${source?.organized_only?'checked':''}>${t('organizedPhotos')}</label><label class="check-row"><input id="source-caption" type="checkbox" ${source?.folder_caption?'checked':''}>${t('folderCaption')}</label><label class="check-row"><input id="source-synology-previews" type="checkbox" ${source?.synology_previews?'checked':''}>${t('synologyPreviews')}</label><p class="hint">${t('folderCaptionHint')}</p></div><div class="field"><label class="check-row"><input id="source-people-filter" type="checkbox" ${source?.people_filter?'checked':''}>${t('peopleFilter')}</label><p class="hint">${t('peopleHint')}</p><button id="source-people-load" type="button">${t('choosePeople')}</button><div id="source-people-panel"><p class="hint">${t('peopleSelected')}${(source?.person_ids||[]).length}</p></div><label for="source-person-match">${t('personMatch')}</label><select id="source-person-match"><option value="any">${t('personAny')}</option><option value="all">${t('personAll')}</option></select></div><div class="field"><label class="check-row"><input id="source-recursive" type="checkbox" ${source?.recursive===false?'':'checked'}>${t('sourceRecursive')}</label><label class="check-row"><input id="source-enabled" type="checkbox" ${source?.enabled===false?'':'checked'}>${t('enabled')}</label></div><div class="modal-actions">${buttons('modal-cancel','cancel')}<button type="submit" class="primary">${t('save')}</button></div></form>`);
|
showModal(`<h2>${t(source?'edit':'addSource')}</h2><p class="notice">${t('sourceIndependentHelp')}</p><form id="photo-source-form"><input id="source-id" type="hidden" value="${esc(source?.id||'photos-'+clientUUID())}"><div class="field"><label for="source-name">${t('name')}</label><input id="source-name" required maxlength="120" value="${esc(source?.name||'')}"></div><div class="field" ${!source||source.kind==='synology-photos'?'hidden':''}><label for="source-kind">${t('sourceKind')}</label><select id="source-kind"><option value="synology-photos">${t('synologyAPISource')}</option><option value="webdav">WebDAV</option><option value="google-drive">Google Drive</option><option value="alipan">阿里云盘 / AliPan</option><option value="baidu">百度网盘 / Baidu</option></select></div><div class="field"><label for="source-location">${t('sourceLocation')}</label><input id="source-location" required maxlength="2048" value="${esc(source?.location||'.')}"><p class="hint">${t('sourceLocationHint')}</p></div><div class="field"><label for="source-credential">${t('sourceCredential')}</label><input id="source-credential" pattern="FRAME_FLOW_SOURCE_[A-Z0-9_]+" value="${esc(source?.credential_env||'')}" placeholder="FRAME_FLOW_SOURCE_CLOUD"><p class="hint">${t('sourceCredentialHint')}</p></div><div class="field"><label for="source-orientation">${t('photoOrientation')}</label><select id="source-orientation">${source&&!source.orientation?`<option value="">${t('orientationLegacy')}</option>`:''}<option value="auto">${t('orientationAuto')}</option><option value="landscape">${t('orientationLandscape')}</option><option value="portrait">${t('orientationPortrait')}</option></select><p class="hint">${t('orientationHint')}</p></div><div class="field"><label class="check-row"><input id="source-organized" type="checkbox" ${source?.organized_only?'checked':''}>${t('organizedPhotos')}</label><label class="check-row"><input id="source-caption" type="checkbox" ${source?.folder_caption?'checked':''}>${t('folderCaption')}</label><label hidden><input id="source-synology-previews" type="checkbox" disabled></label><p class="hint">${t('folderCaptionHint')}</p></div><div class="field"><label class="check-row"><input id="source-people-filter" type="checkbox" ${source?.people_filter?'checked':''}>${t('peopleFilter')}</label><p class="hint">${t('peopleHint')}</p><button id="source-people-load" type="button">${t('choosePeople')}</button><div id="source-people-panel"><p class="hint">${t('peopleSelected')}${(source?.person_ids||[]).length}</p></div><label for="source-person-match">${t('personMatch')}</label><select id="source-person-match"><option value="any">${t('personAny')}</option><option value="all">${t('personAll')}</option></select></div><div class="field"><label class="check-row"><input id="source-recursive" type="checkbox" ${source?.recursive===false?'':'checked'}>${t('sourceRecursive')}</label><label class="check-row"><input id="source-enabled" type="checkbox" ${source?.enabled===false?'':'checked'}>${t('enabled')}</label></div><div class="modal-actions">${buttons('modal-cancel','cancel')}<button type="submit" class="primary">${t('save')}</button></div></form>`);
|
||||||
$('#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='';};
|
$('#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:$('#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');});
|
$('#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){
|
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;
|
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);
|
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(`<h2>${t('preview')}</h2><img id="source-preview" class="preview" alt="${t('preview')}"><p class="hint">${t('sourcePreviewHint')}</p><div class="modal-actions">${buttons('modal-cancel','cancel')}${buttons('source-display','display',true)}</div>`);await blobImage($('#source-preview'),op.result.url);$('#source-display').disabled=!liveWriteAllowed();bind('source-display',()=>displayDialog({type:'image',asset_id:op.result.asset_id}));
|
showModal(`<h2>${t('preview')}</h2><img id="source-preview" class="preview" alt="${t('preview')}"><p class="hint">${t('sourcePreviewHint')}</p><div class="modal-actions">${buttons('modal-cancel','cancel')}${buttons('source-display','display',true)}</div>`);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){
|
async function sourceJobForm(id){
|
||||||
showModal(`<h2>${t('sourceJob')}</h2><form id="source-job-form"><div class="field"><label for="source-job-name">${t('name')}</label><input id="source-job-name" required maxlength="120" value="${t('randomPhoto')}"></div><div class="field"><label for="source-job-cron">Cron · Asia/Shanghai</label><input id="source-job-cron" required value="0 8 * * *"><p class="hint">${t('sourceJobHint')}</p></div><div class="field"><label class="check-row"><input id="source-job-enabled" type="checkbox">${t('enabled')}</label></div><p class="hint">${t('cloudKeepLatest')}</p><div class="modal-actions">${buttons('modal-cancel','cancel')}<button class="primary" type="submit">${t('save')}</button></div></form>`);$('#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');});
|
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});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.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.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.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:'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.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.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.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.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:'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.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.zh,{'photo-sources':'照片数据源'});
|
||||||
Object.assign(MESSAGES.en,{'photo-sources':'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.zh,{studio:'拼版与海报',connections:'连接中心'});
|
||||||
Object.assign(window.MESSAGES.en,{studio:'Layout Studio',connections:'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.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:'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.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:'管理 NAS 照片目录、WebDAV 和网盘来源。浏览与生成预览不会改变画框。'});
|
Object.assign(MESSAGES.zh,{sourceHint:'通过 Synology Photos API 管理照片来源、人物和筛选规则。浏览与预览不会改变画框。'});
|
||||||
Object.assign(MESSAGES.en,{sourceHint:'Manage NAS folders, WebDAV and cloud drives. Browsing and generating previews do not change the frame.'});
|
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.zh,{choosePeople:'选择人物'});
|
||||||
Object.assign(MESSAGES.en,{choosePeople:'Choose people'});
|
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.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:'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.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.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.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.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:'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.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.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'});
|
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.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.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.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 (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.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。Photos API 来源无需 /photos 挂载;本地目录来源仍需挂载。'});
|
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 API sources need no /photos mount; local-folder sources still require it.'});
|
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'});
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
<!doctype html>
|
<!doctype html>
|
||||||
<html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover"><meta name="color-scheme" content="light dark"><title>Pigway Frame Flow</title><link rel="stylesheet" href="/ui/style.css"></head>
|
<html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover"><meta name="color-scheme" content="light dark"><title>Pigway Frame Flow</title><link rel="stylesheet" href="/ui/style.css"></head>
|
||||||
<body><aside class="sidebar"><a class="brand" href="/"><span class="brand-icon">F<span>↗</span></span><span>Frame Flow<small>Pigway · Smart Frame Automation</small></span></a><nav id="nav"></nav><div class="sidebar-bottom"><small>Version 0.1.9</small></div></aside><div class="workspace"><header><div><span class="eyebrow">PIGWAY FRAME FLOW</span><h1 id="page-title"></h1></div><div class="toolbar"><select id="device" aria-label="Device"></select><button id="lang" class="icon-button" aria-label="Change language">EN</button><button id="theme" class="icon-button" aria-label="Change theme">◐</button></div></header><main id="main"></main><footer id="footer"></footer></div><div id="toast" role="status" aria-live="polite"></div><dialog id="modal"><div id="modal-content"></div></dialog><script src="/ui/i18n.js"></script><script src="/ui/features.js"></script><script src="/ui/extensions.js"></script><script src="/ui/app.js"></script></body></html>
|
<body><aside class="sidebar"><a class="brand" href="/"><span class="brand-icon">F<span>↗</span></span><span>Frame Flow<small>Pigway · Smart Frame Automation</small></span></a><nav id="nav"></nav><div class="sidebar-bottom"><small>Version 0.1.9</small></div></aside><div class="workspace"><header><div><span class="eyebrow">PIGWAY FRAME FLOW</span><h1 id="page-title"></h1></div><div class="toolbar"><select id="device" aria-label="Device"></select><button id="lang" class="icon-button" aria-label="Change language">EN</button><button id="theme" class="icon-button" aria-label="Change theme">◐</button></div></header><main id="main"></main><footer id="footer"></footer></div><div id="toast" role="status" aria-live="polite"></div><dialog id="modal"><div id="modal-content"></div></dialog><script src="/ui/i18n.js?v=audit-ui-20261017-history"></script><script src="/ui/features.js?v=audit-ui-20261017-history"></script><script src="/ui/extensions.js?v=audit-ui-20261017-history"></script><script src="/ui/app.js?v=audit-ui-20261017-history"></script></body></html>
|
||||||
|
|||||||
@@ -153,3 +153,16 @@ form>.modal-actions{margin-top:8px}
|
|||||||
.gallery-tabs button{min-width:0;white-space:normal;overflow-wrap:anywhere}
|
.gallery-tabs button{min-width:0;white-space:normal;overflow-wrap:anywhere}
|
||||||
|
|
||||||
.gallery:empty{display:none}
|
.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}}
|
||||||
|
|||||||
@@ -70,6 +70,13 @@ class Deploy:
|
|||||||
return json.loads(self.run("inspect", name).stdout)[0]
|
return json.loads(self.run("inspect", name).stdout)[0]
|
||||||
|
|
||||||
def settings(self):
|
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)]
|
missing = [key for key in REQUIRED if not self.env.get(key)]
|
||||||
if missing:
|
if missing:
|
||||||
raise ValueError("Missing Variables: " + ", ".join(missing))
|
raise ValueError("Missing Variables: " + ", ".join(missing))
|
||||||
@@ -118,20 +125,9 @@ class Deploy:
|
|||||||
or not m.get("RW")
|
or not m.get("RW")
|
||||||
):
|
):
|
||||||
raise ValueError("Production mount mismatch: " + key)
|
raise ValueError("Production mount mismatch: " + key)
|
||||||
photos = self.env.get("PROD_PHOTOS_DIR")
|
if self.env.get("PROD_PHOTOS_DIR") or "/photos" in mounts:
|
||||||
if "/photos" in mounts and (
|
raise ValueError("Filesystem photo mounts are retired; use the Photos API")
|
||||||
not photos
|
if set(mounts) != {"/data", "/config"}:
|
||||||
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()):
|
|
||||||
raise ValueError("Unrecognized persistent mount; review before adopting CD")
|
raise ValueError("Unrecognized persistent mount; review before adopting CD")
|
||||||
bindings = old["HostConfig"].get("PortBindings", {}).get("18787/tcp")
|
bindings = old["HostConfig"].get("PortBindings", {}).get("18787/tcp")
|
||||||
if bindings != [
|
if bindings != [
|
||||||
@@ -180,16 +176,6 @@ class Deploy:
|
|||||||
)
|
)
|
||||||
]
|
]
|
||||||
service["group_add"] = old["HostConfig"].get("GroupAdd") or []
|
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.
|
# Adoption must preserve runtime constraints, not silently change them.
|
||||||
if (
|
if (
|
||||||
old["Config"]["User"] != service["user"]
|
old["Config"]["User"] != service["user"]
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -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)});
|
||||||
@@ -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)});
|
||||||
@@ -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)});
|
||||||
@@ -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)});
|
||||||
@@ -30,3 +30,17 @@ def client(app):
|
|||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def service(app):
|
def service(app):
|
||||||
return app.state.service
|
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)
|
||||||
|
)
|
||||||
|
|||||||
@@ -222,3 +222,7 @@ def test_unreadable_source_is_forbidden_not_empty(
|
|||||||
response = client.get("/api/v1/photo-sources/family/images")
|
response = client.get("/api/v1/photo-sources/family/images")
|
||||||
assert response.status_code == 403
|
assert response.status_code == 403
|
||||||
assert response.json()["error"]["code"] == "FORBIDDEN"
|
assert response.json()["error"]["code"] == "FORBIDDEN"
|
||||||
|
|
||||||
|
|
||||||
|
# Legacy algorithms remain covered; production filesystem access is tested separately.
|
||||||
|
pytestmark = pytest.mark.usefixtures("legacy_photo_engine")
|
||||||
|
|||||||
@@ -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"))
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
from io import BytesIO
|
from io import BytesIO
|
||||||
|
|
||||||
|
import pytest
|
||||||
from PIL import Image
|
from PIL import Image
|
||||||
|
|
||||||
from pigway_frame_flow.providers.photo_sources import PhotoSourceInput
|
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,)
|
"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")
|
||||||
|
|||||||
@@ -138,3 +138,7 @@ def test_auto_preview_uses_requested_device_not_default(
|
|||||||
assert op["status"] == "COMPLETED"
|
assert op["status"] == "COMPLETED"
|
||||||
with Image.open(BytesIO(client.get(op["result"]["url"]).content)) as image:
|
with Image.open(BytesIO(client.get(op["result"]["url"]).content)) as image:
|
||||||
assert image.size == (1200, 1600)
|
assert image.size == (1200, 1600)
|
||||||
|
|
||||||
|
|
||||||
|
# Legacy algorithms remain covered; production filesystem access is tested separately.
|
||||||
|
pytestmark = pytest.mark.usefixtures("legacy_photo_engine")
|
||||||
|
|||||||
@@ -159,3 +159,7 @@ def test_oversized_synology_photo_uses_read_only_cached_preview(service, setting
|
|||||||
"synology", source.model_copy(update={"synology_previews": False})
|
"synology", source.model_copy(update={"synology_previews": False})
|
||||||
)
|
)
|
||||||
assert service.photo_sources.catalog("synology") == []
|
assert service.photo_sources.catalog("synology") == []
|
||||||
|
|
||||||
|
|
||||||
|
# Legacy algorithms remain covered; production filesystem access is tested separately.
|
||||||
|
pytestmark = pytest.mark.usefixtures("legacy_photo_engine")
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import pytest
|
||||||
from PIL import Image
|
from PIL import Image
|
||||||
|
|
||||||
from pigway_frame_flow.providers.photo_sources import PhotoSourceInput
|
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"],)
|
"SELECT write_started FROM operations WHERE id=?", (op["operation_id"],)
|
||||||
)
|
)
|
||||||
assert row["write_started"] == 0
|
assert row["write_started"] == 0
|
||||||
|
|
||||||
|
|
||||||
|
# Legacy algorithms remain covered; production filesystem access is tested separately.
|
||||||
|
pytestmark = pytest.mark.usefixtures("legacy_photo_engine")
|
||||||
|
|||||||
@@ -62,12 +62,16 @@ def test_cached_dimensions_invalidation_and_no_fallback(
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
assert len(oriented.catalog("family")) == 1
|
assert len(oriented.catalog("family")) == 1
|
||||||
|
from pigway_frame_flow.providers.photo_sources import dimensions
|
||||||
|
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
"pigway_frame_flow.providers.photo_sources.dimensions",
|
"pigway_frame_flow.providers.photo_sources.dimensions",
|
||||||
lambda *a: pytest.fail("unchanged dimensions should be cached"),
|
lambda *a: pytest.fail("unchanged dimensions should be cached"),
|
||||||
)
|
)
|
||||||
assert len(oriented.catalog("family", refresh=True)) == 1
|
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")
|
Image.new("RGB", (50, 100)).save(settings.photo_root / "20240101 Day" / "wide.jpg")
|
||||||
assert oriented.catalog("family", refresh=True) == []
|
assert oriented.catalog("family", refresh=True) == []
|
||||||
with pytest.raises(FlowError):
|
with pytest.raises(FlowError):
|
||||||
@@ -110,3 +114,7 @@ def test_orientation_validation_and_api(client, oriented):
|
|||||||
).json()["error"]["code"]
|
).json()["error"]["code"]
|
||||||
== "UNSUPPORTED_CAPABILITY"
|
== "UNSUPPORTED_CAPABILITY"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# Legacy algorithms remain covered; production filesystem access is tested separately.
|
||||||
|
pytestmark = pytest.mark.usefixtures("legacy_photo_engine")
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
|
import pytest
|
||||||
|
|
||||||
|
|
||||||
def test_uploaded_photos_exclude_generated_templates_before_limit(client, service):
|
def test_uploaded_photos_exclude_generated_templates_before_limit(client, service):
|
||||||
photo = service.assets.put(b"fixture", "family.jpg", "images")
|
photo = service.assets.put(b"fixture", "family.jpg", "images")
|
||||||
for _ in range(5):
|
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("travel").model_dump() == before
|
||||||
assert service.photo_sources.get("family").person_ids == [3]
|
assert service.photo_sources.get("family").person_ids == [3]
|
||||||
assert service.photo_sources.get("travel").person_ids == [2]
|
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")
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -248,18 +248,14 @@ def test_actual_verify_rejects_ready_smoke_and_commit_failure(
|
|||||||
tool.verify(config)
|
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
|
from pathlib import Path
|
||||||
|
|
||||||
import yaml
|
import yaml
|
||||||
|
|
||||||
root = Path(__file__).parents[1]
|
root = Path(__file__).parents[1]
|
||||||
workflow = yaml.safe_load((root / ".github/workflows/verify.yml").read_text())
|
workflow = yaml.safe_load((root / ".github/workflows/verify.yml").read_text())
|
||||||
inputs = workflow[True]["workflow_dispatch"]["inputs"]["deploy_action"]
|
assert set(workflow["jobs"]) == {"verify"}
|
||||||
assert inputs["options"] == ["plan", "bootstrap", "deploy", "rollback"]
|
|
||||||
assert inputs["default"] == "plan"
|
|
||||||
assert workflow["jobs"]["deploy"]["needs"] == "verify"
|
|
||||||
assert "success()" in workflow["jobs"]["deploy"]["if"]
|
|
||||||
tool.env["PROD_PROJECT"] = "ffci-invalid"
|
tool.env["PROD_PROJECT"] = "ffci-invalid"
|
||||||
with pytest.raises(ValueError):
|
with pytest.raises(ValueError):
|
||||||
tool.settings()
|
tool.settings()
|
||||||
|
|||||||
@@ -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]
|
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
|
import yaml
|
||||||
|
|
||||||
workflow = yaml.safe_load(
|
workflow = yaml.safe_load(
|
||||||
(Path(__file__).parents[1] / ".github/workflows/verify.yml").read_text()
|
(Path(__file__).parents[1] / ".github/workflows/verify.yml").read_text()
|
||||||
)
|
)
|
||||||
job = workflow["jobs"]["deploy"]
|
assert set(workflow["jobs"]) == {"verify"}
|
||||||
assert job["needs"] == "verify" and "success()" in job["if"]
|
assert workflow[True]["workflow_dispatch"] is None
|
||||||
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()"
|
|
||||||
|
|
||||||
|
|
||||||
def test_recovery_handles_missing_or_crashed_production_container(tool, monkeypatch):
|
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
|
output = capsys.readouterr().out
|
||||||
assert "cleanup deferred" in output
|
assert "cleanup deferred" in output
|
||||||
assert "private diagnostic" not 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()
|
||||||
|
|||||||
@@ -6,10 +6,10 @@ def test_external_forks_do_not_execute_on_nas_runner():
|
|||||||
assert (
|
assert (
|
||||||
"github.event.pull_request.head.repo.full_name == github.repository" in workflow
|
"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()
|
workflow = Path(".github/workflows/verify.yml").read_text()
|
||||||
for key in (
|
for key in (
|
||||||
"PROD_DATA_DIR",
|
"PROD_DATA_DIR",
|
||||||
@@ -19,7 +19,7 @@ def test_private_production_configuration_uses_secrets():
|
|||||||
"SYNOLOGY_PHOTOS_URL",
|
"SYNOLOGY_PHOTOS_URL",
|
||||||
"SYNOLOGY_PHOTOS_CERT_SHA256",
|
"SYNOLOGY_PHOTOS_CERT_SHA256",
|
||||||
):
|
):
|
||||||
assert "${{ secrets." + key + " }}" in workflow
|
assert "${{ secrets." + key + " }}" not in workflow
|
||||||
assert "${{ vars." + 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()
|
workflow = Path(".github/workflows/verify.yml").read_text()
|
||||||
actions = re.findall(r"uses:\s*(\S+)", workflow)
|
actions = re.findall(r"uses:\s*(\S+)", workflow)
|
||||||
assert len(actions) == 4
|
assert len(actions) == 2
|
||||||
for action in actions:
|
for action in actions:
|
||||||
assert re.fullmatch(
|
assert re.fullmatch(
|
||||||
r"https://github\.com/actions/(checkout|setup-python)@[0-9a-f]{40}",
|
r"https://github\.com/actions/(checkout|setup-python)@[0-9a-f]{40}",
|
||||||
|
|||||||
@@ -172,3 +172,7 @@ def test_first_cleanup_runs_after_recent_host_boot(
|
|||||||
]
|
]
|
||||||
== 1
|
== 1
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# Legacy algorithms remain covered; production filesystem access is tested separately.
|
||||||
|
pytestmark = pytest.mark.usefixtures("legacy_photo_engine")
|
||||||
|
|||||||
@@ -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")
|
||||||
@@ -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
|
assert r.status_code == 422 and "input" not in r.text
|
||||||
client.headers.pop("Authorization")
|
client.headers.pop("Authorization")
|
||||||
assert client.get("/api/v1/integrations/synology-photos/people").status_code == 401
|
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")
|
||||||
|
|||||||
Reference in New Issue
Block a user