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.
Files changed (412) hide show
  1. bpp/__init__.py +26 -0
  2. bpp/ai/__init__.py +1 -0
  3. bpp/ai/inpainting.py +244 -0
  4. bpp/cli.py +303 -0
  5. bpp/commands/__init__.py +42 -0
  6. bpp/commands/analyze.py +181 -0
  7. bpp/commands/db_restore.py +216 -0
  8. bpp/commands/db_restore_impl.py +318 -0
  9. bpp/commands/demo.py +69 -0
  10. bpp/commands/model.py +169 -0
  11. bpp/commands/model_commands.py +442 -0
  12. bpp/commands/pick.py +167 -0
  13. bpp/commands/serve.py +329 -0
  14. bpp/config.py +123 -0
  15. bpp/config_resolver.py +233 -0
  16. bpp/config_schema.py +482 -0
  17. bpp/constants.py +268 -0
  18. bpp/db/__init__.py +1 -0
  19. bpp/db/albums.py +447 -0
  20. bpp/db/backup.py +430 -0
  21. bpp/db/batch_rename.py +356 -0
  22. bpp/db/calendar.py +158 -0
  23. bpp/db/clip.py +299 -0
  24. bpp/db/connection.py +297 -0
  25. bpp/db/dedupe.py +241 -0
  26. bpp/db/dialect.py +162 -0
  27. bpp/db/edits.py +179 -0
  28. bpp/db/event_hooks.py +206 -0
  29. bpp/db/face_cluster_ops.py +181 -0
  30. bpp/db/face_embedding_safety.py +243 -0
  31. bpp/db/face_feedback.py +362 -0
  32. bpp/db/face_identity_remap.py +443 -0
  33. bpp/db/face_queries.py +257 -0
  34. bpp/db/groups.py +232 -0
  35. bpp/db/integrity.py +185 -0
  36. bpp/db/journal.py +231 -0
  37. bpp/db/library.py +433 -0
  38. bpp/db/live_photo.py +476 -0
  39. bpp/db/memories.py +320 -0
  40. bpp/db/migrate.py +119 -0
  41. bpp/db/migrations.py +264 -0
  42. bpp/db/migrations_latest.py +438 -0
  43. bpp/db/migrations_recent.py +430 -0
  44. bpp/db/moments.py +154 -0
  45. bpp/db/pets.py +371 -0
  46. bpp/db/photo_hooks.py +120 -0
  47. bpp/db/photos.py +505 -0
  48. bpp/db/photos_dates.py +89 -0
  49. bpp/db/photos_gps.py +139 -0
  50. bpp/db/photos_lifecycle.py +214 -0
  51. bpp/db/photos_missing.py +122 -0
  52. bpp/db/presets.py +58 -0
  53. bpp/db/registry.py +142 -0
  54. bpp/db/schema.py +530 -0
  55. bpp/db/schema_migrate.py +215 -0
  56. bpp/db/settings.py +66 -0
  57. bpp/db/smart_album_document.py +87 -0
  58. bpp/db/smart_album_domains.py +116 -0
  59. bpp/db/smart_album_ensure.py +87 -0
  60. bpp/db/smart_album_groups.py +117 -0
  61. bpp/db/smart_album_people.py +341 -0
  62. bpp/db/smart_album_pets.py +111 -0
  63. bpp/db/smart_album_queries.py +299 -0
  64. bpp/db/smart_album_refreshers.py +444 -0
  65. bpp/db/smart_album_sensitive.py +64 -0
  66. bpp/db/smart_album_tags.py +78 -0
  67. bpp/db/smart_albums.py +441 -0
  68. bpp/db/stats.py +57 -0
  69. bpp/db/tags.py +172 -0
  70. bpp/dedupe/__init__.py +0 -0
  71. bpp/dedupe/cluster.py +173 -0
  72. bpp/dedupe/common.py +20 -0
  73. bpp/dedupe/phash.py +147 -0
  74. bpp/dedupe/semantic.py +328 -0
  75. bpp/dedupe/strategy.py +277 -0
  76. bpp/demo/__init__.py +0 -0
  77. bpp/demo/generate.py +340 -0
  78. bpp/errors.py +234 -0
  79. bpp/exif_utils.py +168 -0
  80. bpp/io_scan.py +102 -0
  81. bpp/media_types.py +78 -0
  82. bpp/output/__init__.py +0 -0
  83. bpp/output/export.py +376 -0
  84. bpp/output/export_metadata.py +207 -0
  85. bpp/output/export_modes.py +296 -0
  86. bpp/output/gallery.py +148 -0
  87. bpp/plugin_protocol.py +240 -0
  88. bpp/plugins/__init__.py +345 -0
  89. bpp/plugins/example.py +328 -0
  90. bpp/plugins/registry_protocol.py +92 -0
  91. bpp/registry/__init__.py +345 -0
  92. bpp/registry/acceptance.py +360 -0
  93. bpp/registry/acceptance_log.py +395 -0
  94. bpp/registry/builtins.py +479 -0
  95. bpp/registry/byom.py +356 -0
  96. bpp/registry/derived_data_purge.py +118 -0
  97. bpp/registry/disclaimers.py +290 -0
  98. bpp/registry/download_chokepoint.py +430 -0
  99. bpp/registry/labels.py +146 -0
  100. bpp/registry/model_registry.py +415 -0
  101. bpp/registry/overlay.py +242 -0
  102. bpp/registry/policy.py +320 -0
  103. bpp/registry/remote_registry.py +286 -0
  104. bpp/registry/removal.py +142 -0
  105. bpp/registry/signed_manifest.py +334 -0
  106. bpp/registry/trusted_keys.py +122 -0
  107. bpp/registry/use_context.py +188 -0
  108. bpp/registry/use_context_store.py +236 -0
  109. bpp/scoring/__init__.py +0 -0
  110. bpp/scoring/_registry_base.py +50 -0
  111. bpp/scoring/aggregate.py +431 -0
  112. bpp/scoring/aggregate_video.py +136 -0
  113. bpp/scoring/blur.py +89 -0
  114. bpp/scoring/clip_embed.py +333 -0
  115. bpp/scoring/clip_tokenizer.py +241 -0
  116. bpp/scoring/composition.py +67 -0
  117. bpp/scoring/enhance.py +92 -0
  118. bpp/scoring/exposure.py +44 -0
  119. bpp/scoring/face.py +277 -0
  120. bpp/scoring/face_blazeface_fr.py +181 -0
  121. bpp/scoring/face_cluster.py +115 -0
  122. bpp/scoring/face_detector_registry.py +138 -0
  123. bpp/scoring/face_embed.py +426 -0
  124. bpp/scoring/face_embed_buffalo.py +132 -0
  125. bpp/scoring/face_embed_buffalo_s.py +500 -0
  126. bpp/scoring/face_embed_detect.py +232 -0
  127. bpp/scoring/face_embed_extractors.py +284 -0
  128. bpp/scoring/face_embed_landmarks.py +218 -0
  129. bpp/scoring/face_embed_sface.py +145 -0
  130. bpp/scoring/face_embed_sface_runtime.py +359 -0
  131. bpp/scoring/face_embedder_registry.py +126 -0
  132. bpp/scoring/face_expression.py +186 -0
  133. bpp/scoring/face_fallback.py +108 -0
  134. bpp/scoring/face_hand_filter.py +202 -0
  135. bpp/scoring/face_mediapipe.py +120 -0
  136. bpp/scoring/face_pipeline.py +418 -0
  137. bpp/scoring/face_score.py +122 -0
  138. bpp/scoring/face_scrfd.py +273 -0
  139. bpp/scoring/face_yunet.py +255 -0
  140. bpp/scoring/model_base.py +436 -0
  141. bpp/scoring/model_load_gate.py +57 -0
  142. bpp/scoring/model_manifest.py +255 -0
  143. bpp/scoring/models/blaze_face_short_range.tflite +0 -0
  144. bpp/scoring/nudity.py +316 -0
  145. bpp/scoring/onnx_providers.py +133 -0
  146. bpp/scoring/pets.py +460 -0
  147. bpp/scoring/pose.py +234 -0
  148. bpp/scoring/registry.py +336 -0
  149. bpp/scoring/segmentation.py +181 -0
  150. bpp/scoring/skin.py +18 -0
  151. bpp/selection/__init__.py +0 -0
  152. bpp/selection/choose.py +130 -0
  153. bpp/selection/diversity.py +129 -0
  154. bpp/utils/__init__.py +0 -0
  155. bpp/utils/cancel.py +279 -0
  156. bpp/utils/concurrency.py +58 -0
  157. bpp/utils/config_snapshot.py +83 -0
  158. bpp/utils/download.py +200 -0
  159. bpp/utils/json_utils.py +32 -0
  160. bpp/utils/logging.py +408 -0
  161. bpp/utils/path_validation.py +70 -0
  162. bpp/utils/paths.py +121 -0
  163. bpp/utils/raw.py +74 -0
  164. bpp/utils/retry.py +102 -0
  165. bpp/utils/safe_subprocess.py +1 -0
  166. bpp/utils/serving_lock.py +302 -0
  167. bpp/utils/subprocess_runner.py +349 -0
  168. bpp/utils/timing.py +34 -0
  169. bpp/utils/video.py +237 -0
  170. bpp/web/__init__.py +1 -0
  171. bpp/web/_deprecated_attr.py +92 -0
  172. bpp/web/analysis_store.py +133 -0
  173. bpp/web/analyze_archive.py +169 -0
  174. bpp/web/analyze_face_extract.py +443 -0
  175. bpp/web/analyze_finalize.py +77 -0
  176. bpp/web/analyze_model_preflight.py +86 -0
  177. bpp/web/analyze_phases.py +160 -0
  178. bpp/web/analyze_scoring.py +202 -0
  179. bpp/web/analyze_subprocess.py +73 -0
  180. bpp/web/analyze_worker.py +470 -0
  181. bpp/web/app.py +384 -0
  182. bpp/web/base_worker.py +224 -0
  183. bpp/web/bp_album_overrides.py +120 -0
  184. bpp/web/bp_albums.py +478 -0
  185. bpp/web/bp_analysis.py +451 -0
  186. bpp/web/bp_calendar.py +147 -0
  187. bpp/web/bp_catalog.py +201 -0
  188. bpp/web/bp_clip.py +103 -0
  189. bpp/web/bp_core.py +477 -0
  190. bpp/web/bp_export.py +361 -0
  191. bpp/web/bp_faces.py +455 -0
  192. bpp/web/bp_faces_bbox.py +351 -0
  193. bpp/web/bp_faces_cluster_ops.py +409 -0
  194. bpp/web/bp_faces_extract.py +172 -0
  195. bpp/web/bp_faces_manage.py +264 -0
  196. bpp/web/bp_faces_photo.py +241 -0
  197. bpp/web/bp_faces_recluster.py +116 -0
  198. bpp/web/bp_faces_review.py +475 -0
  199. bpp/web/bp_groups.py +108 -0
  200. bpp/web/bp_health.py +312 -0
  201. bpp/web/bp_inpaint.py +228 -0
  202. bpp/web/bp_install.py +244 -0
  203. bpp/web/bp_library.py +215 -0
  204. bpp/web/bp_logs.py +210 -0
  205. bpp/web/bp_media.py +344 -0
  206. bpp/web/bp_memories.py +78 -0
  207. bpp/web/bp_model_admin.py +202 -0
  208. bpp/web/bp_model_registry.py +405 -0
  209. bpp/web/bp_models.py +339 -0
  210. bpp/web/bp_os_integration.py +186 -0
  211. bpp/web/bp_pets.py +331 -0
  212. bpp/web/bp_photos.py +355 -0
  213. bpp/web/bp_photos_lifecycle.py +318 -0
  214. bpp/web/bp_photos_manage.py +443 -0
  215. bpp/web/bp_recompute.py +373 -0
  216. bpp/web/bp_search.py +339 -0
  217. bpp/web/bp_settings.py +273 -0
  218. bpp/web/bp_share.py +307 -0
  219. bpp/web/bp_tags.py +222 -0
  220. bpp/web/clip_worker.py +227 -0
  221. bpp/web/derived_recovery.py +255 -0
  222. bpp/web/export_worker.py +149 -0
  223. bpp/web/face_create_helpers.py +260 -0
  224. bpp/web/face_crop.py +81 -0
  225. bpp/web/face_extraction_journal.py +418 -0
  226. bpp/web/face_extraction_phase5.py +346 -0
  227. bpp/web/face_extraction_phase6.py +140 -0
  228. bpp/web/face_extraction_phases.py +436 -0
  229. bpp/web/face_merge_core.py +163 -0
  230. bpp/web/face_orchestrator.py +141 -0
  231. bpp/web/face_phase_classes.py +299 -0
  232. bpp/web/face_phase_pipeline.py +265 -0
  233. bpp/web/face_phase_types.py +130 -0
  234. bpp/web/face_recovery.py +253 -0
  235. bpp/web/face_worker.py +287 -0
  236. bpp/web/filters.py +57 -0
  237. bpp/web/health.py +131 -0
  238. bpp/web/import_worker.py +230 -0
  239. bpp/web/library_lifecycle.py +82 -0
  240. bpp/web/model_cache.py +144 -0
  241. bpp/web/model_filter.py +150 -0
  242. bpp/web/models_status.py +309 -0
  243. bpp/web/photo_dict.py +237 -0
  244. bpp/web/photo_edits.py +380 -0
  245. bpp/web/recompute.py +322 -0
  246. bpp/web/request_validation.py +192 -0
  247. bpp/web/review_meta.py +56 -0
  248. bpp/web/share.py +493 -0
  249. bpp/web/share_devices.py +250 -0
  250. bpp/web/share_proxy.py +199 -0
  251. bpp/web/share_qr.py +146 -0
  252. bpp/web/share_runtime.py +221 -0
  253. bpp/web/sse.py +60 -0
  254. bpp/web/state.py +488 -0
  255. bpp/web/state_compat.py +185 -0
  256. bpp/web/state_helpers.py +112 -0
  257. bpp/web/state_init.py +318 -0
  258. bpp/web/state_init_phases.py +250 -0
  259. bpp/web/state_lifecycle.py +280 -0
  260. bpp/web/state_ops.py +162 -0
  261. bpp/web/static/css/app.css +7608 -0
  262. bpp/web/static/img/apple-touch-icon.png +0 -0
  263. bpp/web/static/img/icon-192.png +0 -0
  264. bpp/web/static/img/icon-512.png +0 -0
  265. bpp/web/static/js/globals.js +446 -0
  266. bpp/web/static/js/modules/action-registry.mjs +109 -0
  267. bpp/web/static/js/modules/activity-humanize.mjs +185 -0
  268. bpp/web/static/js/modules/activity-log.mjs +350 -0
  269. bpp/web/static/js/modules/albums-menus.mjs +400 -0
  270. bpp/web/static/js/modules/albums-render-helpers.mjs +61 -0
  271. bpp/web/static/js/modules/albums-render.mjs +505 -0
  272. bpp/web/static/js/modules/albums-switch.mjs +292 -0
  273. bpp/web/static/js/modules/albums.mjs +356 -0
  274. bpp/web/static/js/modules/analysis-install.mjs +112 -0
  275. bpp/web/static/js/modules/analysis-preflight.mjs +160 -0
  276. bpp/web/static/js/modules/analysis-recompute.mjs +257 -0
  277. bpp/web/static/js/modules/analysis-status.mjs +347 -0
  278. bpp/web/static/js/modules/analysis.mjs +444 -0
  279. bpp/web/static/js/modules/api-client.mjs +141 -0
  280. bpp/web/static/js/modules/app.mjs +412 -0
  281. bpp/web/static/js/modules/batch-rename.mjs +308 -0
  282. bpp/web/static/js/modules/calendar-render.mjs +307 -0
  283. bpp/web/static/js/modules/calendar-selection.mjs +214 -0
  284. bpp/web/static/js/modules/calendar.mjs +433 -0
  285. bpp/web/static/js/modules/client-error-beacon.mjs +56 -0
  286. bpp/web/static/js/modules/clip.mjs +370 -0
  287. bpp/web/static/js/modules/compare-sibling.mjs +311 -0
  288. bpp/web/static/js/modules/compare.mjs +450 -0
  289. bpp/web/static/js/modules/constants.mjs +31 -0
  290. bpp/web/static/js/modules/core.mjs +409 -0
  291. bpp/web/static/js/modules/date-format.mjs +96 -0
  292. bpp/web/static/js/modules/deleted-ctx-menu.mjs +228 -0
  293. bpp/web/static/js/modules/deleted-enhance.mjs +160 -0
  294. bpp/web/static/js/modules/deleted.mjs +440 -0
  295. bpp/web/static/js/modules/dialogs.mjs +157 -0
  296. bpp/web/static/js/modules/dupe-review.mjs +222 -0
  297. bpp/web/static/js/modules/editor-constants.mjs +295 -0
  298. bpp/web/static/js/modules/editor-crop.mjs +382 -0
  299. bpp/web/static/js/modules/editor-inpaint.mjs +427 -0
  300. bpp/web/static/js/modules/editor-preview.mjs +199 -0
  301. bpp/web/static/js/modules/editor-redeye.mjs +118 -0
  302. bpp/web/static/js/modules/editor-rendering.mjs +411 -0
  303. bpp/web/static/js/modules/editor-styles.mjs +312 -0
  304. bpp/web/static/js/modules/editor-tools.mjs +227 -0
  305. bpp/web/static/js/modules/editor.mjs +456 -0
  306. bpp/web/static/js/modules/face-embedders-acceptance.mjs +440 -0
  307. bpp/web/static/js/modules/face-embedders-actions.mjs +270 -0
  308. bpp/web/static/js/modules/face-embedders-popover.mjs +173 -0
  309. bpp/web/static/js/modules/face-embedders-rowstate.mjs +499 -0
  310. bpp/web/static/js/modules/face-embedders-state.mjs +142 -0
  311. bpp/web/static/js/modules/faces-extraction.mjs +183 -0
  312. bpp/web/static/js/modules/faces.mjs +350 -0
  313. bpp/web/static/js/modules/format-helpers.mjs +67 -0
  314. bpp/web/static/js/modules/groups.mjs +322 -0
  315. bpp/web/static/js/modules/import-worker.mjs +274 -0
  316. bpp/web/static/js/modules/inspector.mjs +338 -0
  317. bpp/web/static/js/modules/library.mjs +305 -0
  318. bpp/web/static/js/modules/lightbox-actions.mjs +450 -0
  319. bpp/web/static/js/modules/lightbox-ctxmenu.mjs +77 -0
  320. bpp/web/static/js/modules/lightbox-face-assign.mjs +382 -0
  321. bpp/web/static/js/modules/lightbox-face-edit.mjs +479 -0
  322. bpp/web/static/js/modules/lightbox-face-overlays.mjs +336 -0
  323. bpp/web/static/js/modules/lightbox-face-picker.mjs +475 -0
  324. bpp/web/static/js/modules/lightbox-info.mjs +375 -0
  325. bpp/web/static/js/modules/lightbox-input.mjs +369 -0
  326. bpp/web/static/js/modules/lightbox-open.mjs +280 -0
  327. bpp/web/static/js/modules/lightbox.mjs +462 -0
  328. bpp/web/static/js/modules/map.mjs +273 -0
  329. bpp/web/static/js/modules/memories.mjs +210 -0
  330. bpp/web/static/js/modules/modal.mjs +109 -0
  331. bpp/web/static/js/modules/modals-face-embedders.mjs +496 -0
  332. bpp/web/static/js/modules/modals-models-list.mjs +226 -0
  333. bpp/web/static/js/modules/modals-models.mjs +311 -0
  334. bpp/web/static/js/modules/modals.mjs +487 -0
  335. bpp/web/static/js/modules/moments-stacks.mjs +414 -0
  336. bpp/web/static/js/modules/moments-view.mjs +82 -0
  337. bpp/web/static/js/modules/navigation.mjs +74 -0
  338. bpp/web/static/js/modules/nudges.mjs +151 -0
  339. bpp/web/static/js/modules/on-this-day.mjs +183 -0
  340. bpp/web/static/js/modules/onboarding.mjs +406 -0
  341. bpp/web/static/js/modules/people-actions.mjs +229 -0
  342. bpp/web/static/js/modules/people-album-bar.mjs +441 -0
  343. bpp/web/static/js/modules/people-ctx-menu.mjs +238 -0
  344. bpp/web/static/js/modules/people-merge.mjs +214 -0
  345. bpp/web/static/js/modules/people-pair-review.mjs +400 -0
  346. bpp/web/static/js/modules/people-pickers.mjs +421 -0
  347. bpp/web/static/js/modules/people-rename.mjs +259 -0
  348. bpp/web/static/js/modules/people-review.mjs +475 -0
  349. bpp/web/static/js/modules/people-view.mjs +224 -0
  350. bpp/web/static/js/modules/people.mjs +383 -0
  351. bpp/web/static/js/modules/pets-pickers.mjs +375 -0
  352. bpp/web/static/js/modules/pets.mjs +380 -0
  353. bpp/web/static/js/modules/phash-status.mjs +74 -0
  354. bpp/web/static/js/modules/photo-preview.mjs +79 -0
  355. bpp/web/static/js/modules/photos-batch.mjs +252 -0
  356. bpp/web/static/js/modules/photos-card.mjs +124 -0
  357. bpp/web/static/js/modules/photos-helpers.mjs +45 -0
  358. bpp/web/static/js/modules/photos-hover.mjs +129 -0
  359. bpp/web/static/js/modules/photos-select.mjs +129 -0
  360. bpp/web/static/js/modules/photos-vgrid.mjs +229 -0
  361. bpp/web/static/js/modules/photos.mjs +489 -0
  362. bpp/web/static/js/modules/presets.mjs +223 -0
  363. bpp/web/static/js/modules/review-meta.mjs +58 -0
  364. bpp/web/static/js/modules/score-format.mjs +71 -0
  365. bpp/web/static/js/modules/search.mjs +499 -0
  366. bpp/web/static/js/modules/sensitive.mjs +299 -0
  367. bpp/web/static/js/modules/settings-client.mjs +91 -0
  368. bpp/web/static/js/modules/share-tab.mjs +389 -0
  369. bpp/web/static/js/modules/sidebar-safety.mjs +265 -0
  370. bpp/web/static/js/modules/slideshow.mjs +390 -0
  371. bpp/web/static/js/modules/state.mjs +206 -0
  372. bpp/web/static/js/modules/tags-view.mjs +279 -0
  373. bpp/web/static/js/modules/tags.mjs +409 -0
  374. bpp/web/static/js/modules/text-format.mjs +78 -0
  375. bpp/web/static/js/modules/theme.mjs +62 -0
  376. bpp/web/static/js/modules/timeline.mjs +163 -0
  377. bpp/web/static/js/modules/toast.mjs +140 -0
  378. bpp/web/static/js/modules/toolbar.mjs +426 -0
  379. bpp/web/static/js/modules/tooltip.mjs +112 -0
  380. bpp/web/static/js/modules/tour.mjs +429 -0
  381. bpp/web/static/js/modules/ui-helpers.mjs +81 -0
  382. bpp/web/static/js/modules/update-checker.mjs +157 -0
  383. bpp/web/static/js/modules/use-context-options.mjs +52 -0
  384. bpp/web/static/js/modules/utils.mjs +450 -0
  385. bpp/web/static/js/modules/view-guard.mjs +132 -0
  386. bpp/web/static/js/modules/wizard.mjs +198 -0
  387. bpp/web/static/manifest.json +24 -0
  388. bpp/web/static/vendor/README.md +35 -0
  389. bpp/web/static/vendor/leaflet/LICENSE +25 -0
  390. bpp/web/static/vendor/leaflet/dist/images/layers-2x.png +0 -0
  391. bpp/web/static/vendor/leaflet/dist/images/layers.png +0 -0
  392. bpp/web/static/vendor/leaflet/dist/images/marker-icon-2x.png +0 -0
  393. bpp/web/static/vendor/leaflet/dist/images/marker-icon.png +0 -0
  394. bpp/web/static/vendor/leaflet/dist/images/marker-shadow.png +0 -0
  395. bpp/web/static/vendor/leaflet/dist/leaflet.css +661 -0
  396. bpp/web/static/vendor/leaflet/dist/leaflet.js +6 -0
  397. bpp/web/static/vendor/leaflet.markercluster/MIT-LICENCE.txt +21 -0
  398. bpp/web/static/vendor/leaflet.markercluster/dist/MarkerCluster.Default.css +60 -0
  399. bpp/web/static/vendor/leaflet.markercluster/dist/MarkerCluster.css +14 -0
  400. bpp/web/static/vendor/leaflet.markercluster/dist/leaflet.markercluster.js +2 -0
  401. bpp/web/templates/index.html +1266 -0
  402. bpp/web/templates/pair.html +207 -0
  403. bpp/web/thumbnails.py +239 -0
  404. bpp/web/update_checker.py +183 -0
  405. bpp/web/worker_pool.py +128 -0
  406. bpp/web/worker_registry.py +101 -0
  407. bppicker-0.1.0.dist-info/METADATA +698 -0
  408. bppicker-0.1.0.dist-info/RECORD +412 -0
  409. bppicker-0.1.0.dist-info/WHEEL +4 -0
  410. bppicker-0.1.0.dist-info/entry_points.txt +2 -0
  411. bppicker-0.1.0.dist-info/licenses/LICENSE +21 -0
  412. 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
+ }