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"]