diff --git a/README.zh-CN.md b/README.zh-CN.md index eb31bd6..3bac26f 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -105,3 +105,5 @@ KoKonna 是支持的设备集成。本项目与 KoKonna 无隶属关系,也不 [文档目录](docs/README.md) 历史记录支持服务端翻页、图片名称/请求 ID/任务 ID/来源搜索,以及状态、操作、设备和日期(上海时区)筛选;可选择当前页记录进行单条或批量删除,操作前需确认。删除仅从历史列表移除,内部执行凭据继续供图库来源、幂等及恢复使用,不会删除图片或取消任务;运行中的任务不能删除。 + +源图库选用标记:本地图库支持单张和批量“不再选用/恢复选用”,照片源(Synology Photos、WebDAV、网盘等)的浏览列表支持独立星标及选用标记。后续自动选图和自动拼图跳过被标记的图片,星标不覆盖排除设置;图片仍保留,可手动预览、使用或恢复。标记按图库和图片 ID 独立保存,不取消已提交任务,也不修改网盘原文件。 diff --git a/SHA256SUMS b/SHA256SUMS index ea16337..f95ed3d 100644 --- a/SHA256SUMS +++ b/SHA256SUMS @@ -9,7 +9,7 @@ c8c545718e9b9880555c950a078cac160c82e0cd6e593afcf20bc13727539ec5 .github/workfl 6eb7728e6e73f9568ca6fd5ad6383d13ffc638f2870fefa2d86f72f39685c0fc LICENSE 250a2bcec248b074786d90e0329874628d70d5a0fe3b4fba47c8c57d4f84dcce NOTICE.md b7a41a2c00df1a0dbe3869d059d65c3bcaee8c50e51217c358527c4f1d3d6c98 README.md -2da8a4418059ce61f7f0abfd4181338db0c741b4196dc658989388d142b5ee64 README.zh-CN.md +42e9c414405430ca6152ce7e6472440558a466cac92422b42b60d1c3307be225 README.zh-CN.md 4a81645fe7ee8ee335ee74262004e2c51e804e7282ee9ef17f7a80dfcb7baa84 RELEASE_NOTES.md 3dd364e47fdbd2ce9ce2df83342c523adc21583f093a5439d501b3424878ce2f SECURITY.md e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 build-cache/wheels/.gitkeep @@ -37,24 +37,25 @@ f709156217c798f6b6230dae1f341373e0814a67ab90e881401da577f52ec3d3 docs/brand/pig da6734d8a23305f6254c0936dadff4ec34977b4c934f5067186423c8cfce3fc7 docs/brand/pigway-logo.png 0fa494a3fbd456c754bbea3f7f050be139e32e3b42b09a1ed953094087437210 docs/brand/pigway-logo.svg ac0481196e593ff9cca2e5133ff41fb3614268e6a10728a0deca3ad3bfb80b34 docs/integrations/TICKTAR.md -6921da6e05c08b5f1e3d9b3127095428ce8686d92dbd711121ea8b8640837dbb docs/openapi.json +93ca4bc8261504dce387ec14f07170fda8d8bfbe246dd739aa31e3268b2772b7 docs/openapi.json 54bf7061e0626900ce35e19e81efc5c32c8fd6a0438bdad68f494e1ce81616d4 docs/research/KOKONNA_ACCOUNT_CAPABILITIES.md a07cbe71571c6e2d5a1b94897bd63321c74675c05d15438c4964eef39db0fd76 docs/research/KOKONNA_CONTENT_SUBSCRIPTIONS.md ec53d587b855be84283c50f8fb45be62a4c1caba78b28d436a8260fd171943a2 docs/research/KOKONNA_LOCAL_CONTROL.md 2a68fe95ba4afc24aec6abd3e6ab24a360eb6bc3005ab0fefd696190915d76c1 docs/research/KOKONNA_WIDGET_CATALOG.json e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 pigway_frame_flow/__init__.py e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 pigway_frame_flow/api/__init__.py -32e43ab71a3fbcc44925bd4e265892260cef07dd4bef3e319d9c4473d477ec4e pigway_frame_flow/api/app.py +d351d0107e75ea4183dd1de07847f02a2c393c604065c0a1dca2c23168bfddba 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 8f2220fa90b0dc2e45df2023dc5ac695efe55361644ef266829553c99151949b pigway_frame_flow/core/registry.py e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 pigway_frame_flow/display/__init__.py 81effa34d6c452579cfa65781909f7c5a9e8ddad136e4c47ee0704939851879a pigway_frame_flow/display/cloud_library.py 5e7e272e671a6d01d9b677416c77967ca488b4e2aa559f4211cf0de4ba1e88bc pigway_frame_flow/display/recovery.py e1e4ae3ba321ae7282c5a4e061c52f24d2a6a6e3d4637e69e4b1fbd05004f5f6 pigway_frame_flow/display/retention.py -5f941b8b402efd6d1adab66df1dff29b1a3effda5ab23776daa300d55d5ca924 pigway_frame_flow/display/service.py +d974caaa2772b013cc6f080f997d37cd0d2212b29fa9524002f3fb87dd937364 pigway_frame_flow/display/service.py 0975b24488be19f24e768c4c76d9f3c0348417c1351c4e410897da00f7be6656 pigway_frame_flow/display/upload_reconciliation.py e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 pigway_frame_flow/drivers/__init__.py 9a8386ef368a6df7fd8cb23bcc442c28e784729a5ce4a78911ad86245d5c5794 pigway_frame_flow/drivers/base.py @@ -68,7 +69,7 @@ f548a5a2b01d298eb3543cf6a1ac41177357bc8eb81b5578293347a15310b700 pigway_frame_f e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 pigway_frame_flow/extensions/__init__.py 4655fb6cdf9550efd90d33b8de64511d8d6ff5a96d19782fc52d99498fe2a6c0 pigway_frame_flow/extensions/ai_images.py 394459895a060f43d0ace27a4be0ea06ba1590df5de026b5661b06d42f2b31f9 pigway_frame_flow/extensions/api.py -cf05d8bf20ad1ddb399ba0fc244fe1f36c895133579660f5ab7fc6716f9e66fc pigway_frame_flow/extensions/auto_collage.py +2f10b3644652369a367d3100114dc8b6fe3abaceeeb6d09fc2f55522e1298f9d pigway_frame_flow/extensions/auto_collage.py 46dab9596ba605da4d306ea941b7f19c4f3cde1f714fe1889c9f15bd8c087e42 pigway_frame_flow/extensions/device_events.py d463465ca3aea449e67386bc84a5f25b58535f74d89d7858c5b9bca0d93189d3 pigway_frame_flow/extensions/layout.py 13bea510a6b5ae1958b3e095936429516c3e3bee6b06c3676a7eb31f311766e0 pigway_frame_flow/extensions/mqtt.py @@ -87,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 -ab89887e6b4320d7668a0c554741e165f82c31da90a08d4ae708487d78894a97 pigway_frame_flow/models.py +a6c25a21309a92738832e717871968bb703f4161bcfd6d6871df1b1beb9d88e9 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 @@ -95,14 +96,14 @@ e90b6becc3e19baa83fab763cb156eb8882a96594c04040305dcb315f68e85d0 pigway_frame_f 69e7419e67e8416bf73f37e257a2586d2a1aa4d2706da8f7b42c5cb8ca7f1eb3 pigway_frame_flow/providers/oauth.py abff8ee4b4eb1575451bcd5addf54451056af665d7aaa90173b5ed69a75a4d8e pigway_frame_flow/providers/photo_names.py 7d6fd8ff3ae99798b26d7a82644353e2c9ce5439e18c64393dc261c395ac6f4c pigway_frame_flow/providers/photo_orientation.py -322cf299b1811b61636771b925aacfd57aa1cab0b0d84a245b70a2989eda66c0 pigway_frame_flow/providers/photo_sources.py +885d1945dacd611143eb6992da4621ce32ae304d80514f150d55318bdcb10e0f pigway_frame_flow/providers/photo_sources.py 114adfb3fcd1ab050c1dd2f1fff9d91fdde192b8eced907dee3bc8086037e13d pigway_frame_flow/providers/photos_cache.py 119427bc435ec8d4c91114713f37d07f22067eaeec391261d1441a1436baf5e0 pigway_frame_flow/providers/poetry.py c44bf2b4a3776bebe3689b251ae3e8c352a43088829ae8c3f6fd379b64dea05e pigway_frame_flow/providers/synology_photos.py 7dfc5eab241122c4758d8b0c027369049231451dc8e0e1b49f7da56532a8e87d pigway_frame_flow/providers/weather.py e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 pigway_frame_flow/renderers/__init__.py ce1e3d9dd7d8dc8d76d6206ab9cd05974bd2e81e1bbf28711f4b461332bf9236 pigway_frame_flow/renderers/frame_geometry.py -34a22709b300e7e36813d37ce449cebe5a5051bf6711c2c28f59a649b5e0bdc7 pigway_frame_flow/renderers/images.py +e80029d9edc781e54e523570940110c948a0b0e564dcacf64f2fe1425f4509ea pigway_frame_flow/renderers/images.py b70197edc25101b7ee73434b253ae947cddc8a9d3e55545369ac7189e117b2a2 pigway_frame_flow/renderers/photo_caption.py 241f7d80997a6b6c7d421723d5375d4890f64eea8c70680abefb0fa5d15b9502 pigway_frame_flow/renderers/photo_layout.py d36258296d04b6575d78dd9831f8d3f66cf8b1e9182e2e90a589d10fa0067a04 pigway_frame_flow/renderers/templates.py @@ -111,7 +112,7 @@ ee80352e10eb871293bd8700837fab8d5f2e0da777d22e32811e935cf7572edc pigway_frame_f d54facc006da1345a150780c414830efaa2e0ab75fa0c9673cf5d39e912472b1 pigway_frame_flow/scheduler/upload_retries.py 77464a08816507a2423f08327c7767a9c019f55ea8f232856fa647e72e1e0246 pigway_frame_flow/settings.py e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 pigway_frame_flow/storage/__init__.py -2a288fe13d0c9b626ceaf1c825d079cf1e93f5c3abadc5907fc438211f3bfa46 pigway_frame_flow/storage/database.py +2c306ca452b15588b83f15df46a39d33564954519b941690f3d07a75147f3f1e pigway_frame_flow/storage/database.py 44c0c47d852ffd80cb05969965ee216479c1047daffabf9c3bdc8fe2b036db12 pigway_frame_flow/templates/calendar.json 1b01fcda4b1cba11c3b152dfc2223d4119f1879ddd0b7eaf6a2e2731a6f6bf6d pigway_frame_flow/templates/collage.json 7bfe623e0569beae25b44261d949679c95d42c8bd095ef6b5cca25cf1b0d4c40 pigway_frame_flow/templates/daily-dashboard.json @@ -126,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 -f58b5c088c4a4d55b332c4fd52dc8e2fe436b7bcc607870e06bf4d6d6f497850 pigway_frame_flow/web/features.js -44641bdc3d7735625cd9a37dfa6597825030e0f8896a68ead4a2f434c43dced7 pigway_frame_flow/web/i18n.js +24736cdc11e791bb31e32e7fe1b1597ab7450fe2a22aa1fb40ae73cb8517db6f pigway_frame_flow/web/features.js +e5475f11ea4f81e72e81ef8fde89b713feb7551e08df17984ff72ef57d81495b pigway_frame_flow/web/i18n.js 51796b4db73773cea7e451ea21d3b493fcfaf340945d44a696b5d484a5222ea5 pigway_frame_flow/web/index.html 0fa494a3fbd456c754bbea3f7f050be139e32e3b42b09a1ed953094087437210 pigway_frame_flow/web/logo-symbol.svg -4eca31e33a3d14f1e25d61655715dd4a4789adceb4fb78247a12e041071b6810 pigway_frame_flow/web/style.css +45ca22deabfa32d0133fca6c7da0d39ecf4e500b104c7f48678cb2ccd0956206 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 @@ -178,8 +179,8 @@ d390c1c9a76ddc167a17bfd7afb0bd1a7169809c8a0b30a26a5be4d18c3032de scripts/ui-pro e474740bdb7dbe0617f7c6b170ada918e51f5eed8edbb9c2c47c4590a5dc2359 scripts/verify-photos-api-container.py b7816947e33a77997a4dd7d595ce385081fc578b2821aedb859277c3b490bf07 scripts/verify-photos-api.py d4f04e4c8f51c56aa21389d60115ecd1dda384da444dfd4d40237314fb51e3ea tests/conftest.py -19621a4c9dc5f060741504044710f2314d523530ae158dcf6fa3487ce2945e47 tests/contract.json -c2c398d7323e47255646e580c498a211a8f1a4fee32861954eaa29628bec8301 tests/schema-contract.json +964a29221c6f7a4185067cb686e1ea003b99ce4609d6fb4ee4bacb70c9fe5ce8 tests/contract.json +86aba7819e70b6ea9372eecc17ab5dfee3aa433e38417ebe81785d3450b4ad0d tests/schema-contract.json 23ffc7413f8388648c9d52d3c8f56e2966b73ca1bdc5e209e6c15469c790edba tests/test_accounts.py e87ce325c8260f96e586b20697ab0bac71058918ba73f7412eb482364f83f73e tests/test_ai_images.py a6edb781f4d081b2db45ffe2b02eed52a10f9b87a70e1e201b5d2f7c6813b93b tests/test_api.py @@ -210,6 +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 64117766bf85dbd2a5fac77a55ba24303c1067d5e7d92571385210816dd3e85c tests/test_photo_resolution.py 8226d48f02aadcc60b7d951dc60808d559891d64bf7de7f434731dcf0a90cd44 tests/test_photo_sources.py 1e5f544dd4abb4e360efa7d7057f9ccafef7c2e15d19d1eb68db059f5bf55a90 tests/test_photo_watermark.py diff --git a/docs/openapi.json b/docs/openapi.json index bbddc25..ce5ec62 100644 --- a/docs/openapi.json +++ b/docs/openapi.json @@ -14510,6 +14510,340 @@ } } } + }, + "/api/v1/assets/{asset_id}/exclusion": { + "put": { + "tags": [ + "Images" + ], + "summary": "Exclude Asset", + "operationId": "exclude_asset_api_v1_assets__asset_id__exclusion_put", + "security": [ + { + "HTTPBearer": [] + }, + { + "AccountSession": [] + } + ], + "parameters": [ + { + "name": "asset_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Asset Id" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ImageExclusion" + } + } + } + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Asset" + } + } + } + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Bad Request" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Unauthorized" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Forbidden" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Not Found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Conflict" + }, + "413": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Content Too Large" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Unprocessable Content" + }, + "429": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Too Many Requests" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Internal Server Error" + }, + "502": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Bad Gateway" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Service Unavailable" + } + } + } + }, + "/api/v1/photo-sources/{source_id}/preferences": { + "put": { + "tags": [ + "Photo sources" + ], + "summary": "Source Preference", + "operationId": "source_preference_api_v1_photo_sources__source_id__preferences_put", + "security": [ + { + "HTTPBearer": [] + }, + { + "AccountSession": [] + } + ], + "parameters": [ + { + "name": "source_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Source Id" + } + }, + { + "name": "device_id", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Device Id" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PhotoPreference" + } + } + } + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Bad Request" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Unauthorized" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Forbidden" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Not Found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Conflict" + }, + "413": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Content Too Large" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Unprocessable Content" + }, + "429": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Too Many Requests" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Internal Server Error" + }, + "502": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Bad Gateway" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Service Unavailable" + } + } + } } }, "components": { @@ -14595,6 +14929,11 @@ }, "Asset": { "properties": { + "excluded": { + "type": "boolean", + "title": "Excluded", + "default": false + }, "source_path": { "anyOf": [ { @@ -14696,7 +15035,9 @@ "enum": [ "delete", "star", - "unstar" + "unstar", + "exclude", + "include" ], "title": "Action" } @@ -16608,6 +16949,16 @@ }, "PhotoItem": { "properties": { + "starred": { + "type": "boolean", + "title": "Starred", + "default": false + }, + "excluded": { + "type": "boolean", + "title": "Excluded", + "default": false + }, "id": { "type": "string", "title": "Id" @@ -18174,6 +18525,58 @@ "operation_ids" ], "title": "HistoryDelete" + }, + "ImageExclusion": { + "properties": { + "excluded": { + "type": "boolean", + "title": "Excluded" + } + }, + "additionalProperties": false, + "type": "object", + "required": [ + "excluded" + ], + "title": "ImageExclusion" + }, + "PhotoPreference": { + "properties": { + "item_id": { + "type": "string", + "maxLength": 4096, + "minLength": 1, + "title": "Item Id" + }, + "starred": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "title": "Starred" + }, + "excluded": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "title": "Excluded" + } + }, + "additionalProperties": false, + "type": "object", + "required": [ + "item_id" + ], + "title": "PhotoPreference" } }, "securitySchemes": { diff --git a/pigway_frame_flow/api/app.py b/pigway_frame_flow/api/app.py index 7592cbe..c3554cf 100644 --- a/pigway_frame_flow/api/app.py +++ b/pigway_frame_flow/api/app.py @@ -17,6 +17,7 @@ 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.registry import DeviceRegistry from pigway_frame_flow.display.service import DisplayService from pigway_frame_flow.errors import ErrorCode as E @@ -43,12 +44,14 @@ from pigway_frame_flow.models import ( Health, History, ImageContent, + ImageExclusion, ImageStar, ImageURLConfig, Job, JobInput, Operation, Options, + PhotoPreference, PhotoSourceContent, Playlist, PlaylistInput, @@ -625,6 +628,10 @@ def create_app(settings=None, start_worker=True): 409, ) path.unlink(missing_ok=True) + db.execute( + "DELETE FROM photo_preferences WHERE source_id='@assets' AND item_id=?", + (asset_id,), + ) db.execute("DELETE FROM asset_metadata WHERE asset_id=?", (asset_id,)) db.execute("DELETE FROM asset_sources WHERE asset_id=?", (asset_id,)) db.execute("DELETE FROM assets WHERE id=?", (asset_id,)) @@ -649,6 +656,18 @@ def create_app(settings=None, start_worker=True): ) return assets.info(asset_id) + @app.put( + "/api/v1/assets/{asset_id}/exclusion", + response_model=Asset, + tags=["Images"], + dependencies=[Depends(require("images:write"))], + ) + def exclude_asset(asset_id: str, payload: ImageExclusion): + with service.lock: + assets.info(asset_id) + set_flags(db, "@assets", asset_id, excluded=payload.excluded) + return assets.info(asset_id) + @app.post( "/api/v1/assets/batch", tags=["Images"], @@ -661,6 +680,10 @@ def create_app(settings=None, start_worker=True): try: if payload.action == "delete": delete_asset(id) + elif payload.action in {"exclude", "include"}: + exclude_asset( + id, ImageExclusion(excluded=payload.action == "exclude") + ) else: star_asset(id, ImageStar(starred=payload.action == "star")) results.append({"asset_id": id, "status": "completed"}) @@ -782,13 +805,38 @@ def create_app(settings=None, start_worker=True): source_id, refresh=refresh, target_size=size ) return { - "items": items[offset : offset + limit], + "items": annotate(db, source_id, items[offset : offset + limit]), "total": len(items), "offset": offset, "limit": limit, "catalog_limit": service.photo_sources.get(source_id).catalog_limit, } + @app.put( + "/api/v1/photo-sources/{source_id}/preferences", + tags=["Photo sources"], + dependencies=[Depends(require("admin"))], + ) + def source_preference( + source_id: str, payload: PhotoPreference, device_id: str | None = None + ): + source = service.photo_sources.get(source_id) + size = (1200, 1600) + if source.orientation == "auto": + size = frame_canvas( + registry.get(device_id or settings.device_id).device().display + ) + items = service.photo_sources.catalog(source_id, target_size=size) + if not any(item["id"] == payload.item_id for item in items): + raise FlowError(E.NOT_FOUND, "Photo is not in this source", 404) + return set_flags( + db, + source_id, + payload.item_id, + starred=payload.starred, + excluded=payload.excluded, + ) + @app.delete( "/api/v1/devices/{device_id}/images/{image_id}", response_model=Accepted, diff --git a/pigway_frame_flow/core/photo_preferences.py b/pigway_frame_flow/core/photo_preferences.py new file mode 100644 index 0000000..c48cde7 --- /dev/null +++ b/pigway_frame_flow/core/photo_preferences.py @@ -0,0 +1,56 @@ +"""Persistent selection preferences scoped to a library and its stable item ID.""" + + +def flags(db, source_id, item_id): + row = db.one( + "SELECT starred,excluded FROM photo_preferences WHERE source_id=? AND item_id=?", + (source_id, item_id), + ) + return {key: bool(row and row[key]) for key in ("starred", "excluded")} + + +def set_flags(db, source_id, item_id, *, starred=None, excluded=None): + with db.transaction() as connection: + 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)", + ( + source_id, + item_id, + int(starred or False), + int(excluded or False), + None if starred is None else int(starred), + None if excluded is None else int(excluded), + ), + ) + return flags(db, source_id, item_id) + + +def annotate(db, source_id, items): + preferences = { + row["item_id"]: row + for row in db.all( + "SELECT item_id,starred,excluded FROM photo_preferences WHERE source_id=?", + (source_id,), + ) + } + return [ + { + **item, + **{ + key: bool(preferences.get(item["id"], {}).get(key)) + for key in ("starred", "excluded") + }, + } + for item in items + ] + + +def eligible(db, source_id, items): + excluded = { + row["item_id"] + for row in db.all( + "SELECT item_id FROM photo_preferences WHERE source_id=? AND excluded=1", + (source_id,), + ) + } + return [item for item in items if item["id"] not in excluded] diff --git a/pigway_frame_flow/display/service.py b/pigway_frame_flow/display/service.py index dab74e7..1ba7a73 100644 --- a/pigway_frame_flow/display/service.py +++ b/pigway_frame_flow/display/service.py @@ -5,6 +5,7 @@ import time from datetime import UTC, datetime, timedelta from uuid import uuid4 +from pigway_frame_flow.core.photo_preferences import eligible from pigway_frame_flow.display.cloud_library import CloudLibrary from pigway_frame_flow.display.recovery import RecoveryMonitor from pigway_frame_flow.display.retention import Retention @@ -314,6 +315,8 @@ class DisplayService: import random items = self.photo_sources.catalog(content.source_id, target_size=size) + if not content.item_id: + items = eligible(self.db, content.source_id, items) candidates = ( [item for item in items if item["id"] == content.item_id] if content.item_id diff --git a/pigway_frame_flow/extensions/auto_collage.py b/pigway_frame_flow/extensions/auto_collage.py index 183d113..7c61f20 100644 --- a/pigway_frame_flow/extensions/auto_collage.py +++ b/pigway_frame_flow/extensions/auto_collage.py @@ -5,6 +5,7 @@ from io import BytesIO from PIL import Image, ImageOps +from pigway_frame_flow.core.photo_preferences import eligible from pigway_frame_flow.errors import ErrorCode as E from pigway_frame_flow.errors import FlowError from pigway_frame_flow.extensions.layout import LayoutInput, LayoutStudio @@ -19,7 +20,11 @@ def prepare(service, content, device_id=None): from pigway_frame_flow.extensions.quality import passes, photo_quality source = service.photo_sources.get(content.source_id) - items = service.photo_sources.catalog(content.source_id) + items = eligible( + service.db, + content.source_id, + service.photo_sources.catalog(content.source_id), + ) candidates = rng.sample(items, min(len(items), content.count * 5)) for item in candidates: try: @@ -46,7 +51,7 @@ def prepare(service, content, device_id=None): break else: rows = service.db.all( - "SELECT id AS asset_id FROM assets WHERE kind='images' ORDER BY created_at DESC LIMIT 10000" + "SELECT id AS asset_id FROM assets WHERE kind='images' AND NOT EXISTS (SELECT 1 FROM photo_preferences p WHERE p.source_id='@assets' AND p.item_id=assets.id AND p.excluded=1) ORDER BY created_at DESC LIMIT 10000" ) ids = rng.sample( [row["asset_id"] for row in rows], min(content.count, len(rows)) diff --git a/pigway_frame_flow/models.py b/pigway_frame_flow/models.py index 663b515..58db037 100644 --- a/pigway_frame_flow/models.py +++ b/pigway_frame_flow/models.py @@ -377,6 +377,7 @@ class Readiness(BaseModel): class Asset(BaseModel): + excluded: bool = False source_path: str | None = None starred: bool = False origin: Literal["manual", "task", "external", "template", "unknown"] = "unknown" @@ -502,10 +503,20 @@ class WidgetState(StrictModel): writable: Literal[False] = False +class ImageExclusion(StrictModel): + excluded: bool + + +class PhotoPreference(StrictModel): + item_id: str = Field(min_length=1, max_length=4096) + starred: bool | None = None + excluded: bool | None = None + + class ImageStar(StrictModel): starred: bool class AssetBatch(StrictModel): asset_ids: list[str] = Field(min_length=1, max_length=100) - action: Literal["delete", "star", "unstar"] + action: Literal["delete", "star", "unstar", "exclude", "include"] diff --git a/pigway_frame_flow/providers/photo_sources.py b/pigway_frame_flow/providers/photo_sources.py index 6260c39..ca9ab95 100644 --- a/pigway_frame_flow/providers/photo_sources.py +++ b/pigway_frame_flow/providers/photo_sources.py @@ -15,6 +15,7 @@ from xml.etree import ElementTree from pydantic import Field +from pigway_frame_flow.core.photo_preferences import eligible from pigway_frame_flow.errors import ErrorCode as E from pigway_frame_flow.errors import FlowError from pigway_frame_flow.models import StrictModel @@ -60,6 +61,8 @@ class PhotoSourceView(PhotoSourceInput): class PhotoItem(StrictModel): + starred: bool = False + excluded: bool = False id: str name: str caption: dict[str, str] | None = None @@ -220,6 +223,7 @@ class PhotoSources: self.db.execute( "DELETE FROM app_config WHERE key=?", ("photo_source:" + source_id,) ) + self.db.execute("DELETE FROM photo_preferences WHERE source_id=?", (source_id,)) self.cache.pop(source_id, None) self.api_items.pop(source_id, None) @@ -581,9 +585,11 @@ class PhotoSources: def read(self, source_id, item_id=None, *, target_size=(1200, 1600)): source = self.get(source_id) items = self.catalog(source_id, target_size=target_size) + if not item_id: + items = eligible(self.db, source_id, items) if not items: raise FlowError( - E.NOT_FOUND, "Photo source contains no supported images", 404 + E.NOT_FOUND, "Photo source contains no eligible images", 404 ) item = ( next((x for x in items if x["id"] == item_id), None) diff --git a/pigway_frame_flow/renderers/images.py b/pigway_frame_flow/renderers/images.py index cc2673c..afefc15 100644 --- a/pigway_frame_flow/renderers/images.py +++ b/pigway_frame_flow/renderers/images.py @@ -8,6 +8,7 @@ from uuid import uuid4 from PIL import Image, ImageFilter, ImageOps, ImageStat +from pigway_frame_flow.core.photo_preferences import flags from pigway_frame_flow.errors import ErrorCode as E from pigway_frame_flow.errors import FlowError from pigway_frame_flow.storage.database import now_iso @@ -142,6 +143,7 @@ class Assets: if provenance else None, "starred": bool(meta and meta["starred"]), + "excluded": flags(self.db, "@assets", asset_id)["excluded"], "origin": meta["origin"] if meta else "unknown", "job_id": meta["job_id"] if meta else None, "job_name": meta["job_name"] if meta else None, diff --git a/pigway_frame_flow/storage/database.py b/pigway_frame_flow/storage/database.py index bd50a41..56fe8e0 100644 --- a/pigway_frame_flow/storage/database.py +++ b/pigway_frame_flow/storage/database.py @@ -24,6 +24,7 @@ class Database: PRAGMA busy_timeout=5000; PRAGMA synchronous=FULL; CREATE TABLE IF NOT EXISTS managed_cloud_uploads (device_id TEXT, image_id INTEGER, asset_id TEXT, job_id TEXT, operation_id TEXT, uploaded_at TEXT, confirmed_at TEXT, cleanup_operation TEXT, PRIMARY KEY(device_id,image_id)); + CREATE TABLE IF NOT EXISTS photo_preferences (source_id TEXT NOT NULL, item_id TEXT NOT NULL, starred INTEGER NOT NULL DEFAULT 0, excluded INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(source_id,item_id)); CREATE TABLE IF NOT EXISTS asset_metadata (asset_id TEXT PRIMARY KEY, starred INTEGER NOT NULL DEFAULT 0, origin TEXT NOT NULL DEFAULT 'unknown', job_id TEXT, job_name TEXT); CREATE TABLE IF NOT EXISTS image_retention (device_id TEXT NOT NULL, image_id INTEGER NOT NULL, starred INTEGER NOT NULL DEFAULT 0, expires_at TEXT, upload_operation TEXT, cleanup_operation TEXT, PRIMARY KEY(device_id,image_id)); CREATE TABLE IF NOT EXISTS photo_dimensions (path TEXT PRIMARY KEY, mtime INTEGER NOT NULL, bytes INTEGER NOT NULL, width INTEGER NOT NULL, height INTEGER NOT NULL); diff --git a/pigway_frame_flow/web/features.js b/pigway_frame_flow/web/features.js index 986dcb9..8add7ea 100644 --- a/pigway_frame_flow/web/features.js +++ b/pigway_frame_flow/web/features.js @@ -34,9 +34,9 @@ function displayDialog(content){ function enhanceGallery(){ const grid=$('.gallery');if(!grid)return; 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');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=bar.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','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();});}} + 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);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('download');card.querySelector('.actions').append(download);download.onclick=busy(()=>downloadImage(item.url,item.name));if(state.tab==='local'){const edit=document.createElement('button');edit.textContent=t('continueEdit');card.querySelector('.actions').append(edit);edit.onclick=busy(async()=>{const meta=await api('/api/v1/assets/'+item.asset_id+'/source');if(meta.source?.type==='template')await editTemplateSource(meta.source);else toast(t('templateSourceUnavailable'));});}}); + 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('download');card.querySelector('.actions').append(download);download.onclick=busy(()=>downloadImage(item.url,item.name));if(state.tab==='local'){const edit=document.createElement('button');edit.textContent=t('continueEdit');card.querySelector('.actions').append(edit);edit.onclick=busy(async()=>{const meta=await api('/api/v1/assets/'+item.asset_id+'/source');if(meta.source?.type==='template')await editTemplateSource(meta.source);else toast(t('templateSourceUnavailable'));});}}); $('#gallery-search').value=state.galleryQuery||'';$('#gallery-sort').value=state.galleryOrder||'newest';$('#gallery-search').oninput();$('#gallery-sort').onchange(); } async function weatherSettings(){ @@ -157,7 +157,8 @@ 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')}

${result.items.map((x,i)=>``).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.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)); } async function previewSource(id,item_id=null){ diff --git a/pigway_frame_flow/web/i18n.js b/pigway_frame_flow/web/i18n.js index 274cb65..6cf026e 100644 --- a/pigway_frame_flow/web/i18n.js +++ b/pigway_frame_flow/web/i18n.js @@ -152,3 +152,6 @@ 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.'}); diff --git a/pigway_frame_flow/web/style.css b/pigway_frame_flow/web/style.css index 0811fc3..f29829d 100644 --- a/pigway_frame_flow/web/style.css +++ b/pigway_frame_flow/web/style.css @@ -247,3 +247,8 @@ form#history-filters{display:grid;grid-template-columns:repeat(3,minmax(0,1fr)); .history-pagination select{width:auto} @media(max-width:1050px){form#history-filters{grid-template-columns:repeat(2,minmax(0,1fr))}} @media(max-width:720px){form#history-filters{grid-template-columns:minmax(0,1fr)}} + +.source-photo{min-width:0;display:flex;flex-direction:column;gap:12px;padding:14px;border:1px solid var(--border);border-radius:12px} +.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} diff --git a/tests/contract.json b/tests/contract.json index c446f08..6a74c2c 100644 --- a/tests/contract.json +++ b/tests/contract.json @@ -1478,5 +1478,37 @@ "502", "503" ] + }, + "/api/v1/assets/{asset_id}/exclusion": { + "put": [ + "200", + "400", + "401", + "403", + "404", + "409", + "413", + "422", + "429", + "500", + "502", + "503" + ] + }, + "/api/v1/photo-sources/{source_id}/preferences": { + "put": [ + "200", + "400", + "401", + "403", + "404", + "409", + "413", + "422", + "429", + "500", + "502", + "503" + ] } } diff --git a/tests/schema-contract.json b/tests/schema-contract.json index 195db6c..480d577 100644 --- a/tests/schema-contract.json +++ b/tests/schema-contract.json @@ -1246,6 +1246,16 @@ }, "PhotoItem": { "properties": { + "starred": { + "type": "boolean", + "title": "Starred", + "default": false + }, + "excluded": { + "type": "boolean", + "title": "Excluded", + "default": false + }, "id": { "type": "string", "title": "Id" diff --git a/tests/test_photo_preferences.py b/tests/test_photo_preferences.py new file mode 100644 index 0000000..6818913 --- /dev/null +++ b/tests/test_photo_preferences.py @@ -0,0 +1,122 @@ +import json +from io import BytesIO + +import pytest +from PIL import Image + +from pigway_frame_flow.core.photo_preferences import eligible, flags, set_flags +from pigway_frame_flow.errors import FlowError +from pigway_frame_flow.models import AutoCollageContent, PhotoSourceContent +from pigway_frame_flow.providers.photo_sources import PhotoSourceInput + + +def photo(service, name, color): + data = BytesIO() + Image.new("RGB", (320, 240), color).save(data, "JPEG") + return service.assets.put(data.getvalue(), name) + + +def test_local_exclusion_star_and_restore(client, service): + excluded = photo(service, "excluded.jpg", "red")["asset_id"] + allowed = photo(service, "allowed.jpg", "blue")["asset_id"] + assert ( + client.put( + f"/api/v1/assets/{excluded}/star", json={"starred": True} + ).status_code + == 200 + ) + 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"] + ) + assert next( + x for x in client.get("/api/v1/assets").json() if x["asset_id"] == excluded + )["excluded"] + asset = service.content_asset( + AutoCollageContent(type="auto_collage", count=1, use_ai=False) + ) + saved = json.loads( + service.db.one( + "SELECT payload FROM asset_sources WHERE asset_id=?", (asset["asset_id"],) + )["payload"] + ) + assert [r["asset_id"] for r in saved["data"]["layout"]["regions"]] == [allowed] + assert service.assets.path(excluded).is_file() + assert ( + client.post( + "/api/v1/assets/batch", json={"asset_ids": [allowed], "action": "exclude"} + ).json()["results"][0]["status"] + == "completed" + ) + with pytest.raises(FlowError): + service.content_asset( + AutoCollageContent(type="auto_collage", count=1, use_ai=False) + ) + restored = client.put( + f"/api/v1/assets/{excluded}/exclusion", json={"excluded": False} + ).json() + assert not restored["excluded"] and restored["starred"] + assert service.content_asset( + AutoCollageContent(type="auto_collage", count=1, use_ai=False) + ) + + +def test_source_preferences_are_scoped_and_do_not_mutate_cached_catalog( + client, service, monkeypatch +): + source = PhotoSourceInput( + name="Drive", kind="webdav", location="https://photos.example.test/dav" + ) + items = [{"id": "folder/photo.jpg", "name": "photo.jpg"}] + monkeypatch.setattr(service.photo_sources, "get", lambda _: source) + monkeypatch.setattr(service.photo_sources, "catalog", lambda *args, **kwargs: items) + url = "/api/v1/photo-sources/drive/preferences" + assert ( + 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} + ).json() == {"starred": True, "excluded": True} + result = client.get("/api/v1/photo-sources/drive/images").json()["items"][0] + assert 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 + assert ( + client.put(url, json={"item_id": "missing", "excluded": True}).status_code + == 404 + ) + with pytest.raises(FlowError): + service.content_asset( + AutoCollageContent( + type="auto_collage", source_id="drive", count=1, use_ai=False + ) + ) + with pytest.raises(FlowError): + service.photo_sources.read("drive") + with pytest.raises(FlowError): + service.content_asset( + PhotoSourceContent(type="photo_source", source_id="drive") + ) + 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, + "excluded": False, + } + + +def test_preference_persistence(settings): + from pigway_frame_flow.storage.database import Database + + path = settings.data_dir / "preferences.db" + path.parent.mkdir(parents=True, exist_ok=True) + db = Database(path) + set_flags(db, "drive", "item", excluded=True) + db.conn.close() + reopened = Database(path) + assert flags(reopened, "drive", "item")["excluded"] + reopened.conn.close()