1// Kagn - Photo Storage Module 2// IndexedDB-based photo storage for sightings 3// Photos stored locally on device - much larger capacity than localStorage 4 5(function() { 6 'use strict'; 7 8 // DB name is pack-specific, with fallback for migration 9 function getDBName() { 10 const packId = window.PACK?.id || 'birds'; 11 // Use pack-specific DB, but check for legacy migration 12 return `kagn-${packId}-photos`; 13 } 14 15 const LEGACY_DB_NAME = 'sa-birds-photos'; // For migration 16 const DB_VERSION = 2; 17 const STORE_NAME = 'photos'; 18 19 // Thumbnail size for preview 20 const THUMB_SIZE = 200; 21 const MAX_IMAGE_SIZE = 1920; // Max dimension for stored images 22 23 let db = null; 24 25 // Track if IndexedDB is unavailable (e.g., private browsing, disabled, quota exceeded) 26 let dbUnavailable = false; 27 let dbError = null; 28 29 // =========================================== 30 // DATABASE INITIALIZATION 31 // =========================================== 32 33 /** 34 * Check if IndexedDB is available at all 35 * @returns {boolean} 36 */ 37 function isIndexedDBSupported() { 38 try { 39 // Check if indexedDB exists 40 if (!window.indexedDB) { 41 return false; 42 } 43 // Some browsers have indexedDB but block it in private mode 44 // Try to detect this early 45 return true; 46 } catch (e) { 47 return false; 48 } 49 } 50 51 /** 52 * Show user-friendly error toast/message 53 * @param {string} message - Error message to display 54 */ 55 function showErrorToUser(message) { 56 // Use existing toast system if available 57 if (typeof window.showToast === 'function') { 58 window.showToast(message, 'error'); 59 } else if (typeof window.Kagn?.showToast === 'function') { 60 window.Kagn.showToast(message, 'error'); 61 } else { 62 // Fallback: create a simple toast 63 const toast = document.createElement('div'); 64 toast.textContent = message; 65 toast.style.cssText = ` 66 position: fixed; 67 bottom: 80px; 68 left: 50%; 69 transform: translateX(-50%); 70 background: #d32f2f; 71 color: white; 72 padding: 12px 24px; 73 border-radius: 8px; 74 z-index: 10000; 75 font-size: 14px; 76 box-shadow: 0 4px 12px rgba(0,0,0,0.3); 77 max-width: 90%; 78 text-align: center; 79 `; 80 document.body.appendChild(toast); 81 setTimeout(() => toast.remove(), 5000); 82 } 83 } 84 85 /** 86 * Get a user-friendly error message based on the error 87 * @param {Error} error - The error object 88 * @returns {string} 89 */ 90 function getErrorMessage(error) { 91 const errorName = error?.name || ''; 92 const errorMessage = error?.message || ''; 93 94 // Quota exceeded 95 if (errorName === 'QuotaExceededError' || errorMessage.includes('quota')) { 96 return 'Storage is full. Please delete some photos to free up space.'; 97 } 98 99 // Private browsing / blocked 100 if (errorName === 'SecurityError' || errorMessage.includes('security')) { 101 return 'Photo storage is blocked. Try disabling private browsing mode.'; 102 } 103 104 // Database corrupted or version issue 105 if (errorName === 'VersionError') { 106 return 'Photo database needs to be reset. Please clear site data and try again.'; 107 } 108 109 // Generic unavailable 110 if (errorName === 'InvalidStateError' || errorMessage.includes('deleted') || errorMessage.includes('closed')) { 111 return 'Photo storage is temporarily unavailable. Please refresh the page.'; 112 } 113 114 // Default 115 return 'Unable to access photo storage. Photos may not be saved.'; 116 } 117 118 function openDB() { 119 return new Promise((resolve, reject) => { 120 // Return cached connection 121 if (db) { 122 resolve(db); 123 return; 124 } 125 126 // If we already know DB is unavailable, reject immediately 127 if (dbUnavailable) { 128 reject(dbError || new Error('IndexedDB unavailable')); 129 return; 130 } 131 132 // Check basic support 133 if (!isIndexedDBSupported()) { 134 dbUnavailable = true; 135 dbError = new Error('IndexedDB not supported in this browser'); 136 console.error('ð· IndexedDB not supported'); 137 reject(dbError); 138 return; 139 } 140 141 const dbName = getDBName(); 142 143 let request; 144 try { 145 request = indexedDB.open(dbName, DB_VERSION); 146 } catch (e) { 147 // Can happen in Firefox private mode 148 dbUnavailable = true; 149 dbError = e; 150 console.error('ð· Failed to open IndexedDB:', e); 151 reject(e); 152 return; 153 } 154 155 // Timeout for blocked opens (e.g., user prompt never answered) 156 const timeout = setTimeout(() => { 157 console.warn('ð· IndexedDB open timed out - may be blocked by user prompt'); 158 }, 5000); 159 160 request.onerror = () => { 161 clearTimeout(timeout); 162 const error = request.error; 163 console.error('ð· Failed to open photos database:', error); 164 165 // Mark as unavailable so we don't keep trying 166 dbUnavailable = true; 167 dbError = error; 168 169 reject(error); 170 }; 171 172 request.onblocked = () => { 173 clearTimeout(timeout); 174 console.warn('ð· Database blocked - close other tabs using this app'); 175 // Don't reject yet - may unblock 176 }; 177 178 request.onsuccess = () => { 179 clearTimeout(timeout); 180 db = request.result; 181
182 // Handle unexpected close 183 db.onclose = () => { 184 console.warn('ð· Database unexpectedly closed'); 185 db = null; 186 }; 187 188 // Handle version change (another tab upgraded) 189 db.onversionchange = () => { 190 db.close(); 191 db = null; 192 console.log('ð· Database version changed - please refresh'); 193 }; 194 195 resolve(db); 196 }; 197 198 request.onupgradeneeded = (event) => { 199 const database = event.target.result; 200 201 // Create photos store 202 if (!database.objectStoreNames.contains(STORE_NAME)) { 203 const store = database.createObjectStore(STORE_NAME, { keyPath: 'id' }); 204 205 // Index by species for quick lookups 206 store.createIndex('speciesId', 'speciesId', { unique: false }); 207 208 // Index by sighting for linking 209 store.createIndex('sightingId', 'sightingId', { unique: false }); 210 211 // Index by date for sorting 212 store.createIndex('createdAt', 'createdAt', { unique: false }); 213 } 214 215 // Create videos store (metadata + thumbnail only, no blob) 216 if (!database.objectStoreNames.contains('videos')) { 217 const videoStore = database.createObjectStore('videos', { keyPath: 'id' }); 218 videoStore.createIndex('sightingId', 'sightingId', { unique: false }); 219 videoStore.createIndex('speciesId', 'speciesId', { unique: false }); 220 } 221 222 // Create audio store (full blob â small files ~1-3MB) 223 if (!database.objectStoreNames.contains('audio')) { 224 const audioStore = database.createObjectStore('audio', { keyPath: 'id' }); 225 audioStore.createIndex('sightingId', 'sightingId', { unique: false }); 226 audioStore.createIndex('speciesId', 'speciesId', { unique: false }); 227 } 228 }; 229 }); 230 } 231 232 /** 233 * Safe wrapper for DB operations that handles unavailability gracefully 234 * @param {Function} operation - Async function that performs DB operation 235 * @param {*} fallbackValue - Value to return if DB is unavailable 236 * @param {boolean} showError - Whether to show error to user 237 * @returns {Promise<*>} 238 */ 239 /** 240 * Run one write inside a readwrite transaction and settle on COMMIT. 241 * Request success fires before the transaction commits; an abort after 242 * it (quota, lost connection) would otherwise report a write that never 243 * reached disk â the same bug class as savePhoto/saveAudio before 09-06. 244 */ 245 function writeAndCommit(database, storeNames, run, value = true) { 246 return new Promise((resolve, reject) => { 247 const tx = database.transaction(storeNames, 'readwrite'); 248 tx.oncomplete = () => resolve(typeof value === 'function' ? value() : value); 249 tx.onabort = () => reject(tx.error || new Error('Storage transaction aborted')); 250 const request = run(tx.objectStore(Array.isArray(storeNames) ? storeNames[0] : storeNames), tx); 251 if (request) request.onerror = () => reject(request.error); 252 }); 253 } 254 255 async function safeDBOperation(operation, fallbackValue = null, showError = false) { 256 try { 257 return await operation(); 258 } catch (error) { 259 console.error('ð· Database operation failed:', error); 260 261 if (showError) { 262 showErrorToUser(getErrorMessage(error)); 263 } 264 265 return fallbackValue; 266 } 267 } 268 269 /** 270 * Check if photo storage is available 271 * @returns {Promise<{available: boolean, error?: string}>} 272 */ 273 async function checkAvailability() { 274 if (dbUnavailable) { 275 return { available: false, error: getErrorMessage(dbError) }; 276 } 277 278 try { 279 await openDB(); 280 return { available: true }; 281 } catch (error) { 282 return { available: false, error: getErrorMessage(error) }; 283 } 284 } 285 286 // =========================================== 287 // IMAGE PROCESSING 288 // =========================================== 289 290 // Resize image to max dimensions while preserving aspect ratio 291 function resizeImage(file, maxSize) { 292 return new Promise((resolve, reject) => { 293 const img = new Image(); 294 const canvas = document.createElement('canvas'); 295 const ctx = canvas.getContext('2d'); 296 297 img.onload = () => { 298 let { width, height } = img; 299 300 // Calculate new dimensions 301 if (width > maxSize || height > maxSize) { 302 if (width > height) { 303 height = Math.round((height * maxSize) / width); 304 width = maxSize; 305 } else { 306 width = Math.round((width * maxSize) / height); 307 height = maxSize; 308 } 309 } 310 311 canvas.width = width; 312 canvas.height = height; 313 314 // Draw resized image 315 ctx.drawImage(img, 0, 0, width, height); 316 317 // Convert to blob 318 canvas.toBlob( 319 (blob) => { 320 if (blob) { 321 resolve(blob); 322 } else { 323 reject(new Error('Failed to create blob')); 324 } 325 }, 326 'image/jpeg', 327 0.85 // Quality 328 ); 329 }; 330 331 img.onerror = () => reject(new Error('Failed to load image')); 332 333 // Load from file 334 const reader = new FileReader(); 335 reader.onload = (e) => { img.src = e.target.result; }; 336 reader.onerror = () => reject(new Error('Failed to read file'));
337 reader.readAsDataURL(file); 338 }); 339 } 340 341 // Create an UPLOAD thumbnail: aspect-preserving, longest edge <= maxSize, 342 // encoded webp (to match the server's _thumb.webp key). Returns a Blob, or 343 // null if the source can't be decoded (caller then uploads without a thumb). 344 // This is what the hub/community grids load instead of the full ~280KB 345 // original â a ~400px webp is ~15-30KB. 346 function createUploadThumbnail(file, maxSize) { 347 const target = maxSize || 400; 348 return new Promise((resolve) => { 349 const img = new Image(); 350 const done = (blob) => resolve(blob || null); 351 img.onload = () => { 352 let { width, height } = img; 353 if (width > target || height > target) { 354 if (width >= height) { height = Math.round((height * target) / width); width = target; } 355 else { width = Math.round((width * target) / height); height = target; } 356 } 357 const canvas = document.createElement('canvas'); 358 canvas.width = width; canvas.height = height; 359 canvas.getContext('2d').drawImage(img, 0, 0, width, height); 360 // Prefer webp; if the browser can't encode it, toBlob yields null 361 // OR a png â either way fall back to jpeg so we still get a small file. 362 canvas.toBlob((blob) => { 363 if (blob && blob.type === 'image/webp') return done(blob); 364 canvas.toBlob(done, 'image/jpeg', 0.72); 365 }, 'image/webp', 0.72); 366 }; 367 img.onerror = () => done(null); 368 const reader = new FileReader(); 369 reader.onload = (e) => { img.src = e.target.result; }; 370 reader.onerror = () => done(null); 371 reader.readAsDataURL(file); 372 }); 373 } 374 375 // Create thumbnail as data URL 376 function createThumbnail(blob) { 377 return new Promise((resolve, reject) => { 378 const img = new Image(); 379 const canvas = document.createElement('canvas'); 380 const ctx = canvas.getContext('2d'); 381 382 const blobUrl = URL.createObjectURL(blob); 383 384 img.onload = () => { 385 URL.revokeObjectURL(blobUrl); // Clean up blob URL 386 387 let { width, height } = img; 388 389 // Square crop from center 390 const size = Math.min(width, height); 391 const sx = (width - size) / 2; 392 const sy = (height - size) / 2; 393 394 canvas.width = THUMB_SIZE; 395 canvas.height = THUMB_SIZE; 396 397 // Draw cropped square 398 ctx.drawImage(img, sx, sy, size, size, 0, 0, THUMB_SIZE, THUMB_SIZE); 399 400 // Return as data URL for easy display 401 resolve(canvas.toDataURL('image/jpeg', 0.7)); 402 }; 403 404 img.onerror = () => { 405 URL.revokeObjectURL(blobUrl); // Clean up on error too 406 reject(new Error('Failed to load image for thumbnail')); 407 }; 408 img.src = blobUrl; 409 }); 410 } 411 412 // =========================================== 413 // PHOTO OPERATIONS 414 // =========================================== 415 416 // Save a photo 417 async function savePhoto(sightingId, speciesId, file, photoPath) { 418 // Check availability first and show error if not available 419 const { available, error } = await checkAvailability(); 420 if (!available) { 421 showErrorToUser(error || 'Photo storage unavailable'); 422 throw new Error(error || 'Photo storage unavailable'); 423 } 424 425 const database = await openDB(); 426 427 // Process image - resize if needed 428 let imageBlob; 429 if (file.size > 500000 || file.type !== 'image/jpeg') { 430 // Resize large images or convert non-JPEG 431 imageBlob = await resizeImage(file, MAX_IMAGE_SIZE); 432 } else { 433 // Small JPEG, use as-is 434 imageBlob = file; 435 } 436 437 // Create thumbnail 438 const thumbnail = await createThumbnail(imageBlob); 439 440 // Generate unique ID 441 const id = Date.now().toString(36) + Math.random().toString(36).slice(2, 11); 442 443 const photo = { 444 id, 445 sightingId, 446 speciesId, 447 blob: imageBlob, 448 thumbnail, 449 createdAt: new Date().toISOString(), 450 fileType: 'image/jpeg', 451 fileSize: imageBlob.size, 452 photoPath: photoPath || null // R2 path â set when photo came from cloud or after upload 453 }; 454 455 return new Promise((resolve, reject) => { 456 const tx = database.transaction([STORE_NAME], 'readwrite'); 457 const store = tx.objectStore(STORE_NAME); 458 459 const request = store.add(photo); 460 461 tx.oncomplete = () => resolve(photo); 462 tx.onabort = () => reject(tx.error || new Error('Photo save was aborted')); 463 request.onerror = () => { 464 const error = request.error; 465 console.error('ð· Failed to save photo:', error); 466 showErrorToUser(getErrorMessage(error)); 467 reject(error); 468 }; 469 470 // Also handle transaction errors (e.g., quota exceeded during commit) 471 tx.onerror = () => { 472 const error = tx.error; 473 console.error('ð· Transaction failed:', error); 474 showErrorToUser(getErrorMessage(error)); 475 reject(error); 476 }; 477 }); 478 } 479 480 // Get all photos for a species
481 async function getPhotosForSpecies(speciesId) { 482 return safeDBOperation(async () => { 483 const database = await openDB(); 484 485 return new Promise((resolve, reject) => { 486 const tx = database.transaction([STORE_NAME], 'readonly'); 487 const store = tx.objectStore(STORE_NAME); 488 const index = store.index('speciesId'); 489 490 const request = index.getAll(speciesId); 491 492 request.onsuccess = () => { 493 // Sort by date descending (newest first) 494 const photos = omitTrashed(request.result).sort((a, b) => 495 new Date(b.createdAt) - new Date(a.createdAt) 496 ); 497 resolve(photos); 498 }; 499 request.onerror = () => reject(request.error); 500 }); 501 }, []); // Return empty array if unavailable 502 } 503 504 // Get single photo by ID 505 async function getPhoto(photoId) { 506 return safeDBOperation(async () => { 507 const database = await openDB(); 508 509 return new Promise((resolve, reject) => { 510 const tx = database.transaction([STORE_NAME], 'readonly'); 511 const store = tx.objectStore(STORE_NAME); 512 513 const request = store.get(photoId); 514 515 request.onsuccess = () => resolve(request.result); 516 request.onerror = () => reject(request.error); 517 }); 518 }, null); 519 } 520 521 // Get photos for a specific sighting 522 async function getPhotosForSighting(sightingId, sourcePack) { 523 // If a specific source pack is given and it differs from current pack, 524 // query that pack's photo DB directly 525 if (sourcePack && sourcePack !== (window.PACK?.id || 'birds')) { 526 return _queryPackPhotoDB(sightingId, sourcePack); 527 } 528 529 const photos = await safeDBOperation(async () => { 530 const database = await openDB(); 531 532 return new Promise((resolve, reject) => { 533 const tx = database.transaction([STORE_NAME], 'readonly'); 534 const store = tx.objectStore(STORE_NAME); 535 const index = store.index('sightingId'); 536 537 const request = index.getAll(sightingId); 538 539 request.onsuccess = () => resolve(omitTrashed(request.result)); 540 request.onerror = () => reject(request.error); 541 }); 542 }, []); // Return empty array if unavailable 543 544 // Cross-pack photo fallback: photos may live in a different pack's DB 545 // (e.g. sighting rescued from explorer to regional pack, but photo stayed in explorer DB) 546 if (photos.length === 0 && !sourcePack) { 547 if (window.PACK?.isExplorer) { 548 // Explorer: search regional pack DBs 549 const packs = typeof PACK.getRegionalPacks === 'function' ? PACK.getRegionalPacks() : []; 550 for (const pid of packs) { 551 const packPhotos = await _queryPackPhotoDB(sightingId, pid); 552 if (packPhotos.length > 0) return packPhotos; 553 } 554 } else { 555 // Regional pack: check explorer DB (sightings rescued from explorer keep photos there) 556 const packPhotos = await _queryPackPhotoDB(sightingId, 'explorer'); 557 if (packPhotos.length > 0) return packPhotos; 558 } 559 } 560 561 return photos; 562 } 563 564 /** 565 * Query another pack's photo DB for a sighting (uses existing openPackDB) 566 */ 567 async function _queryPackPhotoDB(sightingId, packId) { 568 let database = null; 569 try { 570 database = await openPackDB(packId); 571 if (!database) return []; 572 573 return await new Promise((resolve) => { 574 try { 575 const tx = database.transaction([STORE_NAME], 'readonly'); 576 const store = tx.objectStore(STORE_NAME); 577 const index = store.index('sightingId'); 578 const request = index.getAll(sightingId); 579 request.onsuccess = () => resolve(omitTrashed(request.result)); 580 request.onerror = () => resolve([]); 581 } catch (e) { 582 resolve([]); 583 } 584 }); 585 } catch (e) { 586 return []; 587 } finally { 588 if (database) try { database.close(); } catch (_) {} 589 } 590 } 591 592 /** 593 * Get photos for a sighting with cloud fallback 594 * First tries local IndexedDB, if not found and sighting has photoPath, 595 * downloads from cloud and saves locally 596 * @param {string} sightingId - The sighting ID 597 * @param {object} sighting - Optional sighting object with photoPath and speciesId 598 * @returns {Promise<Array>} - Array of photo objects 599 */ 600 async function getPhotosForSightingWithCloudFallback(sightingId, sighting = null) { 601 // Try local first 602 const localPhotos = await getPhotosForSighting(sightingId); 603 604 if (localPhotos && localPhotos.length > 0) { 605 return localPhotos; 606 } 607 608 // No local photos - check if sighting has a cloud photo 609 if (!sighting) { 610 // Try to get sighting data from KagnTrips 611 if (window.KagnTrips) { 612 sighting = KagnTrips.getSighting(sightingId); 613 } 614 } 615 616 if (!sighting || !sighting.photoPath) { 617 // No cloud photo available 618 return []; 619 } 620 621 // Check if we're online and have sync capability 622 if (!navigator.onLine) { 623 return []; 624 } 625 626 // Try to download from cloud 627 if (window.KagnSync && KagnSync.downloadCloudPhoto) { 628 try { 629 console.log(`[Photos] Downloading cloud photo for sighting ${sightingId}`); 630 const result = await KagnSync.downloadCloudPhoto( 631 sighting.photoPath, 632 sightingId, 633 sighting.speciesId 634 ); 635 636 if (result.success) { 637 // Return the newly saved photo 638 return await getPhotosForSighting(sightingId); 639 } 640 } catch (e) { 641 console.warn('[Photos] Cloud fallback failed:', e); 642 } 643 } 644 645 return []; 646 } 647 648 // =========================================== 649 // TOMBSTONE GRACE WINDOW (local trash) 650 // =========================================== 651 // A pulled photo tombstone used to hard-delete the local blob instantly â
652 // combined with the 90-day R2 reclaim, a stale device winning 653 // last-write-wins with hasPhoto=0 destroyed every copy of a photo with no 654 // recovery. Remote tombstones now TRASH the local record (trashedAt 655 // stamp): readers hide it, discovery won't re-upload it, and a reversed 656 // tombstone restores it. Blobs purge locally after TRASH_GRACE_DAYS. 657 // Explicit user deletes on this device remain hard deletes â that's 658 // stated intent, and their R2 cleanup is queued at delete time. 659 const TRASH_GRACE_DAYS = 30; 660 661 function omitTrashed(photos) { 662 return (photos || []).filter(p => p && !p.trashedAt); 663 } 664 665 // sourcePack (optional) targets the pack DB that owns the record â pass 666 // it whenever the record came from an all-packs lookup, or the write 667 // silently misses on any page whose active pack isn't the owner. 668 async function trashPhoto(photoId, sourcePack) { 669 const currentPackId = window.PACK?.id || 'birds'; 670 const foreign = sourcePack && sourcePack !== currentPackId ? sourcePack : null; 671 return safeDBOperation(async () => { 672 const database = foreign ? await openPackDB(foreign) : await openDB(); 673 if (!database) return false; 674 try { 675 return await new Promise((resolve, reject) => { 676 const tx = database.transaction([STORE_NAME], 'readwrite'); 677 const store = tx.objectStore(STORE_NAME); 678 let written = false; 679 tx.oncomplete = () => resolve(written); 680 tx.onabort = () => reject(tx.error || new Error('Storage transaction aborted')); 681 const getReq = store.get(photoId); 682 getReq.onsuccess = () => { 683 const photo = getReq.result; 684 if (!photo) return; 685 photo.trashedAt = new Date().toISOString(); 686 const putReq = store.put(photo); 687 putReq.onsuccess = () => { written = true; }; 688 putReq.onerror = () => reject(putReq.error); 689 }; 690 getReq.onerror = () => reject(getReq.error); 691 }); 692 } finally { 693 if (foreign) { try { database.close(); } catch (_) {} } 694 } 695 }, false); 696 } 697 698 async function restorePhoto(photoId, sourcePack) { 699 const currentPackId = window.PACK?.id || 'birds'; 700 const foreign = sourcePack && sourcePack !== currentPackId ? sourcePack : null; 701 return safeDBOperation(async () => { 702 const database = foreign ? await openPackDB(foreign) : await openDB(); 703 if (!database) return false; 704 try { 705 return await new Promise((resolve, reject) => { 706 const tx = database.transaction([STORE_NAME], 'readwrite'); 707 const store = tx.objectStore(STORE_NAME); 708 let written = false; 709 tx.oncomplete = () => resolve(written); 710 tx.onabort = () => reject(tx.error || new Error('Storage transaction aborted')); 711 const getReq = store.get(photoId); 712 getReq.onsuccess = () => { 713 const photo = getReq.result; 714 if (!photo || !photo.trashedAt) return; 715 delete photo.trashedAt; 716 const putReq = store.put(photo); 717 putReq.onsuccess = () => { written = true; }; 718 putReq.onerror = () => reject(putReq.error); 719 }; 720 getReq.onerror = () => reject(getReq.error); 721 }); 722 } finally { 723 if (foreign) { try { database.close(); } catch (_) {} } 724 } 725 }, false); 726 } 727 728 // Reclaim blobs whose grace expired. Current pack's DB only â each pack 729 // purges when it's next opened, which bounds the work per session. 730 async function purgeTrashedPhotos() { 731 const cutoff = Date.now() - TRASH_GRACE_DAYS * 24 * 60 * 60 * 1000; 732 return safeDBOperation(async () => { 733 const database = await openDB(); 734 const expired = await new Promise((resolve, reject) => { 735 const tx = database.transaction([STORE_NAME], 'readonly'); 736 const request = tx.objectStore(STORE_NAME).getAll(); 737 request.onsuccess = () => resolve((request.result || []).filter(p => 738 p.trashedAt && new Date(p.trashedAt).getTime() < cutoff 739 )); 740 request.onerror = () => reject(request.error); 741 }); 742 for (const p of expired) { 743 try { await deletePhoto(p.id); } catch (_) { /* next session */ } 744 } 745 if (expired.length > 0) { 746 console.log(`[Photos] Purged ${expired.length} trashed photos past ${TRASH_GRACE_DAYS}d grace`); 747 } 748 return expired.length; 749 }, 0); 750 } 751 752 // One lazy purge per session, well after startup so it never competes 753 // with initial sync/render. 754 setTimeout(() => { purgeTrashedPhotos().catch(() => {}); }, 30000); 755 756 // Delete a photo 757 async function deletePhoto(photoId) { 758 return safeDBOperation(async () => { 759 const database = await openDB(); 760 761 return writeAndCommit(database, [STORE_NAME], store => store.delete(photoId)); 762 }, false, true); // Show error on delete failure 763 } 764 765 // Delete all photos for a species (with cloud cleanup)
766 async function deletePhotosForSpecies(speciesId) { 767 const photos = await getPhotosForSpecies(speciesId); 768 769 let deleted = 0; 770 for (const photo of photos) { 771 // Queue cloud deletion if photo has a cloud path 772 if (typeof KagnSync !== 'undefined' && photo.photoPath) { 773 KagnSync.queuePhotoDelete(photo.photoPath, photo.sightingId); 774 } 775 776 // Delete from local IndexedDB 777 const success = await deletePhoto(photo.id); 778 if (success) deleted++; 779 } 780 781 if (deleted > 0) { 782 console.log(`[Photos] Deleted ${deleted} photos for species ${speciesId}`); 783 } 784 785 return deleted; 786 } 787 788 // Delete all photos for a sighting (with cloud cleanup) 789 async function deletePhotosForSighting(sightingId) { 790 const photos = await getPhotosForSighting(sightingId); 791 792 let deleted = 0; 793 for (const photo of photos) { 794 // Queue cloud deletion if photo has a cloud path 795 if (typeof KagnSync !== 'undefined' && photo.photoPath) { 796 KagnSync.queuePhotoDelete(photo.photoPath, sightingId); 797 } 798 799 // Delete from local IndexedDB 800 const success = await deletePhoto(photo.id); 801 if (success) deleted++; 802 } 803 804 if (deleted > 0) { 805 console.log(`[Photos] Deleted ${deleted} photos for sighting ${sightingId}`); 806 } 807 808 return deleted; 809 } 810 811 // Store the cloud r2_path on a local photo record â so future per-photo 812 // deletes can be propagated to R2, and primary-promotion logic can find 813 // another photo's cloud path when the current primary is removed. 814 // sourcePack (optional): which pack's photo DB holds the record. The 815 // upload queue carries it for photos discovered cross-pack (blob captured 816 // under one pack, sighting owned by another) â without it the cloud path 817 // is written to the WRONG DB's namespace (a silent no-op), the record 818 // keeps photoPath=null, and the same photo re-uploads on every sync. 819 async function updatePhotoCloudPath(photoId, photoPath, sourcePack) { 820 if (!photoId || !photoPath) return false; 821 const currentPackId = window.PACK?.id || 'birds'; 822 const foreign = sourcePack && sourcePack !== currentPackId ? sourcePack : null; 823 return safeDBOperation(async () => { 824 const database = foreign ? await openPackDB(foreign) : await openDB(); 825 if (!database) return false; 826 try { 827 const photo = await new Promise((resolve, reject) => { 828 const tx = database.transaction([STORE_NAME], 'readonly'); 829 const request = tx.objectStore(STORE_NAME).get(photoId); 830 request.onsuccess = () => resolve(request.result); 831 request.onerror = () => reject(request.error); 832 }); 833 if (!photo) return false; 834 if (photo.photoPath === photoPath) return true; 835 photo.photoPath = photoPath; 836 return await writeAndCommit(database, [STORE_NAME], store => store.put(photo)); 837 } finally { 838 if (foreign) { try { database.close(); } catch (_) {} } 839 } 840 }, false); 841 } 842 843 // Look up a local photo by its cloud r2_path. Needed when the server tells 844 // us a photo was deleted remotely and we have to mirror that locally. 845 // Searches EVERY pack's photo DB, not just the active one: the multi-pack 846 // sync loop pulls all packs while PACK.id stays the page's pack, so a 847 // birds tombstone processed on the explorer hub used to miss the record 848 // (the per-pack cursor then advanced and the delete was never re- 849 // delivered â the photo haunted the device forever). The returned record 850 // carries `sourcePack` so writers target the owning DB. 851 async function getPhotoByCloudPath(photoPath) { 852 if (!photoPath) return null; 853 const currentPackId = window.PACK?.id || 'birds'; 854 const packs = new Set(window.PACK?.getAvailablePacks?.() || ['birds']); 855 packs.add(currentPackId); 856 packs.add('explorer'); 857 for (const packId of packs) { 858 let database = null; 859 const foreign = packId !== currentPackId; 860 try { 861 database = foreign ? await openPackDB(packId) : await openDB(); 862 if (!database) continue; 863 const all = await new Promise((resolve) => { 864 try { 865 const tx = database.transaction([STORE_NAME], 'readonly'); 866 const request = tx.objectStore(STORE_NAME).getAll(); 867 request.onsuccess = () => resolve(request.result || []); 868 request.onerror = () => resolve([]); 869 } catch (e) { resolve([]); } 870 }); 871 const hit = all.find(p => p.photoPath === photoPath); 872 if (hit) return { ...hit, sourcePack: packId }; 873 } catch (e) { 874 // Pack might not have a photos DB yet â skip 875 } finally { 876 if (database && foreign) { try { database.close(); } catch (_) {} } 877 } 878 } 879 return null; 880 } 881 882 // Update speciesId on all photos for a given sighting. Uses the 883 // all-packs lookup and writes each record back to the pack DB that owns 884 // it â the old current-DB write duplicated cross-pack records (blob and 885 // all) into the wrong DB and left the real record un-rekeyed. 886 async function updatePhotoSpeciesId(sightingId, newSpeciesId) { 887 return safeDBOperation(async () => { 888 const photos = await getPhotosForSightingAllPacks(sightingId); 889 if (!photos || photos.length === 0) return 0; 890 891 const currentPackId = window.PACK?.id || 'birds'; 892 let updated = 0; 893 894 for (const photo of photos) { 895 if (photo.speciesId === newSpeciesId) continue; 896 const foreign = photo.sourcePack && photo.sourcePack !== currentPackId 897 ? photo.sourcePack : null; 898 const database = foreign ? await openPackDB(foreign) : await openDB(); 899 if (!database) continue; 900 // Strip the sourcePack annotation before writing â it's a 901 // read-time decoration, not a stored field. 902 const { sourcePack, ...record } = photo; 903 record.speciesId = newSpeciesId; 904 905 try { 906 await writeAndCommit(database, [STORE_NAME], store => store.put(record)); 907 updated++; 908 } finally { 909 if (foreign) { try { database.close(); } catch (_) {} } 910 } 911 } 912 913 if (updated > 0) { 914 console.log(`[Photos] Updated speciesId to ${newSpeciesId} on ${updated} photos for sighting ${sightingId}`); 915 } 916 return updated; 917 }, 0); 918 } 919 920 // Get all photos (for stats/export) 921 async function getAllPhotos() { 922 return safeDBOperation(async () => { 923 const database = await openDB(); 924 925 return new Promise((resolve, reject) => { 926 const tx = database.transaction([STORE_NAME], 'readonly'); 927 const store = tx.objectStore(STORE_NAME); 928 929 const request = store.getAll(); 930 931 request.onsuccess = () => resolve(omitTrashed(request.result)); 932 request.onerror = () => reject(request.error); 933 }); 934 }, []); // Return empty array if unavailable 935 } 936 937 // Get photo count 938 async function getPhotoCount() { 939 return safeDBOperation(async () => { 940 const database = await openDB(); 941 942 return new Promise((resolve, reject) => { 943 const tx = database.transaction([STORE_NAME], 'readonly'); 944 const store = tx.objectStore(STORE_NAME); 945 946 const request = store.count(); 947 948 request.onsuccess = () => resolve(request.result); 949 request.onerror = () => reject(request.error); 950 }); 951 }, 0); // Return 0 if unavailable 952 } 953 954 // Get storage usage estimate 955 async function getStorageUsage() { 956 const photos = await getAllPhotos(); 957 let totalSize = 0; 958 959 for (const photo of photos) { 960 totalSize += photo.fileSize || 0; 961 } 962 963 return { 964 count: photos.length, 965 totalBytes: totalSize, 966 totalMB: (totalSize / (1024 * 1024)).toFixed(2), 967 available: !dbUnavailable // Include availability status 968 }; 969 } 970 971 // Alias for freemium.js compatibility 972 async function getStorageStats() { 973 return getStorageUsage(); 974 } 975 976 // =========================================== 977 // VIDEO OPERATIONS 978 // =========================================== 979 980 // Save video metadata and thumbnail (does NOT save video blob) 981 async function saveVideoMeta(sightingId, speciesId, thumbnail, metadata) { 982 const database = await openDB(); 983 984 const video = { 985 id: 'vid_' + sightingId + '_' + Date.now(), 986 sightingId, 987 speciesId, 988 thumbnail, 989 duration: metadata?.duration || null, 990 fileSize: metadata?.fileSize || null, 991 videoStreamId: metadata?.videoStreamId || null, 992 uploadStatus: metadata?.uploadStatus || 'pending', 993 createdAt: new Date().toISOString() 994 }; 995 996 return new Promise((resolve, reject) => { 997 const tx = database.transaction(['videos'], 'readwrite'); 998 const store = tx.objectStore('videos'); 999 1000 const request = store.add(video); 1001 1002 tx.oncomplete = () => resolve(video); 1003 tx.onabort = () => reject(tx.error || new Error('Video save was aborted')); 1004 request.onerror = () => { 1005 const error = request.error; 1006 console.error('ð· Failed to save video metadata:', error); 1007 showErrorToUser(getErrorMessage(error)); 1008 reject(error); 1009 }; 1010 1011 tx.onerror = () => { 1012 const error = tx.error; 1013 console.error('ð· Video metadata transaction failed:', error); 1014 showErrorToUser(getErrorMessage(error)); 1015 reject(error); 1016 }; 1017 }); 1018 } 1019 1020 // Get all video metadata records for a sighting 1021 async function getVideosForSighting(sightingId) { 1022 return safeDBOperation(async () => { 1023 const database = await openDB(); 1024 1025 return new Promise((resolve, reject) => { 1026 const tx = database.transaction(['videos'], 'readonly'); 1027 const store = tx.objectStore('videos'); 1028 const index = store.index('sightingId'); 1029 1030 const request = index.getAll(sightingId); 1031 1032 request.onsuccess = () => resolve(request.result); 1033 request.onerror = () => reject(request.error); 1034 }); 1035 }, []); // Return empty array if unavailable 1036 } 1037 1038 // Delete a single video record 1039 async function deleteVideo(videoId) { 1040 return safeDBOperation(async () => { 1041 const database = await openDB(); 1042 1043 return writeAndCommit(database, ['videos'], store => store.delete(videoId)); 1044 }, false, true); // Show error on delete failure 1045 } 1046 1047 // Delete all video records for a sighting 1048 async function deleteVideosForSighting(sightingId) { 1049 const videos = await getVideosForSighting(sightingId); 1050 1051 let deleted = 0; 1052 for (const video of videos) { 1053 const success = await deleteVideo(video.id); 1054 if (success) deleted++; 1055 } 1056 1057 if (deleted > 0) { 1058 console.log(`[Photos] Deleted ${deleted} video records for sighting ${sightingId}`); 1059 } 1060 1061 return deleted; 1062 } 1063 1064 // =========================================== 1065 // AUDIO OPERATIONS 1066 // =========================================== 1067 1068 // Save full audio blob (small files ~1-3MB) 1069 async function saveAudio(sightingId, speciesId, blob, options = {}) { 1070 const database = await openDB(); 1071 1072 const audio = { 1073 id: options.id || 'aud_' + sightingId + '_' + Date.now() + '_' + Math.random().toString(36).slice(2), 1074 sightingId, 1075 speciesId, 1076 blob, 1077 fileSize: blob.size, 1078 mimeType: blob.type || 'audio/webm', 1079 uploadStatus: options.uploadStatus || 'pending', 1080 pendingUpload: options.uploadStatus !== 'uploaded', 1081 audioPath: options.audioPath || null, 1082 createdAt: new Date().toISOString() 1083 }; 1084 1085 return new Promise((resolve, reject) => { 1086 const tx = database.transaction(['audio'], 'readwrite'); 1087 const store = tx.objectStore('audio'); 1088 1089 const request = store.put(audio); 1090 1091 tx.oncomplete = () => resolve(audio); 1092 tx.onabort = () => reject(tx.error || new Error('Audio save was aborted')); 1093 request.onerror = () => { 1094 const error = request.error; 1095 console.error('ð· Failed to save audio:', error); 1096 showErrorToUser(getErrorMessage(error)); 1097 reject(error); 1098 }; 1099 1100 tx.onerror = () => { 1101 const error = tx.error; 1102 console.error('ð· Audio transaction failed:', error); 1103 showErrorToUser(getErrorMessage(error)); 1104 reject(error); 1105 }; 1106 }); 1107 } 1108 1109 // Get all audio records for a sighting 1110 async function getAudioForSighting(sightingId) { 1111 return safeDBOperation(async () => { 1112 const database = await openDB(); 1113 1114 return new Promise((resolve, reject) => { 1115 const tx = database.transaction(['audio'], 'readonly'); 1116 const store = tx.objectStore('audio'); 1117 const index = store.index('sightingId'); 1118 1119 const request = index.getAll(sightingId); 1120 1121 request.onsuccess = () => resolve(request.result); 1122 request.onerror = () => reject(request.error); 1123 }); 1124 }, []); // Return empty array if unavailable 1125 } 1126 1127 // Delete a single audio record 1128 async function deleteAudio(audioId) { 1129 return safeDBOperation(async () => { 1130 const database = await openDB(); 1131 1132 return writeAndCommit(database, ['audio'], store => store.delete(audioId)); 1133 }, false, true); // Show error on delete failure 1134 } 1135 1136 // Delete all audio records for a sighting 1137 async function deleteAudioForSighting(sightingId) { 1138 const audioList = await getAudioForSighting(sightingId); 1139 1140 let deleted = 0; 1141 for (const audio of audioList) { 1142 const success = await deleteAudio(audio.id); 1143 if (success) deleted++; 1144 } 1145 1146 if (deleted > 0) { 1147 console.log(`[Photos] Deleted ${deleted} audio records for sighting ${sightingId}`); 1148 } 1149 1150 return deleted; 1151 } 1152 1153 // =========================================== 1154 // PHOTO VIEWER 1155 // =========================================== 1156 1157 function showPhotoViewer(photo, onDelete) { 1158 // Guard against missing blob (bug #13) 1159 if (!photo?.blob) { 1160 console.error('Photo blob missing'); 1161 showErrorToUser('Unable to load photo'); 1162 return; 1163 } 1164 1165 // Create blob URL and track it for cleanup 1166 const blobUrl = URL.createObjectURL(photo.blob); 1167 1168 // Cleanup function to revoke blob URL 1169 const cleanup = () => { 1170 URL.revokeObjectURL(blobUrl); 1171 }; 1172 1173 // Create overlay
1174 const overlay = document.createElement('div'); 1175 overlay.id = 'photoViewerOverlay'; 1176 overlay.style.cssText = ` 1177 position: fixed; 1178 top: 0; 1179 left: 0; 1180 right: 0; 1181 bottom: 0; 1182 background: rgba(0,0,0,0.95); 1183 z-index: 10000; 1184 display: flex; 1185 flex-direction: column; 1186 align-items: center; 1187 justify-content: center; 1188 padding: 20px; 1189 box-sizing: border-box; 1190 `; 1191 1192 // Create close button 1193 const closeBtn = document.createElement('button'); 1194 closeBtn.innerHTML = 'Ã'; 1195 closeBtn.type = 'button'; // Bug #11 1196 closeBtn.style.cssText = ` 1197 position: absolute; 1198 top: 20px; 1199 right: 20px; 1200 width: 44px; 1201 height: 44px; 1202 border-radius: 50%; 1203 background: rgba(255,255,255,0.1); 1204 color: white; 1205 border: none; 1206 font-size: 28px; 1207 cursor: pointer; 1208 display: flex; 1209 align-items: center; 1210 justify-content: center; 1211 z-index: 10001; 1212 `; 1213 closeBtn.onclick = () => { 1214 cleanup(); 1215 overlay.remove(); 1216 document.removeEventListener('keydown', escHandler); 1217 }; 1218 1219 // Create image 1220 const img = document.createElement('img'); 1221 img.src = blobUrl; 1222 img.style.cssText = ` 1223 max-width: 100%; 1224 max-height: calc(100vh - 120px); 1225 object-fit: contain; 1226 border-radius: 8px; 1227 `; 1228 1229 // Create bottom toolbar 1230 const toolbar = document.createElement('div'); 1231 toolbar.style.cssText = ` 1232 position: absolute; 1233 bottom: 20px; 1234 left: 20px; 1235 right: 20px; 1236 display: flex; 1237 justify-content: center; 1238 gap: 12px; 1239 `; 1240 1241 // Date info 1242 const dateInfo = document.createElement('span'); 1243 const date = new Date(photo.createdAt); 1244 dateInfo.textContent = date.toLocaleDateString((window.PACK?.locale || 'en-ZA'), { 1245 year: 'numeric', 1246 month: 'short', 1247 day: 'numeric' 1248 }); 1249 dateInfo.style.cssText = ` 1250 color: rgba(255,255,255,0.7); 1251 font-size: 14px; 1252 position: absolute; 1253 left: 0; 1254 bottom: 0; 1255 `; 1256 1257 // Delete button 1258 const deleteBtn = document.createElement('button'); 1259 deleteBtn.innerHTML = 'ðï¸ Delete'; 1260 deleteBtn.type = 'button'; // Bug #11 1261 deleteBtn.style.cssText = ` 1262 padding: 12px 24px; 1263 background: rgba(255,255,255,0.1); 1264 color: white; 1265 border: 1px solid rgba(255,255,255,0.2); 1266 border-radius: 8px; 1267 font-size: 16px; 1268 cursor: pointer; 1269 `; 1270 deleteBtn.onclick = async () => { 1271 if (confirm('Delete this photo?')) { 1272 try { 1273 await deletePhoto(photo.id); 1274 cleanup(); 1275 overlay.remove(); 1276 if (onDelete) onDelete(photo); 1277 } catch (e) { 1278 console.error('Failed to delete photo:', e); 1279 showErrorToUser('Failed to delete photo'); 1280 } 1281 } 1282 }; 1283 1284 // Share button (if supported) 1285 if (navigator.share) { 1286 const shareBtn = document.createElement('button'); 1287 shareBtn.innerHTML = 'âï¸ Share'; 1288 shareBtn.type = 'button'; // Bug #11 1289 shareBtn.style.cssText = ` 1290 padding: 12px 24px; 1291 background: rgba(255,255,255,0.1); 1292 color: white; 1293 border: 1px solid rgba(255,255,255,0.2); 1294 border-radius: 8px; 1295 font-size: 16px; 1296 cursor: pointer; 1297 `; 1298 shareBtn.onclick = async () => { 1299 try { 1300 const packName = window.PACK?.config?.taxonomy?.singular || 'Wildlife'; 1301 const file = new File([photo.blob], 'kagn-sighting.jpg', { type: 'image/jpeg' }); 1302 await navigator.share({ 1303 title: `${packName} Sighting`, 1304 files: [file] 1305 }); 1306 } catch (e) {
1307 console.log('Share cancelled or failed:', e); 1308 } 1309 }; 1310 toolbar.appendChild(shareBtn); 1311 } 1312 1313 toolbar.appendChild(deleteBtn); 1314 1315 // Assemble 1316 overlay.appendChild(closeBtn); 1317 overlay.appendChild(img); 1318 overlay.appendChild(toolbar); 1319 overlay.appendChild(dateInfo); 1320 1321 // Close on overlay click (but not image) 1322 overlay.onclick = (e) => { 1323 if (e.target === overlay) { 1324 cleanup(); 1325 overlay.remove(); 1326 document.removeEventListener('keydown', escHandler); 1327 } 1328 }; 1329 1330 // Close on escape 1331 const escHandler = (e) => { 1332 if (e.key === 'Escape') { 1333 cleanup(); 1334 overlay.remove(); 1335 document.removeEventListener('keydown', escHandler); 1336 } 1337 }; 1338 document.addEventListener('keydown', escHandler); 1339 1340 document.body.appendChild(overlay); 1341 } 1342 1343 // =========================================== 1344 // EXPORT/IMPORT 1345 // =========================================== 1346 1347 // Export photos as JSON (base64 encoded) 1348 // Warning: This can get very large! 1349 async function exportPhotos() { 1350 const photos = await getAllPhotos(); 1351 1352 // Convert blobs to base64 1353 const exportData = await Promise.all(photos.map(async (photo) => { 1354 const reader = new FileReader(); 1355 const base64 = await new Promise((resolve) => { 1356 reader.onload = () => resolve(reader.result); 1357 reader.readAsDataURL(photo.blob); 1358 }); 1359 1360 return { 1361 id: photo.id, 1362 sightingId: photo.sightingId, 1363 speciesId: photo.speciesId, 1364 imageData: base64, 1365 thumbnail: photo.thumbnail, 1366 createdAt: photo.createdAt, 1367 fileType: photo.fileType, 1368 fileSize: photo.fileSize 1369 }; 1370 })); 1371 1372 return exportData; 1373 } 1374 1375 // Import photos from exported data 1376 // Returns: number of photos imported (for freemium.js compatibility) 1377 async function importPhotos(exportData, options = {}) { 1378 // Check availability first 1379 const { available, error } = await checkAvailability(); 1380 if (!available) { 1381 showErrorToUser(error || 'Photo storage unavailable - cannot import'); 1382 return 0; 1383 } 1384 1385 const database = await openDB(); 1386 const { overwrite = false } = options; 1387 1388 let imported = 0; 1389 let skipped = 0; 1390 1391 for (const item of exportData) { 1392 try { 1393 // Check if photo already exists 1394 const existing = await getPhoto(item.id); 1395 1396 if (existing && !overwrite) { 1397 skipped++; 1398 continue; 1399 } 1400 1401 // Convert base64 back to blob 1402 const response = await fetch(item.imageData); 1403 const blob = await response.blob(); 1404 1405 const photo = { 1406 id: item.id, 1407 sightingId: item.sightingId, 1408 speciesId: item.speciesId, 1409 blob: blob, 1410 thumbnail: item.thumbnail, 1411 createdAt: item.createdAt, 1412 fileType: item.fileType, 1413 fileSize: item.fileSize 1414 }; 1415 1416 await writeAndCommit(database, [STORE_NAME], store => overwrite ? store.put(photo) : store.add(photo)); 1417 1418 imported++; 1419 } catch (e) { 1420 console.error('Failed to import photo:', item.id, e); 1421 skipped++; 1422 } 1423 } 1424 1425 console.log(`ð· Imported ${imported} photos, skipped ${skipped}`); 1426 return imported; 1427 } 1428 1429 // Clear all photos 1430 async function clearAllPhotos() { 1431 return safeDBOperation(async () => { 1432 const database = await openDB(); 1433 1434 return writeAndCommit(database, [STORE_NAME], store => store.clear()); 1435 }, false, true); // Show error on failure 1436 } 1437 1438 // =========================================== 1439 // CROSS-PACK PHOTO LOOKUP 1440 // =========================================== 1441 1442 /** 1443 * Open another pack's photo database (without caching â temporary use only) 1444 */ 1445 function openPackDB(packId) { 1446 return new Promise((resolve, reject) => { 1447 if (!isIndexedDBSupported()) { 1448 resolve(null); 1449 return; 1450 } 1451 const dbName = `kagn-${packId}-photos`; 1452 let request; 1453 try { 1454 request = indexedDB.open(dbName, DB_VERSION); 1455 } catch (e) { 1456 resolve(null); 1457 return; 1458 } 1459 const timeout = setTimeout(() => { resolve(null); }, 3000); 1460 request.onerror = () => { clearTimeout(timeout); resolve(null); }; 1461 request.onsuccess = () => { clearTimeout(timeout); resolve(request.result); };
1462 request.onupgradeneeded = (event) => { 1463 // DB doesn't exist yet for this pack â abort to avoid creating empty DBs 1464 event.target.transaction.abort(); 1465 }; 1466 }); 1467 } 1468 1469 /** 1470 * Get photos for a species across ALL packs 1471 * Searches each pack's IndexedDB for matching species photos 1472 * @param {string} speciesId - Scientific name 1473 * @returns {Promise<Array>} photos with sourcePack annotation 1474 */ 1475 async function getPhotosForSpeciesAllPacks(speciesId) { 1476 const allPhotos = []; 1477 const packs = window.PACK?.getAvailablePacks?.() || ['birds']; 1478 const currentPackId = window.PACK?.id || 'birds'; 1479 1480 for (const packId of packs) { 1481 let database = null; 1482 try { 1483 if (packId === currentPackId) { 1484 database = await openDB(); 1485 } else { 1486 database = await openPackDB(packId); 1487 } 1488 if (!database) continue; 1489 1490 const photos = await new Promise((resolve) => { 1491 try { 1492 const tx = database.transaction([STORE_NAME], 'readonly'); 1493 const store = tx.objectStore(STORE_NAME); 1494 const index = store.index('speciesId'); 1495 const request = index.getAll(speciesId); 1496 request.onsuccess = () => resolve(omitTrashed(request.result)); 1497 request.onerror = () => resolve([]); 1498 } catch (e) { 1499 resolve([]); 1500 } 1501 }); 1502 1503 photos.forEach(p => allPhotos.push({ ...p, sourcePack: packId })); 1504 } catch (e) { 1505 // Pack might not have a photos DB yet â skip 1506 } finally { 1507 if (database && packId !== currentPackId) { 1508 try { database.close(); } catch (_) {} 1509 } 1510 } 1511 } 1512 1513 // Sort newest first, deduplicate by photo id 1514 const seen = new Set(); 1515 return allPhotos 1516 .sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt)) 1517 .filter(p => { 1518 if (seen.has(p.id)) return false; 1519 seen.add(p.id); 1520 return true; 1521 }); 1522 } 1523 1524 /** 1525 * Get photos for a sighting across ALL packs (for cross-pack sighting display) 1526 * @param {string} sightingId - The sighting ID to search for 1527 * @returns {Promise<Array>} photos with sourcePack annotation 1528 */ 1529 async function getPhotosForSightingAllPacks(sightingId) { 1530 const allPhotos = []; 1531 const currentPackId = window.PACK?.id || 'birds'; 1532 // Seed with current + explorer like getAllPhotosAllPacks: before the 1533 // pack registry loads, getAvailablePacks() is [] and the scan would 1534 // silently find nothing (e.g. a defer-ID confirm right after cold 1535 // start would no-op its photo re-key). 1536 const packs = new Set(window.PACK?.getAvailablePacks?.() || ['birds']); 1537 packs.add(currentPackId); 1538 packs.add('explorer'); 1539 1540 for (const packId of packs) { 1541 let database = null; 1542 try { 1543 if (packId === currentPackId) { 1544 database = await openDB(); 1545 } else { 1546 database = await openPackDB(packId); 1547 } 1548 if (!database) continue; 1549 1550 const photos = await new Promise((resolve) => { 1551 try { 1552 const tx = database.transaction([STORE_NAME], 'readonly'); 1553 const store = tx.objectStore(STORE_NAME); 1554 const index = store.index('sightingId'); 1555 const request = index.getAll(sightingId); 1556 request.onsuccess = () => resolve(omitTrashed(request.result)); 1557 request.onerror = () => resolve([]); 1558 } catch (e) { 1559 resolve([]); 1560 } 1561 }); 1562 1563 photos.forEach(p => allPhotos.push({ ...p, sourcePack: packId })); 1564 } catch (e) { 1565 // Pack might not have a photos DB yet â skip 1566 } finally { 1567 if (database && packId !== currentPackId) { 1568 try { database.close(); } catch (_) {} 1569 } 1570 } 1571 } 1572 1573 return allPhotos; 1574 } 1575 1576 /** 1577 * Get ALL photo records across every pack's photo DB, annotated with 1578 * sourcePack. Used by the sync photo-discovery pass and the journal 1579 * thumbnail cache: a photo captured under one pack (identify flow saves 1580 * into the capture-time pack's DB) must still be found when the journal 1581 * or sync runs under a different pack context â the single-
1581DB 1582 * getAllPhotos() misses it and the photo never uploads / never renders 1583 * in the lifelist or Photos tab. 1584 */ 1585 async function getAllPhotosAllPacks() { 1586 const packs = new Set(window.PACK?.getAvailablePacks?.() || ['birds']); 1587 const currentPackId = window.PACK?.id || 'birds'; 1588 packs.add(currentPackId); 1589 packs.add('explorer'); 1590 1591 const allPhotos = []; 1592 for (const packId of packs) { 1593 let database = null; 1594 try { 1595 if (packId === currentPackId) { 1596 database = await openDB(); 1597 } else { 1598 database = await openPackDB(packId); 1599 } 1600 if (!database) continue; 1601 1602 const photos = await new Promise((resolve) => { 1603 try { 1604 const tx = database.transaction([STORE_NAME], 'readonly'); 1605 const request = tx.objectStore(STORE_NAME).getAll(); 1606 request.onsuccess = () => resolve(omitTrashed(request.result)); 1607 request.onerror = () => resolve([]); 1608 } catch (e) { 1609 resolve([]); 1610 } 1611 }); 1612 photos.forEach(p => allPhotos.push({ ...p, sourcePack: packId })); 1613 } catch (e) { 1614 // Pack might not have a photos DB yet â skip 1615 } finally { 1616 if (database && packId !== currentPackId) { 1617 try { database.close(); } catch (_) {} 1618 } 1619 } 1620 } 1621 1622 // Dedup by id, PREFERRING the copy that carries a photoPath: the 1623 // pre-fix updatePhotoSpeciesId duplicated records (blob and all) 1624 // into the wrong pack DB, so the same id can exist twice with 1625 // divergent upload state. A recorded photoPath means the upload 1626 // happened â letting the null copy win by iteration order would 1627 // re-upload it every sync (or hide a real needs-upload record). 1628 const byId = new Map(); 1629 for (const p of allPhotos) { 1630 const prev = byId.get(p.id); 1631 if (!prev || (!prev.photoPath && p.photoPath)) byId.set(p.id, p); 1632 } 1633 return [...byId.values()]; 1634 } 1635 1636 /** 1637 * Get a single photo record from a specific pack's DB (falls back to the 1638 * current pack when sourcePack is absent/current). Companion to 1639 * getAllPhotosAllPacks for upload-queue rehydration after a reload. 1640 */ 1641 async function getPhotoFromPack(photoId, sourcePack) { 1642 if (!photoId) return null; 1643 const currentPackId = window.PACK?.id || 'birds'; 1644 if (!sourcePack || sourcePack === currentPackId) return getPhoto(photoId); 1645 let database = null; 1646 try { 1647 database = await openPackDB(sourcePack); 1648 if (!database) return null; 1649 return await new Promise((resolve) => { 1650 try { 1651 const tx = database.transaction([STORE_NAME], 'readonly'); 1652 const request = tx.objectStore(STORE_NAME).get(photoId); 1653 request.onsuccess = () => resolve(request.result || null); 1654 request.onerror = () => resolve(null); 1655 } catch (e) { 1656 resolve(null); 1657 } 1658 }); 1659 } catch (e) { 1660 return null; 1661 } finally { 1662 if (database) { try { database.close(); } catch (_) {} } 1663 } 1664 } 1665 1666 // =========================================== 1667 // PUBLIC API 1668 // =========================================== 1669 1670 window.KagnPhotos = { 1671 // Core operations 1672 savePhoto, 1673 getPhoto, 1674 getPhotosForSpecies, 1675 getPhotosForSighting, 1676 getPhotosForSightingWithCloudFallback, 1677 deletePhoto, 1678 trashPhoto, 1679 restorePhoto, 1680 purgeTrashedPhotos, 1681 deletePhotosForSpecies, 1682 deletePhotosForSighting, 1683 updatePhotoSpeciesId, 1684 updatePhotoCloudPath, 1685 getPhotoByCloudPath, 1686 getAllPhotos, 1687 1688 // Cross-pack operations 1689 openPackDB, 1690 getPhotosForSpeciesAllPacks, 1691 getPhotosForSightingAllPacks, 1692 getAllPhotosAllPacks, 1693 getPhotoFromPack, 1694 1695 // Stats 1696 getPhotoCount, 1697 getStorageUsage, 1698 getStorageStats, // Alias for freemium.js 1699 1700 // UI 1701 showPhotoViewer, 1702 1703 // Export/Import 1704 exportPhotos, 1705 importPhotos, 1706 clearAllPhotos, 1707 1708 // Video operations 1709 saveVideoMeta, 1710 getVideosForSighting, 1711 deleteVideo, 1712 deleteVideosForSighting, 1713 1714 // Audio operations 1715 saveAudio, 1716 getAudioForSighting, 1717 deleteAudio, 1718 deleteAudioForSighting, 1719 1720 // Availability check (new for bug #5) 1721 checkAvailability, 1722 isAvailable: () => !dbUnavailable, 1723 1724 // Image processing 1725 createUploadThumbnail, 1726 1727 // Init (called automatically, but exposed for manual init) 1728 init: openDB 1729 }; 1730 1731 // Initialize on load 1732 if (document.readyState === 'loading') { 1733 document.addEventListener('DOMContentLoaded', () => { 1734 openDB().catch(err => { 1735 console.warn('ð· Photo storage unavailable:', getErrorMessage(err)); 1736 // Don't show error on init - only when user tries to use photos 1737 }); 1738 }); 1739 } else { 1740 openDB().catch(err => { 1741 console.warn('ð· Photo storage unavailable:', getErrorMessage(err)); 1742 }); 1743 } 1744 1745 console.log('ð· KagnPhotos module loaded'); 1746})();
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.