fix: make photo retention and exclusion mutually exclusive
Community checks / verify (push) Skipped
Verify Frame Flow / verify (push) Successful in 6m10s

This commit is contained in:
渡口浪人
2026-10-20 19:19:30 +08:00
parent b770626a66
commit 95625c5857
9 changed files with 187 additions and 34 deletions
+1 -1
View File
@@ -106,4 +106,4 @@ KoKonna 是支持的设备集成。本项目与 KoKonna 无隶属关系,也不
历史记录支持服务端翻页、图片名称/请求 ID/任务 ID/来源搜索,以及状态、操作、设备和日期(上海时区)筛选;可选择当前页记录进行单条或批量删除,操作前需确认。删除仅从历史列表移除,内部执行凭据继续供图库来源、幂等及恢复使用,不会删除图片或取消任务;运行中的任务不能删除。
源图库选用标记:本地图库支持单张和批量“不再选用/恢复选用”,照片源(Synology Photos、WebDAV、网盘等)的浏览列表支持独立星标及选用标记。后续自动选图和自动拼图跳过被标记的图片,星标不覆盖排除设置;图片仍保留,可手动预览、使用或恢复。标记按图库和图片 ID 独立保存,不取消已提交任务,也不修改网盘原文件。
源图库选用标记:本地图库支持单张和批量“不再选用/恢复选用”,照片源(Synology Photos、WebDAV、网盘等)的浏览列表支持独立星标及选用标记。后续自动选图和自动拼图跳过被标记的图片,星标保留与不再选用互斥,需先取消原标记再切换;图片仍保留,可手动预览、使用或恢复。标记按图库和图片 ID 独立保存,不取消已提交任务,也不修改网盘原文件。
+8 -8
View File
@@ -9,7 +9,7 @@ c8c545718e9b9880555c950a078cac160c82e0cd6e593afcf20bc13727539ec5 .github/workfl
6eb7728e6e73f9568ca6fd5ad6383d13ffc638f2870fefa2d86f72f39685c0fc LICENSE
250a2bcec248b074786d90e0329874628d70d5a0fe3b4fba47c8c57d4f84dcce NOTICE.md
b7a41a2c00df1a0dbe3869d059d65c3bcaee8c50e51217c358527c4f1d3d6c98 README.md
42e9c414405430ca6152ce7e6472440558a466cac92422b42b60d1c3307be225 README.zh-CN.md
94184f76401192678ec420c2ccd23142d90e07a8ccc882a4a0d4701a0c278afc README.zh-CN.md
4a81645fe7ee8ee335ee74262004e2c51e804e7282ee9ef17f7a80dfcb7baa84 RELEASE_NOTES.md
3dd364e47fdbd2ce9ce2df83342c523adc21583f093a5439d501b3424878ce2f SECURITY.md
e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 build-cache/wheels/.gitkeep
@@ -44,12 +44,12 @@ ec53d587b855be84283c50f8fb45be62a4c1caba78b28d436a8260fd171943a2 docs/research/
2a68fe95ba4afc24aec6abd3e6ab24a360eb6bc3005ab0fefd696190915d76c1 docs/research/KOKONNA_WIDGET_CATALOG.json
e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 pigway_frame_flow/__init__.py
e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 pigway_frame_flow/api/__init__.py
d351d0107e75ea4183dd1de07847f02a2c393c604065c0a1dca2c23168bfddba pigway_frame_flow/api/app.py
e16fb262482c6be5aa58a2f63926a5acfb2ca021b772aff924d8cced9b16c524 pigway_frame_flow/api/app.py
1efe9b1fc774a97e5be72d81199b1b65b25c258956fdf166aebff732cc820d7c pigway_frame_flow/api/history.py
e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 pigway_frame_flow/core/__init__.py
0b8402603c2f397f4f42491873b27bf363959194b05a0c67c7b1d49596288b95 pigway_frame_flow/core/accounts.py
818e8fb29d22c13159a6cb22bcbc6b9e211a8a1301588126eb192ac4ed71441b pigway_frame_flow/core/auth.py
9f81eb7b662c1087da7f5711fcae34e34634fe0e88ea12488c82f4ca4d9d9953 pigway_frame_flow/core/photo_preferences.py
2f03a04c1f0f32d95bfd3d27d37ca98f9b79daaa20453999024b16fc8f34cd5d pigway_frame_flow/core/photo_preferences.py
8f2220fa90b0dc2e45df2023dc5ac695efe55361644ef266829553c99151949b pigway_frame_flow/core/registry.py
e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 pigway_frame_flow/display/__init__.py
81effa34d6c452579cfa65781909f7c5a9e8ddad136e4c47ee0704939851879a pigway_frame_flow/display/cloud_library.py
@@ -88,7 +88,7 @@ a518965aa4db9abeb60865c315229c3053c8e9edd26d80f24dc0af0528bad9e6 pigway_frame_f
2a68fe95ba4afc24aec6abd3e6ab24a360eb6bc3005ab0fefd696190915d76c1 pigway_frame_flow/integrations/kokonna_official/widget_catalog.json
aea594fb4e4c9a6e2094d4d7973eb627ac02fb632811b2d1a558a0ee1cd12e77 pigway_frame_flow/integrations/kokonna_official/widgets.py
1fe53f4bfb407b8db774fe730131ecf1213e16d752a3506c4c56b46365ea14e8 pigway_frame_flow/main.py
a6c25a21309a92738832e717871968bb703f4161bcfd6d6871df1b1beb9d88e9 pigway_frame_flow/models.py
a456b4d18414546563db4eae4709689c8ddfac0ac6b3ffe840c6796f2fe74a9c pigway_frame_flow/models.py
e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 pigway_frame_flow/providers/__init__.py
b7a6cb8eb40d8dedfbce18777f8fbd433a174f14bcdadf498bd834132aa568fd pigway_frame_flow/providers/cloud_drives.py
58b070f0c814d639a4a912d079479d751d459816d59f2199d0be03e4d573ec3b pigway_frame_flow/providers/external.py
@@ -127,11 +127,11 @@ f332fac63f611572597812a06ae0cc266a59e5677efda0d6e9b6925acf9f9309 pigway_frame_f
d7d55b06ead173b4c23a277fa39800cadfc18f1163c500e8617cfc1f4ee0dea9 pigway_frame_flow/web/extensions.js
94558dbf524a1068e83283eb814c1b1501512d4b7e75e092b52e9aabfea720e7 pigway_frame_flow/web/favicon.ico
0fa494a3fbd456c754bbea3f7f050be139e32e3b42b09a1ed953094087437210 pigway_frame_flow/web/favicon.svg
1fac3bde40a6aa15701e9d2f02955575d0b180d2ffd7c2d299191bc1577aa87d pigway_frame_flow/web/features.js
fe7c47c5de765b9611082f8336c33c74dc16333b983c05e48ac9658941489cd5 pigway_frame_flow/web/i18n.js
4c7a6d81a4397896e5dcca9020e0a162e60116ff05a840f161834afdf6ea54c5 pigway_frame_flow/web/features.js
4995020f673252abfd745ac8a1a6e9c5081cf9bfa89dd4b7d1ab30a41e0b6eb9 pigway_frame_flow/web/i18n.js
51796b4db73773cea7e451ea21d3b493fcfaf340945d44a696b5d484a5222ea5 pigway_frame_flow/web/index.html
0fa494a3fbd456c754bbea3f7f050be139e32e3b42b09a1ed953094087437210 pigway_frame_flow/web/logo-symbol.svg
45ca22deabfa32d0133fca6c7da0d39ecf4e500b104c7f48678cb2ccd0956206 pigway_frame_flow/web/style.css
8de143a813cb49954cf02f24c4523c092bc4882544c67001214fd63ada0bfce5 pigway_frame_flow/web/style.css
cfc7749b96f63bd31c3c42b5c471bf756814053e847c10f3eb003417bc523d30 pigway_frame_flow/web/swagger-LICENSE
050bc415ee7048dcd881682678f720264e7da5e373f7461d7c58c755305255f7 pigway_frame_flow/web/swagger-ui-bundle.js
1ac324f7dcd27e4b9386b4bd6421271ec147e922a22c05ba24b11515e9aa6321 pigway_frame_flow/web/swagger-ui.css
@@ -211,7 +211,7 @@ cbc44ca3bd0eab93b39f0aabfe5c9d923105f4556df7fd7b86593c06daddcae5 tests/test_ica
cad91937e8429647319cd01fd1925132b7afe057d5420be9586c5fe63f79e498 tests/test_photo_failures.py
2bfc64fe11c882dd7de0ad0d9cc5d73a3f5a3394771adc84e7b010243a452a9c tests/test_photo_orientation.py
f14bad45534041ff0b7dead21d2952ec9d6c60e9f718ab5393bdf5a47b2a6d3e tests/test_photo_paths.py
dfaa930ab989139e888a18aa603e5d52599b50d7aba38f894c88fc6f5a3a7386 tests/test_photo_preferences.py
3138c696c64d0e67d0cb03b18b79b0d0e1a4217f391231af27941a9e560c8050 tests/test_photo_preferences.py
64117766bf85dbd2a5fac77a55ba24303c1067d5e7d92571385210816dd3e85c tests/test_photo_resolution.py
8226d48f02aadcc60b7d951dc60808d559891d64bf7de7f434731dcf0a90cd44 tests/test_photo_sources.py
1e5f544dd4abb4e360efa7d7057f9ccafef7c2e15d19d1eb68db059f5bf55a90 tests/test_photo_watermark.py
+16 -10
View File
@@ -17,7 +17,11 @@ from starlette.exceptions import HTTPException
from pigway_frame_flow.api.history import install_history
from pigway_frame_flow.core.accounts import install_accounts, seed_accounts
from pigway_frame_flow.core.auth import require, seed_client
from pigway_frame_flow.core.photo_preferences import annotate, set_flags
from pigway_frame_flow.core.photo_preferences import (
annotate,
ensure_compatible,
set_flags,
)
from pigway_frame_flow.core.registry import DeviceRegistry
from pigway_frame_flow.display.service import DisplayService
from pigway_frame_flow.errors import ErrorCode as E
@@ -645,15 +649,17 @@ def create_app(settings=None, start_worker=True):
)
def star_asset(asset_id: str, payload: ImageStar):
with service.lock:
assets.info(asset_id)
db.execute(
"INSERT INTO asset_metadata(asset_id,starred) VALUES(?,?) ON CONFLICT(asset_id) DO UPDATE SET starred=excluded.starred",
(asset_id, int(payload.starred)),
)
db.execute(
"UPDATE image_retention SET starred=? WHERE (device_id,image_id) IN (SELECT device_id,image_id FROM image_sources WHERE asset_id=?)",
(int(payload.starred), asset_id),
)
with db.transaction() as connection:
current = assets.info(asset_id)
ensure_compatible(payload.starred, current["excluded"])
connection.execute(
"INSERT INTO asset_metadata(asset_id,starred) VALUES(?,?) ON CONFLICT(asset_id) DO UPDATE SET starred=excluded.starred",
(asset_id, int(payload.starred)),
)
connection.execute(
"UPDATE image_retention SET starred=? WHERE (device_id,image_id) IN (SELECT device_id,image_id FROM image_sources WHERE asset_id=?)",
(int(payload.starred), asset_id),
)
return assets.info(asset_id)
@app.put(
@@ -1,5 +1,17 @@
"""Persistent selection preferences scoped to a library and its stable item ID."""
from pigway_frame_flow.errors import ErrorCode as E
from pigway_frame_flow.errors import FlowError
def ensure_compatible(starred, excluded):
if starred and excluded:
raise FlowError(
E.CONFLICT,
"Starred photos cannot be excluded; clear the existing mark first",
409,
)
def flags(db, source_id, item_id):
row = db.one(
@@ -11,6 +23,16 @@ def flags(db, source_id, item_id):
def set_flags(db, source_id, item_id, *, starred=None, excluded=None):
with db.transaction() as connection:
current = flags(db, source_id, item_id)
if source_id == "@assets":
meta = db.one(
"SELECT starred FROM asset_metadata WHERE asset_id=?", (item_id,)
)
current["starred"] = bool(meta and meta["starred"])
ensure_compatible(
current["starred"] if starred is None else starred,
current["excluded"] if excluded is None else excluded,
)
connection.execute(
"INSERT INTO photo_preferences(source_id,item_id,starred,excluded) VALUES(?,?,?,?) ON CONFLICT(source_id,item_id) DO UPDATE SET starred=coalesce(?,starred),excluded=coalesce(?,excluded)",
(
+5 -5
View File
@@ -2,7 +2,7 @@ from datetime import datetime
from enum import StrEnum
from typing import Annotated, Any, Literal
from pydantic import BaseModel, ConfigDict, Field, model_validator
from pydantic import BaseModel, ConfigDict, Field, StrictBool, model_validator
from pigway_frame_flow.errors import ErrorCode
@@ -504,17 +504,17 @@ class WidgetState(StrictModel):
class ImageExclusion(StrictModel):
excluded: bool
excluded: StrictBool
class PhotoPreference(StrictModel):
item_id: str = Field(min_length=1, max_length=4096)
starred: bool | None = None
excluded: bool | None = None
starred: StrictBool | None = None
excluded: StrictBool | None = None
class ImageStar(StrictModel):
starred: bool
starred: StrictBool
class AssetBatch(StrictModel):
+7 -2
View File
@@ -36,7 +36,12 @@ function enhanceGallery(){
const controls=document.createElement('div');controls.className='form-grid gallery-filters';controls.innerHTML=`<div class="field"><label for="gallery-search">${t('searchImages')}</label><input id="gallery-search" type="search"></div><div class="field"><label for="gallery-sort">${t('sortOrder')}</label><select id="gallery-sort"><option value="newest">${t('sortNewest')}</option><option value="oldest">${t('sortOldest')}</option><option value="name">${t('sortName')}</option></select></div>`;grid.before(controls);
if(state.tab==='local'){controls.classList.add('gallery-filters-local');const bar=document.createElement('div');bar.className='actions';bar.innerHTML=`<select id="asset-origin" aria-label="${t('originFilter')}">${['all','manual','task','external','template','unknown'].map(x=>`<option value="${x}">${t('origin_'+x)}</option>`).join('')}</select><select id="asset-job" aria-label="${t('taskOrigin')}"><option value="">${t('allTasks')}</option></select><button id="asset-prev">${t('previousPage')}</button><button id="asset-next">${t('nextPage')}</button><button id="asset-all">${t('selectAll')}</button><button id="asset-invert">${t('invertSelection')}</button><button id="asset-batch-star">${t('star')}</button><button id="asset-batch-unstar">${t('unstar')}</button><button id="asset-batch-exclude">${t('excludePhoto')}</button><button id="asset-batch-include">${t('includePhoto')}</button><button id="asset-batch-delete" class="danger">${t('deleteSelected')}</button>`;grid.before(bar);for(const [id,key] of [['asset-origin','originFilter'],['asset-job','taskOrigin']]){const field=document.createElement('div');field.className='field';const label=document.createElement('label');label.htmlFor=id;label.textContent=t(key);field.append(label,bar.querySelector('#'+id));controls.append(field);}const help=document.createElement('p');help.className='hint';help.id='gallery-selection-help';help.textContent=t('gallerySelectionHelp')+' '+t('starHelp')+' '+t('excludePhotoHelp');grid.before(help);bar.setAttribute('aria-describedby',help.id);$('#asset-origin').value=state.assetOrigin||'all';if(state.galleryJobsEpoch!==state.epoch){state.galleryJobsEpoch=state.epoch;state.galleryJobsRequest=api('/api/v1/jobs');}void state.galleryJobsRequest.then(jobs=>{if(!bar.isConnected)return;const picker=controls.querySelector('#asset-job');for(const job of jobs){const option=document.createElement('option');option.value=job.id;option.textContent=job.name;picker.append(option);}picker.value=state.assetJob||'';}).catch(error=>{if(bar.isConnected)toast(error.message);});$('#asset-origin').onchange=()=>{state.assetOffset=0;state.assetOrigin=$('#asset-origin').value;return navigate();};$('#asset-job').onchange=()=>{state.assetOffset=0;state.assetJob=$('#asset-job').value;state.assetOrigin=state.assetJob?'task':state.assetOrigin;return navigate();};$('#asset-prev').disabled=!(state.assetOffset||0);$('#asset-next').disabled=state.gallery.length<200;bind('asset-prev',()=>{state.assetOffset=Math.max(0,(state.assetOffset||0)-200);return navigate();});bind('asset-next',()=>{state.assetOffset=(state.assetOffset||0)+200;return navigate();});const visibleSelection=()=>[...grid.querySelectorAll('.asset-select')].filter(x=>!x.closest('.image-card').hidden);$('#asset-all').onclick=()=>{const boxes=visibleSelection();const checked=boxes.some(x=>!x.checked);boxes.forEach(x=>x.checked=checked);};$('#asset-invert').onclick=()=>visibleSelection().forEach(x=>x.checked=!x.checked);for(const action of ['star','unstar','exclude','include','delete']){bind('asset-batch-'+action,async()=>{const ids=[...document.querySelectorAll('.asset-select:checked')].map(x=>x.dataset.asset);if(!ids.length){toast(t('selectImagesFirst'));return;}if(action==='delete'&&!confirm(t('deleteSelectedConfirm')))return;const results=[];for(let i=0;i<ids.length;i+=100){const r=await api('/api/v1/assets/batch',{method:'POST',body:{asset_ids:ids.slice(i,i+100),action}});results.push(...r.results);}const failed=results.filter(x=>x.status==='failed');if(failed.length)toast(t('batchFailed')+' '+failed.length);await navigate();});}}
const cards=[...grid.querySelectorAll('.image-card')];$('#gallery-search').oninput=()=>{state.galleryQuery=$('#gallery-search').value;const query=state.galleryQuery.toLowerCase();cards.forEach(card=>card.hidden=!card.textContent.toLowerCase().includes(query));};$('#gallery-sort').onchange=()=>{const order=$('#gallery-sort').value;state.galleryOrder=order;const indexes=cards.map((card,index)=>({card,item:state.gallery[index]}));indexes.sort((a,b)=>order==='name'?a.item.name.localeCompare(b.item.name):order==='oldest'?String(a.item.created_at).localeCompare(String(b.item.created_at)):String(b.item.created_at).localeCompare(String(a.item.created_at)));indexes.forEach(x=>grid.append(x.card));};
cards.forEach((card,i)=>{const item=state.gallery[i];if(state.tab==='local'){const star=document.createElement('button');star.textContent=t(item.starred?'unstar':'star');star.setAttribute('aria-pressed',String(item.starred));card.querySelector('.actions').append(star);const exclude=document.createElement('button');exclude.textContent=t(item.excluded?'includePhoto':'excludePhoto');exclude.setAttribute('aria-pressed',String(Boolean(item.excluded)));card.querySelector('.actions').append(exclude);exclude.onclick=busy(async()=>{await api('/api/v1/assets/'+item.asset_id+'/exclusion',{method:'PUT',body:{excluded:!item.excluded}});await navigate();});if(item.excluded){const badge=document.createElement('small');badge.className='excluded-photo';badge.textContent=t('photoExcluded');card.querySelector('h3').after(badge);}star.onclick=busy(async()=>{await api('/api/v1/assets/'+item.asset_id+'/star',{method:'PUT',body:{starred:!item.starred}});await navigate();});const label=document.createElement('small');label.textContent=item.origin==='task'?t('taskOrigin')+' · '+(item.job_name||item.job_id):t('origin_'+item.origin);card.querySelector('h3').after(label);const select=document.createElement('input');select.type='checkbox';select.className='asset-select';select.dataset.asset=item.asset_id;select.setAttribute('aria-label',t('selectImage'));card.prepend(select);}if(item.width&&item.height){const dimensions=document.createElement('small');dimensions.textContent=item.width+' × '+item.height;card.querySelector('h3').after(dimensions);}const download=document.createElement('button');download.textContent=t(state.tab==='local'?'downloadShort':'download');card.querySelector('.actions').append(download);download.onclick=busy(()=>downloadImage(item.url,item.name));if(state.tab==='local')card.querySelector('.actions').append(card.querySelector('[data-delete]'));});
cards.forEach((card,i)=>{const item=state.gallery[i];if(state.tab==='local'){const star=document.createElement('button');star.textContent=t(item.starred?'unstar':'star');star.setAttribute('aria-pressed',String(item.starred));star.disabled=Boolean(item.excluded);star.title=item.excluded?t('clearExclusionFirst'):'';card.querySelector('.actions').append(star);const exclude=document.createElement('button');exclude.textContent=t(item.excluded?'includePhoto':'excludePhoto');exclude.setAttribute('aria-pressed',String(Boolean(item.excluded)));exclude.disabled=Boolean(item.starred);exclude.title=item.starred?t('clearStarFirst'):'';card.querySelector('.actions').append(exclude);exclude.onclick=busy(async()=>{await api('/api/v1/assets/'+item.asset_id+'/exclusion',{method:'PUT',body:{excluded:!item.excluded}});await navigate();});if(item.starred){const kept=document.createElement('small');kept.className='photo-mark';kept.textContent=t('photoStarred');card.querySelector('h3').after(kept);}if(item.excluded){const badge=document.createElement('small');badge.className='excluded-photo';badge.textContent=t('photoExcluded');card.querySelector('h3').after(badge);}star.onclick=busy(async()=>{await api('/api/v1/assets/'+item.asset_id+'/star',{method:'PUT',body:{starred:!item.starred}});await navigate();});const label=document.createElement('small');label.textContent=item.origin==='task'?t('taskOrigin')+' · '+(item.job_name||item.job_id):t('origin_'+item.origin);card.querySelector('h3').after(label);const select=document.createElement('input');select.type='checkbox';select.className='asset-select';select.dataset.asset=item.asset_id;select.setAttribute('aria-label',t('selectImage'));card.prepend(select);}if(item.width&&item.height){const dimensions=document.createElement('small');dimensions.textContent=item.width+' × '+item.height;card.querySelector('h3').after(dimensions);}const download=document.createElement('button');download.textContent=t(state.tab==='local'?'downloadShort':'download');card.querySelector('.actions').append(download);download.onclick=busy(()=>downloadImage(item.url,item.name));if(state.tab==='local')card.querySelector('.actions').append(card.querySelector('[data-delete]'));});
if(state.tab==='local'){
const updateMarks=()=>{const selected=[...grid.querySelectorAll('.asset-select:checked')].map(box=>state.gallery.find(item=>item.asset_id===box.dataset.asset));const starred=selected.some(item=>item?.starred),excluded=selected.some(item=>item?.excluded);$('#asset-batch-star').disabled=!selected.length||excluded;$('#asset-batch-star').title=excluded?t('clearExclusionFirst'):'';$('#asset-batch-exclude').disabled=!selected.length||starred;$('#asset-batch-exclude').title=starred?t('clearStarFirst'):'';};
grid.querySelectorAll('.asset-select').forEach(box=>box.onchange=updateMarks);
for(const id of ['asset-all','asset-invert']){const previous=$('#'+id).onclick;$('#'+id).onclick=event=>{previous(event);updateMarks();};}updateMarks();
}
$('#gallery-search').value=state.galleryQuery||'';$('#gallery-sort').value=state.galleryOrder||'newest';$('#gallery-search').oninput();$('#gallery-sort').onchange();
}
async function weatherSettings(){
@@ -157,7 +162,7 @@ async function photoSourceForm(source=null){
}
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;
box.innerHTML=`<h4>${t('sourceImages')} · ${result.total}${result.total>=result.catalog_limit?' (limit)':''}</h4><p class="hint">${t('sourceBrowseHint')} ${t('excludePhotoHelp')}</p><div class="source-items">${result.items.map((x,i)=>`<article class="source-photo"><button data-source-item="${i}">${esc(x.name)}${x.width?`<small>${x.width} × ${x.height}</small>`:''}${x.caption?`<small>${esc([x.caption.date,x.caption.people,x.caption.title,x.caption.place,x.caption.filename].filter(Boolean).join(' · '))}</small>`:''}</button><div class="actions"><button data-source-star="${i}" aria-pressed="${Boolean(x.starred)}">${t(x.starred?'unstar':'star')}</button><button data-source-exclude="${i}" aria-pressed="${Boolean(x.excluded)}">${t(x.excluded?'includePhoto':'excludePhoto')}</button></div>${x.excluded?`<small class="excluded-photo">${t('photoExcluded')}</small>`:''}</article>`).join('')||t('noSourcePhotos')}</div><div class="actions"><button id="source-prev" ${offset?'':'disabled'}>${t('previousPage')}</button><button id="source-next" ${offset+result.items.length<result.total?'':'disabled'}>${t('nextPage')}</button>${buttons('source-refresh','refresh')}</div>`;
box.innerHTML=`<h4>${t('sourceImages')} · ${result.total}${result.total>=result.catalog_limit?' (limit)':''}</h4><p class="hint">${t('sourceBrowseHint')} ${t('excludePhotoHelp')}</p><div class="source-items">${result.items.map((x,i)=>`<article class="source-photo"><button data-source-item="${i}">${esc(x.name)}${x.width?`<small>${x.width} × ${x.height}</small>`:''}${x.caption?`<small>${esc([x.caption.date,x.caption.people,x.caption.title,x.caption.place,x.caption.filename].filter(Boolean).join(' · '))}</small>`:''}</button><div class="actions"><button data-source-star="${i}" aria-pressed="${Boolean(x.starred)}" ${x.excluded?'disabled':''} title="${x.excluded?t('clearExclusionFirst'):''}">${t(x.starred?'unstar':'star')}</button><button data-source-exclude="${i}" aria-pressed="${Boolean(x.excluded)}" ${x.starred?'disabled':''} title="${x.starred?t('clearStarFirst'):''}">${t(x.excluded?'includePhoto':'excludePhoto')}</button></div>${x.starred?`<small class="photo-mark">${t('photoStarred')}</small>`:''}${x.excluded?`<small class="excluded-photo">${t('photoExcluded')}</small>`:''}</article>`).join('')||t('noSourcePhotos')}</div><div class="actions"><button id="source-prev" ${offset?'':'disabled'}>${t('previousPage')}</button><button id="source-next" ${offset+result.items.length<result.total?'':'disabled'}>${t('nextPage')}</button>${buttons('source-refresh','refresh')}</div>`;
for(const key of ['star','exclude'])box.querySelectorAll('[data-source-'+key+']').forEach(button=>button.onclick=busy(async()=>{const item=result.items[Number(button.dataset[key==='star'?'sourceStar':'sourceExclude'])],field=key==='star'?'starred':'excluded';await api('/api/v1/photo-sources/'+id+'/preferences?device_id='+encodeURIComponent(state.device),{method:'PUT',body:{item_id:item.id,[field]:!item[field]}});await browseSource(id,offset);}));
box.querySelectorAll('[data-source-item]').forEach(b=>b.onclick=busy(()=>previewSource(id,result.items[Number(b.dataset.sourceItem)].id)));bind('source-prev',()=>browseSource(id,Math.max(0,offset-50)));bind('source-next',()=>browseSource(id,offset+50));bind('source-refresh',()=>browseSource(id,0,true));
}
+4 -2
View File
@@ -153,7 +153,9 @@ Object.assign(MESSAGES.en,{RESTORE_IMAGE_PROTECTED:'This image is still needed t
Object.assign(MESSAGES.zh,{searchHistory:'搜索图片名称、请求 ID、任务 ID 或来源',allStatuses:'全部状态',allActions:'全部操作',allDevices:'全部设备',startDate:'开始日期',endDate:'结束日期',filter:'筛选',resetFilters:'重置筛选',dateRangeInvalid:'结束日期不能早于开始日期',historyTotal:'共 {count} 条',pageSize:'每页条数',historySelected:'已选 {count} 条(仅当前页)',selectHistory:'选择记录',deleteHistoryConfirm:'确认删除这 {count} 条历史记录?记录将从历史列表移除,不会删除图片或取消任务。此操作无法撤销。'});
Object.assign(MESSAGES.en,{searchHistory:'Search image name, request ID, operation ID or client',allStatuses:'All statuses',allActions:'All actions',allDevices:'All devices',startDate:'Start date',endDate:'End date',filter:'Filter',resetFilters:'Reset filters',dateRangeInvalid:'End date must not precede start date',historyTotal:'{count} records',pageSize:'Rows per page',historySelected:'{count} selected (current page only)',selectHistory:'Select record',deleteHistoryConfirm:'Delete these {count} history records? This removes them from history without deleting images or cancelling tasks. This cannot be undone.'});
Object.assign(MESSAGES.zh,{excludePhoto:'不再选用',includePhoto:'恢复选用',photoExcluded:'已标记不再选用',excludePhotoHelp:'不再选用的图片将被后续自动选图和自动拼图跳过;图片不删除,星标独立保留,仍可手动使用或恢复选用。标记按图库独立保存。'});
Object.assign(MESSAGES.en,{excludePhoto:'Exclude from selection',includePhoto:'Allow selection',photoExcluded:'Excluded from selection',excludePhotoHelp:'Excluded photos are skipped by future automatic selection and collages. Photos and stars are retained; manual use and restoring eligibility remain available. Preferences are saved separately per library.'});
Object.assign(MESSAGES.zh,{excludePhoto:'不再选用',includePhoto:'恢复选用',photoExcluded:'已标记不再选用',excludePhotoHelp:'不再选用的图片将被后续自动选图和自动拼图跳过;图片不删除,仍可手动使用或恢复选用。星标保留与不再选用互斥,切换前请先取消原标记。标记按图库独立保存。'});
Object.assign(MESSAGES.en,{excludePhoto:'Exclude from selection',includePhoto:'Allow selection',photoExcluded:'Excluded from selection',excludePhotoHelp:'Excluded photos are skipped by future automatic selection and collages. Photos are retained; manual use and restoring eligibility remain available. Starred and excluded states are mutually exclusive; clear the current mark before switching. Preferences are saved separately per library.'});
Object.assign(MESSAGES.zh,{downloadShort:'下载图片'});Object.assign(MESSAGES.en,{downloadShort:'Download'});
Object.assign(MESSAGES.zh,{photoStarred:'已星标保留',clearExclusionFirst:'请先恢复选用,再设置星标保留',clearStarFirst:'请先取消星标,再设置不再选用'});Object.assign(MESSAGES.en,{photoStarred:'Starred for retention',clearExclusionFirst:'Allow selection before starring',clearStarFirst:'Remove the star before excluding'});
+3
View File
@@ -252,3 +252,6 @@ form#history-filters{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));
.source-photo>[data-source-item]{width:100%;text-align:left;overflow-wrap:anywhere}
.source-photo .actions{margin-top:auto}
.excluded-photo{display:block;color:var(--muted);font-size:12px}
.image-card>small{display:block}
.photo-mark{display:block;color:var(--accent);font-size:12px}
+121 -6
View File
@@ -26,10 +26,26 @@ def test_local_exclusion_star_and_restore(client, service):
== 200
)
result = client.put(f"/api/v1/assets/{excluded}/exclusion", json={"excluded": True})
assert result.status_code == 409
client.put(f"/api/v1/assets/{excluded}/star", json={"starred": False})
result = client.put(f"/api/v1/assets/{excluded}/exclusion", json={"excluded": True})
assert (
result.status_code == 200
and result.json()["excluded"]
and result.json()["starred"]
and not result.json()["starred"]
)
assert (
client.put(
f"/api/v1/assets/{excluded}/star", json={"starred": True}
).status_code
== 409
)
batch = client.post(
"/api/v1/assets/batch", json={"asset_ids": [excluded], "action": "star"}
).json()
assert (
batch["results"][0]["status"] == "failed"
and batch["results"][0]["code"] == "CONFLICT"
)
assert next(
x for x in client.get("/api/v1/assets").json() if x["asset_id"] == excluded
@@ -57,7 +73,13 @@ def test_local_exclusion_star_and_restore(client, service):
restored = client.put(
f"/api/v1/assets/{excluded}/exclusion", json={"excluded": False}
).json()
assert not restored["excluded"] and restored["starred"]
assert not restored["excluded"] and not restored["starred"]
assert (
client.put(
f"/api/v1/assets/{excluded}/star", json={"starred": True}
).status_code
== 200
)
assert service.content_asset(
AutoCollageContent(type="auto_collage", count=1, use_ai=False)
)
@@ -77,11 +99,21 @@ def test_source_preferences_are_scoped_and_do_not_mutate_cached_catalog(
client.put(url, json={"item_id": items[0]["id"], "starred": True}).status_code
== 200
)
assert (
client.put(url, json={"item_id": items[0]["id"], "excluded": True}).status_code
== 409
)
assert (
client.put(
url, json={"item_id": items[0]["id"], "starred": True, "excluded": True}
).status_code
== 409
)
assert client.put(
url, json={"item_id": items[0]["id"], "excluded": True}
).json() == {"starred": True, "excluded": True}
url, json={"item_id": items[0]["id"], "starred": False, "excluded": True}
).json() == {"starred": False, "excluded": True}
result = client.get("/api/v1/photo-sources/drive/images").json()["items"][0]
assert result["starred"] and result["excluded"]
assert not result["starred"] and result["excluded"]
assert "excluded" not in items[0]
assert eligible(service.db, "drive", items) == []
assert eligible(service.db, "another-drive", items) == items
@@ -104,7 +136,7 @@ def test_source_preferences_are_scoped_and_do_not_mutate_cached_catalog(
client.put(url, json={"item_id": items[0]["id"], "excluded": False})
assert eligible(service.db, "drive", items) == items
assert flags(service.db, "drive", items[0]["id"]) == {
"starred": True,
"starred": False,
"excluded": False,
}
@@ -120,3 +152,86 @@ def test_preference_persistence(settings):
reopened = Database(path)
assert flags(reopened, "drive", "item")["excluded"]
reopened.conn.close()
@pytest.mark.parametrize("value", ["true", "false", 1, 0, [], {}])
def test_marks_reject_non_boolean_payloads(client, service, value):
key = photo(service, "fixture.jpg", "red")["asset_id"]
assert (
client.put(f"/api/v1/assets/{key}/star", json={"starred": value}).status_code
== 422
)
assert (
client.put(
f"/api/v1/assets/{key}/exclusion", json={"excluded": value}
).status_code
== 422
)
assert (
client.put(
"/api/v1/photo-sources/drive/preferences",
json={"item_id": "fixture", "starred": value},
).status_code
== 422
)
def test_asset_marks_are_mutually_exclusive_under_concurrent_requests(client, service):
from concurrent.futures import ThreadPoolExecutor
key = photo(service, "fixture.jpg", "blue")["asset_id"]
def request_star():
return client.put(
f"/api/v1/assets/{key}/star", json={"starred": True}
).status_code
def request_exclusion():
return client.put(
f"/api/v1/assets/{key}/exclusion", json={"excluded": True}
).status_code
with ThreadPoolExecutor(max_workers=2) as pool:
first = pool.submit(request_star)
second = pool.submit(request_exclusion)
assert sorted([first.result(), second.result()]) == [200, 409]
final = service.assets.info(key)
assert final["starred"] != final["excluded"]
def test_source_marks_are_mutually_exclusive_under_concurrent_requests(service):
from concurrent.futures import ThreadPoolExecutor
def change(**mark):
try:
set_flags(service.db, "drive", "fixture", **mark)
return 200
except FlowError as error:
return error.status
with ThreadPoolExecutor(max_workers=2) as pool:
first = pool.submit(change, starred=True)
second = pool.submit(change, excluded=True)
assert sorted([first.result(), second.result()]) == [200, 409]
final = flags(service.db, "drive", "fixture")
assert final["starred"] != final["excluded"]
def test_batch_exclusion_rejects_starred_and_unknown_payload_fields(client, service):
key = photo(service, "fixture.jpg", "red")["asset_id"]
assert (
client.put(
f"/api/v1/assets/{key}/star", json={"starred": True, "excluded": False}
).status_code
== 422
)
client.put(f"/api/v1/assets/{key}/star", json={"starred": True})
result = client.post(
"/api/v1/assets/batch", json={"asset_ids": [key], "action": "exclude"}
).json()
assert (
result["results"][0]["status"] == "failed"
and result["results"][0]["code"] == "CONFLICT"
)
final = service.assets.info(key)
assert final["starred"] and not final["excluded"]