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/来源搜索,以及状态、操作、设备和日期(上海时区)筛选;可选择当前页记录进行单条或批量删除,操作前需确认。删除仅从历史列表移除,内部执行凭据继续供图库来源、幂等及恢复使用,不会删除图片或取消任务;运行中的任务不能删除。 历史记录支持服务端翻页、图片名称/请求 ID/任务 ID/来源搜索,以及状态、操作、设备和日期(上海时区)筛选;可选择当前页记录进行单条或批量删除,操作前需确认。删除仅从历史列表移除,内部执行凭据继续供图库来源、幂等及恢复使用,不会删除图片或取消任务;运行中的任务不能删除。
源图库选用标记:本地图库支持单张和批量“不再选用/恢复选用”,照片源(Synology Photos、WebDAV、网盘等)的浏览列表支持独立星标及选用标记。后续自动选图和自动拼图跳过被标记的图片,星标不覆盖排除设置;图片仍保留,可手动预览、使用或恢复。标记按图库和图片 ID 独立保存,不取消已提交任务,也不修改网盘原文件。 源图库选用标记:本地图库支持单张和批量“不再选用/恢复选用”,照片源(Synology Photos、WebDAV、网盘等)的浏览列表支持独立星标及选用标记。后续自动选图和自动拼图跳过被标记的图片,星标保留与不再选用互斥,需先取消原标记再切换;图片仍保留,可手动预览、使用或恢复。标记按图库和图片 ID 独立保存,不取消已提交任务,也不修改网盘原文件。
+8 -8
View File
@@ -9,7 +9,7 @@ c8c545718e9b9880555c950a078cac160c82e0cd6e593afcf20bc13727539ec5 .github/workfl
6eb7728e6e73f9568ca6fd5ad6383d13ffc638f2870fefa2d86f72f39685c0fc LICENSE 6eb7728e6e73f9568ca6fd5ad6383d13ffc638f2870fefa2d86f72f39685c0fc LICENSE
250a2bcec248b074786d90e0329874628d70d5a0fe3b4fba47c8c57d4f84dcce NOTICE.md 250a2bcec248b074786d90e0329874628d70d5a0fe3b4fba47c8c57d4f84dcce NOTICE.md
b7a41a2c00df1a0dbe3869d059d65c3bcaee8c50e51217c358527c4f1d3d6c98 README.md b7a41a2c00df1a0dbe3869d059d65c3bcaee8c50e51217c358527c4f1d3d6c98 README.md
42e9c414405430ca6152ce7e6472440558a466cac92422b42b60d1c3307be225 README.zh-CN.md 94184f76401192678ec420c2ccd23142d90e07a8ccc882a4a0d4701a0c278afc README.zh-CN.md
4a81645fe7ee8ee335ee74262004e2c51e804e7282ee9ef17f7a80dfcb7baa84 RELEASE_NOTES.md 4a81645fe7ee8ee335ee74262004e2c51e804e7282ee9ef17f7a80dfcb7baa84 RELEASE_NOTES.md
3dd364e47fdbd2ce9ce2df83342c523adc21583f093a5439d501b3424878ce2f SECURITY.md 3dd364e47fdbd2ce9ce2df83342c523adc21583f093a5439d501b3424878ce2f SECURITY.md
e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 build-cache/wheels/.gitkeep e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 build-cache/wheels/.gitkeep
@@ -44,12 +44,12 @@ ec53d587b855be84283c50f8fb45be62a4c1caba78b28d436a8260fd171943a2 docs/research/
2a68fe95ba4afc24aec6abd3e6ab24a360eb6bc3005ab0fefd696190915d76c1 docs/research/KOKONNA_WIDGET_CATALOG.json 2a68fe95ba4afc24aec6abd3e6ab24a360eb6bc3005ab0fefd696190915d76c1 docs/research/KOKONNA_WIDGET_CATALOG.json
e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 pigway_frame_flow/__init__.py e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 pigway_frame_flow/__init__.py
e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 pigway_frame_flow/api/__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 1efe9b1fc774a97e5be72d81199b1b65b25c258956fdf166aebff732cc820d7c pigway_frame_flow/api/history.py
e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 pigway_frame_flow/core/__init__.py e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 pigway_frame_flow/core/__init__.py
0b8402603c2f397f4f42491873b27bf363959194b05a0c67c7b1d49596288b95 pigway_frame_flow/core/accounts.py 0b8402603c2f397f4f42491873b27bf363959194b05a0c67c7b1d49596288b95 pigway_frame_flow/core/accounts.py
818e8fb29d22c13159a6cb22bcbc6b9e211a8a1301588126eb192ac4ed71441b pigway_frame_flow/core/auth.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 8f2220fa90b0dc2e45df2023dc5ac695efe55361644ef266829553c99151949b pigway_frame_flow/core/registry.py
e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 pigway_frame_flow/display/__init__.py e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 pigway_frame_flow/display/__init__.py
81effa34d6c452579cfa65781909f7c5a9e8ddad136e4c47ee0704939851879a pigway_frame_flow/display/cloud_library.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 2a68fe95ba4afc24aec6abd3e6ab24a360eb6bc3005ab0fefd696190915d76c1 pigway_frame_flow/integrations/kokonna_official/widget_catalog.json
aea594fb4e4c9a6e2094d4d7973eb627ac02fb632811b2d1a558a0ee1cd12e77 pigway_frame_flow/integrations/kokonna_official/widgets.py aea594fb4e4c9a6e2094d4d7973eb627ac02fb632811b2d1a558a0ee1cd12e77 pigway_frame_flow/integrations/kokonna_official/widgets.py
1fe53f4bfb407b8db774fe730131ecf1213e16d752a3506c4c56b46365ea14e8 pigway_frame_flow/main.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 e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 pigway_frame_flow/providers/__init__.py
b7a6cb8eb40d8dedfbce18777f8fbd433a174f14bcdadf498bd834132aa568fd pigway_frame_flow/providers/cloud_drives.py b7a6cb8eb40d8dedfbce18777f8fbd433a174f14bcdadf498bd834132aa568fd pigway_frame_flow/providers/cloud_drives.py
58b070f0c814d639a4a912d079479d751d459816d59f2199d0be03e4d573ec3b pigway_frame_flow/providers/external.py 58b070f0c814d639a4a912d079479d751d459816d59f2199d0be03e4d573ec3b pigway_frame_flow/providers/external.py
@@ -127,11 +127,11 @@ f332fac63f611572597812a06ae0cc266a59e5677efda0d6e9b6925acf9f9309 pigway_frame_f
d7d55b06ead173b4c23a277fa39800cadfc18f1163c500e8617cfc1f4ee0dea9 pigway_frame_flow/web/extensions.js d7d55b06ead173b4c23a277fa39800cadfc18f1163c500e8617cfc1f4ee0dea9 pigway_frame_flow/web/extensions.js
94558dbf524a1068e83283eb814c1b1501512d4b7e75e092b52e9aabfea720e7 pigway_frame_flow/web/favicon.ico 94558dbf524a1068e83283eb814c1b1501512d4b7e75e092b52e9aabfea720e7 pigway_frame_flow/web/favicon.ico
0fa494a3fbd456c754bbea3f7f050be139e32e3b42b09a1ed953094087437210 pigway_frame_flow/web/favicon.svg 0fa494a3fbd456c754bbea3f7f050be139e32e3b42b09a1ed953094087437210 pigway_frame_flow/web/favicon.svg
1fac3bde40a6aa15701e9d2f02955575d0b180d2ffd7c2d299191bc1577aa87d pigway_frame_flow/web/features.js 4c7a6d81a4397896e5dcca9020e0a162e60116ff05a840f161834afdf6ea54c5 pigway_frame_flow/web/features.js
fe7c47c5de765b9611082f8336c33c74dc16333b983c05e48ac9658941489cd5 pigway_frame_flow/web/i18n.js 4995020f673252abfd745ac8a1a6e9c5081cf9bfa89dd4b7d1ab30a41e0b6eb9 pigway_frame_flow/web/i18n.js
51796b4db73773cea7e451ea21d3b493fcfaf340945d44a696b5d484a5222ea5 pigway_frame_flow/web/index.html 51796b4db73773cea7e451ea21d3b493fcfaf340945d44a696b5d484a5222ea5 pigway_frame_flow/web/index.html
0fa494a3fbd456c754bbea3f7f050be139e32e3b42b09a1ed953094087437210 pigway_frame_flow/web/logo-symbol.svg 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 cfc7749b96f63bd31c3c42b5c471bf756814053e847c10f3eb003417bc523d30 pigway_frame_flow/web/swagger-LICENSE
050bc415ee7048dcd881682678f720264e7da5e373f7461d7c58c755305255f7 pigway_frame_flow/web/swagger-ui-bundle.js 050bc415ee7048dcd881682678f720264e7da5e373f7461d7c58c755305255f7 pigway_frame_flow/web/swagger-ui-bundle.js
1ac324f7dcd27e4b9386b4bd6421271ec147e922a22c05ba24b11515e9aa6321 pigway_frame_flow/web/swagger-ui.css 1ac324f7dcd27e4b9386b4bd6421271ec147e922a22c05ba24b11515e9aa6321 pigway_frame_flow/web/swagger-ui.css
@@ -211,7 +211,7 @@ cbc44ca3bd0eab93b39f0aabfe5c9d923105f4556df7fd7b86593c06daddcae5 tests/test_ica
cad91937e8429647319cd01fd1925132b7afe057d5420be9586c5fe63f79e498 tests/test_photo_failures.py cad91937e8429647319cd01fd1925132b7afe057d5420be9586c5fe63f79e498 tests/test_photo_failures.py
2bfc64fe11c882dd7de0ad0d9cc5d73a3f5a3394771adc84e7b010243a452a9c tests/test_photo_orientation.py 2bfc64fe11c882dd7de0ad0d9cc5d73a3f5a3394771adc84e7b010243a452a9c tests/test_photo_orientation.py
f14bad45534041ff0b7dead21d2952ec9d6c60e9f718ab5393bdf5a47b2a6d3e tests/test_photo_paths.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 64117766bf85dbd2a5fac77a55ba24303c1067d5e7d92571385210816dd3e85c tests/test_photo_resolution.py
8226d48f02aadcc60b7d951dc60808d559891d64bf7de7f434731dcf0a90cd44 tests/test_photo_sources.py 8226d48f02aadcc60b7d951dc60808d559891d64bf7de7f434731dcf0a90cd44 tests/test_photo_sources.py
1e5f544dd4abb4e360efa7d7057f9ccafef7c2e15d19d1eb68db059f5bf55a90 tests/test_photo_watermark.py 1e5f544dd4abb4e360efa7d7057f9ccafef7c2e15d19d1eb68db059f5bf55a90 tests/test_photo_watermark.py
+10 -4
View File
@@ -17,7 +17,11 @@ from starlette.exceptions import HTTPException
from pigway_frame_flow.api.history import install_history 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.accounts import install_accounts, seed_accounts
from pigway_frame_flow.core.auth import require, seed_client 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.core.registry import DeviceRegistry
from pigway_frame_flow.display.service import DisplayService from pigway_frame_flow.display.service import DisplayService
from pigway_frame_flow.errors import ErrorCode as E from pigway_frame_flow.errors import ErrorCode as E
@@ -645,12 +649,14 @@ def create_app(settings=None, start_worker=True):
) )
def star_asset(asset_id: str, payload: ImageStar): def star_asset(asset_id: str, payload: ImageStar):
with service.lock: with service.lock:
assets.info(asset_id) with db.transaction() as connection:
db.execute( 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", "INSERT INTO asset_metadata(asset_id,starred) VALUES(?,?) ON CONFLICT(asset_id) DO UPDATE SET starred=excluded.starred",
(asset_id, int(payload.starred)), (asset_id, int(payload.starred)),
) )
db.execute( connection.execute(
"UPDATE image_retention SET starred=? WHERE (device_id,image_id) IN (SELECT device_id,image_id FROM image_sources WHERE asset_id=?)", "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), (int(payload.starred), asset_id),
) )
@@ -1,5 +1,17 @@
"""Persistent selection preferences scoped to a library and its stable item ID.""" """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): def flags(db, source_id, item_id):
row = db.one( 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): def set_flags(db, source_id, item_id, *, starred=None, excluded=None):
with db.transaction() as connection: 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( 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)", "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 enum import StrEnum
from typing import Annotated, Any, Literal 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 from pigway_frame_flow.errors import ErrorCode
@@ -504,17 +504,17 @@ class WidgetState(StrictModel):
class ImageExclusion(StrictModel): class ImageExclusion(StrictModel):
excluded: bool excluded: StrictBool
class PhotoPreference(StrictModel): class PhotoPreference(StrictModel):
item_id: str = Field(min_length=1, max_length=4096) item_id: str = Field(min_length=1, max_length=4096)
starred: bool | None = None starred: StrictBool | None = None
excluded: bool | None = None excluded: StrictBool | None = None
class ImageStar(StrictModel): class ImageStar(StrictModel):
starred: bool starred: StrictBool
class AssetBatch(StrictModel): 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); 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();});}} 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));}; 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(); $('#gallery-search').value=state.galleryQuery||'';$('#gallery-sort').value=state.galleryOrder||'newest';$('#gallery-search').oninput();$('#gallery-sort').onchange();
} }
async function weatherSettings(){ async function weatherSettings(){
@@ -157,7 +162,7 @@ async function photoSourceForm(source=null){
} }
async function browseSource(id,offset=0,refresh=false){ async function browseSource(id,offset=0,refresh=false){
const result=await api('/api/v1/photo-sources/'+id+'/images?device_id='+encodeURIComponent(state.device)+'&offset='+offset+'&limit=50&refresh='+refresh);const box=$('#source-browser');if(!box)return; const result=await api('/api/v1/photo-sources/'+id+'/images?device_id='+encodeURIComponent(state.device)+'&offset='+offset+'&limit=50&refresh='+refresh);const box=$('#source-browser');if(!box)return;
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);})); 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)); 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.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.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.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.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,{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>[data-source-item]{width:100%;text-align:left;overflow-wrap:anywhere}
.source-photo .actions{margin-top:auto} .source-photo .actions{margin-top:auto}
.excluded-photo{display:block;color:var(--muted);font-size:12px} .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 == 200
) )
result = client.put(f"/api/v1/assets/{excluded}/exclusion", json={"excluded": True}) 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 ( assert (
result.status_code == 200 result.status_code == 200
and result.json()["excluded"] 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( assert next(
x for x in client.get("/api/v1/assets").json() if x["asset_id"] == excluded 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( restored = client.put(
f"/api/v1/assets/{excluded}/exclusion", json={"excluded": False} f"/api/v1/assets/{excluded}/exclusion", json={"excluded": False}
).json() ).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( assert service.content_asset(
AutoCollageContent(type="auto_collage", count=1, use_ai=False) 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 client.put(url, json={"item_id": items[0]["id"], "starred": True}).status_code
== 200 == 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( assert client.put(
url, json={"item_id": items[0]["id"], "excluded": True} url, json={"item_id": items[0]["id"], "starred": False, "excluded": True}
).json() == {"starred": True, "excluded": True} ).json() == {"starred": False, "excluded": True}
result = client.get("/api/v1/photo-sources/drive/images").json()["items"][0] 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 "excluded" not in items[0]
assert eligible(service.db, "drive", items) == [] assert eligible(service.db, "drive", items) == []
assert eligible(service.db, "another-drive", items) == 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}) client.put(url, json={"item_id": items[0]["id"], "excluded": False})
assert eligible(service.db, "drive", items) == items assert eligible(service.db, "drive", items) == items
assert flags(service.db, "drive", items[0]["id"]) == { assert flags(service.db, "drive", items[0]["id"]) == {
"starred": True, "starred": False,
"excluded": False, "excluded": False,
} }
@@ -120,3 +152,86 @@ def test_preference_persistence(settings):
reopened = Database(path) reopened = Database(path)
assert flags(reopened, "drive", "item")["excluded"] assert flags(reopened, "drive", "item")["excluded"]
reopened.conn.close() 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"]