bppicker 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- bpp/__init__.py +26 -0
- bpp/ai/__init__.py +1 -0
- bpp/ai/inpainting.py +244 -0
- bpp/cli.py +303 -0
- bpp/commands/__init__.py +42 -0
- bpp/commands/analyze.py +181 -0
- bpp/commands/db_restore.py +216 -0
- bpp/commands/db_restore_impl.py +318 -0
- bpp/commands/demo.py +69 -0
- bpp/commands/model.py +169 -0
- bpp/commands/model_commands.py +442 -0
- bpp/commands/pick.py +167 -0
- bpp/commands/serve.py +329 -0
- bpp/config.py +123 -0
- bpp/config_resolver.py +233 -0
- bpp/config_schema.py +482 -0
- bpp/constants.py +268 -0
- bpp/db/__init__.py +1 -0
- bpp/db/albums.py +447 -0
- bpp/db/backup.py +430 -0
- bpp/db/batch_rename.py +356 -0
- bpp/db/calendar.py +158 -0
- bpp/db/clip.py +299 -0
- bpp/db/connection.py +297 -0
- bpp/db/dedupe.py +241 -0
- bpp/db/dialect.py +162 -0
- bpp/db/edits.py +179 -0
- bpp/db/event_hooks.py +206 -0
- bpp/db/face_cluster_ops.py +181 -0
- bpp/db/face_embedding_safety.py +243 -0
- bpp/db/face_feedback.py +362 -0
- bpp/db/face_identity_remap.py +443 -0
- bpp/db/face_queries.py +257 -0
- bpp/db/groups.py +232 -0
- bpp/db/integrity.py +185 -0
- bpp/db/journal.py +231 -0
- bpp/db/library.py +433 -0
- bpp/db/live_photo.py +476 -0
- bpp/db/memories.py +320 -0
- bpp/db/migrate.py +119 -0
- bpp/db/migrations.py +264 -0
- bpp/db/migrations_latest.py +438 -0
- bpp/db/migrations_recent.py +430 -0
- bpp/db/moments.py +154 -0
- bpp/db/pets.py +371 -0
- bpp/db/photo_hooks.py +120 -0
- bpp/db/photos.py +505 -0
- bpp/db/photos_dates.py +89 -0
- bpp/db/photos_gps.py +139 -0
- bpp/db/photos_lifecycle.py +214 -0
- bpp/db/photos_missing.py +122 -0
- bpp/db/presets.py +58 -0
- bpp/db/registry.py +142 -0
- bpp/db/schema.py +530 -0
- bpp/db/schema_migrate.py +215 -0
- bpp/db/settings.py +66 -0
- bpp/db/smart_album_document.py +87 -0
- bpp/db/smart_album_domains.py +116 -0
- bpp/db/smart_album_ensure.py +87 -0
- bpp/db/smart_album_groups.py +117 -0
- bpp/db/smart_album_people.py +341 -0
- bpp/db/smart_album_pets.py +111 -0
- bpp/db/smart_album_queries.py +299 -0
- bpp/db/smart_album_refreshers.py +444 -0
- bpp/db/smart_album_sensitive.py +64 -0
- bpp/db/smart_album_tags.py +78 -0
- bpp/db/smart_albums.py +441 -0
- bpp/db/stats.py +57 -0
- bpp/db/tags.py +172 -0
- bpp/dedupe/__init__.py +0 -0
- bpp/dedupe/cluster.py +173 -0
- bpp/dedupe/common.py +20 -0
- bpp/dedupe/phash.py +147 -0
- bpp/dedupe/semantic.py +328 -0
- bpp/dedupe/strategy.py +277 -0
- bpp/demo/__init__.py +0 -0
- bpp/demo/generate.py +340 -0
- bpp/errors.py +234 -0
- bpp/exif_utils.py +168 -0
- bpp/io_scan.py +102 -0
- bpp/media_types.py +78 -0
- bpp/output/__init__.py +0 -0
- bpp/output/export.py +376 -0
- bpp/output/export_metadata.py +207 -0
- bpp/output/export_modes.py +296 -0
- bpp/output/gallery.py +148 -0
- bpp/plugin_protocol.py +240 -0
- bpp/plugins/__init__.py +345 -0
- bpp/plugins/example.py +328 -0
- bpp/plugins/registry_protocol.py +92 -0
- bpp/registry/__init__.py +345 -0
- bpp/registry/acceptance.py +360 -0
- bpp/registry/acceptance_log.py +395 -0
- bpp/registry/builtins.py +479 -0
- bpp/registry/byom.py +356 -0
- bpp/registry/derived_data_purge.py +118 -0
- bpp/registry/disclaimers.py +290 -0
- bpp/registry/download_chokepoint.py +430 -0
- bpp/registry/labels.py +146 -0
- bpp/registry/model_registry.py +415 -0
- bpp/registry/overlay.py +242 -0
- bpp/registry/policy.py +320 -0
- bpp/registry/remote_registry.py +286 -0
- bpp/registry/removal.py +142 -0
- bpp/registry/signed_manifest.py +334 -0
- bpp/registry/trusted_keys.py +122 -0
- bpp/registry/use_context.py +188 -0
- bpp/registry/use_context_store.py +236 -0
- bpp/scoring/__init__.py +0 -0
- bpp/scoring/_registry_base.py +50 -0
- bpp/scoring/aggregate.py +431 -0
- bpp/scoring/aggregate_video.py +136 -0
- bpp/scoring/blur.py +89 -0
- bpp/scoring/clip_embed.py +333 -0
- bpp/scoring/clip_tokenizer.py +241 -0
- bpp/scoring/composition.py +67 -0
- bpp/scoring/enhance.py +92 -0
- bpp/scoring/exposure.py +44 -0
- bpp/scoring/face.py +277 -0
- bpp/scoring/face_blazeface_fr.py +181 -0
- bpp/scoring/face_cluster.py +115 -0
- bpp/scoring/face_detector_registry.py +138 -0
- bpp/scoring/face_embed.py +426 -0
- bpp/scoring/face_embed_buffalo.py +132 -0
- bpp/scoring/face_embed_buffalo_s.py +500 -0
- bpp/scoring/face_embed_detect.py +232 -0
- bpp/scoring/face_embed_extractors.py +284 -0
- bpp/scoring/face_embed_landmarks.py +218 -0
- bpp/scoring/face_embed_sface.py +145 -0
- bpp/scoring/face_embed_sface_runtime.py +359 -0
- bpp/scoring/face_embedder_registry.py +126 -0
- bpp/scoring/face_expression.py +186 -0
- bpp/scoring/face_fallback.py +108 -0
- bpp/scoring/face_hand_filter.py +202 -0
- bpp/scoring/face_mediapipe.py +120 -0
- bpp/scoring/face_pipeline.py +418 -0
- bpp/scoring/face_score.py +122 -0
- bpp/scoring/face_scrfd.py +273 -0
- bpp/scoring/face_yunet.py +255 -0
- bpp/scoring/model_base.py +436 -0
- bpp/scoring/model_load_gate.py +57 -0
- bpp/scoring/model_manifest.py +255 -0
- bpp/scoring/models/blaze_face_short_range.tflite +0 -0
- bpp/scoring/nudity.py +316 -0
- bpp/scoring/onnx_providers.py +133 -0
- bpp/scoring/pets.py +460 -0
- bpp/scoring/pose.py +234 -0
- bpp/scoring/registry.py +336 -0
- bpp/scoring/segmentation.py +181 -0
- bpp/scoring/skin.py +18 -0
- bpp/selection/__init__.py +0 -0
- bpp/selection/choose.py +130 -0
- bpp/selection/diversity.py +129 -0
- bpp/utils/__init__.py +0 -0
- bpp/utils/cancel.py +279 -0
- bpp/utils/concurrency.py +58 -0
- bpp/utils/config_snapshot.py +83 -0
- bpp/utils/download.py +200 -0
- bpp/utils/json_utils.py +32 -0
- bpp/utils/logging.py +408 -0
- bpp/utils/path_validation.py +70 -0
- bpp/utils/paths.py +121 -0
- bpp/utils/raw.py +74 -0
- bpp/utils/retry.py +102 -0
- bpp/utils/safe_subprocess.py +1 -0
- bpp/utils/serving_lock.py +302 -0
- bpp/utils/subprocess_runner.py +349 -0
- bpp/utils/timing.py +34 -0
- bpp/utils/video.py +237 -0
- bpp/web/__init__.py +1 -0
- bpp/web/_deprecated_attr.py +92 -0
- bpp/web/analysis_store.py +133 -0
- bpp/web/analyze_archive.py +169 -0
- bpp/web/analyze_face_extract.py +443 -0
- bpp/web/analyze_finalize.py +77 -0
- bpp/web/analyze_model_preflight.py +86 -0
- bpp/web/analyze_phases.py +160 -0
- bpp/web/analyze_scoring.py +202 -0
- bpp/web/analyze_subprocess.py +73 -0
- bpp/web/analyze_worker.py +470 -0
- bpp/web/app.py +384 -0
- bpp/web/base_worker.py +224 -0
- bpp/web/bp_album_overrides.py +120 -0
- bpp/web/bp_albums.py +478 -0
- bpp/web/bp_analysis.py +451 -0
- bpp/web/bp_calendar.py +147 -0
- bpp/web/bp_catalog.py +201 -0
- bpp/web/bp_clip.py +103 -0
- bpp/web/bp_core.py +477 -0
- bpp/web/bp_export.py +361 -0
- bpp/web/bp_faces.py +455 -0
- bpp/web/bp_faces_bbox.py +351 -0
- bpp/web/bp_faces_cluster_ops.py +409 -0
- bpp/web/bp_faces_extract.py +172 -0
- bpp/web/bp_faces_manage.py +264 -0
- bpp/web/bp_faces_photo.py +241 -0
- bpp/web/bp_faces_recluster.py +116 -0
- bpp/web/bp_faces_review.py +475 -0
- bpp/web/bp_groups.py +108 -0
- bpp/web/bp_health.py +312 -0
- bpp/web/bp_inpaint.py +228 -0
- bpp/web/bp_install.py +244 -0
- bpp/web/bp_library.py +215 -0
- bpp/web/bp_logs.py +210 -0
- bpp/web/bp_media.py +344 -0
- bpp/web/bp_memories.py +78 -0
- bpp/web/bp_model_admin.py +202 -0
- bpp/web/bp_model_registry.py +405 -0
- bpp/web/bp_models.py +339 -0
- bpp/web/bp_os_integration.py +186 -0
- bpp/web/bp_pets.py +331 -0
- bpp/web/bp_photos.py +355 -0
- bpp/web/bp_photos_lifecycle.py +318 -0
- bpp/web/bp_photos_manage.py +443 -0
- bpp/web/bp_recompute.py +373 -0
- bpp/web/bp_search.py +339 -0
- bpp/web/bp_settings.py +273 -0
- bpp/web/bp_share.py +307 -0
- bpp/web/bp_tags.py +222 -0
- bpp/web/clip_worker.py +227 -0
- bpp/web/derived_recovery.py +255 -0
- bpp/web/export_worker.py +149 -0
- bpp/web/face_create_helpers.py +260 -0
- bpp/web/face_crop.py +81 -0
- bpp/web/face_extraction_journal.py +418 -0
- bpp/web/face_extraction_phase5.py +346 -0
- bpp/web/face_extraction_phase6.py +140 -0
- bpp/web/face_extraction_phases.py +436 -0
- bpp/web/face_merge_core.py +163 -0
- bpp/web/face_orchestrator.py +141 -0
- bpp/web/face_phase_classes.py +299 -0
- bpp/web/face_phase_pipeline.py +265 -0
- bpp/web/face_phase_types.py +130 -0
- bpp/web/face_recovery.py +253 -0
- bpp/web/face_worker.py +287 -0
- bpp/web/filters.py +57 -0
- bpp/web/health.py +131 -0
- bpp/web/import_worker.py +230 -0
- bpp/web/library_lifecycle.py +82 -0
- bpp/web/model_cache.py +144 -0
- bpp/web/model_filter.py +150 -0
- bpp/web/models_status.py +309 -0
- bpp/web/photo_dict.py +237 -0
- bpp/web/photo_edits.py +380 -0
- bpp/web/recompute.py +322 -0
- bpp/web/request_validation.py +192 -0
- bpp/web/review_meta.py +56 -0
- bpp/web/share.py +493 -0
- bpp/web/share_devices.py +250 -0
- bpp/web/share_proxy.py +199 -0
- bpp/web/share_qr.py +146 -0
- bpp/web/share_runtime.py +221 -0
- bpp/web/sse.py +60 -0
- bpp/web/state.py +488 -0
- bpp/web/state_compat.py +185 -0
- bpp/web/state_helpers.py +112 -0
- bpp/web/state_init.py +318 -0
- bpp/web/state_init_phases.py +250 -0
- bpp/web/state_lifecycle.py +280 -0
- bpp/web/state_ops.py +162 -0
- bpp/web/static/css/app.css +7608 -0
- bpp/web/static/img/apple-touch-icon.png +0 -0
- bpp/web/static/img/icon-192.png +0 -0
- bpp/web/static/img/icon-512.png +0 -0
- bpp/web/static/js/globals.js +446 -0
- bpp/web/static/js/modules/action-registry.mjs +109 -0
- bpp/web/static/js/modules/activity-humanize.mjs +185 -0
- bpp/web/static/js/modules/activity-log.mjs +350 -0
- bpp/web/static/js/modules/albums-menus.mjs +400 -0
- bpp/web/static/js/modules/albums-render-helpers.mjs +61 -0
- bpp/web/static/js/modules/albums-render.mjs +505 -0
- bpp/web/static/js/modules/albums-switch.mjs +292 -0
- bpp/web/static/js/modules/albums.mjs +356 -0
- bpp/web/static/js/modules/analysis-install.mjs +112 -0
- bpp/web/static/js/modules/analysis-preflight.mjs +160 -0
- bpp/web/static/js/modules/analysis-recompute.mjs +257 -0
- bpp/web/static/js/modules/analysis-status.mjs +347 -0
- bpp/web/static/js/modules/analysis.mjs +444 -0
- bpp/web/static/js/modules/api-client.mjs +141 -0
- bpp/web/static/js/modules/app.mjs +412 -0
- bpp/web/static/js/modules/batch-rename.mjs +308 -0
- bpp/web/static/js/modules/calendar-render.mjs +307 -0
- bpp/web/static/js/modules/calendar-selection.mjs +214 -0
- bpp/web/static/js/modules/calendar.mjs +433 -0
- bpp/web/static/js/modules/client-error-beacon.mjs +56 -0
- bpp/web/static/js/modules/clip.mjs +370 -0
- bpp/web/static/js/modules/compare-sibling.mjs +311 -0
- bpp/web/static/js/modules/compare.mjs +450 -0
- bpp/web/static/js/modules/constants.mjs +31 -0
- bpp/web/static/js/modules/core.mjs +409 -0
- bpp/web/static/js/modules/date-format.mjs +96 -0
- bpp/web/static/js/modules/deleted-ctx-menu.mjs +228 -0
- bpp/web/static/js/modules/deleted-enhance.mjs +160 -0
- bpp/web/static/js/modules/deleted.mjs +440 -0
- bpp/web/static/js/modules/dialogs.mjs +157 -0
- bpp/web/static/js/modules/dupe-review.mjs +222 -0
- bpp/web/static/js/modules/editor-constants.mjs +295 -0
- bpp/web/static/js/modules/editor-crop.mjs +382 -0
- bpp/web/static/js/modules/editor-inpaint.mjs +427 -0
- bpp/web/static/js/modules/editor-preview.mjs +199 -0
- bpp/web/static/js/modules/editor-redeye.mjs +118 -0
- bpp/web/static/js/modules/editor-rendering.mjs +411 -0
- bpp/web/static/js/modules/editor-styles.mjs +312 -0
- bpp/web/static/js/modules/editor-tools.mjs +227 -0
- bpp/web/static/js/modules/editor.mjs +456 -0
- bpp/web/static/js/modules/face-embedders-acceptance.mjs +440 -0
- bpp/web/static/js/modules/face-embedders-actions.mjs +270 -0
- bpp/web/static/js/modules/face-embedders-popover.mjs +173 -0
- bpp/web/static/js/modules/face-embedders-rowstate.mjs +499 -0
- bpp/web/static/js/modules/face-embedders-state.mjs +142 -0
- bpp/web/static/js/modules/faces-extraction.mjs +183 -0
- bpp/web/static/js/modules/faces.mjs +350 -0
- bpp/web/static/js/modules/format-helpers.mjs +67 -0
- bpp/web/static/js/modules/groups.mjs +322 -0
- bpp/web/static/js/modules/import-worker.mjs +274 -0
- bpp/web/static/js/modules/inspector.mjs +338 -0
- bpp/web/static/js/modules/library.mjs +305 -0
- bpp/web/static/js/modules/lightbox-actions.mjs +450 -0
- bpp/web/static/js/modules/lightbox-ctxmenu.mjs +77 -0
- bpp/web/static/js/modules/lightbox-face-assign.mjs +382 -0
- bpp/web/static/js/modules/lightbox-face-edit.mjs +479 -0
- bpp/web/static/js/modules/lightbox-face-overlays.mjs +336 -0
- bpp/web/static/js/modules/lightbox-face-picker.mjs +475 -0
- bpp/web/static/js/modules/lightbox-info.mjs +375 -0
- bpp/web/static/js/modules/lightbox-input.mjs +369 -0
- bpp/web/static/js/modules/lightbox-open.mjs +280 -0
- bpp/web/static/js/modules/lightbox.mjs +462 -0
- bpp/web/static/js/modules/map.mjs +273 -0
- bpp/web/static/js/modules/memories.mjs +210 -0
- bpp/web/static/js/modules/modal.mjs +109 -0
- bpp/web/static/js/modules/modals-face-embedders.mjs +496 -0
- bpp/web/static/js/modules/modals-models-list.mjs +226 -0
- bpp/web/static/js/modules/modals-models.mjs +311 -0
- bpp/web/static/js/modules/modals.mjs +487 -0
- bpp/web/static/js/modules/moments-stacks.mjs +414 -0
- bpp/web/static/js/modules/moments-view.mjs +82 -0
- bpp/web/static/js/modules/navigation.mjs +74 -0
- bpp/web/static/js/modules/nudges.mjs +151 -0
- bpp/web/static/js/modules/on-this-day.mjs +183 -0
- bpp/web/static/js/modules/onboarding.mjs +406 -0
- bpp/web/static/js/modules/people-actions.mjs +229 -0
- bpp/web/static/js/modules/people-album-bar.mjs +441 -0
- bpp/web/static/js/modules/people-ctx-menu.mjs +238 -0
- bpp/web/static/js/modules/people-merge.mjs +214 -0
- bpp/web/static/js/modules/people-pair-review.mjs +400 -0
- bpp/web/static/js/modules/people-pickers.mjs +421 -0
- bpp/web/static/js/modules/people-rename.mjs +259 -0
- bpp/web/static/js/modules/people-review.mjs +475 -0
- bpp/web/static/js/modules/people-view.mjs +224 -0
- bpp/web/static/js/modules/people.mjs +383 -0
- bpp/web/static/js/modules/pets-pickers.mjs +375 -0
- bpp/web/static/js/modules/pets.mjs +380 -0
- bpp/web/static/js/modules/phash-status.mjs +74 -0
- bpp/web/static/js/modules/photo-preview.mjs +79 -0
- bpp/web/static/js/modules/photos-batch.mjs +252 -0
- bpp/web/static/js/modules/photos-card.mjs +124 -0
- bpp/web/static/js/modules/photos-helpers.mjs +45 -0
- bpp/web/static/js/modules/photos-hover.mjs +129 -0
- bpp/web/static/js/modules/photos-select.mjs +129 -0
- bpp/web/static/js/modules/photos-vgrid.mjs +229 -0
- bpp/web/static/js/modules/photos.mjs +489 -0
- bpp/web/static/js/modules/presets.mjs +223 -0
- bpp/web/static/js/modules/review-meta.mjs +58 -0
- bpp/web/static/js/modules/score-format.mjs +71 -0
- bpp/web/static/js/modules/search.mjs +499 -0
- bpp/web/static/js/modules/sensitive.mjs +299 -0
- bpp/web/static/js/modules/settings-client.mjs +91 -0
- bpp/web/static/js/modules/share-tab.mjs +389 -0
- bpp/web/static/js/modules/sidebar-safety.mjs +265 -0
- bpp/web/static/js/modules/slideshow.mjs +390 -0
- bpp/web/static/js/modules/state.mjs +206 -0
- bpp/web/static/js/modules/tags-view.mjs +279 -0
- bpp/web/static/js/modules/tags.mjs +409 -0
- bpp/web/static/js/modules/text-format.mjs +78 -0
- bpp/web/static/js/modules/theme.mjs +62 -0
- bpp/web/static/js/modules/timeline.mjs +163 -0
- bpp/web/static/js/modules/toast.mjs +140 -0
- bpp/web/static/js/modules/toolbar.mjs +426 -0
- bpp/web/static/js/modules/tooltip.mjs +112 -0
- bpp/web/static/js/modules/tour.mjs +429 -0
- bpp/web/static/js/modules/ui-helpers.mjs +81 -0
- bpp/web/static/js/modules/update-checker.mjs +157 -0
- bpp/web/static/js/modules/use-context-options.mjs +52 -0
- bpp/web/static/js/modules/utils.mjs +450 -0
- bpp/web/static/js/modules/view-guard.mjs +132 -0
- bpp/web/static/js/modules/wizard.mjs +198 -0
- bpp/web/static/manifest.json +24 -0
- bpp/web/static/vendor/README.md +35 -0
- bpp/web/static/vendor/leaflet/LICENSE +25 -0
- bpp/web/static/vendor/leaflet/dist/images/layers-2x.png +0 -0
- bpp/web/static/vendor/leaflet/dist/images/layers.png +0 -0
- bpp/web/static/vendor/leaflet/dist/images/marker-icon-2x.png +0 -0
- bpp/web/static/vendor/leaflet/dist/images/marker-icon.png +0 -0
- bpp/web/static/vendor/leaflet/dist/images/marker-shadow.png +0 -0
- bpp/web/static/vendor/leaflet/dist/leaflet.css +661 -0
- bpp/web/static/vendor/leaflet/dist/leaflet.js +6 -0
- bpp/web/static/vendor/leaflet.markercluster/MIT-LICENCE.txt +21 -0
- bpp/web/static/vendor/leaflet.markercluster/dist/MarkerCluster.Default.css +60 -0
- bpp/web/static/vendor/leaflet.markercluster/dist/MarkerCluster.css +14 -0
- bpp/web/static/vendor/leaflet.markercluster/dist/leaflet.markercluster.js +2 -0
- bpp/web/templates/index.html +1266 -0
- bpp/web/templates/pair.html +207 -0
- bpp/web/thumbnails.py +239 -0
- bpp/web/update_checker.py +183 -0
- bpp/web/worker_pool.py +128 -0
- bpp/web/worker_registry.py +101 -0
- bppicker-0.1.0.dist-info/METADATA +698 -0
- bppicker-0.1.0.dist-info/RECORD +412 -0
- bppicker-0.1.0.dist-info/WHEEL +4 -0
- bppicker-0.1.0.dist-info/entry_points.txt +2 -0
- bppicker-0.1.0.dist-info/licenses/LICENSE +21 -0
- bppicker-0.1.0.dist-info/licenses/NOTICE.txt +281 -0
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
/**
|
|
3
|
+
* Single source of truth for the use-context radio-card content.
|
|
4
|
+
*
|
|
5
|
+
* The use-context selector appears in two places:
|
|
6
|
+
*
|
|
7
|
+
* 1. Onboarding wizard, step 3 (``_stepUseContext`` in
|
|
8
|
+
* ``onboarding.mjs``).
|
|
9
|
+
* 2. Settings → Models picker header (``_useContextControl`` in
|
|
10
|
+
* ``modals-face-embedders.mjs``).
|
|
11
|
+
*
|
|
12
|
+
* The project conventions call out that the wording on each card MUST stay
|
|
13
|
+
* identical across both surfaces — a user re-reading their own
|
|
14
|
+
* declaration in Settings should see exactly the same copy they
|
|
15
|
+
* saw when they made the choice in onboarding. Inlining the array
|
|
16
|
+
* in both files invites silent drift (already observed: "Most
|
|
17
|
+
* people." appeared in one and not the other). This module pins
|
|
18
|
+
* the canonical copy; both renderers import from here.
|
|
19
|
+
*
|
|
20
|
+
* If you need to change the wording, change it ONCE here. Both
|
|
21
|
+
* surfaces update automatically, and the parity test in
|
|
22
|
+
* ``tests-js/use-context-options.module.test.mjs`` guards against
|
|
23
|
+
* future re-inlining.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* @typedef {Object} UseContextOption
|
|
28
|
+
* @property {"personal" | "research" | "commercial"} value
|
|
29
|
+
* @property {string} title
|
|
30
|
+
* @property {string} desc
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
/** @type {ReadonlyArray<UseContextOption>} */
|
|
34
|
+
export const USE_CONTEXT_OPTIONS = Object.freeze([
|
|
35
|
+
Object.freeze({
|
|
36
|
+
value: "personal",
|
|
37
|
+
title: "Personal",
|
|
38
|
+
desc: "Curating my own photos. Most people.",
|
|
39
|
+
}),
|
|
40
|
+
Object.freeze({
|
|
41
|
+
value: "research",
|
|
42
|
+
title: "Research",
|
|
43
|
+
desc: "Academic or non-commercial research work.",
|
|
44
|
+
}),
|
|
45
|
+
Object.freeze({
|
|
46
|
+
value: "commercial",
|
|
47
|
+
title: "Commercial",
|
|
48
|
+
desc:
|
|
49
|
+
"Paid work, client work, business. Restricted-license models " +
|
|
50
|
+
"will be hard-blocked unless you assert separate rights.",
|
|
51
|
+
}),
|
|
52
|
+
]);
|
|
@@ -0,0 +1,450 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
/**
|
|
3
|
+
* Misc UI utilities: the export modal flow (toggle quality field, run
|
|
4
|
+
* export, open the destination folder), the danger-zone "clear library"
|
|
5
|
+
* + analysis-cache + recompute-hashes endpoints, and `show()` / `hide()`
|
|
6
|
+
* primitives that flip the `hidden` class on a DOM node by id.
|
|
7
|
+
*
|
|
8
|
+
* Cross-file callees (`getExportPaths`, `hideSettings`, `validateInput`,
|
|
9
|
+
* `loadAlbumList`, `showEmptyLibrary`, `renderGrid`) and the photos /
|
|
10
|
+
* favorites / overrides / selectedPaths globals stay classic-side and are
|
|
11
|
+
* looked up on `window`.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { _authToken, apiFetch, authEventSource } from "./api-client.mjs";
|
|
15
|
+
import { appConfirm } from "./dialogs.mjs";
|
|
16
|
+
import { reviewSensitiveBeforeExport } from "./sensitive.mjs";
|
|
17
|
+
import { esc, escapeJsAttr } from "./text-format.mjs";
|
|
18
|
+
import { toast, toastError } from "./toast.mjs";
|
|
19
|
+
|
|
20
|
+
// L-S3: large exports (100+ photos with format conversion) used to run
|
|
21
|
+
// synchronously inside the Flask request — the UI showed nothing for
|
|
22
|
+
// 30-60s and looked frozen. Above this threshold the export modal now
|
|
23
|
+
// switches to the streaming worker path (/api/v1/export/start +
|
|
24
|
+
// /api/v1/export/progress SSE) for per-photo progress. Smaller batches
|
|
25
|
+
// keep the synchronous path so the back-compat tests / sync callers
|
|
26
|
+
// don't change shape unnecessarily.
|
|
27
|
+
const _EXPORT_STREAM_THRESHOLD = 25;
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Stream large export jobs via /api/v1/export/start + the SSE
|
|
31
|
+
* /api/v1/export/progress stream. Returns the same shape doExport
|
|
32
|
+
* gets from the synchronous /api/v1/export — { count, failed, outdir,
|
|
33
|
+
* disk_error } — so the toast / status-line render code stays one
|
|
34
|
+
* implementation. Updates ``statusEl`` with per-photo progress
|
|
35
|
+
* ("Exporting 23 of 100…") as events stream.
|
|
36
|
+
*
|
|
37
|
+
* @param {Record<string, any>} body
|
|
38
|
+
* @param {HTMLElement | null} statusEl
|
|
39
|
+
* @returns {Promise<{count: number, failed: number, outdir: string, disk_error: any}>}
|
|
40
|
+
*/
|
|
41
|
+
async function _streamExport(body, statusEl) {
|
|
42
|
+
// 1) Kick off the worker; 202 means streaming has begun.
|
|
43
|
+
const startResp = await apiFetch("/api/v1/export/start", {
|
|
44
|
+
method: "POST",
|
|
45
|
+
headers: { "Content-Type": "application/json" },
|
|
46
|
+
body: JSON.stringify(body),
|
|
47
|
+
});
|
|
48
|
+
const total = startResp.total || 0;
|
|
49
|
+
if (statusEl) statusEl.textContent = `Exporting 0 of ${total}…`;
|
|
50
|
+
|
|
51
|
+
// 2) Open the SSE stream and update the status line per event. The
|
|
52
|
+
// promise resolves on the 'done' event with the same payload the
|
|
53
|
+
// synchronous endpoint returns directly.
|
|
54
|
+
return new Promise((resolve, reject) => {
|
|
55
|
+
const es = authEventSource("/api/v1/export/progress");
|
|
56
|
+
es.onmessage = (ev) => {
|
|
57
|
+
let msg;
|
|
58
|
+
try {
|
|
59
|
+
msg = JSON.parse(ev.data);
|
|
60
|
+
} catch {
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
if (msg.type === "export_progress") {
|
|
64
|
+
if (statusEl) {
|
|
65
|
+
statusEl.textContent = `Exporting ${msg.current} of ${msg.total}…`;
|
|
66
|
+
}
|
|
67
|
+
} else if (msg.type === "done") {
|
|
68
|
+
es.close();
|
|
69
|
+
resolve({
|
|
70
|
+
count: msg.count,
|
|
71
|
+
failed: msg.failed,
|
|
72
|
+
outdir: msg.outdir,
|
|
73
|
+
disk_error: msg.disk_error,
|
|
74
|
+
});
|
|
75
|
+
} else if (msg.type === "error" || msg.type === "cancelled") {
|
|
76
|
+
es.close();
|
|
77
|
+
reject(new Error(msg.message || msg.type));
|
|
78
|
+
}
|
|
79
|
+
};
|
|
80
|
+
es.onerror = () => {
|
|
81
|
+
es.close();
|
|
82
|
+
reject(new Error("Lost connection to export stream"));
|
|
83
|
+
};
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function toggleExportQuality() {
|
|
88
|
+
const fmtEl = /** @type {HTMLSelectElement | null} */ (document.getElementById("export-format"));
|
|
89
|
+
const qField = /** @type {HTMLElement | null} */ (
|
|
90
|
+
document.getElementById("export-quality-field")
|
|
91
|
+
);
|
|
92
|
+
if (!fmtEl || !qField) return;
|
|
93
|
+
qField.style.display = fmtEl.value === "jpeg" ? "flex" : "none";
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Revert the post-success UI (hidden Export button + 'Done' label)
|
|
97
|
+
* back to the pre-export state. Called by the input listener
|
|
98
|
+
* installed in _armPostSuccessRevert and by showExportModal on next
|
|
99
|
+
* open. Also clears the status line so the green 'Exported N photos'
|
|
100
|
+
* doesn't linger over a re-configured export. */
|
|
101
|
+
function _resetExportModalToReady() {
|
|
102
|
+
const btn = document.getElementById("btn-do-export");
|
|
103
|
+
if (btn instanceof HTMLElement) {
|
|
104
|
+
// Project UI rule: don't set style.display = "" (would fall back
|
|
105
|
+
// to CSS, which is currently 'inline-block' from .btn-primary but
|
|
106
|
+
// could change). Explicit 'block' restores from the post-success
|
|
107
|
+
// 'none' to a visible state, behaving the same in the flex parent.
|
|
108
|
+
btn.style.display = "block";
|
|
109
|
+
/** @type {HTMLButtonElement} */ (btn).disabled = false;
|
|
110
|
+
}
|
|
111
|
+
const cancelBtn = document.querySelector(
|
|
112
|
+
'#export-modal-overlay [data-action="hideExportModal"]'
|
|
113
|
+
);
|
|
114
|
+
if (cancelBtn) cancelBtn.textContent = "Cancel";
|
|
115
|
+
const statusEl = document.getElementById("export-status");
|
|
116
|
+
if (statusEl) {
|
|
117
|
+
statusEl.textContent = "";
|
|
118
|
+
statusEl.className = "export-status export-status-center";
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** After a successful export the Export button is hidden and Cancel
|
|
123
|
+
* reads 'Done'. If the user then changes ANY field (scope, outdir,
|
|
124
|
+
* format, max-size, quality), they're configuring a new export — flip
|
|
125
|
+
* the UI back so they can re-fire. One-shot: removes itself on the
|
|
126
|
+
* first change. */
|
|
127
|
+
function _armPostSuccessRevert() {
|
|
128
|
+
const modal = document.getElementById("export-modal-overlay");
|
|
129
|
+
if (!modal) return;
|
|
130
|
+
/** @param {Event} _e */
|
|
131
|
+
const handler = (_e) => {
|
|
132
|
+
modal.removeEventListener("input", handler, true);
|
|
133
|
+
modal.removeEventListener("change", handler, true);
|
|
134
|
+
_resetExportModalToReady();
|
|
135
|
+
};
|
|
136
|
+
modal.addEventListener("input", handler, true);
|
|
137
|
+
modal.addEventListener("change", handler, true);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export async function doExport() {
|
|
141
|
+
/** @type {any} */
|
|
142
|
+
const win = window;
|
|
143
|
+
const outdirEl = /** @type {HTMLInputElement | null} */ (document.getElementById("export-dir"));
|
|
144
|
+
const outdir = (outdirEl?.value || "").trim();
|
|
145
|
+
if (!outdir) {
|
|
146
|
+
toast("Enter an output folder path.", true);
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// Sensitive-photo review gate (P0): runs BEFORE the busy state flips
|
|
151
|
+
// on, so a cancel leaves the export modal exactly as it was. A
|
|
152
|
+
// selection with no flagged photos passes through with no dialog.
|
|
153
|
+
const _allPaths = win.getExportPaths ? [...win.getExportPaths()] : [];
|
|
154
|
+
const _review = await reviewSensitiveBeforeExport(_allPaths);
|
|
155
|
+
if (!_review.proceed) return;
|
|
156
|
+
|
|
157
|
+
const statusEl = document.getElementById("export-status");
|
|
158
|
+
if (statusEl) {
|
|
159
|
+
statusEl.textContent = "Exporting…";
|
|
160
|
+
statusEl.className = "export-status";
|
|
161
|
+
}
|
|
162
|
+
const btn = /** @type {HTMLButtonElement | null} */ (document.getElementById("btn-do-export"));
|
|
163
|
+
if (btn) btn.disabled = true;
|
|
164
|
+
|
|
165
|
+
const fmt = /** @type {HTMLSelectElement | null} */ (
|
|
166
|
+
document.getElementById("export-format")
|
|
167
|
+
)?.value;
|
|
168
|
+
const maxSizeVal = /** @type {HTMLInputElement | null} */ (
|
|
169
|
+
document.getElementById("export-max-size")
|
|
170
|
+
)?.value;
|
|
171
|
+
const quality = parseInt(
|
|
172
|
+
/** @type {HTMLInputElement | null} */ (document.getElementById("export-quality"))?.value ||
|
|
173
|
+
"0",
|
|
174
|
+
10
|
|
175
|
+
);
|
|
176
|
+
|
|
177
|
+
const exportPaths = _review.paths;
|
|
178
|
+
// ZIP-bundle mode: one best-photos.zip instead of a loose folder.
|
|
179
|
+
// The server skips the gallery in this mode (it can't ride inside the
|
|
180
|
+
// single file), so don't request one.
|
|
181
|
+
const zipBundle = /** @type {HTMLInputElement | null} */ (
|
|
182
|
+
document.getElementById("export-zip-toggle")
|
|
183
|
+
)?.checked;
|
|
184
|
+
// Copy method from the registry-populated dropdown (copy/hardlink/symlink
|
|
185
|
+
// + plugin modes). The zip checkbox overrides it (zip is its own mode).
|
|
186
|
+
const copyMode =
|
|
187
|
+
/** @type {HTMLSelectElement | null} */ (document.getElementById("export-mode"))?.value ||
|
|
188
|
+
"copy";
|
|
189
|
+
/** @type {Record<string, any>} */
|
|
190
|
+
const body = {
|
|
191
|
+
outdir,
|
|
192
|
+
selected_paths: exportPaths,
|
|
193
|
+
gallery: !zipBundle,
|
|
194
|
+
fmt,
|
|
195
|
+
quality,
|
|
196
|
+
mode: zipBundle ? "zip" : copyMode,
|
|
197
|
+
};
|
|
198
|
+
if (maxSizeVal) body.max_size = parseInt(maxSizeVal, 10);
|
|
199
|
+
|
|
200
|
+
// L-S3: stream large exports so the user sees per-photo progress
|
|
201
|
+
// instead of a 30-60s blank pause. Small batches keep the
|
|
202
|
+
// synchronous path — same return shape, no UI flicker.
|
|
203
|
+
const useStreaming = exportPaths.length >= _EXPORT_STREAM_THRESHOLD;
|
|
204
|
+
|
|
205
|
+
let data;
|
|
206
|
+
try {
|
|
207
|
+
if (useStreaming) {
|
|
208
|
+
data = await _streamExport(body, statusEl);
|
|
209
|
+
} else {
|
|
210
|
+
data = await apiFetch("/api/v1/export", {
|
|
211
|
+
method: "POST",
|
|
212
|
+
headers: { "Content-Type": "application/json" },
|
|
213
|
+
body: JSON.stringify(body),
|
|
214
|
+
});
|
|
215
|
+
}
|
|
216
|
+
} catch (e) {
|
|
217
|
+
if (btn) btn.disabled = false;
|
|
218
|
+
if (statusEl) {
|
|
219
|
+
const err = /** @type {any} */ (e);
|
|
220
|
+
statusEl.textContent = "Error: " + (err.message || "unknown");
|
|
221
|
+
statusEl.className = "export-status error";
|
|
222
|
+
}
|
|
223
|
+
toastError("export your photos", e);
|
|
224
|
+
return;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// Post-success: hide the Export button so the user can't re-fire the
|
|
228
|
+
// same export by accident, and rename 'Cancel' → 'Done' so closing
|
|
229
|
+
// the modal reads as completion, not abort. showExportModal()
|
|
230
|
+
// restores both on the next open. If the user changes ANY field
|
|
231
|
+
// (scope, outdir, format, size, quality) the post-success UI reverts
|
|
232
|
+
// — they're configuring a NEW export, so re-firing is intentional.
|
|
233
|
+
if (btn instanceof HTMLElement) btn.style.display = "none";
|
|
234
|
+
const cancelBtn = document.querySelector(
|
|
235
|
+
'#export-modal-overlay [data-action="hideExportModal"]'
|
|
236
|
+
);
|
|
237
|
+
if (cancelBtn) cancelBtn.textContent = "Done";
|
|
238
|
+
_armPostSuccessRevert();
|
|
239
|
+
|
|
240
|
+
let msg = `Exported ${data.count} photo${data.count !== 1 ? "s" : ""}`;
|
|
241
|
+
if (data.failed > 0) msg += ` (${data.failed} failed)`;
|
|
242
|
+
if (statusEl) {
|
|
243
|
+
statusEl.innerHTML =
|
|
244
|
+
`<span class="export-result-msg">${esc(msg)}</span>` +
|
|
245
|
+
`<button class="btn-open-folder" data-action="_openExportFolder" data-arg0="${escapeJsAttr(data.outdir)}">Open Folder</button>`;
|
|
246
|
+
statusEl.className = "export-status success";
|
|
247
|
+
}
|
|
248
|
+
// Categorised disk error takes precedence over the generic "N failed"
|
|
249
|
+
// toast — when the loop aborted it's because every subsequent photo
|
|
250
|
+
// would have failed the same way, and the user needs that context to
|
|
251
|
+
// actually fix it (free space / pick a writable folder) rather than
|
|
252
|
+
// thinking 14 photos were corrupt.
|
|
253
|
+
if (data.disk_error) {
|
|
254
|
+
/** @type {Record<string, string>} */
|
|
255
|
+
const labels = {
|
|
256
|
+
no_space: "Disk full",
|
|
257
|
+
permission: "Permission denied",
|
|
258
|
+
read_only_fs: "Folder is read-only",
|
|
259
|
+
};
|
|
260
|
+
const label = labels[data.disk_error.category] || "Filesystem error";
|
|
261
|
+
const at = data.disk_error.first_failed_index;
|
|
262
|
+
toast(`${label} — export stopped at photo ${at}. Free space or pick another folder and retry.`, true);
|
|
263
|
+
} else if (data.failed > 0) {
|
|
264
|
+
// Non-disk failures (corrupt source, bad filename, format
|
|
265
|
+
// conversion error) — surface a 'View log' shortcut. The per-photo
|
|
266
|
+
// details are in Settings → Activity; without this affordance the
|
|
267
|
+
// user has nowhere to go from a 'N failed' toast. Matches the
|
|
268
|
+
// pattern Bug #8 (activity-log warning toast) and the window
|
|
269
|
+
// onerror handler in app.mjs use.
|
|
270
|
+
toast(`${data.failed} photo${data.failed !== 1 ? "s" : ""} failed to export`, "error", { /* toast-ok: summary, not an error pattern */
|
|
271
|
+
action: {
|
|
272
|
+
label: "View log",
|
|
273
|
+
fn: () => {
|
|
274
|
+
const win = /** @type {any} */ (window);
|
|
275
|
+
if (typeof win.showActivityLog === "function") win.showActivityLog();
|
|
276
|
+
},
|
|
277
|
+
},
|
|
278
|
+
});
|
|
279
|
+
} else {
|
|
280
|
+
toast(msg);
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* @param {string} path
|
|
286
|
+
*/
|
|
287
|
+
export async function _openExportFolder(path) {
|
|
288
|
+
try {
|
|
289
|
+
await apiFetch("/api/v1/open-folder", {
|
|
290
|
+
method: "POST",
|
|
291
|
+
headers: { "Content-Type": "application/json" },
|
|
292
|
+
body: JSON.stringify({ path }),
|
|
293
|
+
});
|
|
294
|
+
} catch (e) {
|
|
295
|
+
toastError("open the export folder", e);
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/**
|
|
300
|
+
* Show element by id (remove `hidden` class).
|
|
301
|
+
*
|
|
302
|
+
* @param {string} id
|
|
303
|
+
*/
|
|
304
|
+
export function show(id) {
|
|
305
|
+
document.getElementById(id)?.classList.remove("hidden");
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/**
|
|
309
|
+
* Hide element by id (add `hidden` class).
|
|
310
|
+
*
|
|
311
|
+
* @param {string} id
|
|
312
|
+
*/
|
|
313
|
+
export function hide(id) {
|
|
314
|
+
document.getElementById(id)?.classList.add("hidden");
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
export function validateClearConfirm() {
|
|
318
|
+
const inp = /** @type {HTMLInputElement | null} */ (
|
|
319
|
+
document.getElementById("clear-confirm-input")
|
|
320
|
+
);
|
|
321
|
+
const btn = document.getElementById("btn-clear-library");
|
|
322
|
+
if (!inp || !btn) return;
|
|
323
|
+
if (inp.value.trim().toLowerCase() === "delete") {
|
|
324
|
+
btn.classList.add("enabled");
|
|
325
|
+
} else {
|
|
326
|
+
btn.classList.remove("enabled");
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
export async function clearLibrary() {
|
|
331
|
+
/** @type {any} */
|
|
332
|
+
const win = window;
|
|
333
|
+
const inp = /** @type {HTMLInputElement | null} */ (
|
|
334
|
+
document.getElementById("clear-confirm-input")
|
|
335
|
+
);
|
|
336
|
+
if (!inp || inp.value.trim().toLowerCase() !== "delete") return;
|
|
337
|
+
const btn = /** @type {HTMLButtonElement | null} */ (
|
|
338
|
+
document.getElementById("btn-clear-library")
|
|
339
|
+
);
|
|
340
|
+
if (btn) {
|
|
341
|
+
btn.textContent = "Deleting...";
|
|
342
|
+
btn.classList.remove("enabled");
|
|
343
|
+
}
|
|
344
|
+
try {
|
|
345
|
+
const resp = await fetch("/api/v1/library", {
|
|
346
|
+
method: "DELETE",
|
|
347
|
+
headers: { "Content-Type": "application/json", "X-Auth-Token": _authToken },
|
|
348
|
+
body: JSON.stringify({ confirmation: "delete" }),
|
|
349
|
+
});
|
|
350
|
+
const data = await resp.json();
|
|
351
|
+
if (!resp.ok) {
|
|
352
|
+
toast(data.error || "Failed to clear", true);
|
|
353
|
+
return;
|
|
354
|
+
}
|
|
355
|
+
win.hideSettings?.();
|
|
356
|
+
toast(`Cleared: ${data.photos_deleted} photos removed`);
|
|
357
|
+
inp.value = "";
|
|
358
|
+
validateClearConfirm();
|
|
359
|
+
win.photos = [];
|
|
360
|
+
win.selectedPaths = new Set();
|
|
361
|
+
win.overrides = {};
|
|
362
|
+
win.favorites = new Set();
|
|
363
|
+
win.renderGrid?.();
|
|
364
|
+
win.loadAlbumList?.();
|
|
365
|
+
win.showEmptyLibrary?.();
|
|
366
|
+
} catch (e) {
|
|
367
|
+
toastError("clear the library", e);
|
|
368
|
+
} finally {
|
|
369
|
+
if (btn) btn.textContent = "Delete All";
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
export async function recomputeHashes() {
|
|
374
|
+
try {
|
|
375
|
+
const data = await apiFetch("/api/v1/compute-hashes", { method: "POST" });
|
|
376
|
+
if (data.status === "done") {
|
|
377
|
+
toast("All photos already have hashes — duplicates are up to date");
|
|
378
|
+
} else {
|
|
379
|
+
toast(`Computing hashes for ${data.missing} photos in the background`);
|
|
380
|
+
}
|
|
381
|
+
} catch (e) {
|
|
382
|
+
toastError("start hash computation", e);
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
export async function clearAnalysisCache() {
|
|
387
|
+
const ok = await appConfirm(
|
|
388
|
+
"Clear the scoring cache? Next analysis will re-run all ML models from scratch. This does not delete photos."
|
|
389
|
+
);
|
|
390
|
+
if (!ok) return;
|
|
391
|
+
try {
|
|
392
|
+
const data = await apiFetch("/api/v1/analysis-cache", { method: "DELETE" });
|
|
393
|
+
if (data.status === "no_cache") {
|
|
394
|
+
toast("No cache to clear");
|
|
395
|
+
return;
|
|
396
|
+
}
|
|
397
|
+
toast("Analysis cache cleared — re-analyze to rescore with latest models");
|
|
398
|
+
} catch (e) {
|
|
399
|
+
toastError("clear the cache", e);
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
/* ── Native file picker ── */
|
|
404
|
+
export async function openBrowser() {
|
|
405
|
+
/** @type {any} */
|
|
406
|
+
const win = window;
|
|
407
|
+
try {
|
|
408
|
+
const data = await apiFetch("/api/v1/pick", {
|
|
409
|
+
method: "POST",
|
|
410
|
+
headers: { "Content-Type": "application/json" },
|
|
411
|
+
body: JSON.stringify({ mode: "folder" }),
|
|
412
|
+
});
|
|
413
|
+
if (data.path) {
|
|
414
|
+
const inp = /** @type {HTMLInputElement | null} */ (document.getElementById("input-dir"));
|
|
415
|
+
if (inp) inp.value = data.path;
|
|
416
|
+
win.validateInput?.();
|
|
417
|
+
}
|
|
418
|
+
} catch (e) {
|
|
419
|
+
toastError("open the folder browser", e);
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
export async function openExportBrowser() {
|
|
424
|
+
try {
|
|
425
|
+
const data = await apiFetch("/api/v1/pick", {
|
|
426
|
+
method: "POST",
|
|
427
|
+
headers: { "Content-Type": "application/json" },
|
|
428
|
+
body: JSON.stringify({ mode: "folder" }),
|
|
429
|
+
});
|
|
430
|
+
if (data.path) {
|
|
431
|
+
const inp = /** @type {HTMLInputElement | null} */ (document.getElementById("export-dir"));
|
|
432
|
+
if (inp) {
|
|
433
|
+
inp.value = data.path;
|
|
434
|
+
// Programmatic value assignment does NOT fire 'input' in any
|
|
435
|
+
// browser, so the modal's #export-dir listener (which enables /
|
|
436
|
+
// disables the Export button based on emptiness) wouldn't see
|
|
437
|
+
// the new value and the button would stay disabled after Browse.
|
|
438
|
+
// Dispatching synthesises the event so all bound listeners react.
|
|
439
|
+
inp.dispatchEvent(new Event("input", { bubbles: true }));
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
} catch (e) {
|
|
443
|
+
toastError("open the folder browser", e);
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
/** @deprecated kept for HTML callers — import flow now uses a modal. */
|
|
448
|
+
export function validateInput() {
|
|
449
|
+
/* no-op */
|
|
450
|
+
}
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
/**
|
|
3
|
+
* View-aware fetch guard.
|
|
4
|
+
*
|
|
5
|
+
* The app keeps `window.currentView` as a string identifying the active
|
|
6
|
+
* view (library / album / people / calendar / etc.). Loaders kick off
|
|
7
|
+
* `apiFetch(...)` calls and write results back to DOM/globals when they
|
|
8
|
+
* resolve. If the user switches views mid-fetch, those late writes
|
|
9
|
+
* paint stale data into the new view's containers.
|
|
10
|
+
*
|
|
11
|
+
* This module exposes:
|
|
12
|
+
*
|
|
13
|
+
* - `installViewGuard()` — installs a setter on `window.currentView` so
|
|
14
|
+
* every assignment bumps an internal token and aborts the shared
|
|
15
|
+
* AbortController. Idempotent; safe to call multiple times.
|
|
16
|
+
*
|
|
17
|
+
* - `viewFetch(url, opts)` — drop-in replacement for `apiFetch` that
|
|
18
|
+
* captures the current view token at call time, attaches the per-view
|
|
19
|
+
* AbortSignal to the fetch, and returns `null` if (a) the request was
|
|
20
|
+
* aborted by a view switch, or (b) the response arrived after the
|
|
21
|
+
* view changed. Callers do `const data = await viewFetch(...); if
|
|
22
|
+
* (!data) return;` and stay race-safe with no further bookkeeping.
|
|
23
|
+
*
|
|
24
|
+
* - `currentViewToken()` / `viewStillCurrent(token)` — escape hatches for
|
|
25
|
+
* loaders that do work *after* a non-fetch await (e.g. image decode,
|
|
26
|
+
* setTimeout) and need a manual guard.
|
|
27
|
+
*
|
|
28
|
+
* The 30s default-timeout behaviour of `apiFetch` is preserved via
|
|
29
|
+
* `AbortSignal.any([viewSignal, AbortSignal.timeout(30_000)])`.
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
import { apiFetch } from "./api-client.mjs";
|
|
33
|
+
|
|
34
|
+
let _viewToken = 0;
|
|
35
|
+
/** @type {AbortController | null} */
|
|
36
|
+
let _controller = null;
|
|
37
|
+
|
|
38
|
+
function _bump() {
|
|
39
|
+
_viewToken += 1;
|
|
40
|
+
if (_controller && !_controller.signal.aborted) {
|
|
41
|
+
_controller.abort();
|
|
42
|
+
}
|
|
43
|
+
_controller = null;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function _activeController() {
|
|
47
|
+
if (!_controller || _controller.signal.aborted) {
|
|
48
|
+
_controller = new AbortController();
|
|
49
|
+
}
|
|
50
|
+
return _controller;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** @returns {number} */
|
|
54
|
+
export function currentViewToken() {
|
|
55
|
+
return _viewToken;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* @param {number} token
|
|
60
|
+
* @returns {boolean}
|
|
61
|
+
*/
|
|
62
|
+
export function viewStillCurrent(token) {
|
|
63
|
+
return token === _viewToken;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Wrap `apiFetch` so a mid-flight view switch silently bails the caller.
|
|
68
|
+
* Returns `null` on abort or stale response; throws on real HTTP errors
|
|
69
|
+
* exactly like `apiFetch`.
|
|
70
|
+
*
|
|
71
|
+
* @param {string} url
|
|
72
|
+
* @param {RequestInit & { timeoutMs?: number }} [opts]
|
|
73
|
+
* @returns {Promise<any | null>}
|
|
74
|
+
*/
|
|
75
|
+
export async function viewFetch(url, opts) {
|
|
76
|
+
const token = _viewToken;
|
|
77
|
+
const viewSignal = _activeController().signal;
|
|
78
|
+
const timeoutMs = (opts && opts.timeoutMs) ?? 30000;
|
|
79
|
+
const signals = [viewSignal];
|
|
80
|
+
if (typeof AbortSignal !== "undefined" && typeof AbortSignal.timeout === "function") {
|
|
81
|
+
signals.push(AbortSignal.timeout(timeoutMs));
|
|
82
|
+
}
|
|
83
|
+
// @ts-ignore — AbortSignal.any is widely available in Chromium/WebKit 2023+
|
|
84
|
+
const signal = AbortSignal.any ? AbortSignal.any(signals) : viewSignal;
|
|
85
|
+
const merged = { ...(opts || {}), signal };
|
|
86
|
+
// Strip our extension before forwarding to apiFetch.
|
|
87
|
+
delete /** @type {any} */ (merged).timeoutMs;
|
|
88
|
+
try {
|
|
89
|
+
const data = await apiFetch(url, merged);
|
|
90
|
+
return viewStillCurrent(token) ? data : null;
|
|
91
|
+
} catch (err) {
|
|
92
|
+
if (err && (err.name === "AbortError" || err.name === "TimeoutError")) return null;
|
|
93
|
+
throw err;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Install the property setter that ties `window.currentView` writes to
|
|
99
|
+
* the internal token. Idempotent.
|
|
100
|
+
*
|
|
101
|
+
* @param {Window & typeof globalThis} [win]
|
|
102
|
+
*/
|
|
103
|
+
export function installViewGuard(win) {
|
|
104
|
+
const w = /** @type {any} */ (win || globalThis);
|
|
105
|
+
if (w.__viewGuardInstalled) return;
|
|
106
|
+
let view = w.currentView ?? "library";
|
|
107
|
+
Object.defineProperty(w, "currentView", {
|
|
108
|
+
configurable: true,
|
|
109
|
+
enumerable: true,
|
|
110
|
+
get() {
|
|
111
|
+
return view;
|
|
112
|
+
},
|
|
113
|
+
set(next) {
|
|
114
|
+
if (next !== view) {
|
|
115
|
+
view = next;
|
|
116
|
+
_bump();
|
|
117
|
+
} else {
|
|
118
|
+
view = next;
|
|
119
|
+
}
|
|
120
|
+
},
|
|
121
|
+
});
|
|
122
|
+
w.__viewGuardInstalled = true;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Test-only reset. Not exported through the runtime bridge.
|
|
127
|
+
*/
|
|
128
|
+
export function _resetForTests() {
|
|
129
|
+
_viewToken = 0;
|
|
130
|
+
if (_controller && !_controller.signal.aborted) _controller.abort();
|
|
131
|
+
_controller = null;
|
|
132
|
+
}
|