Keep generated template previews out of photo selection

This commit is contained in:
Pigway
2026-10-05 00:31:56 +08:00
parent a180dbc076
commit 5dc10a10e9
5 changed files with 32 additions and 6 deletions
+9 -4
View File
@@ -3,7 +3,7 @@ import os
import re
from contextlib import asynccontextmanager
from pathlib import Path
from typing import Any
from typing import Any, Literal
from uuid import uuid4
from fastapi import Depends, FastAPI, File, Form, Header, Query, Request, UploadFile
@@ -479,12 +479,17 @@ def create_app(settings=None, start_worker=True):
tags=["Images"],
dependencies=[Depends(require("images:read"))],
)
def list_assets(limit: int = Query(default=100, ge=1, le=200)):
def list_assets(
limit: int = Query(default=100, ge=1, le=200),
source: Literal["all", "uploaded", "generated"] = "all",
):
return [
assets.info(r["id"])
for r in db.all(
"SELECT id FROM assets WHERE kind!='cache' ORDER BY created_at DESC LIMIT ?",
(limit,),
"SELECT id FROM assets WHERE kind!='cache' AND "
"(?='all' OR (?='uploaded' AND kind='images') OR (?='generated' AND kind='generated')) "
"ORDER BY created_at DESC LIMIT ?",
(source, source, source, limit),
)
]
+1
View File
@@ -291,6 +291,7 @@ class Readiness(BaseModel):
class Asset(BaseModel):
source: Literal["uploaded", "generated"] = "uploaded"
asset_id: str
name: str
url: str
+1
View File
@@ -93,6 +93,7 @@ class Assets:
raise FlowError(E.NOT_FOUND, "Asset not found", 404)
return {
"asset_id": row["id"],
"source": "generated" if row["kind"] == "generated" else "uploaded",
"name": row["name"],
"url": f"/api/v1/assets/{row['id']}",
"created_at": row["created_at"],
+2 -2
View File
@@ -43,12 +43,12 @@ function detail(label,value){return `<div><small>${t(label)}</small><strong>${es
function liveWriteAllowed(){return state.settings?.mock||state.settings?.write_enabled;}
function guardDeviceButtons(){if(!liveWriteAllowed())document.querySelectorAll('[data-show],'+(state.tab==='cloud'?'.image-card .danger,':'')+'#show-name,#display-template,#display-upload,#restore').forEach(b=>{b.disabled=true;b.title=t('readOnlyWarning');});}
async function templateEditor(selected){
const raw=$('#template-data');const assets=await api('/api/v1/assets');let values=JSON.parse(raw.value);
const raw=$('#template-data');const assets=await api('/api/v1/assets?source=uploaded&limit=200');let values=JSON.parse(raw.value);
const panel=document.createElement('div');panel.id='template-fields';raw.before(panel);
const labels={title:state.lang==='zh'?'标题':'Title',message:state.lang==='zh'?'正文':'Message',quote:state.lang==='zh'?'一句话':'Quote',author:state.lang==='zh'?'作者':'Author',events:t('eventsHelp'),date:t('date'),weekday:state.lang==='zh'?'星期(留空自动)':'Weekday (blank for automatic)',weather:state.lang==='zh'?'天气数据(JSON,可留空使用 Provider)':'Weather JSON (blank uses provider)'};
const fields=[...new Set(['title',...selected.fields.filter(k=>Object.hasOwn(selected.defaults,k)),...(['daily-dashboard','calendar'].includes(selected.id)?['events','date','weekday']:selected.id==='daily-poetry'?['date']:[]),...(selected.id==='weather'||selected.id==='daily-dashboard'?['weather']:[]),...(['photo-text','daily-dashboard'].includes(selected.id)?['photo_id']:[])])];
function invalidate(){state.templatePreview=null;$('#display-template').disabled=true;$('#template-preview').className='empty';$('#template-preview').textContent=t('previewHint');}
function build(){panel.innerHTML=`<h3>${t('formEditor')}</h3>`+fields.map(k=>k==='photo_id'?`<div class="field"><label for="field-photo_id">${t('photo')}</label><select id="field-photo_id" data-field="photo_id"><option value="">${t('noPhoto')}</option>${assets.map(a=>`<option value="${esc(a.asset_id)}" ${a.asset_id===values.photo_id?'selected':''}>${esc(a.name)}</option>`).join('')}</select></div>`:`<div class="field"><label for="field-${k}">${esc(labels[k]||t(k))}</label><textarea id="field-${k}" data-field="${k}" rows="${['events','message','weather','quote'].includes(k)?3:1}">${esc(k==='events'?(values[k]||[]).map(v=>typeof v==='object'?v.title:v).join('\n'):['weather','forecast'].includes(k)?(values[k]?JSON.stringify(values[k],null,2):''):values[k]||'')}</textarea></div>`).join('');panel.querySelectorAll('[data-field]').forEach(el=>el.oninput=()=>{try{const k=el.dataset.field;let v=el.value;if(k==='events')v=v.split('\n').filter(x=>x.trim());if(['weather','forecast'].includes(k))v=v.trim()?JSON.parse(v):null;if(v===null||v==='')delete values[k];else values[k]=v;raw.value=JSON.stringify(values,null,2);el.setCustomValidity('');invalidate();}catch{el.setCustomValidity(t('INVALID_REQUEST'));el.reportValidity();}});}
function build(){panel.innerHTML=`<h3>${t('formEditor')}</h3>`+fields.map(k=>k==='photo_id'?`<div class="field"><label for="field-photo_id">${t('photo')}</label><select id="field-photo_id" data-field="photo_id"><option value="">${t('noPhoto')}</option>${values.photo_id&&!assets.some(a=>a.asset_id===values.photo_id)?`<option value="${esc(values.photo_id)}" selected disabled>${state.lang==='zh'?'已有选择不在上传照片列表中,请重新选择':'Previous selection is outside uploaded photos; choose again'}</option>`:''}${assets.map(a=>`<option value="${esc(a.asset_id)}" ${a.asset_id===values.photo_id?'selected':''}>${esc(a.name)}</option>`).join('')}</select><p class="hint">${state.lang==='zh'?'只列出上传的图片,不包含模板生成图。没有照片时,请使用下方文件选择器上传。':'Uploaded images only; generated templates are excluded. Upload a photo below if needed.'}</p></div>`:`<div class="field"><label for="field-${k}">${esc(labels[k]||t(k))}</label><textarea id="field-${k}" data-field="${k}" rows="${['events','message','weather','quote'].includes(k)?3:1}">${esc(k==='events'?(values[k]||[]).map(v=>typeof v==='object'?v.title:v).join('\n'):['weather','forecast'].includes(k)?(values[k]?JSON.stringify(values[k],null,2):''):values[k]||'')}</textarea></div>`).join('');panel.querySelectorAll('[data-field]').forEach(el=>el.oninput=()=>{try{const k=el.dataset.field;let v=el.value;if(k==='events')v=v.split('\n').filter(x=>x.trim());if(['weather','forecast'].includes(k))v=v.trim()?JSON.parse(v):null;if(v===null||v==='')delete values[k];else values[k]=v;raw.value=JSON.stringify(values,null,2);el.setCustomValidity('');invalidate();}catch{el.setCustomValidity(t('INVALID_REQUEST'));el.reportValidity();}});}
build();const advanced=document.createElement('details');advanced.innerHTML=`<summary>${t('advanced')}</summary>`;raw.before(advanced);advanced.append(raw);raw.oninput=()=>{invalidate();try{values=JSON.parse(raw.value);if(!values||typeof values!=='object'||Array.isArray(values))throw Error();raw.setCustomValidity('');build();}catch{raw.setCustomValidity(t('INVALID_REQUEST'));}};
const reset=document.createElement('button');reset.id='reset-template';reset.textContent=t('resetTemplate');$('#save-template').after(reset);bind('reset-template',()=>confirmDialog(t('resetConfirm'),async()=>{await api('/api/v1/templates/'+selected.id+'/config',{method:'DELETE'});await navigate();}));
const save=$('#save-template');const oldSave=save.onclick;save.onclick=async e=>{if([...panel.querySelectorAll('textarea'),raw].some(x=>!x.checkValidity())){toast(t('INVALID_REQUEST'));return;}await oldSave(e);};
+19
View File
@@ -0,0 +1,19 @@
def test_uploaded_photos_exclude_generated_templates_before_limit(client, service):
photo = service.assets.put(b"fixture", "family.jpg", "images")
for _ in range(5):
service.assets.put(b"fixture", "quote.jpg", "generated")
response = client.get("/api/v1/assets?source=uploaded&limit=1")
assert response.status_code == 200
assert response.json()[0]["asset_id"] == photo["asset_id"]
assert response.json()[0]["source"] == "uploaded"
generated = client.get("/api/v1/assets?source=generated").json()
assert len(generated) == 5 and all(a["source"] == "generated" for a in generated)
assert len(client.get("/api/v1/assets").json()) == 6
def test_filename_does_not_determine_photo_source(client, service):
service.assets.put(b"fixture", "weather.jpg", "images")
assert (
client.get("/api/v1/assets?source=uploaded").json()[0]["name"] == "weather.jpg"
)
assert client.get("/api/v1/assets?source=invalid").status_code == 422