import { matchesRuleSet, resolveSmartAlbum } from '../lib/smartAlbums'; import type { Album, MediaItem } from '../types'; import type { GalleryState, GridFilter } from './store'; /** Items that are not in the recycle bin and not hidden. */ export function liveMedia(media: MediaItem[]): MediaItem[] { return media.filter((m) => !m.deletedAt && !m.hidden); } export function trashedMedia(media: MediaItem[]): MediaItem[] { return media .filter((m) => m.deletedAt) .sort((a, b) => (b.deletedAt ?? 0) - (a.deletedAt ?? 0)); } export function albumById(albums: Album[], id: string): Album | undefined { return albums.find((a) => a.id === id); } /** Resolve the members of any album (smart albums are computed live). */ export function albumMedia(album: Album, media: MediaItem[]): MediaItem[] { if (album.kind === 'smart' && album.ruleSet) { const ids = new Set(resolveSmartAlbum(album, media)); return liveMedia(media) .filter((m) => ids.has(m.id)) .sort((a, b) => b.takenAt - a.takenAt); } const order = new Map(album.mediaIds.map((id, i) => [id, i])); return liveMedia(media) .filter((m) => order.has(m.id)) .sort((a, b) => (order.get(a.id)! - order.get(b.id)!)); } function applyGridFilter(items: MediaItem[], filter: GridFilter): MediaItem[] { switch (filter) { case 'favourites': return items.filter((m) => m.favorite); case 'edited': return items.filter((m) => Boolean(m.edits)); case 'photos': return items.filter((m) => m.kind === 'image'); case 'videos': return items.filter((m) => m.kind === 'video'); case 'screenshots': return items.filter((m) => m.source === 'screenshot'); case 'not-in-album': return items.filter((m) => m.albumIds.length === 0); default: return items; } } /** Full-text-ish search across the most useful fields. */ export function searchMedia(items: MediaItem[], query: string): MediaItem[] { const q = query.trim().toLowerCase(); if (!q) return items; const tokens = q.split(/\s+/).filter(Boolean); return items.filter((m) => { const haystack = [ m.name, m.source, m.kind, m.caption ?? '', m.ocrText ?? '', m.location?.place ?? '', m.location?.city ?? '', m.location?.country ?? '', ...m.tags, ...m.objectLabels, ] .join(' ') .toLowerCase(); // Every token must match somewhere (AND semantics). return tokens.every((t) => haystack.includes(t)); }); } /** * The media shown for the current view, after applying trash exclusion, * grid filter, search and object-focus. Returns reverse-chronological order. */ export function mediaForView(state: GalleryState): MediaItem[] { const { media, albums, view, gridFilter, searchQuery, objectFocus, tagFocus, personFocus, searchPreset, semanticResults, recentlyViewed } = state; let items: MediaItem[]; // When a search ranks results (keyword + semantic), preserve that order // instead of re-sorting chronologically. let ranked = false; // Quick search presets (from the search dropdown) take precedence on the search view. if (view === 'search' && searchPreset) { const live = liveMedia(media); if (searchPreset === 'edited') { // Edited = has an edit timestamp (baked crops/AI) or a live edit stack (filters/markup). return live .filter((m) => m.editedAt || m.edits) .sort((a, b) => (b.editedAt ?? 0) - (a.editedAt ?? 0)); } if (searchPreset === 'added') { return [...live].sort((a, b) => b.importedAt - a.importedAt); } // viewed const order = new Map(recentlyViewed.map((id, i) => [id, i])); return live.filter((m) => order.has(m.id)).sort((a, b) => order.get(a.id)! - order.get(b.id)!); } if (view === 'recently-deleted') { items = trashedMedia(media); } else if (view === 'duplicates') { items = liveMedia(media); } else if (view.startsWith('album:') || view.startsWith('sys:')) { const album = albumById(albums, view); items = album ? albumMedia(album, media) : []; } else if (view === 'favourites') { items = liveMedia(media).filter((m) => m.favorite); } else if (view === 'recently-saved') { items = liveMedia(media).filter((m) => m.source === 'download' || m.source === 'social'); } else if (view === 'videos') { items = liveMedia(media).filter((m) => m.kind === 'video'); } else if (view === 'screenshots') { items = liveMedia(media).filter((m) => m.source === 'screenshot'); } else { items = liveMedia(media); } if (view !== 'recently-deleted') { items = applyGridFilter(items, gridFilter); } if (objectFocus) { items = items.filter((m) => m.objectLabels.includes(objectFocus)); } if (tagFocus) { items = items.filter((m) => m.tags.includes(tagFocus)); } if (personFocus) { items = items.filter((m) => m.personIds.includes(personFocus)); } if (searchQuery && (view === 'search' || view === 'library')) { const keyword = searchMedia(items, searchQuery); // Blend in semantic ("looks like") matches after the keyword hits, in // similarity order, skipping any already matched by keyword. if (semanticResults && semanticResults.length) { const inView = new Map(items.map((m) => [m.id, m])); const kwIds = new Set(keyword.map((m) => m.id)); const semantic = semanticResults .filter((id) => !kwIds.has(id) && inView.has(id)) .map((id) => inView.get(id)!); items = [...keyword, ...semantic]; } else { items = keyword; } ranked = true; } // Default reverse-chronological unless an album imposed its own order or the // results are relevance-ranked by search. if (!view.startsWith('album:') && !ranked) { items = [...items].sort((a, b) => b.takenAt - a.takenAt); } return items; } /** Distinct object labels across the live library (for AI auto-albums). */ export function objectLabelCounts(media: MediaItem[]): Map { const counts = new Map(); for (const m of liveMedia(media)) { for (const label of m.objectLabels) { counts.set(label, (counts.get(label) ?? 0) + 1); } } return counts; } /** Items with a geolocation (for the Map view). Any source — incl. screenshots. */ export function locatedMedia(media: MediaItem[]): MediaItem[] { return liveMedia(media).filter((m) => m.location); } export interface LocationCluster { /** Centroid of the grouped photos. */ lat: number; lng: number; /** Photos at this location, most-recent first (first = pin cover). */ items: MediaItem[]; } /** * Group located media into pins by rounding coordinates to `precision` decimals * (1 ≈ 11 km, 2 ≈ 1.1 km). The Map view raises precision as you zoom in, so * clusters split apart — macOS-style. */ export function clusterByLocation(items: MediaItem[], precision = 1): LocationCluster[] { const groups = new Map(); for (const m of items) { if (!m.location) continue; const key = `${m.location.lat.toFixed(precision)},${m.location.lng.toFixed(precision)}`; const arr = groups.get(key); if (arr) arr.push(m); else groups.set(key, [m]); } return [...groups.values()].map((arr) => { const lat = arr.reduce((s, m) => s + m.location!.lat, 0) / arr.length; const lng = arr.reduce((s, m) => s + m.location!.lng, 0) / arr.length; const sorted = [...arr].sort((a, b) => b.takenAt - a.takenAt); return { lat, lng, items: sorted }; }); } export { matchesRuleSet };