diff --git a/README.zh-CN.md b/README.zh-CN.md
index 3bac26f..b0c787e 100644
--- a/README.zh-CN.md
+++ b/README.zh-CN.md
@@ -106,4 +106,4 @@ KoKonna 是支持的设备集成。本项目与 KoKonna 无隶属关系,也不
历史记录支持服务端翻页、图片名称/请求 ID/任务 ID/来源搜索,以及状态、操作、设备和日期(上海时区)筛选;可选择当前页记录进行单条或批量删除,操作前需确认。删除仅从历史列表移除,内部执行凭据继续供图库来源、幂等及恢复使用,不会删除图片或取消任务;运行中的任务不能删除。
-源图库选用标记:本地图库支持单张和批量“不再选用/恢复选用”,照片源(Synology Photos、WebDAV、网盘等)的浏览列表支持独立星标及选用标记。后续自动选图和自动拼图跳过被标记的图片,星标不覆盖排除设置;图片仍保留,可手动预览、使用或恢复。标记按图库和图片 ID 独立保存,不取消已提交任务,也不修改网盘原文件。
+源图库选用标记:本地图库支持单张和批量“不再选用/恢复选用”,照片源(Synology Photos、WebDAV、网盘等)的浏览列表支持独立星标及选用标记。后续自动选图和自动拼图跳过被标记的图片,星标保留与不再选用互斥,需先取消原标记再切换;图片仍保留,可手动预览、使用或恢复。标记按图库和图片 ID 独立保存,不取消已提交任务,也不修改网盘原文件。
diff --git a/SHA256SUMS b/SHA256SUMS
index b4d0642..ffac3b8 100644
--- a/SHA256SUMS
+++ b/SHA256SUMS
@@ -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
diff --git a/pigway_frame_flow/api/app.py b/pigway_frame_flow/api/app.py
index c3554cf..ebe1abc 100644
--- a/pigway_frame_flow/api/app.py
+++ b/pigway_frame_flow/api/app.py
@@ -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(
diff --git a/pigway_frame_flow/core/photo_preferences.py b/pigway_frame_flow/core/photo_preferences.py
index c48cde7..5859e3e 100644
--- a/pigway_frame_flow/core/photo_preferences.py
+++ b/pigway_frame_flow/core/photo_preferences.py
@@ -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)",
(
diff --git a/pigway_frame_flow/models.py b/pigway_frame_flow/models.py
index 58db037..8815086 100644
--- a/pigway_frame_flow/models.py
+++ b/pigway_frame_flow/models.py
@@ -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):
diff --git a/pigway_frame_flow/web/features.js b/pigway_frame_flow/web/features.js
index 0b9bf84..919e800 100644
--- a/pigway_frame_flow/web/features.js
+++ b/pigway_frame_flow/web/features.js
@@ -36,7 +36,12 @@ function enhanceGallery(){
const controls=document.createElement('div');controls.className='form-grid gallery-filters';controls.innerHTML=`
`;grid.before(controls);
if(state.tab==='local'){controls.classList.add('gallery-filters-local');const bar=document.createElement('div');bar.className='actions';bar.innerHTML=``;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;ix.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=`${t('sourceImages')} · ${result.total}${result.total>=result.catalog_limit?' (limit)':''}
${t('sourceBrowseHint')} ${t('excludePhotoHelp')}
${result.items.map((x,i)=>`
${x.excluded?`${t('photoExcluded')}`:''}`).join('')||t('noSourcePhotos')}
${buttons('source-refresh','refresh')}
`;
+ box.innerHTML=`${t('sourceImages')} · ${result.total}${result.total>=result.catalog_limit?' (limit)':''}
${t('sourceBrowseHint')} ${t('excludePhotoHelp')}
${result.items.map((x,i)=>`
${x.starred?`${t('photoStarred')}`:''}${x.excluded?`${t('photoExcluded')}`:''}`).join('')||t('noSourcePhotos')}
${buttons('source-refresh','refresh')}
`;
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));
}
diff --git a/pigway_frame_flow/web/i18n.js b/pigway_frame_flow/web/i18n.js
index 1afc130..f1b8a02 100644
--- a/pigway_frame_flow/web/i18n.js
+++ b/pigway_frame_flow/web/i18n.js
@@ -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'});
diff --git a/pigway_frame_flow/web/style.css b/pigway_frame_flow/web/style.css
index f29829d..25bd893 100644
--- a/pigway_frame_flow/web/style.css
+++ b/pigway_frame_flow/web/style.css
@@ -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}
diff --git a/tests/test_photo_preferences.py b/tests/test_photo_preferences.py
index 6818913..136eb6b 100644
--- a/tests/test_photo_preferences.py
+++ b/tests/test_photo_preferences.py
@@ -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"]