diff --git a/.gitignore b/.gitignore index 45a0ee8..51246a1 100644 --- a/.gitignore +++ b/.gitignore @@ -12,3 +12,4 @@ .env !.env.example.txt ServerFiles/data/ +/package-lock.json diff --git a/assets/icons/camera.svg b/assets/icons/camera.svg new file mode 100644 index 0000000..1705dee --- /dev/null +++ b/assets/icons/camera.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/assets/icons/check.svg b/assets/icons/check.svg new file mode 100644 index 0000000..b802483 --- /dev/null +++ b/assets/icons/check.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/icons/device.svg b/assets/icons/device.svg new file mode 100644 index 0000000..6ce9407 --- /dev/null +++ b/assets/icons/device.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/assets/icons/duplicates.svg b/assets/icons/duplicates.svg new file mode 100644 index 0000000..28f4c99 --- /dev/null +++ b/assets/icons/duplicates.svg @@ -0,0 +1,4 @@ + + + + diff --git a/assets/icons/empty-mail.svg b/assets/icons/empty-mail.svg new file mode 100644 index 0000000..449a9b6 --- /dev/null +++ b/assets/icons/empty-mail.svg @@ -0,0 +1,4 @@ + + + + diff --git a/assets/icons/empty-photo.svg b/assets/icons/empty-photo.svg new file mode 100644 index 0000000..40e6efa --- /dev/null +++ b/assets/icons/empty-photo.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/assets/icons/folder-open.svg b/assets/icons/folder-open.svg new file mode 100644 index 0000000..9e31873 --- /dev/null +++ b/assets/icons/folder-open.svg @@ -0,0 +1,4 @@ + + + + diff --git a/assets/icons/folder.svg b/assets/icons/folder.svg new file mode 100644 index 0000000..d43c343 --- /dev/null +++ b/assets/icons/folder.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/icons/google-photos.svg b/assets/icons/google-photos.svg new file mode 100644 index 0000000..fad25b0 --- /dev/null +++ b/assets/icons/google-photos.svg @@ -0,0 +1,4 @@ + + + + diff --git a/assets/icons/hidden.svg b/assets/icons/hidden.svg new file mode 100644 index 0000000..5b5d094 --- /dev/null +++ b/assets/icons/hidden.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/assets/icons/icloud.svg b/assets/icons/icloud.svg new file mode 100644 index 0000000..404aa47 --- /dev/null +++ b/assets/icons/icloud.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/icons/library-grid.svg b/assets/icons/library-grid.svg new file mode 100644 index 0000000..0d05d8f --- /dev/null +++ b/assets/icons/library-grid.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/assets/icons/nas.svg b/assets/icons/nas.svg new file mode 100644 index 0000000..f5ea9c9 --- /dev/null +++ b/assets/icons/nas.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/assets/icons/search.svg b/assets/icons/search.svg new file mode 100644 index 0000000..e87f1f5 --- /dev/null +++ b/assets/icons/search.svg @@ -0,0 +1,4 @@ + + + + diff --git a/assets/icons/trash.svg b/assets/icons/trash.svg new file mode 100644 index 0000000..1c09e47 --- /dev/null +++ b/assets/icons/trash.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/pfp.png b/assets/pfp.png new file mode 100644 index 0000000..1a56727 Binary files /dev/null and b/assets/pfp.png differ diff --git a/metro.config.js b/metro.config.js new file mode 100644 index 0000000..acfa030 --- /dev/null +++ b/metro.config.js @@ -0,0 +1,12 @@ +const { getDefaultConfig } = require('expo/metro-config'); + +const config = getDefaultConfig(__dirname); + +// Ensure SVGs are transformed into React components (Expo-compatible transformer). +config.transformer.babelTransformerPath = require.resolve('react-native-svg-transformer/expo'); +config.resolver.assetExts = config.resolver.assetExts.filter((ext) => ext !== 'svg'); +if (!config.resolver.sourceExts.includes('svg')) { + config.resolver.sourceExts.push('svg'); +} + +module.exports = config; diff --git a/package.json b/package.json index c19cfc3..72eba8e 100644 --- a/package.json +++ b/package.json @@ -27,6 +27,7 @@ "expo-status-bar": "~55.0.5", "expo-system-ui": "^55.0.15", "expo-web-browser": "~55.0.14", + "fuse.js": "^7.1.0", "react": "19.2.0", "react-native": "0.83.4", "react-native-gesture-handler": "~2.30.0", diff --git a/src/components/AlbumPickerModal.js b/src/components/AlbumPickerModal.js new file mode 100644 index 0000000..dcfe0f3 --- /dev/null +++ b/src/components/AlbumPickerModal.js @@ -0,0 +1,120 @@ +import React, { useState, useEffect } from 'react'; +import { + View, + Text, + TextInput, + TouchableOpacity, + FlatList, + StyleSheet, +} from 'react-native'; +import { useTheme } from '../services/ThemeContext'; +import ModalShell from './ModalShell'; + +const AlbumPickerModal = ({ + visible, + onClose, + albums, + onPickAlbum, + onCreateAlbum, +}) => { + const { theme } = useTheme(); + const [name, setName] = useState(''); + + useEffect(() => { + if (!visible) setName(''); + }, [visible]); + + const create = () => { + const n = name.trim(); + if (!n) return; + onCreateAlbum?.(n); + setName(''); + }; + + return ( + + Add to album + + Choose an album or create a new one + + + String(a.id)} + style={styles.list} + renderItem={({ item }) => ( + onPickAlbum?.(item.id)} + > + {item.name} + + )} + ListEmptyComponent={ + No albums yet — create one below. + } + /> + + New album name + + + + Close + + + Create & add + + + + ); +}; + +const styles = StyleSheet.create({ + title: { fontSize: 18, fontWeight: '700', marginBottom: 4 }, + hint: { fontSize: 13, marginBottom: 12 }, + list: { maxHeight: 200, marginBottom: 12 }, + albumRow: { + paddingVertical: 12, + paddingHorizontal: 12, + borderRadius: 10, + borderWidth: 1, + marginBottom: 8, + }, + albumName: { fontSize: 15, fontWeight: '600' }, + empty: { fontSize: 13, paddingVertical: 8 }, + label: { fontSize: 12, marginBottom: 6 }, + input: { + borderWidth: 1, + borderRadius: 10, + paddingHorizontal: 12, + paddingVertical: 10, + fontSize: 15, + marginBottom: 14, + }, + row: { flexDirection: 'row', justifyContent: 'flex-end', gap: 10 }, + secondary: { + paddingVertical: 10, + paddingHorizontal: 16, + borderRadius: 10, + borderWidth: 1, + }, + primary: { + paddingVertical: 10, + paddingHorizontal: 18, + borderRadius: 10, + }, + primaryText: { color: '#fff', fontWeight: '600', fontSize: 14 }, +}); + +export default AlbumPickerModal; diff --git a/src/components/CenteredState.js b/src/components/CenteredState.js new file mode 100644 index 0000000..7962a41 --- /dev/null +++ b/src/components/CenteredState.js @@ -0,0 +1,63 @@ +import React from 'react'; +import { ActivityIndicator, StyleSheet, Text, TouchableOpacity, View } from 'react-native'; +import { useTheme } from '../services/ThemeContext'; + +const CenteredState = ({ + loading = false, + icon = null, + title, + description, + actionLabel, + onAction, + containerStyle, + iconStyle, + titleStyle, + descriptionStyle, + actionStyle, + actionTextStyle, +}) => { + const { theme } = useTheme(); + + return ( + + {loading ? ( + + ) : ( + icon ? {icon} : null + )} + {title ? {title} : null} + {description ? ( + + {description} + + ) : null} + {actionLabel && onAction ? ( + + {actionLabel} + + ) : null} + + ); +}; + +const styles = StyleSheet.create({ + container: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + paddingHorizontal: 24, + }, + iconWrap: { marginBottom: 16 }, + title: { fontSize: 18, fontWeight: '600', marginBottom: 6, textAlign: 'center' }, + description: { fontSize: 13, textAlign: 'center', lineHeight: 19 }, + action: { + marginTop: 20, + paddingHorizontal: 24, + paddingVertical: 10, + borderRadius: 20, + }, + actionText: { color: '#fff', fontSize: 14, fontWeight: '600' }, +}); + +export default CenteredState; + diff --git a/src/components/MediaDetailDrawer.js b/src/components/MediaDetailDrawer.js new file mode 100644 index 0000000..3b31052 --- /dev/null +++ b/src/components/MediaDetailDrawer.js @@ -0,0 +1,278 @@ +import React, { useRef, useEffect, useState, useCallback, useMemo } from 'react'; +import { + View, + Text, + StyleSheet, + Animated, + PanResponder, + Dimensions, + ActivityIndicator, + ScrollView, + Image, +} from 'react-native'; +import MediaMetadataService from '../services/MediaMetadataService'; +import ServiceGlyph from './ServiceGlyph'; + +const { height: SCREEN_H } = Dimensions.get('window'); +const COLLAPSED_H = 120; +const EXPANDED_H = Math.min(SCREEN_H * 0.58, 460); + +const SERVICE_NAMES = { DEVICE: 'On Device', GOOGLE_PHOTOS: 'Google Photos', ICLOUD_PHOTOS: 'iCloud', NAS: 'NAS' }; + +const labelCapture = (kind) => { + if (kind === 'screenshot') return 'Screenshot'; + if (kind === 'camera') return 'Camera photo'; + return 'Unknown / other'; +}; + +const MediaDetailDrawer = ({ + theme, + displayMedia, + resolveNasMetadata, +}) => { + const heightAnim = useRef(new Animated.Value(COLLAPSED_H)).current; + const dragStartHeight = useRef(COLLAPSED_H); + const [nasLoading, setNasLoading] = useState(false); + const [nasExtra, setNasExtra] = useState(null); + const [measured, setMeasured] = useState({ width: undefined, height: undefined }); + const fetchedFor = useRef(null); + + const meta = { ...MediaMetadataService.parseMetadata(displayMedia), ...nasExtra }; + + useEffect(() => { + heightAnim.setValue(COLLAPSED_H); + setNasExtra(null); + setMeasured({ width: undefined, height: undefined }); + fetchedFor.current = null; + }, [displayMedia?.source_id, displayMedia?.local_path, heightAnim]); + + useEffect(() => { + const uri = displayMedia?.local_path; + if (!uri || displayMedia?.media_type === 'video') return; + let cancelled = false; + Image.getSize( + uri, + (w, h) => { + if (!cancelled && w > 0 && h > 0) setMeasured({ width: w, height: h }); + }, + () => {} + ); + return () => { cancelled = true; }; + }, [displayMedia?.local_path, displayMedia?.media_type]); + + const maybeFetchNas = useCallback(async () => { + if (!resolveNasMetadata || displayMedia?.source_service !== 'NAS') return; + const key = displayMedia.remote_path || displayMedia.title; + if (!key || fetchedFor.current === key) return; + fetchedFor.current = key; + setNasLoading(true); + try { + const remote = await resolveNasMetadata(displayMedia); + if (remote) setNasExtra(remote); + } finally { + setNasLoading(false); + } + }, [displayMedia, resolveNasMetadata]); + + const fetchNasRef = useRef(maybeFetchNas); + fetchNasRef.current = maybeFetchNas; + + const snapTo = useCallback((target) => { + Animated.spring(heightAnim, { + toValue: target, + useNativeDriver: false, + friction: 9, + tension: 65, + }).start(({ finished }) => { + if (finished && target >= EXPANDED_H - 8) fetchNasRef.current?.(); + }); + }, [heightAnim]); + + const toggleExpanded = useCallback(() => { + heightAnim.stopAnimation((v) => { + const open = v < (COLLAPSED_H + EXPANDED_H) / 2; + snapTo(open ? EXPANDED_H : COLLAPSED_H); + }); + }, [heightAnim, snapTo]); + + const pan = useMemo( + () => PanResponder.create({ + onStartShouldSetPanResponder: () => true, + onPanResponderGrant: () => { + heightAnim.stopAnimation((v) => { + dragStartHeight.current = v; + }); + }, + onPanResponderMove: (_, g) => { + const next = Math.min( + EXPANDED_H, + Math.max(COLLAPSED_H, dragStartHeight.current - g.dy) + ); + heightAnim.setValue(next); + }, + onPanResponderRelease: (_, g) => { + if (Math.abs(g.dy) < 10 && Math.abs(g.dx) < 10) { + toggleExpanded(); + return; + } + heightAnim.stopAnimation((v) => { + const mid = (COLLAPSED_H + EXPANDED_H) / 2; + let target = v >= mid ? EXPANDED_H : COLLAPSED_H; + if (g.vy < -0.85) target = EXPANDED_H; + if (g.vy > 0.85) target = COLLAPSED_H; + snapTo(target); + }); + }, + }), + [heightAnim, snapTo, toggleExpanded] + ); + + const formattedDate = MediaMetadataService.formatDateTakenForDisplay(displayMedia); + + const w = meta.width || measured.width || displayMedia?.width; + const h = meta.height || measured.height || displayMedia?.height; + const resolution = w && h ? `${w} × ${h}` : '—'; + + const captureKind = meta.captureKind + || MediaMetadataService.inferCaptureKind(meta, displayMedia?.title); + + return ( + + + + + Drag up/down · tap here to toggle + + + {displayMedia?.title || 'Photo'} + + + + + + Source + + {displayMedia?.sources && displayMedia.sources.length > 1 ? ( + <> + {(displayMedia.sources || []).slice(0, 3).map((s) => ( + + ))} + {(displayMedia.sources || []).map((s) => SERVICE_NAMES[s] || s).join(' · ')} + + ) : ( + <> + + {SERVICE_NAMES[displayMedia?.source_service] || displayMedia?.source_service} + + )} + + + + {displayMedia?.sources && displayMedia.sources.length > 1 && ( + + Available on + {displayMedia.sources.length} services + + )} + + + Resolution + {resolution} + + + + Date taken + {formattedDate} + + + + Capture type + {labelCapture(captureKind)} + + + {(meta.cameraMake || meta.cameraModel) && ( + + Camera + + {[meta.cameraMake, meta.cameraModel].filter(Boolean).join(' ')} + + + )} + + {(meta.fileSize != null || displayMedia?.size != null) && ( + + Size + + {formatBytes(meta.fileSize ?? displayMedia?.size)} + + + )} + + {nasLoading && ( + + + Loading file details… + + )} + + + ); +}; + +function formatBytes(n) { + if (n == null || Number.isNaN(n)) return '—'; + if (n < 1024) return `${n} B`; + if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`; + return `${(n / (1024 * 1024)).toFixed(2)} MB`; +} + +const styles = StyleSheet.create({ + sheet: { + position: 'absolute', + left: 0, + right: 0, + bottom: 0, + borderTopLeftRadius: 16, + borderTopRightRadius: 16, + overflow: 'hidden', + /* Above thumbnail carousel (MediaViewer thumb strip uses lower z-index). */ + zIndex: 50, + elevation: 50, + }, + dragStrip: { + paddingTop: 8, + paddingHorizontal: 16, + paddingBottom: 6, + minHeight: 56, + }, + pressStrip: { alignItems: 'center', paddingBottom: 6 }, + handle: { + width: 40, + height: 5, + borderRadius: 3, + marginBottom: 4, + }, + hint: { color: '#9aa0a6', fontSize: 10, marginBottom: 2 }, + title: { fontSize: 15, fontWeight: '600', alignSelf: 'stretch' }, + scroll: { flexGrow: 1 }, + scrollContent: { paddingHorizontal: 16, paddingBottom: 28 }, + row: { flexDirection: 'row', justifyContent: 'space-between', marginBottom: 8, gap: 12 }, + label: { fontSize: 12, color: '#9aa0a6' }, + value: { fontSize: 12, color: '#e8eaed', fontWeight: '500', flex: 1, textAlign: 'right' }, + sourceLine: { flexDirection: 'row', alignItems: 'center', gap: 6, flex: 1, justifyContent: 'flex-end' }, + loadingRow: { + flexDirection: 'row', + alignItems: 'center', + gap: 8, + marginTop: 8, + }, + loadingText: { color: '#9aa0a6', fontSize: 12 }, +}); + +export default MediaDetailDrawer; diff --git a/src/components/MediaSortControls.js b/src/components/MediaSortControls.js new file mode 100644 index 0000000..dddf00a --- /dev/null +++ b/src/components/MediaSortControls.js @@ -0,0 +1,64 @@ +import React from 'react'; +import { View, Text, TouchableOpacity, StyleSheet, ScrollView } from 'react-native'; +import { useTheme } from '../services/ThemeContext'; + +const OPTIONS = [ + { key: 'date_taken', label: 'Date taken' }, + { key: 'date_added', label: 'Date added' }, + { key: 'name', label: 'Name' }, +]; + +const MediaSortControls = ({ value, onChange }) => { + const { theme } = useTheme(); + return ( + + Sort + + {OPTIONS.map((opt) => ( + onChange(opt.key)} + > + + {opt.label} + + + ))} + + + ); +}; + +const styles = StyleSheet.create({ + wrap: { + flexDirection: 'row', + alignItems: 'center', + paddingHorizontal: 12, + paddingBottom: 4, + gap: 8, + }, + prefix: { fontSize: 12, fontWeight: '600' }, + row: { gap: 6, alignItems: 'center', paddingRight: 8 }, + chip: { + paddingHorizontal: 10, + paddingVertical: 4, + borderRadius: 14, + height: 28, + justifyContent: 'center', + }, + chipText: { fontSize: 12, fontWeight: '500' }, +}); + +export default MediaSortControls; diff --git a/src/components/ModalShell.js b/src/components/ModalShell.js new file mode 100644 index 0000000..fba8828 --- /dev/null +++ b/src/components/ModalShell.js @@ -0,0 +1,33 @@ +import React from 'react'; +import { + KeyboardAvoidingView, + Modal, + Platform, + StyleSheet, + TouchableOpacity, + View, +} from 'react-native'; + +const ModalShell = ({ visible, onClose, children, cardStyle, overlayStyle }) => ( + + + + {children} + + +); + +const styles = StyleSheet.create({ + overlay: { flex: 1, justifyContent: 'center', paddingHorizontal: 20 }, + backdrop: { ...StyleSheet.absoluteFillObject, backgroundColor: 'rgba(0,0,0,0.45)' }, + card: { + borderRadius: 14, + padding: 16, + }, +}); + +export default ModalShell; + diff --git a/src/components/RenameMediaModal.js b/src/components/RenameMediaModal.js new file mode 100644 index 0000000..40a5c2c --- /dev/null +++ b/src/components/RenameMediaModal.js @@ -0,0 +1,90 @@ +import React, { useEffect, useState } from 'react'; +import { + View, + Text, + TextInput, + TouchableOpacity, + StyleSheet, +} from 'react-native'; +import { useTheme } from '../services/ThemeContext'; +import ModalShell from './ModalShell'; + +const RenameMediaModal = ({ visible, initialName, onClose, onSave, saving }) => { + const { theme } = useTheme(); + const [name, setName] = useState(initialName || ''); + + useEffect(() => { + if (visible) setName(initialName || ''); + }, [visible, initialName]); + + const submit = () => { + const trimmed = (name || '').trim(); + if (!trimmed || saving) return; + onSave?.(trimmed); + }; + + return ( + + Rename media + + This updates the name shown in your library and keeps it after refresh. + + + + + Cancel + + + {saving ? 'Saving…' : 'Save'} + + + + ); +}; + +const styles = StyleSheet.create({ + title: { fontSize: 18, fontWeight: '700', marginBottom: 4 }, + hint: { fontSize: 13, marginBottom: 12 }, + input: { + borderWidth: 1, + borderRadius: 10, + paddingHorizontal: 12, + paddingVertical: 10, + fontSize: 15, + marginBottom: 14, + }, + row: { flexDirection: 'row', gap: 10, justifyContent: 'flex-end' }, + secondary: { + borderWidth: 1, + borderRadius: 10, + paddingHorizontal: 14, + paddingVertical: 10, + }, + primary: { + borderRadius: 10, + paddingHorizontal: 16, + paddingVertical: 10, + }, + primaryText: { color: '#fff', fontWeight: '700' }, +}); + +export default RenameMediaModal; diff --git a/src/components/SelectionToolbar.js b/src/components/SelectionToolbar.js new file mode 100644 index 0000000..564a264 --- /dev/null +++ b/src/components/SelectionToolbar.js @@ -0,0 +1,58 @@ +import React from 'react'; +import { View, Text, TouchableOpacity, StyleSheet } from 'react-native'; +import { useTheme } from '../services/ThemeContext'; + +const SelectionToolbar = ({ + visible, + selectedCount, + onAddToAlbum, + onClear, +}) => { + const { theme } = useTheme(); + if (!visible || selectedCount < 1) return null; + + return ( + + + {selectedCount} selected + + + + Add to album + + + Cancel + + + + ); +}; + +const styles = StyleSheet.create({ + bar: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + paddingHorizontal: 14, + paddingVertical: 10, + borderBottomWidth: StyleSheet.hairlineWidth, + zIndex: 50, + elevation: 6, + }, + count: { fontSize: 14, fontWeight: '600' }, + actions: { flexDirection: 'row', alignItems: 'center', gap: 12 }, + btn: { + paddingHorizontal: 14, + paddingVertical: 8, + borderRadius: 20, + }, + btnText: { color: '#fff', fontSize: 13, fontWeight: '600' }, + clearBtn: { paddingVertical: 6, paddingHorizontal: 4 }, + clearText: { fontSize: 14, fontWeight: '500' }, +}); + +export default SelectionToolbar; diff --git a/src/components/ServiceGlyph.js b/src/components/ServiceGlyph.js new file mode 100644 index 0000000..ef9b3f4 --- /dev/null +++ b/src/components/ServiceGlyph.js @@ -0,0 +1,20 @@ +import React from 'react'; +import DeviceIcon from '../../assets/icons/device.svg'; +import GooglePhotosIcon from '../../assets/icons/google-photos.svg'; +import ICloudIcon from '../../assets/icons/icloud.svg'; +import NasIcon from '../../assets/icons/nas.svg'; + +const SERVICE_ICONS = { + GOOGLE_PHOTOS: GooglePhotosIcon, + ICLOUD_PHOTOS: ICloudIcon, + NAS: NasIcon, + DEVICE: DeviceIcon, +}; + +const ServiceGlyph = React.memo(({ serviceType, size = 20, color }) => { + const Icon = SERVICE_ICONS[serviceType] || DeviceIcon; + return ; +}); + +export default ServiceGlyph; + diff --git a/src/services/AuthSession.js b/src/services/AuthSession.js new file mode 100644 index 0000000..8d6fb24 --- /dev/null +++ b/src/services/AuthSession.js @@ -0,0 +1,32 @@ +import * as SecureStore from 'expo-secure-store'; + +const USER_ID_KEY = 'auth_user_id'; + +export async function getStoredUserId() { + try { + const raw = await SecureStore.getItemAsync(USER_ID_KEY); + if (!raw) return null; + const n = Number(raw); + return Number.isFinite(n) && n > 0 ? n : null; + } catch { + return null; + } +} + +export async function setStoredUserId(userId) { + try { + if (userId == null) return; + await SecureStore.setItemAsync(USER_ID_KEY, String(userId)); + } catch { + /* ignore */ + } +} + +export async function clearStoredUserId() { + try { + await SecureStore.deleteItemAsync(USER_ID_KEY); + } catch { + /* ignore */ + } +} + diff --git a/src/services/DatabaseService.js b/src/services/DatabaseService.js index 6e4a01a..4c1dd6b 100644 --- a/src/services/DatabaseService.js +++ b/src/services/DatabaseService.js @@ -1,4 +1,5 @@ import * as SQLite from 'expo-sqlite'; +import Fuse from 'fuse.js'; import CredentialEncryption from './CredentialEncryption'; import MediaMetadataService from './MediaMetadataService'; import { logError } from './ErrorHandler'; @@ -9,6 +10,17 @@ let writeQueue = Promise.resolve(); const MEDIA_INSERT_SQL = `INSERT INTO media (user_id, source_service, source_id, title, description, local_path, remote_path, media_type, created_date, metadata_json, search_text) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`; const EMPTY_PAGE = { mediaItems: [], endCursor: null, hasNextPage: false }; +const MEDIA_FUZZY_OPTIONS = { + includeScore: true, + shouldSort: true, + ignoreLocation: true, + threshold: 0.34, + minMatchCharLength: 2, + keys: [ + { name: 'title', weight: 0.45 }, + { name: '__fuzzyText', weight: 0.55 }, + ], +}; const hasValue = (value) => value || value === 0; const withDecryptedAccount = (row) => ({ ...row, account_data: CredentialEncryption.decrypt(row.account_data) }); @@ -32,6 +44,42 @@ const parseJsonSafe = (value, fallback = {}) => { return { ...fallback }; } }; +const createdDateMs = (row) => { + const t = Date.parse(row?.created_date || ''); + return Number.isNaN(t) ? 0 : t; +}; +const enrichRowForFuzzySearch = (row) => ({ + ...row, + __fuzzyText: [ + row.title, + row.description, + row.search_text, + row.metadata_json, + row.source_service, + row.source_id, + row.remote_path, + row.local_path, + ] + .filter((part) => part != null && String(part).length > 0) + .join(' ') + .toLowerCase(), +}); +const runFuzzyMediaSearch = (rows, query) => { + if (!Array.isArray(rows) || rows.length === 0) return []; + const searchableRows = rows.map(enrichRowForFuzzySearch); + const fuse = new Fuse(searchableRows, MEDIA_FUZZY_OPTIONS); + return fuse + .search(query) + .sort((a, b) => { + const scoreDelta = (a.score ?? 1) - (b.score ?? 1); + if (Math.abs(scoreDelta) > 0.0001) return scoreDelta; + return createdDateMs(b.item) - createdDateMs(a.item); + }) + .map(({ item }) => { + const { __fuzzyText, ...clean } = item; + return clean; + }); +}; const enqueueDbWrite = async (operation) => { const run = async () => operation(); const next = writeQueue.then(run, run); @@ -537,17 +585,8 @@ const DatabaseService = { if (!(await DatabaseService.ensureDb())) return []; const q = (query || '').trim(); if (!q) return []; - const searchTerm = `%${q}%`; - const likeRows = await db.getAllAsync( - `SELECT * FROM media WHERE user_id = ? AND ( - title LIKE ? OR description LIKE ? OR search_text LIKE ? OR metadata_json LIKE ? - )`, - [userId, searchTerm, searchTerm, searchTerm, searchTerm] - ); - const ranked = MediaMetadataService.rankMediaBySearchQuery(likeRows, q); - if (ranked.length > 0) return ranked; const allRows = await db.getAllAsync('SELECT * FROM media WHERE user_id = ?', [userId]); - return MediaMetadataService.rankMediaBySearchQuery(allRows, q); + return runFuzzyMediaSearch(allRows, q); } catch (error) { logError('Search Media', error); return []; diff --git a/src/services/MediaMetadataService.js b/src/services/MediaMetadataService.js new file mode 100644 index 0000000..034f1a4 --- /dev/null +++ b/src/services/MediaMetadataService.js @@ -0,0 +1,415 @@ +import * as MediaLibrary from 'expo-media-library'; + +export const mediaFingerprint = (item) => { + const svc = item.source_service || item.sourceService || 'UNKNOWN'; + const sid = item.source_id || item.sourceId; + if (sid) return `${svc}:${sid}`; + const lp = item.local_path || item.localPath; + if (lp) return `${svc}:${lp}`; + const id = item.id != null ? String(item.id) : ''; + const title = item.title || ''; + return `${svc}:${id || title || 'item'}`; +}; + +const numDim = (v) => { + if (v == null || v === '') return undefined; + const n = typeof v === 'number' ? v : Number(String(v).replace(/[^\d.]/g, '')); + return Number.isFinite(n) && n > 0 ? Math.round(n) : undefined; +}; + +/** + * Parses EXIF-style "YYYY:MM:DD HH:mm:ss", ISO strings, epoch seconds/ms, and Date.parse fallbacks. + */ +export const parseFlexibleDateToMs = (value) => { + if (value == null || value === '') return null; + if (typeof value === 'number' && Number.isFinite(value)) { + const ms = value > 1e12 ? value : value > 1e10 ? value : value * 1000; + const d = new Date(ms); + return Number.isNaN(d.getTime()) ? null : ms; + } + const s = String(value).trim(); + if (!s) return null; + if (/^\d+$/.test(s)) { + const n = Number(s); + if (!Number.isFinite(n)) return null; + const ms = n > 1e12 ? n : n > 1e10 ? n : n * 1000; + const d = new Date(ms); + return Number.isNaN(d.getTime()) ? null : ms; + } + const exif = s.match(/^(\d{4}):(\d{2}):(\d{2})[\sT](\d{2}):(\d{2}):(\d{2})/); + if (exif) { + const iso = `${exif[1]}-${exif[2]}-${exif[3]}T${exif[4]}:${exif[5]}:${exif[6]}`; + const t = Date.parse(iso); + return Number.isNaN(t) ? null : t; + } + const t = Date.parse(s); + return Number.isNaN(t) ? null : t; +}; + +export const dateTakenMsToIso = (ms) => { + if (ms == null || !Number.isFinite(ms) || ms <= 0) return null; + return new Date(ms).toISOString(); +}; + +/** Single ISO string for DB `created_date` (capture time), never import clock unless no signal. */ +export const resolveCreatedDateIsoForStorage = (item) => { + const meta = parseMetadata(item); + const chain = [ + meta.dateTaken, + meta.DateTimeOriginal, + meta.dateTimeDigitized, + meta.CreateDate, + meta.ModifyDate, + item.creationTime, + item.modifyTime, + item.created_date, + ]; + for (const v of chain) { + const ms = parseFlexibleDateToMs(v); + if (ms != null && ms > 0) return new Date(ms).toISOString(); + } + return null; +}; + +export const parseMetadata = (itemOrJson) => { + if (!itemOrJson) return {}; + if (typeof itemOrJson === 'object') { + let m = {}; + if (itemOrJson.metadata_json && typeof itemOrJson.metadata_json === 'string') { + try { + m = JSON.parse(itemOrJson.metadata_json); + } catch { + m = {}; + } + } else if (itemOrJson.metadata && typeof itemOrJson.metadata === 'object') { + m = { ...itemOrJson.metadata }; + } + const w = numDim(m.width) ?? numDim(itemOrJson.width); + const h = numDim(m.height) ?? numDim(itemOrJson.height); + if (w) m.width = w; + if (h) m.height = h; + return m; + } + const raw = typeof itemOrJson === 'string' ? itemOrJson : null; + if (!raw || typeof raw !== 'string') return {}; + try { + const m = JSON.parse(raw); + const w = numDim(m.width); + const h = numDim(m.height); + if (w) m.width = w; + if (h) m.height = h; + return m; + } catch { + return {}; + } +}; + +export const inferCaptureKind = (meta = {}, filename = '') => { + const name = (filename || meta.filename || meta.title || '').toLowerCase(); + if (/screenshot|screen[_-]?shot|screencap|screen ?recording/i.test(name)) return 'screenshot'; + if (meta.isScreenshot) return 'screenshot'; + const make = `${meta.cameraMake || meta.make || ''}`.toLowerCase(); + const model = `${meta.cameraModel || meta.model || ''}`.toLowerCase(); + if (make || model) { + if (/iphone|ipad|pixel|galaxy|nikon|canon|sony|olympus|fujifilm|dsc-|dsc/i.test(`${make} ${model}`)) { + return 'camera'; + } + return 'camera'; + } + if (/^img_|^mvimg_|^dsc|^photo_\d|^p\d{6,}/i.test(name)) return 'camera'; + return 'unknown'; +}; + +export const buildSearchText = (item, metadataJsonString) => { + let meta = {}; + try { + meta = metadataJsonString ? JSON.parse(metadataJsonString) : {}; + } catch { + meta = {}; + } + const parts = [ + item.title, + item.description, + item.source_service, + meta.cameraMake, + meta.cameraModel, + meta.make, + meta.model, + meta.captureKind, + meta.mimeType, + meta.dateTaken, + item.remote_path, + item.local_path, + ]; + return parts.filter((p) => p != null && String(p).length > 0).join(' ').toLowerCase(); +}; + +export const mergeMetadataJson = (existingJson, incomingJson) => { + let a = {}; + let b = {}; + try { + if (existingJson) a = JSON.parse(existingJson); + } catch { + a = {}; + } + try { + if (incomingJson) b = typeof incomingJson === 'string' ? JSON.parse(incomingJson) : incomingJson; + } catch { + b = {}; + } + return JSON.stringify({ ...a, ...b }); +}; + + +export const getDateTakenMs = (item) => { + if (!item) return 0; + const meta = parseMetadata(item); + const chain = [ + meta.dateTaken, + meta.DateTimeOriginal, + meta.CreateDate, + meta.ModifyDate, + meta.dateTimeDigitized, + item.creationTime, + item.modifyTime, + item.created_date, + ]; + for (const v of chain) { + const ms = parseFlexibleDateToMs(v); + if (ms != null && ms > 0) return ms; + } + return 0; +}; + +export const getDateAddedMs = (item) => { + const raw = item.created_at || item.indexed_at; + const ms = parseFlexibleDateToMs(raw); + if (ms != null && ms > 0) return ms; + return getDateTakenMs(item); +}; + +export const sortMediaItems = (items, mode) => { + const copy = [...items]; + if (mode === 'name') { + copy.sort((a, b) => (a.title || '').localeCompare(b.title || '', undefined, { sensitivity: 'base' })); + return copy; + } + if (mode === 'date_added') { + copy.sort((a, b) => getDateAddedMs(b) - getDateAddedMs(a)); + return copy; + } + copy.sort((a, b) => getDateTakenMs(b) - getDateTakenMs(a)); + return copy; +}; + +const mapBasicAsset = (asset) => ({ + source_service: 'DEVICE', + source_id: asset.id, + title: asset.filename || 'Untitled', + description: '', + local_path: asset.uri, + remote_path: '', + media_type: asset.mediaType === 'video' ? 'video' : 'photo', + created_date: (() => { + const ms = parseFlexibleDateToMs(asset.creationTime); + return ms != null && ms > 0 ? new Date(ms).toISOString() : null; + })(), + width: asset.width, + height: asset.height, + duration: asset.duration || 0, +}); + +export const deviceAssetToMediaItem = async (asset) => { + const base = mapBasicAsset(asset); + let exif = {}; + try { + const info = await MediaLibrary.getAssetInfoAsync(asset); + if (info?.exif && typeof info.exif === 'object') exif = info.exif; + if (info?.width) base.width = info.width; + if (info?.height) base.height = info.height; + } catch { + /* optional enrichment */ + } + + const meta = { + width: numDim(exif.ImageWidth) || numDim(exif.PixelXDimension) || numDim(base.width), + height: numDim(exif.ImageHeight) || numDim(exif.PixelYDimension) || numDim(base.height), + cameraMake: exif.Make || exif.LensMake, + cameraModel: exif.Model || exif.LensModel, + orientation: exif.Orientation, + mimeType: exif.MIMEType || (base.media_type === 'video' ? 'video/mp4' : 'image/jpeg'), + }; + + const takenMs = parseFlexibleDateToMs(asset.creationTime); + if (takenMs != null && takenMs > 0) { + meta.dateTaken = new Date(takenMs).toISOString(); + } + + meta.captureKind = inferCaptureKind({ ...meta, filename: asset.filename, title: base.title }); + const metadata_json = JSON.stringify(meta); + return { + ...base, + creationTime: asset.creationTime, + created_date: meta.dateTaken || base.created_date, + metadata_json, + search_text: buildSearchText({ ...base, metadata_json }, metadata_json), + }; +}; + +export const enrichGooglePickerItem = (item) => { + const meta = parseMetadata(item.metadata_json || '{}'); + const width = numDim(item.width) ?? numDim(meta.width); + const height = numDim(item.height) ?? numDim(meta.height); + const takenMs = parseFlexibleDateToMs(item.created_date) ?? parseFlexibleDateToMs(meta.dateTaken); + const dateTakenIso = takenMs != null && takenMs > 0 ? new Date(takenMs).toISOString() : meta.dateTaken; + const merged = { + ...meta, + width, + height, + mimeType: item.mime_type || meta.mimeType, + dateTaken: dateTakenIso, + captureKind: inferCaptureKind({ ...meta, filename: item.title, title: item.title }), + }; + const metadata_json = JSON.stringify(merged); + const createdIso = dateTakenIso || item.created_date; + return { + ...item, + created_date: createdIso, + metadata_json, + search_text: buildSearchText({ ...item, metadata_json }, metadata_json), + }; +}; + +export const enrichNasItem = (item, serverMeta = {}) => { + const fromServerMs = parseFlexibleDateToMs(serverMeta.DateTimeOriginal) + ?? parseFlexibleDateToMs(serverMeta.dateTaken) + ?? parseFlexibleDateToMs(serverMeta.CreateDate); + const dateTakenIso = fromServerMs != null && fromServerMs > 0 + ? new Date(fromServerMs).toISOString() + : (parseMetadata(item.metadata_json).dateTaken || item.created_date); + const meta = { + ...parseMetadata(item.metadata_json), + width: numDim(serverMeta.width) ?? numDim(serverMeta.ExifImageWidth) ?? numDim(item.width), + height: numDim(serverMeta.height) ?? numDim(serverMeta.ExifImageHeight) ?? numDim(item.height), + cameraMake: serverMeta.Make || serverMeta.make, + cameraModel: serverMeta.Model || serverMeta.model, + dateTaken: dateTakenIso, + fileSize: serverMeta.fileSize ?? item.size, + mimeType: serverMeta.MIMEType || serverMeta.mimeType, + }; + if (!meta.captureKind) { + meta.captureKind = inferCaptureKind({ ...meta, filename: item.title }); + } + const metadata_json = JSON.stringify(meta); + const createdIso = parseFlexibleDateToMs(dateTakenIso) ? dateTakenIso : item.created_date; + return { + ...item, + created_date: createdIso, + metadata_json, + search_text: buildSearchText({ ...item, metadata_json }, metadata_json), + }; +}; + +export const chunkIntoRows = (items, columns = 3) => { + const rows = []; + for (let i = 0; i < items.length; i += columns) { + rows.push(items.slice(i, i + columns)); + } + return rows; +}; + +const localDateKeyFromMs = (ms) => { + const d = new Date(ms); + const y = d.getFullYear(); + const m = String(d.getMonth() + 1).padStart(2, '0'); + const day = String(d.getDate()).padStart(2, '0'); + return `${y}-${m}-${day}`; +}; + +const humanDaySectionTitle = (dateKey) => { + const d = new Date(`${dateKey}T12:00:00`); + const now = new Date(); + const todayKey = localDateKeyFromMs(now.getTime()); + const y = new Date(now); + y.setDate(y.getDate() - 1); + const yestKey = localDateKeyFromMs(y.getTime()); + const longFmt = d.toLocaleDateString(undefined, { weekday: 'long', month: 'long', day: 'numeric' }); + if (dateKey === todayKey) return `Today · ${longFmt}`; + if (dateKey === yestKey) return `Yesterday · ${longFmt}`; + return d.toLocaleDateString(undefined, { weekday: 'long', month: 'long', day: 'numeric', year: 'numeric' }); +}; + +const UNKNOWN_DAY = '_unknown'; + +/** Google Photos–style sections: newest calendar day first; each section’s rows are `columns`-wide grid strips. */ +export const groupMediaByDayForTimeline = (items, columns = 3) => { + const sorted = sortMediaItems([...items], 'date_taken'); + const byDay = new Map(); + for (const item of sorted) { + const ms = getDateTakenMs(item); + const key = ms > 0 ? localDateKeyFromMs(ms) : UNKNOWN_DAY; + if (!byDay.has(key)) byDay.set(key, []); + byDay.get(key).push(item); + } + const datedKeys = [...byDay.keys()].filter((k) => k !== UNKNOWN_DAY).sort((a, b) => b.localeCompare(a)); + const keys = byDay.has(UNKNOWN_DAY) ? [...datedKeys, UNKNOWN_DAY] : datedKeys; + return keys.map((dateKey) => { + const rows = chunkIntoRows(byDay.get(dateKey), columns); + const data = rows.map((row, ri) => ({ + row, + rowKey: `${dateKey}-${ri}-${row.map((c) => (c ? mediaFingerprint(c) : '·')).join('|')}`, + })); + return { + dateKey, + title: dateKey === UNKNOWN_DAY ? 'No capture date' : humanDaySectionTitle(dateKey), + data, + }; + }); +}; + +/** For UI labels (drawer, etc.) */ +export const formatDateTakenForDisplay = (item) => { + const ms = getDateTakenMs(item); + if (!ms) return '—'; + try { + return new Date(ms).toLocaleString(); + } catch { + return '—'; + } +}; + +export const resolveAlbumEntriesToMediaItems = (entries, dbMedia) => { + const byFp = new Map((dbMedia || []).map((m) => [mediaFingerprint(m), m])); + return (entries || []).map((e) => { + const hit = byFp.get(e.media_fingerprint); + if (hit) return { ...hit }; + return { + source_service: e.source_service || 'DEVICE', + title: e.title_snapshot || 'Photo', + local_path: e.local_path, + remote_path: e.remote_path, + metadata_json: e.metadata_json, + }; + }); +}; + +export default { + mediaFingerprint, + parseMetadata, + inferCaptureKind, + buildSearchText, + mergeMetadataJson, + parseFlexibleDateToMs, + dateTakenMsToIso, + resolveCreatedDateIsoForStorage, + getDateTakenMs, + getDateAddedMs, + sortMediaItems, + deviceAssetToMediaItem, + enrichGooglePickerItem, + enrichNasItem, + resolveAlbumEntriesToMediaItems, + chunkIntoRows, + groupMediaByDayForTimeline, + formatDateTakenForDisplay, +};