PageSourceSearch

https://kagn.app/photos.js

js kagn.app collected 2026-09-25 13:38:40 UTC 67,107 bytes, 1,746 lines download raw bytes

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.