diff --git a/.gitignore b/.gitignore index c4961db..45a0ee8 100644 --- a/.gitignore +++ b/.gitignore @@ -11,3 +11,4 @@ *.tmp .env !.env.example.txt +ServerFiles/data/ diff --git a/App.js b/App.js index c9e9006..e8b05cf 100644 --- a/App.js +++ b/App.js @@ -4,6 +4,7 @@ import { NavigationContainer } from '@react-navigation/native'; import { createNativeStackNavigator } from '@react-navigation/native-stack'; import { createBottomTabNavigator } from '@react-navigation/bottom-tabs'; import { ActivityIndicator, View, Text, TouchableOpacity } from 'react-native'; +import { registerRootComponent } from 'expo'; import * as WebBrowser from 'expo-web-browser'; import { ThemeProvider, useTheme } from './src/services/ThemeContext'; import DatabaseService from './src/services/DatabaseService'; @@ -132,10 +133,14 @@ function AppInner() { ); } -export default function App() { +function App() { return ( ); } + +registerRootComponent(App); + +export default App; diff --git a/ServerFiles/package.json b/ServerFiles/package.json index a061fb1..e520185 100644 --- a/ServerFiles/package.json +++ b/ServerFiles/package.json @@ -1,7 +1,7 @@ { - "name": "media-app-sftp-backend", - "version": "1.0.0", - "description": "SFTP Backend API for Media Aggregation App", + "name": "media-app-nas-backend", + "version": "2.0.0", + "description": "NAS REST backend (CRUDL vault, base64 storage) for Media Aggregation App", "main": "server.js", "scripts": { "start": "node server.js", @@ -9,23 +9,21 @@ "test": "echo \"Error: no test specified\" && exit 1" }, "keywords": [ - "sftp", - "backend", - "api", + "nas", + "rest", + "crud", "express" ], "author": "", "license": "MIT", "dependencies": { - "body-parser": "^2.2.2", "cors": "^2.8.6", - "dotenv": "^16.6.1", - "express": "^4.22.1", - "express-fileupload": "^1.4.0", - "jsonwebtoken": "^9.0.2", - "node-fetch": "^2.7.0" + "dotenv": "^17.4.2", + "express": "^5.2.1", + "jsonwebtoken": "^9.0.3", + "node-fetch": "^3.3.2" }, "devDependencies": { - "nodemon": "^3.0.1" + "nodemon": "^3.1.14" } } diff --git a/ServerFiles/server.js b/ServerFiles/server.js index 3cda39c..4f929a7 100644 --- a/ServerFiles/server.js +++ b/ServerFiles/server.js @@ -2,69 +2,15 @@ const express = require('express'); const jwt = require('jsonwebtoken'); const cors = require('cors'); const path = require('path'); -const os = require('os'); -const fs = require('fs'); -const util = require('util'); -const { execFile } = require('child_process'); -const fileUpload = require('express-fileupload'); require('dotenv').config({ path: path.join(__dirname, '.env') }); -const fetch = require('node-fetch'); -const execFileAsync = util.promisify(execFile); +const nasVaultStore = require('./nasVaultStore'); -const SSH_OPTIONS = ['-o', 'StrictHostKeyChecking=no', '-o', 'UserKnownHostsFile=/dev/null']; - -const NAS_CONFIG_PATH = path.join(__dirname, 'nas_config.txt'); -let DEFAULT_PHOTO_DIR = '/home/user/photos'; -try { - const raw = fs.readFileSync(NAS_CONFIG_PATH, 'utf8').trim(); - if (raw) DEFAULT_PHOTO_DIR = raw.split('\n')[0].trim(); -} catch (_) {} - -function runCommand(cmd, args, options = {}) { - return execFileAsync(cmd, args, { - timeout: 30000, - maxBuffer: 10 * 1024 * 1024, - ...options, - }); -} - -function buildScpArgs(conn, source, dest) { - const args = ['-P', String(conn.port || 22), ...SSH_OPTIONS]; - if (conn.privateKeyPath) args.push('-i', conn.privateKeyPath); - args.push(source, dest); - return args; -} - -function buildSftpArgs(conn, batchFilePath) { - const args = ['-P', String(conn.port || 22), ...SSH_OPTIONS, '-b', batchFilePath]; - if (conn.privateKeyPath) args.push('-i', conn.privateKeyPath); - args.push(`${conn.username}@${conn.host}`); - return args; -} - -async function runSftpBatch(conn, batchCommands) { - const batchFilePath = path.join( - os.tmpdir(), - `sftp_batch_${Date.now()}_${Math.random().toString(36).slice(2)}.txt` - ); - fs.writeFileSync(batchFilePath, batchCommands.join('\n'), 'utf8'); - try { - const args = buildSftpArgs(conn, batchFilePath); - if (conn.password) { - const sshpass = process.env.SSHPASS_PATH || 'sshpass'; - return await runCommand(sshpass, ['-p', conn.password, 'sftp', ...args]); - } - return await runCommand('sftp', args); - } finally { - fs.unlinkSync(batchFilePath); - } -} +const fetch = (...args) => import('node-fetch').then(({ default: nodeFetch }) => nodeFetch(...args)); const app = express(); -app.use(express.json()); -app.use(express.urlencoded({ extended: true })); -app.use(fileUpload()); +app.use(express.json({ limit: process.env.JSON_BODY_LIMIT || '150mb' })); +app.use(express.urlencoded({ extended: true, limit: process.env.JSON_BODY_LIMIT || '150mb' })); app.use(cors()); const SECRET_KEY = process.env.JWT_SECRET || 'your-secret-key-change-in-production'; @@ -75,16 +21,18 @@ app.use((req, res, next) => { next(); }); -function verifyToken(req, res, next) { +function verifyNasSession(req, res, next) { const authHeader = req.headers.authorization; const token = authHeader?.split(' ')[1]; if (!token) return res.status(401).json({ error: 'No token provided' }); try { const decoded = jwt.verify(token, SECRET_KEY); + const conn = connections.get(decoded.connectionId); + if (!conn) return res.status(401).json({ error: 'Session expired or not found' }); req.connectionId = decoded.connectionId; - req.connection = connections.get(decoded.connectionId); - if (!req.connection) return res.status(401).json({ error: 'Connection expired or not found' }); + req.connection = conn; + req.vaultKey = conn.vaultKey; next(); } catch (err) { res.status(401).json({ error: 'Invalid token: ' + err.message }); @@ -95,141 +43,103 @@ app.get('/api/health', (req, res) => { res.json({ status: 'ok', timestamp: new Date().toISOString(), - activeConnections: connections.size, - defaultPhotoDir: DEFAULT_PHOTO_DIR, + activeSessions: connections.size, + nasMode: 'rest-crudl', }); }); -app.get('/api/config/photo-dir', (req, res) => { - res.json({ photoDir: DEFAULT_PHOTO_DIR }); -}); - -app.post('/api/sftp/connect', (req, res) => { +/** + * Open a NAS client session (REST vault). Credentials scope the vault; media is stored + * server-side as base64 with preserved metadata (no SFTP / exifr pipeline). + */ +app.post('/api/nas/sessions', (req, res) => { const { host, port = 22, username, password } = req.body; if (!host || !username || !password) { return res.status(400).json({ error: 'Missing required fields: host, username, password' }); } - const connectionId = `conn_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; - connections.set(connectionId, { host, port, username, password, createdAt: new Date() }); + const vaultKey = nasVaultStore.vaultKeyFromCredentials(host, username); + const connectionId = `nas_${Date.now()}_${Math.random().toString(36).slice(2, 11)}`; + connections.set(connectionId, { + host, + port: Number(port) || 22, + username, + password, + vaultKey, + createdAt: new Date(), + }); const token = jwt.sign({ connectionId }, SECRET_KEY, { expiresIn: '24h' }); - console.log(`Connection prepared: ${host}:${port} (${connectionId})`); + console.log(`NAS session: vault=${vaultKey.slice(0, 12)}… (${connectionId})`); res.json({ success: true, connectionToken: token, - message: `Connection prepared for ${host}:${port}`, - defaultPhotoDir: DEFAULT_PHOTO_DIR, + message: 'NAS REST session established', }); }); -app.post('/api/sftp/list', verifyToken, async (req, res) => { - const { path: dirPath = DEFAULT_PHOTO_DIR } = req.body; - const conn = req.connection; - +/** List — GET /api/nas/media?offset=&limit=&include=metadata|full */ +app.get('/api/nas/media', verifyNasSession, (req, res) => { try { - const result = await runSftpBatch(conn, [`ls -l ${dirPath}`]); - const files = result.stdout - .split('\n') - .filter(Boolean) - .map((line) => { - const parts = line.split(/\s+/); - return { - name: parts.pop(), - isDirectory: parts[0]?.charAt(0) === 'd', - size: parseInt(parts[4], 10), - modifyTime: new Date(`${parts[5]} ${parts[6]} ${parts[7]} ${parts[8]}`), - permissions: parts[0], - }; - }); - - res.json({ path: dirPath, files, count: files.length }); + const offset = Math.max(0, parseInt(req.query.offset, 10) || 0); + const limit = Math.min(500, Math.max(1, parseInt(req.query.limit, 10) || 100)); + const include = req.query.include === 'full' ? 'full' : 'metadata'; + const { items, total } = nasVaultStore.list(req.vaultKey, { offset, limit, include }); + res.json({ items, total, offset, limit, include }); } catch (err) { - console.error('List directory error:', err.message); - res.status(500).json({ error: 'Failed to list directory: ' + err.message }); + console.error('NAS list error:', err.message); + res.status(500).json({ error: err.message || 'List failed' }); } }); -app.get('/api/sftp/download', verifyToken, async (req, res) => { - const { path: filePath } = req.query; - if (!filePath) return res.status(400).json({ error: 'Missing path parameter' }); - - const conn = req.connection; - const filename = path.basename(filePath); - const tempFilePath = path.join(os.tmpdir(), filename); +/** Read — GET /api/nas/media/:id?include=metadata|full */ +app.get('/api/nas/media/:id', verifyNasSession, (req, res) => { + const row = nasVaultStore.getById(req.vaultKey, req.params.id); + if (!row) return res.status(404).json({ error: 'Media not found' }); + const include = req.query.include === 'full' ? 'full' : 'metadata'; + res.json(nasVaultStore.shapeRow(row, include)); +}); +/** Create — POST /api/nas/media */ +app.post('/api/nas/media', verifyNasSession, (req, res) => { try { - await runCommand('scp', buildScpArgs(conn, `${conn.username}@${conn.host}:${filePath}`, tempFilePath)); - res.download(tempFilePath, filename, () => { - try { fs.unlinkSync(tempFilePath); } catch (_) {} - }); + const { filename, mimeType, base64, metadata } = req.body || {}; + if (!base64) return res.status(400).json({ error: 'Missing base64 in body' }); + const created = nasVaultStore.create(req.vaultKey, { filename, mimeType, base64, metadata }); + res.status(201).json(created); } catch (err) { - console.error('Download error:', err.message); - res.status(500).json({ error: 'Download failed: ' + err.message }); + console.error('NAS create error:', err.message); + res.status(400).json({ error: err.message || 'Create failed' }); } }); -app.post('/api/sftp/upload', verifyToken, async (req, res) => { - const { remotePath } = req.body; - if (!remotePath || !req.files?.file) { - return res.status(400).json({ error: 'Missing remotePath or file' }); - } - - const file = req.files.file; - const conn = req.connection; - const tempFilePath = path.join(os.tmpdir(), file.name); - +/** Update — PUT /api/nas/media/:id */ +app.put('/api/nas/media/:id', verifyNasSession, (req, res) => { try { - await file.mv(tempFilePath); - await runCommand( - 'scp', - buildScpArgs(conn, tempFilePath, `${conn.username}@${conn.host}:${remotePath}`) - ); - fs.unlinkSync(tempFilePath); - console.log(`Uploaded: ${remotePath}`); - res.json({ success: true, remotePath, message: 'File uploaded' }); + const updated = nasVaultStore.update(req.vaultKey, req.params.id, req.body || {}); + if (!updated) return res.status(404).json({ error: 'Media not found' }); + res.json(updated); } catch (err) { - console.error('Upload error:', err.message); - res.status(500).json({ error: 'Upload failed: ' + err.message }); + console.error('NAS update error:', err.message); + res.status(400).json({ error: err.message || 'Update failed' }); } }); -app.post('/api/sftp/mkdir', verifyToken, async (req, res) => { - const { path: dirPath } = req.body; - if (!dirPath) return res.status(400).json({ error: 'Missing path parameter' }); - - try { - await runSftpBatch(req.connection, [`mkdir ${dirPath}`]); - console.log(`Created directory: ${dirPath}`); - res.json({ success: true, path: dirPath, message: 'Directory created' }); - } catch (err) { - console.error('Mkdir error:', err.message); - res.status(500).json({ error: 'Failed to create directory: ' + err.message }); - } +/** Delete — DELETE /api/nas/media/:id */ +app.delete('/api/nas/media/:id', verifyNasSession, (req, res) => { + const ok = nasVaultStore.remove(req.vaultKey, req.params.id); + if (!ok) return res.status(404).json({ error: 'Media not found' }); + res.status(204).send(); }); -app.post('/api/sftp/delete', verifyToken, async (req, res) => { - const { path: filePath } = req.body; - if (!filePath) return res.status(400).json({ error: 'Missing path parameter' }); - - try { - await runSftpBatch(req.connection, [`rm ${filePath}`]); - console.log(`Deleted: ${filePath}`); - res.json({ success: true, path: filePath, message: 'File deleted' }); - } catch (err) { - console.error('Delete error:', err.message); - res.status(500).json({ error: 'Failed to delete file: ' + err.message }); - } -}); - -app.post('/api/sftp/disconnect', verifyToken, (req, res) => { +/** End session (does not delete vault data) */ +app.delete('/api/nas/sessions/current', verifyNasSession, (req, res) => { connections.delete(req.connectionId); - console.log(`Disconnected: ${req.connectionId}`); - res.json({ success: true, message: 'Disconnected from SFTP server' }); + res.json({ success: true, message: 'Session closed' }); }); -app.post('/api/google-photos/fetch', verifyToken, async (req, res) => { +app.post('/api/google-photos/fetch', verifyNasSession, async (req, res) => { const { googleAccessToken, pageSize = 50, pageToken } = req.body; if (!googleAccessToken) { return res.status(400).json({ error: 'Google access token is required' }); @@ -269,12 +179,11 @@ app.post('/api/google-photos/fetch', verifyToken, async (req, res) => { } }); -app.post('/api/google-photos/download-to-nas', verifyToken, async (req, res) => { - const { googleAccessToken, baseUrl, filename, remotePath } = req.body; - const conn = req.connection; - - if (!googleAccessToken || !baseUrl || !remotePath) { - return res.status(400).json({ error: 'googleAccessToken, baseUrl, and remotePath are required' }); +/** Saves a Google image into the NAS vault as base64 with metadata (CRUD Create). */ +app.post('/api/google-photos/download-to-nas', verifyNasSession, async (req, res) => { + const { googleAccessToken, baseUrl, filename, metadata = {} } = req.body; + if (!googleAccessToken || !baseUrl) { + return res.status(400).json({ error: 'googleAccessToken and baseUrl are required' }); } try { @@ -285,30 +194,39 @@ app.post('/api/google-photos/download-to-nas', verifyToken, async (req, res) => return res.status(imageResponse.status).json({ error: 'Failed to download from Google Photos' }); } - const buffer = await imageResponse.buffer(); + const arrayBuffer = await imageResponse.arrayBuffer(); + const base64 = Buffer.from(arrayBuffer).toString('base64'); const safeName = filename || `gphoto_${Date.now()}.jpg`; - const tempFilePath = path.join(os.tmpdir(), safeName); - fs.writeFileSync(tempFilePath, buffer); + const mimeType = imageResponse.headers.get('content-type') || 'image/jpeg'; - const fullRemotePath = `${conn.username}@${conn.host}:${remotePath}/${safeName}`; - await runCommand('scp', buildScpArgs(conn, tempFilePath, fullRemotePath)); - fs.unlinkSync(tempFilePath); + const created = nasVaultStore.create(req.vaultKey, { + filename: safeName, + mimeType, + base64, + metadata: { + ...metadata, + source: 'google_photos_offload', + width: metadata.width, + height: metadata.height, + dateTaken: metadata.creationTime || metadata.dateTaken, + }, + }); - console.log(`Saved Google Photo to NAS: ${remotePath}/${safeName}`); - res.json({ success: true, message: `Photo saved to NAS at ${remotePath}/${safeName}` }); + console.log(`Saved Google Photo to NAS vault: ${created.id}`); + res.json({ success: true, media: created, message: 'Photo stored in NAS vault' }); } catch (err) { - console.error('Download-to-NAS error:', err.message); - res.status(500).json({ error: 'Failed to save photo to NAS: ' + err.message }); + console.error('Google → NAS error:', err.message); + res.status(500).json({ error: 'Failed to save photo to NAS vault: ' + err.message }); } }); -app.post('/api/google-photos/sync-status', verifyToken, (req, res) => { +app.post('/api/google-photos/sync-status', verifyNasSession, (req, res) => { res.json({ success: true, status: 'idle', message: 'No active sync in progress', lastSync: null }); }); process.on('SIGINT', () => { console.log('\nShutting down...'); - connections.forEach((_, id) => connections.delete(id)); + connections.clear(); process.exit(0); }); @@ -326,12 +244,10 @@ app.use((req, res) => { const PORT = process.env.PORT || 3001; app.listen(PORT, () => { - console.log(`\n SFTP Backend Server`); - console.log(` Port: ${PORT}`); - console.log(` URL: http://localhost:${PORT}`); - console.log(` Photo Dir: ${DEFAULT_PHOTO_DIR}`); - console.log(` Status: Ready\n`); + console.log(`\n NAS REST backend (CRUDL + base64 vault)`); + console.log(` Port: ${PORT}`); + console.log(` URL: http://localhost:${PORT}`); + console.log(` Status: Ready\n`); }); module.exports = app; - diff --git a/TEST/testNASAPI.js b/TEST/testNASAPI.js index 357092c..2776e86 100644 --- a/TEST/testNASAPI.js +++ b/TEST/testNASAPI.js @@ -1,7 +1,7 @@ const BACKEND_URL = process.env.BACKEND_URL || 'http://localhost:3001'; async function main() { - console.log('\n--- NAS Backend API Test ---'); + console.log('\n--- NAS REST backend test ---'); console.log(`Backend: ${BACKEND_URL}\n`); console.log('[1] Health check...'); @@ -9,38 +9,31 @@ async function main() { const res = await fetch(`${BACKEND_URL}/api/health`); if (!res.ok) throw new Error(`HTTP ${res.status}`); const data = await res.json(); - console.log(` Status: ${data.status}, connections: ${data.activeConnections}, photo dir: ${data.defaultPhotoDir}\n`); + console.log(` Status: ${data.status}, activeSessions: ${data.activeSessions}, mode: ${data.nasMode}\n`); } catch (err) { console.log(` Failed: ${err.message}`); - console.log(' Start the backend first: cd ServerFiles && node server.js\n'); + console.log(' Start the backend first: cd ServerFiles && npm start\n'); process.exit(1); } - const host = process.env.NAS_HOST; + const host = process.env.NAS_HOST || '127.0.0.1'; const port = parseInt(process.env.NAS_PORT || '22', 10); - const username = process.env.NAS_USER; - const password = process.env.NAS_PASS; - const folder = process.env.NAS_FOLDER || '/home/user/photos'; - - if (!host || !username || !password) { - console.log('[2-4] Skipped — set NAS_HOST, NAS_USER, NAS_PASS to run connection tests.\n'); - console.log('--- Tests complete (partial) ---\n'); - return; - } + const username = process.env.NAS_USER || 'demo'; + const password = process.env.NAS_PASS || 'demo'; let connectionToken = null; - console.log(`[2] SFTP connect to ${host}:${port}...`); + console.log(`[2] POST /api/nas/sessions (${host}, ${username})...`); try { - const res = await fetch(`${BACKEND_URL}/api/sftp/connect`, { + const res = await fetch(`${BACKEND_URL}/api/nas/sessions`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ host, port, username, password }), }); if (!res.ok) throw new Error(`HTTP ${res.status}: ${await res.text()}`); const data = await res.json(); - if (!data.success) throw new Error(data.error || 'Connect failed'); + if (!data.success) throw new Error(data.error || 'Session failed'); connectionToken = data.connectionToken; - console.log(` Connected.\n`); + console.log(' Session OK.\n'); } catch (err) { console.log(` Failed: ${err.message}\n`); process.exit(1); @@ -51,29 +44,73 @@ async function main() { Authorization: `Bearer ${connectionToken}`, }; - console.log(`[3] List directory "${folder}"...`); + console.log('[3] GET /api/nas/media (list)...'); try { - const res = await fetch(`${BACKEND_URL}/api/sftp/list`, { - method: 'POST', - headers: authHeaders, - body: JSON.stringify({ path: folder }), + const res = await fetch(`${BACKEND_URL}/api/nas/media?limit=10&include=metadata`, { + headers: { Authorization: `Bearer ${connectionToken}` }, }); if (!res.ok) throw new Error(`HTTP ${res.status}: ${await res.text()}`); const data = await res.json(); - console.log(` Found ${data.count || 0} items in ${data.path}`); - (data.files || []).slice(0, 5).forEach((f, i) => { - console.log(` ${i + 1}. ${f.name} ${f.isDirectory ? '(dir)' : `(${f.size} bytes)`}`); - }); - console.log(); + console.log(` total=${data.total}, returned=${(data.items || []).length}\n`); } catch (err) { console.log(` Failed: ${err.message}\n`); } - console.log('[4] Disconnect...'); + console.log('[4] POST /api/nas/media (tiny PNG create)...'); + let createdId = null; try { - const res = await fetch(`${BACKEND_URL}/api/sftp/disconnect`, { + const onePxPng = + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg=='; + const res = await fetch(`${BACKEND_URL}/api/nas/media`, { method: 'POST', headers: authHeaders, + body: JSON.stringify({ + filename: 'test-pixel.png', + mimeType: 'image/png', + base64: onePxPng, + metadata: { DateTimeOriginal: '2020:01:01 12:00:00', test: true }, + }), + }); + if (!res.ok) throw new Error(`HTTP ${res.status}: ${await res.text()}`); + const data = await res.json(); + createdId = data.id; + console.log(` Created id=${createdId}\n`); + } catch (err) { + console.log(` Failed: ${err.message}\n`); + } + + if (createdId) { + console.log('[5] GET /api/nas/media/:id ...'); + try { + const res = await fetch( + `${BACKEND_URL}/api/nas/media/${encodeURIComponent(createdId)}?include=metadata`, + { headers: { Authorization: `Bearer ${connectionToken}` } } + ); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + const data = await res.json(); + console.log(` filename=${data.filename}, byteLength=${data.byteLength}\n`); + } catch (err) { + console.log(` Failed: ${err.message}\n`); + } + + console.log('[6] DELETE /api/nas/media/:id ...'); + try { + const res = await fetch(`${BACKEND_URL}/api/nas/media/${encodeURIComponent(createdId)}`, { + method: 'DELETE', + headers: { Authorization: `Bearer ${connectionToken}` }, + }); + if (!res.ok && res.status !== 204) throw new Error(`HTTP ${res.status}`); + console.log(' Deleted.\n'); + } catch (err) { + console.log(` Failed: ${err.message}\n`); + } + } + + console.log('[7] DELETE /api/nas/sessions/current ...'); + try { + const res = await fetch(`${BACKEND_URL}/api/nas/sessions/current`, { + method: 'DELETE', + headers: { Authorization: `Bearer ${connectionToken}` }, }); if (!res.ok) throw new Error(`HTTP ${res.status}`); const data = await res.json(); diff --git a/app.json b/app.json index 7f7c250..bbbcd66 100644 --- a/app.json +++ b/app.json @@ -1,6 +1,5 @@ { "expo": { - "entryPoint": "./App.js", "name": "MASS Photo App", "slug": "mass-expo", "owner": "bobthebob", diff --git a/package.json b/package.json index f6c5e70..de3cebd 100644 --- a/package.json +++ b/package.json @@ -3,42 +3,42 @@ "version": "1.0.0", "main": "App.js", "scripts": { - "start": "expo start", - "android": "expo run:android", - "ios": "expo run:ios", - "web": "expo start --web", + "start": "npx expo start", + "android": "npx expo run:android", + "ios": "npx expo run:ios", + "web": "npx expo start --web", "backend": "node ServerFiles/server.js" }, "dependencies": { - "@react-navigation/bottom-tabs": "^6.5.20", - "@react-navigation/native": "^6.1.18", - "@react-navigation/native-stack": "^6.9.26", - "axios": "^1.7.9", - "expo": "~54.0.33", - "expo-application": "~7.0.8", - "expo-auth-session": "~7.0.10", - "expo-blur": "~15.0.8", - "expo-crypto": "~15.0.8", - "expo-file-system": "~19.0.21", - "expo-image-picker": "~17.0.10", - "expo-linking": "~8.0.11", - "expo-media-library": "~18.2.1", - "expo-secure-store": "~15.0.8", - "expo-sqlite": "~16.0.10", - "expo-status-bar": "~3.0.9", - "expo-system-ui": "^55.0.9", - "expo-web-browser": "~15.0.10", - "react": "19.1.0", - "react-native": "0.81.5", - "react-native-gesture-handler": "~2.28.0", - "react-native-reanimated": "~4.1.1", - "react-native-safe-area-context": "~5.6.0", - "react-native-screens": "~4.16.0", - "react-native-worklets": "0.5.1" + "@react-navigation/bottom-tabs": "^7.15.9", + "@react-navigation/native": "^7.2.2", + "@react-navigation/native-stack": "^7.14.11", + "axios": "^1.15.0", + "expo": "~55.0.15", + "expo-application": "~55.0.14", + "expo-auth-session": "~55.0.14", + "expo-blur": "~55.0.14", + "expo-crypto": "~55.0.14", + "expo-file-system": "~55.0.16", + "expo-image-picker": "~55.0.18", + "expo-linking": "~55.0.13", + "expo-media-library": "~55.0.14", + "expo-secure-store": "~55.0.13", + "expo-sqlite": "~55.0.15", + "expo-status-bar": "~55.0.5", + "expo-system-ui": "^55.0.15", + "expo-web-browser": "~55.0.14", + "react": "19.2.0", + "react-native": "0.83.4", + "react-native-gesture-handler": "~2.30.0", + "react-native-reanimated": "4.2.1", + "react-native-safe-area-context": "~5.6.2", + "react-native-screens": "~4.23.0", + "react-native-worklets": "0.7.2" }, "devDependencies": { "@babel/core": "^7.29.0", - "babel-preset-expo": "~12.0.0" + "babel-preset-expo": "~55.0.17" }, "overrides": { "jscodeshift": "0.16.1", diff --git a/src/API/NASAPI.js b/src/API/NASAPI.js index 69eff9a..6dffbfc 100644 --- a/src/API/NASAPI.js +++ b/src/API/NASAPI.js @@ -1,7 +1,33 @@ import axios from 'axios'; +import { File } from 'expo-file-system'; const DEFAULT_BACKEND_URL = process.env.EXPO_PUBLIC_BACKEND_URL || 'http://localhost:3001'; +function arrayBufferToBase64(buffer) { + const bytes = new Uint8Array(buffer); + const chunk = 0x8000; + let binary = ''; + for (let i = 0; i < bytes.length; i += chunk) { + binary += String.fromCharCode.apply(null, bytes.subarray(i, i + chunk)); + } + return typeof btoa !== 'undefined' ? btoa(binary) : Buffer.from(bytes).toString('base64'); +} + +function guessMimeFromName(name = '') { + const ext = `.${name.toLowerCase().split('.').pop()}`; + const map = { + '.jpg': 'image/jpeg', + '.jpeg': 'image/jpeg', + '.png': 'image/png', + '.gif': 'image/gif', + '.webp': 'image/webp', + '.heic': 'image/heic', + '.mp4': 'video/mp4', + '.mov': 'video/quicktime', + }; + return map[ext] || 'application/octet-stream'; +} + class NASAPI { constructor(backendUrl = DEFAULT_BACKEND_URL) { this.backendUrl = backendUrl; @@ -10,6 +36,14 @@ class NASAPI { this.credentials = null; } + getHeaders() { + if (!this.connectionToken) return {}; + return { Authorization: `Bearer ${this.connectionToken}` }; + } + + /** + * Opens a REST session (vault scoped by host + username). + */ async connect(host, port, username, password, photoFolder = '/Photos') { if (!host || !username || !password) throw new Error('Host, username, and password are required'); @@ -17,107 +51,193 @@ class NASAPI { try { const response = await axios.post( - `${this.backendUrl}/api/sftp/connect`, + `${this.backendUrl}/api/nas/sessions`, { host, port: port || 22, username, password }, { timeout: 10000 } ); - if (response.data?.success) { + if (response.data?.success && response.data.connectionToken) { this.connectionToken = response.data.connectionToken; this.isConnected = true; - return { success: true, message: `Connected to ${host}` }; + return { success: true, message: `Connected to NAS REST API (${host})` }; } } catch (_) { - console.log('Backend not available, storing NAS credentials locally'); + console.log('NAS backend not available, credentials stored locally only'); } this.isConnected = true; - return { success: true, message: `NAS configured: ${host} (backend connection pending)`, connectionData: this.credentials }; + return { + success: true, + message: `NAS configured: ${host} (backend session pending)`, + connectionData: this.credentials, + }; } - getHeaders() { - if (!this.connectionToken) return {}; - return { Authorization: `Bearer ${this.connectionToken}` }; + /** List — GET /api/nas/media */ + async listMedia({ offset = 0, limit = 100, include = 'metadata' } = {}) { + if (!this.connectionToken) return { items: [], total: 0 }; + const response = await axios.get(`${this.backendUrl}/api/nas/media`, { + headers: this.getHeaders(), + params: { offset, limit, include }, + timeout: 120000, + }); + return response.data; } - async listDirectory(path = '/') { - if (!this.connectionToken) return { path, files: [], message: 'Backend not connected' }; + /** Read — GET /api/nas/media/:id */ + async getMedia(id, { include = 'metadata' } = {}) { + if (!this.connectionToken) throw new Error('Backend not connected'); + const response = await axios.get(`${this.backendUrl}/api/nas/media/${encodeURIComponent(id)}`, { + headers: this.getHeaders(), + params: { include }, + timeout: 120000, + }); + return response.data; + } + + /** Create — POST /api/nas/media */ + async createMedia({ filename, mimeType, base64, metadata = {} }) { + if (!this.connectionToken) throw new Error('Backend not connected'); const response = await axios.post( - `${this.backendUrl}/api/sftp/list`, { path }, - { headers: this.getHeaders(), timeout: 15000 } + `${this.backendUrl}/api/nas/media`, + { filename, mimeType, base64, metadata }, + { headers: { ...this.getHeaders(), 'Content-Type': 'application/json' }, timeout: 120000 } ); return response.data; } - async getPhotos(folder = null) { - const targetFolder = folder || this.credentials?.photoFolder || '/Photos'; - if (!this.connectionToken) return { mediaItems: [], folder: targetFolder, message: 'Backend not connected' }; + /** Update — PUT /api/nas/media/:id */ + async updateMedia(id, payload) { + if (!this.connectionToken) throw new Error('Backend not connected'); + const response = await axios.put( + `${this.backendUrl}/api/nas/media/${encodeURIComponent(id)}`, + payload, + { headers: { ...this.getHeaders(), 'Content-Type': 'application/json' }, timeout: 120000 } + ); + return response.data; + } + + /** Delete — DELETE /api/nas/media/:id */ + async deleteMedia(id) { + if (!this.connectionToken) throw new Error('Backend not connected'); + await axios.delete(`${this.backendUrl}/api/nas/media/${encodeURIComponent(id)}`, { + headers: this.getHeaders(), + timeout: 30000, + }); + return { success: true }; + } + + /** + * Maps vault rows to app media items. `include=full` loads data URLs for thumbnails. + */ + async getPhotos(_folder = null) { + const targetFolder = _folder || this.credentials?.photoFolder || '/Photos'; + if (!this.connectionToken) { + return { mediaItems: [], folder: targetFolder, message: 'Backend not connected' }; + } try { - const listing = await this.listDirectory(targetFolder); - const photoExts = ['.jpg', '.jpeg', '.png', '.gif', '.bmp', '.webp', '.heic']; - const videoExts = ['.mp4', '.mov', '.avi', '.mkv', '.webm']; + const { items = [], total = 0 } = await this.listMedia({ offset: 0, limit: 200, include: 'full' }); + const mediaItems = items.map((row) => { + const meta = row.metadata || {}; + const dateTaken = meta.dateTaken || meta.DateTimeOriginal || meta.creationTime || row.createdAt; + const isVideo = (row.mimeType || '').startsWith('video'); + const dateTakenIso = meta.dateTaken + || (typeof dateTaken === 'string' ? dateTaken : null) + || (dateTaken != null ? new Date(dateTaken).toISOString() : null); + return { + source_service: 'NAS', + source_id: row.id, + title: row.filename, + remote_path: row.id, + media_type: isVideo ? 'video' : 'photo', + created_date: dateTakenIso || dateTaken, + size: row.byteLength, + metadata_json: JSON.stringify({ + width: meta.width, + height: meta.height, + cameraMake: meta.cameraMake || meta.Make, + cameraModel: meta.cameraModel || meta.Model, + dateTaken: dateTakenIso, + mimeType: row.mimeType, + ...meta, + }), + local_path: row.dataUrl || null, + }; + }); - const mediaItems = (listing.files || []) - .filter((file) => { - const ext = '.' + (file.name || '').toLowerCase().split('.').pop(); - return photoExts.includes(ext) || videoExts.includes(ext); - }) - .map((file) => { - const ext = '.' + (file.name || '').toLowerCase().split('.').pop(); - return { - source_service: 'NAS', - source_id: `nas_${file.name}`, - title: file.name, - remote_path: `${targetFolder}/${file.name}`, - media_type: videoExts.includes(ext) ? 'video' : 'photo', - created_date: file.modifyTime, - size: file.size, - }; - }); - - return { mediaItems, folder: targetFolder }; + return { mediaItems, folder: targetFolder, total }; } catch (error) { - console.error('Get photos error:', error.message); + console.error('NAS getPhotos error:', error.message); return { mediaItems: [], folder: targetFolder }; } } + /** Flat shape expected by MediaMetadataService.enrichNasItem */ + async getFileMetadata(mediaId) { + const row = await this.getMedia(mediaId, { include: 'metadata' }); + const m = row.metadata || {}; + return { + width: m.width, + height: m.height, + ExifImageWidth: m.width, + ExifImageHeight: m.height, + Make: m.cameraMake || m.Make, + Model: m.cameraModel || m.Model, + DateTimeOriginal: m.dateTaken || m.DateTimeOriginal || m.CreateDate || row.createdAt, + dateTaken: m.dateTaken, + CreateDate: m.CreateDate, + MIMEType: row.mimeType, + fileSize: row.byteLength, + }; + } + + async listDirectory(_path = '/') { + const { items = [] } = await this.listMedia({ limit: 500, include: 'metadata' }); + return { + path: '/', + files: items.map((i) => ({ + name: i.filename, + isDirectory: false, + size: i.byteLength, + modifyTime: i.updatedAt, + id: i.id, + })), + count: items.length, + }; + } + async uploadFile(localPath, remotePath) { - if (!this.connectionToken) throw new Error('Backend not connected — cannot upload'); - const formData = new FormData(); - formData.append('remotePath', remotePath); - formData.append('file', { uri: localPath, name: remotePath.split('/').pop(), type: 'application/octet-stream' }); - - const response = await axios.post( - `${this.backendUrl}/api/sftp/upload`, formData, - { headers: { ...this.getHeaders(), 'Content-Type': 'multipart/form-data' }, timeout: 60000 } - ); - return response.data; + const file = new File(localPath); + if (!file.exists) throw new Error('Local file not found'); + const buf = await file.arrayBuffer(); + const base64 = arrayBufferToBase64(buf); + const name = (remotePath && remotePath.split('/').pop()) || file.name || 'upload'; + return this.createMedia({ + filename: name, + mimeType: guessMimeFromName(name), + base64, + metadata: { uploadedFrom: localPath }, + }); } - async createDirectory(remotePath) { - if (!this.connectionToken) throw new Error('Backend not connected'); - const response = await axios.post( - `${this.backendUrl}/api/sftp/mkdir`, { path: remotePath }, - { headers: this.getHeaders(), timeout: 10000 } - ); - return response.data; + async createDirectory(_remotePath) { + return { success: true, message: 'REST vault has no directories; use POST /api/nas/media' }; } - async deleteFile(remotePath) { - if (!this.connectionToken) throw new Error('Backend not connected'); - const response = await axios.post( - `${this.backendUrl}/api/sftp/delete`, { path: remotePath }, - { headers: this.getHeaders(), timeout: 10000 } - ); - return response.data; + async deleteFile(mediaId) { + return this.deleteMedia(mediaId); } async disconnect() { if (this.connectionToken) { try { - await axios.post(`${this.backendUrl}/api/sftp/disconnect`, {}, { headers: this.getHeaders(), timeout: 5000 }); - } catch (_) {} + await axios.delete(`${this.backendUrl}/api/nas/sessions/current`, { + headers: this.getHeaders(), + timeout: 5000, + }); + } catch (_) { + /* session may already be invalid */ + } } this.connectionToken = null; this.isConnected = false; diff --git a/src/AUTH/GoogleAUTH.js b/src/AUTH/GoogleAUTH.js index b4ff691..93f86b9 100644 --- a/src/AUTH/GoogleAUTH.js +++ b/src/AUTH/GoogleAUTH.js @@ -3,6 +3,7 @@ import * as WebBrowser from 'expo-web-browser'; import * as Application from 'expo-application'; import { File, Directory, Paths } from 'expo-file-system'; import { AuthServiceBase } from './AuthServiceBase'; +import MediaMetadataService from '../services/MediaMetadataService'; import { logError } from '../services/ErrorHandler'; WebBrowser.maybeCompleteAuthSession(); @@ -29,6 +30,8 @@ const PICKER_API = 'https://photospicker.googleapis.com/v1'; const CACHE_DIR = new Directory(Paths.cache, 'google_photos'); const REDIRECT_URI = `${Application.applicationId}:/oauthredirect`; +const EMPTY_PICK_RESULT = { mediaItems: [], nextPageToken: null, sessionId: null }; +const TOKEN_FALLBACK_EXPIRES_MS = 3600 * 1000; class GoogleAUTH extends AuthServiceBase { constructor() { @@ -39,12 +42,26 @@ class GoogleAUTH extends AuthServiceBase { this.email = null; } + _setTokenState(tokenData) { + this.accessToken = tokenData.access_token; + this.refreshToken = tokenData.refresh_token || this.refreshToken || null; + this.expiresAt = Date.now() + (tokenData.expires_in || 3600) * 1000; + } + + _buildPickerError(code, message, sessionId = null) { + return { + ...EMPTY_PICK_RESULT, + sessionId, + error: { code, message }, + }; + } + async authenticate() { if (!ACTIVE_CLIENT_ID) { console.warn('Google Auth: no Android client ID — using placeholder mode'); this.accessToken = 'placeholder_google_token_' + Date.now(); this.refreshToken = 'placeholder_refresh_' + Date.now(); - this.expiresAt = Date.now() + 3600000; + this.expiresAt = Date.now() + TOKEN_FALLBACK_EXPIRES_MS; this.email = 'google-user@placeholder.com'; return { success: true, @@ -99,9 +116,7 @@ class GoogleAUTH extends AuthServiceBase { throw err; } - this.accessToken = tokenData.access_token; - this.refreshToken = tokenData.refresh_token || null; - this.expiresAt = Date.now() + (tokenData.expires_in || 3600) * 1000; + this._setTokenState(tokenData); console.log('Google token granted scopes:', tokenData.scope || '(none returned)'); @@ -154,8 +169,7 @@ class GoogleAUTH extends AuthServiceBase { throw err; } - this.accessToken = data.access_token; - this.expiresAt = Date.now() + (data.expires_in || 3600) * 1000; + this._setTokenState(data); return this.accessToken; } @@ -201,6 +215,13 @@ class GoogleAUTH extends AuthServiceBase { return await res.json(); } + _parseFileExtension(item) { + if (item.mime_type?.includes('png')) return '.png'; + if (item.mime_type?.includes('gif')) return '.gif'; + if (item.mime_type?.includes('webp')) return '.webp'; + return '.jpg'; + } + async _waitForSession(sessionId) { const token = await this.getAccessToken(); const maxAttempts = 20; @@ -245,13 +266,6 @@ class GoogleAUTH extends AuthServiceBase { } const data = await res.json(); - - if (data.mediaItems?.length) { - console.log('Picker raw item[0]:', JSON.stringify(data.mediaItems[0], null, 2)); - } else { - console.log('Picker returned no mediaItems. Keys:', Object.keys(data)); - } - return data; } @@ -267,6 +281,8 @@ class GoogleAUTH extends AuthServiceBase { const creationTime = file.mediaFileMetadata?.creationTime || item.mediaFileMetadata?.creationTime || null; + const mw = file.mediaFileMetadata?.width ?? item.mediaFileMetadata?.width; + const mh = file.mediaFileMetadata?.height ?? item.mediaFileMetadata?.height; if (idx === 0) { console.log(`Normalising item[0]: id=${item.id}, baseUrl=${baseUrl?.substring(0, 60)}…, filename=${filename}, mime=${mimeType}`); @@ -283,8 +299,10 @@ class GoogleAUTH extends AuthServiceBase { media_type: isVideo ? 'video' : 'photo', mime_type: mimeType, created_date: creationTime, + width: mw, + height: mh, }; - }); + }).map((m) => MediaMetadataService.enrichGooglePickerItem(m)); } async downloadPickerPhotos(items) { @@ -312,10 +330,7 @@ class GoogleAUTH extends AuthServiceBase { try { const safeId = (item.source_id || 'photo').replace(/[^a-zA-Z0-9_-]/g, '_'); - const ext = item.mime_type?.includes('png') ? '.png' - : item.mime_type?.includes('gif') ? '.gif' - : item.mime_type?.includes('webp') ? '.webp' - : '.jpg'; + const ext = this._parseFileExtension(item); const destFile = new File(CACHE_DIR, `${safeId}${ext}`); if (destFile.exists && destFile.size > 1024) { @@ -369,16 +384,10 @@ class GoogleAUTH extends AuthServiceBase { const session = await this._createPickerSession(); if (session?.error) { - return { - mediaItems: [], nextPageToken: null, sessionId: null, - error: { code: session.error.code, message: session.error.message }, - }; + return this._buildPickerError(session.error.code, session.error.message); } if (!session?.id || !session?.pickerUri) { - return { - mediaItems: [], nextPageToken: null, sessionId: null, - error: { code: 0, message: 'Failed to create Picker session' }, - }; + return this._buildPickerError(0, 'Failed to create Picker session'); } console.log('Picker session created:', session.id); @@ -393,10 +402,7 @@ class GoogleAUTH extends AuthServiceBase { const ready = await this._waitForSession(session.id); if (!ready) { - return { - mediaItems: [], nextPageToken: null, sessionId: session.id, - error: { code: 0, message: 'No photos were selected or the picker was dismissed' }, - }; + return this._buildPickerError(0, 'No photos were selected or the picker was dismissed', session.id); } const result = await this._fetchPickerMediaItems(session.id, 100, null); @@ -413,7 +419,7 @@ class GoogleAUTH extends AuthServiceBase { }; } catch (error) { logError('Pick Photos', error); - return { mediaItems: [], nextPageToken: null, sessionId: null }; + return EMPTY_PICK_RESULT; } } diff --git a/src/AUTH/NASAUTH.js b/src/AUTH/NASAUTH.js index c8e777a..8bd78fe 100644 --- a/src/AUTH/NASAUTH.js +++ b/src/AUTH/NASAUTH.js @@ -13,6 +13,10 @@ class NASAUTH extends AuthServiceBase { this.isConnected = false; } + ensureConnected() { + if (!this.isConnected) throw new Error('Not connected to NAS'); + } + async authenticate(host, port = 22, username, password, photoFolder = '/Photos') { try { if (!host || !username || !password) throw new Error('Host, username, and password are required'); @@ -35,7 +39,7 @@ class NASAUTH extends AuthServiceBase { } async getAccessToken() { - if (!this.isConnected) throw new Error('Not connected to NAS'); + this.ensureConnected(); return { host: this.host, username: this.username, isConnected: this.isConnected }; } @@ -61,39 +65,45 @@ class NASAUTH extends AuthServiceBase { } async getUserInfo() { - if (!this.isConnected) throw new Error('Not connected to NAS'); + this.ensureConnected(); return { host: this.host, username: this.username, port: this.port, photoFolder: this.photoFolder, connectedAt: new Date().toISOString(), - serverType: 'NAS SFTP', + serverType: 'NAS REST (CRUDL)', }; } async listDirectory(remotePath = '/') { - if (!this.isConnected) throw new Error('Not connected to NAS'); + this.ensureConnected(); return await this.nasAPI.listDirectory(remotePath); } async uploadFile(localPath, remotePath) { - if (!this.isConnected) throw new Error('Not connected to NAS'); + this.ensureConnected(); return await this.nasAPI.uploadFile(localPath, remotePath); } async getPhotos(photoFolder = null) { - if (!this.isConnected) throw new Error('Not connected to NAS'); + this.ensureConnected(); return await this.nasAPI.getPhotos(photoFolder || this.photoFolder || '/Photos'); } + /** Fetches EXIF/size metadata for a remote file via the REST backend (see ServerFiles/server.js). */ + async fetchFileMetadata(remotePath) { + this.ensureConnected(); + return await this.nasAPI.getFileMetadata(remotePath); + } + async createDirectory(remotePath) { - if (!this.isConnected) throw new Error('Not connected to NAS'); + this.ensureConnected(); return await this.nasAPI.createDirectory(remotePath); } async deleteFile(remotePath) { - if (!this.isConnected) throw new Error('Not connected to NAS'); + this.ensureConnected(); return await this.nasAPI.deleteFile(remotePath); } } diff --git a/src/components/MediaViewer.js b/src/components/MediaViewer.js index 214ca92..c7a5525 100644 --- a/src/components/MediaViewer.js +++ b/src/components/MediaViewer.js @@ -4,16 +4,14 @@ import { Dimensions, FlatList, } from 'react-native'; import { useTheme } from '../services/ThemeContext'; +import MediaDetailDrawer from './MediaDetailDrawer'; + +const DRAWER_PEEK = 124; const { width: SCREEN_WIDTH, height: SCREEN_HEIGHT } = Dimensions.get('window'); const THUMB_SIZE = 60; const THUMB_GAP = 4; - -const getServiceIcon = (type) => - ({ DEVICE: '📱', GOOGLE_PHOTOS: '📷', ICLOUD_PHOTOS: '🍎', NAS: '💾' }[type] || '📱'); -const getServiceName = (type) => - ({ DEVICE: 'On Device', GOOGLE_PHOTOS: 'Google Photos', ICLOUD_PHOTOS: 'iCloud', NAS: 'NAS' }[type] || type); -const MediaViewer = ({ visible, media, allMedia, onClose }) => { +const MediaViewer = ({ visible, media, allMedia, onClose, resolveNasMetadata }) => { const { theme } = useTheme(); const mainListRef = useRef(null); const thumbListRef = useRef(null); @@ -70,17 +68,20 @@ const MediaViewer = ({ visible, media, allMedia, onClose }) => { } }; - if (!media) return null; + const renderImageOrPlaceholder = (item, placeholderSize) => { + if (item.local_path) { + return ; + } + return ( + + 📷 + + ); + }; const renderMainItem = ({ item }) => ( - - {item.local_path ? ( - - ) : ( - - 📷 - - )} + + {renderImageOrPlaceholder(item, 64)} ); @@ -105,6 +106,8 @@ const MediaViewer = ({ visible, media, allMedia, onClose }) => { const displayMedia = currentMedia || media; + if (!media) return null; + return ( @@ -138,17 +141,13 @@ const MediaViewer = ({ visible, media, allMedia, onClose }) => { style={styles.mainList} /> ) : ( - displayMedia.local_path ? ( - - ) : ( - - 📷 - - ) + + {renderImageOrPlaceholder(displayMedia, 64)} + )} {hasCarousel && ( - + { )} - - {displayMedia.title || 'Photo'} - - Source - - {displayMedia.sources && displayMedia.sources.length > 1 - ? displayMedia.sources.map((s) => `${getServiceIcon(s)} ${getServiceName(s)}`).join(' · ') - : `${getServiceIcon(displayMedia.source_service)} ${getServiceName(displayMedia.source_service)}` - } - - - {displayMedia.sources && displayMedia.sources.length > 1 && ( - - Available on - - {displayMedia.sources.length} services - - - )} - {displayMedia.created_date && ( - - Date - - {new Date(displayMedia.created_date).toLocaleDateString()} - - - )} + + @@ -201,6 +179,12 @@ const MediaViewer = ({ visible, media, allMedia, onClose }) => { const styles = StyleSheet.create({ container: { flex: 1 }, + drawerLayer: { + ...StyleSheet.absoluteFillObject, + zIndex: 50, + elevation: 50, + justifyContent: 'flex-end', + }, closeButton: { position: 'absolute', top: 44, right: 16, zIndex: 20, width: 36, height: 36, borderRadius: 18, @@ -217,7 +201,10 @@ const styles = StyleSheet.create({ fullImage: { flex: 1, width: '100%' }, fullPlaceholder: { flex: 1, justifyContent: 'center', alignItems: 'center' }, thumbCarousel: { - position: 'absolute', bottom: 110, left: 0, right: 0, zIndex: 15, + position: 'absolute', left: 0, right: 0, + /* Below MediaDetailDrawer (drawer zIndex 50) so expanded metadata sits on top. */ + zIndex: 8, + elevation: 8, backgroundColor: 'rgba(0,0,0,0.4)', paddingVertical: 8, }, @@ -231,14 +218,6 @@ const styles = StyleSheet.create({ width: '100%', height: '100%', backgroundColor: '#333', justifyContent: 'center', alignItems: 'center', }, - infoPanel: { - padding: 16, paddingBottom: 32, - borderTopLeftRadius: 16, borderTopRightRadius: 16, - }, - title: { fontSize: 16, fontWeight: '600', color: '#fff', marginBottom: 8 }, - row: { flexDirection: 'row', justifyContent: 'space-between', marginBottom: 4 }, - label: { fontSize: 12, color: '#9aa0a6' }, - value: { fontSize: 12, color: '#e8eaed', fontWeight: '500' }, }); export default MediaViewer; diff --git a/src/components/NASSetupInstructions.js b/src/components/NASSetupInstructions.js index f962779..df13b6f 100644 --- a/src/components/NASSetupInstructions.js +++ b/src/components/NASSetupInstructions.js @@ -23,53 +23,34 @@ const NASSetupInstructions = () => { {expanded && ( + What this is + + The app talks to your machine as a REST “NAS client”: photos live in a vault on the Node server (base64 + JSON metadata), not over SFTP directory listing. Host, port, username, and password open a session and choose which vault file your media is stored in. + + Prerequisites - • An Ubuntu server (20.04 LTS or later) with SSH access enabled{'\n'} - • A user account with sudo access on the server{'\n'} - • The server's IP address and SSH port (default: 22) + • Node.js on the machine running the backend{'\n'} + • A reachable URL for that backend (LAN IP or tunnel){'\n'} + • Same host/username pair always maps to the same on-disk vault - Step 1: Enable SSH + Step 1: Run the backend - If SSH is not already enabled on your Ubuntu server, open a terminal on the server and run:{'\n\n'} - sudo apt update && sudo apt install openssh-server{'\n'} - sudo systemctl enable ssh && sudo systemctl start ssh - - - Step 2: Create a Photos Directory - - Create a dedicated folder for your photo storage:{'\n\n'} - mkdir -p ~/photos{'\n'} - chmod 755 ~/photos - - - Step 3: Configure Firewall (if needed) - - If your server uses UFW, allow SSH connections:{'\n\n'} - sudo ufw allow ssh{'\n'} - sudo ufw enable - - - Step 4: Find Your Server IP - - Run this on your server to find its local IP:{'\n\n'} - hostname -I{'\n\n'} - Use that IP address in the "Server Address" field above. - - - Step 5: Connect from the App - - Enter the server IP, SSH port (22), your username, password, and the photos folder path (e.g. /home/youruser/photos).{'\n\n'} - The app's backend server will handle the SFTP connection securely. - - - Step 6: Run the Backend Server - - The NAS connection uses a backend server. Navigate to the ServerFiles folder and run:{'\n\n'} + From the ServerFiles folder:{'\n\n'} npm install{'\n'} - node server.js{'\n\n'} - Ensure the backend URL in the app settings matches the server address (e.g. http://192.168.1.100:3001). + npm start{'\n\n'} + Optional: set JWT_SECRET and JSON_BODY_LIMIT in .env for production. + + + Step 2: Connect from the app + + Enter the backend host (e.g. your PC LAN IP), port 22 is accepted for compatibility, username, password, and a folder label if shown — listing uses the REST vault (CRUDL), not that path on disk. Set EXPO_PUBLIC_BACKEND_URL so the app points at http://YOUR_IP:3001. + + + API shape + + POST /api/nas/sessions → JWT. Then GET/POST/PUT/DELETE /api/nas/media for list/create/read/update/delete. Metadata you send on create is returned on read and used by the app for sorting and the detail drawer. )} diff --git a/src/components/PhotoGrid.js b/src/components/PhotoGrid.js index 3476803..ba92966 100644 --- a/src/components/PhotoGrid.js +++ b/src/components/PhotoGrid.js @@ -1,16 +1,24 @@ import React from 'react'; import { - View, Text, FlatList, TouchableOpacity, Image, Dimensions, StyleSheet, + View, Text, FlatList, SectionList, TouchableOpacity, Image, Dimensions, StyleSheet, } from 'react-native'; import { useTheme } from '../services/ThemeContext'; +import MediaMetadataService from '../services/MediaMetadataService'; const { width } = Dimensions.get('window'); const GRID_GAP = 2; +const SERVICE_ICONS = { DEVICE: '📱', GOOGLE_PHOTOS: '📷', ICLOUD_PHOTOS: '🍎', NAS: '💾' }; const PhotoGrid = ({ data, - onItemPress, + timelineSections, columns = 3, + onItemPress, + onLongPressItem, + onToggleSelect, + selectionMode, + selectedKeys, + getItemKey, refreshControl, ListHeaderComponent, ListEmptyComponent, @@ -21,7 +29,7 @@ const PhotoGrid = ({ const itemSize = (width - GRID_GAP * (columns + 1)) / columns; const getServiceIcon = (type) => - ({ DEVICE: '📱', GOOGLE_PHOTOS: '📷', ICLOUD_PHOTOS: '🍎', NAS: '💾' }[type] || '📱'); + (SERVICE_ICONS[type] || SERVICE_ICONS.DEVICE); const renderSourceBadges = (item) => { const sources = item.sources && item.sources.length > 0 @@ -45,38 +53,113 @@ const PhotoGrid = ({ ); }; - const renderItem = ({ item }) => ( - onItemPress?.(item)} - activeOpacity={0.8} - > - {item.local_path ? ( - console.warn(`Image load failed [${item.source_service}]: ${item.local_path?.substring(0, 80)}`, e.nativeEvent?.error)} + const keyOf = (item) => (getItemKey ? getItemKey(item) : MediaMetadataService.mediaFingerprint(item)); + + const renderCell = (item) => { + if (!item) { + return ( + - ) : ( - - 📷 - - )} - {item.media_type === 'video' && ( - - ▶ - - )} - {renderSourceBadges(item)} - - ); + ); + } + const k = keyOf(item); + const selected = selectionMode && selectedKeys?.has(k); + return ( + { + if (selectionMode) { + onToggleSelect?.(item); + return; + } + onItemPress?.(item); + }} + onLongPress={() => onLongPressItem?.(item)} + delayLongPress={380} + activeOpacity={0.8} + > + {item.local_path ? ( + console.warn(`Image load failed [${item.source_service}]: ${item.local_path?.substring(0, 80)}`, e.nativeEvent?.error)} + /> + ) : ( + + 📷 + + )} + {item.media_type === 'video' && ( + + ▶ + + )} + {renderSourceBadges(item)} + {selected && ( + + ✓ + + )} + + ); + }; + + const renderRow = ({ item }) => { + const row = item.row || item; + return ( + + {Array.from({ length: columns }).map((_, i) => ( + + {renderCell(row[i])} + + ))} + + ); + }; + + if (timelineSections && timelineSections.length > 0) { + return ( + item.rowKey || item.row?.map((c) => (c ? keyOf(c) : '·')).join('|')} + renderItem={renderRow} + renderSectionHeader={({ section }) => ( + + {section.title} + + )} + stickySectionHeadersEnabled + contentContainerStyle={styles.gridContent} + refreshControl={refreshControl} + ListHeaderComponent={ListHeaderComponent} + ListEmptyComponent={ListEmptyComponent} + ListFooterComponent={ListFooterComponent} + onEndReached={onEndReached} + onEndReachedThreshold={0.4} + /> + ); + } return ( item.id?.toString() || item.source_id || i.toString()} + renderItem={({ item }) => renderCell(item)} + keyExtractor={(item, i) => keyOf(item) || i.toString()} numColumns={columns} contentContainerStyle={styles.gridContent} refreshControl={refreshControl} @@ -91,6 +174,19 @@ const PhotoGrid = ({ const styles = StyleSheet.create({ gridContent: { paddingBottom: 20 }, + rowWrap: { + flexDirection: 'row', + flexWrap: 'nowrap', + justifyContent: 'flex-start', + paddingHorizontal: GRID_GAP / 2, + marginBottom: GRID_GAP, + }, + sectionHeader: { + paddingHorizontal: 14, + paddingTop: 12, + paddingBottom: 8, + }, + sectionTitle: { fontSize: 15, fontWeight: '700' }, gridItem: { margin: GRID_GAP / 2, position: 'relative', @@ -112,7 +208,14 @@ const styles = StyleSheet.create({ borderRadius: 8, paddingHorizontal: 4, paddingVertical: 2, flexDirection: 'row', gap: 2, }, + checkBadge: { + position: 'absolute', top: 4, left: 4, + width: 24, height: 24, borderRadius: 12, + backgroundColor: 'rgba(0,0,0,0.55)', + borderWidth: 2, + justifyContent: 'center', alignItems: 'center', + }, + checkMark: { fontSize: 14, fontWeight: '800' }, }); export default PhotoGrid; - diff --git a/src/components/TopBar.js b/src/components/TopBar.js index d6582e3..b15f45b 100644 --- a/src/components/TopBar.js +++ b/src/components/TopBar.js @@ -4,6 +4,7 @@ import { useTheme } from '../services/ThemeContext'; const TopBar = ({ currentUser, onSearchPress, onProfilePress, onMenuPress }) => { const { theme } = useTheme(); + const profileInitial = currentUser?.username?.charAt(0)?.toUpperCase() || '?'; return ( @@ -18,9 +19,7 @@ const TopBar = ({ currentUser, onSearchPress, onProfilePress, onMenuPress }) => style={[styles.profileButton, { backgroundColor: theme.profileBg }]} onPress={onProfilePress} > - - {currentUser?.username?.charAt(0)?.toUpperCase() || '?'} - + {profileInitial} ⋮ diff --git a/src/screens/AlbumsScreen.js b/src/screens/AlbumsScreen.js index 26661fd..d6c4eab 100644 --- a/src/screens/AlbumsScreen.js +++ b/src/screens/AlbumsScreen.js @@ -6,12 +6,15 @@ import { import { useFocusEffect } from '@react-navigation/native'; import { useTheme } from '../services/ThemeContext'; import DatabaseService from '../services/DatabaseService'; +import MediaMetadataService from '../services/MediaMetadataService'; import MediaViewer from '../components/MediaViewer'; const { width } = Dimensions.get('window'); const ALBUM_COLUMNS = 2; const ALBUM_GAP = 12; const albumSize = (width - ALBUM_GAP * (ALBUM_COLUMNS + 1)) / ALBUM_COLUMNS; +const SERVICE_ICONS = { DEVICE: '📱', GOOGLE_PHOTOS: '📷', ICLOUD_PHOTOS: '🍎', NAS: '💾' }; +const SERVICE_NAMES = { DEVICE: 'On Device', GOOGLE_PHOTOS: 'Google Photos', ICLOUD_PHOTOS: 'iCloud Photos', NAS: 'NAS Storage' }; const CollectionsScreen = ({ route }) => { const userId = route?.params?.userId; @@ -30,8 +33,13 @@ const CollectionsScreen = ({ route }) => { }, [userId]) ); - const getServiceIcon = (type) => ({ DEVICE: '📱', GOOGLE_PHOTOS: '📷', ICLOUD_PHOTOS: '🍎', NAS: '💾' }[type] || '📱'); - const getServiceName = (type) => ({ DEVICE: 'On Device', GOOGLE_PHOTOS: 'Google Photos', ICLOUD_PHOTOS: 'iCloud Photos', NAS: 'NAS Storage' }[type] || type); + const getServiceIcon = (type) => SERVICE_ICONS[type] || SERVICE_ICONS.DEVICE; + const getServiceName = (type) => SERVICE_NAMES[type] || type; + + const refreshCollections = () => { + setRefreshing(true); + loadCollections(); + }; const loadCollections = async () => { try { @@ -39,6 +47,28 @@ const CollectionsScreen = ({ route }) => { const allMedia = await DatabaseService.getMediaByUser(userId); const sectionList = []; + const userAlbumRows = await DatabaseService.getUserAlbums(userId); + const myAlbums = []; + for (const al of userAlbumRows) { + const entries = await DatabaseService.getAlbumEntries(userId, al.id); + const resolved = MediaMetadataService.resolveAlbumEntriesToMediaItems(entries, allMedia); + myAlbums.push({ + name: al.name, + icon: '📁', + count: resolved.length, + coverImage: resolved[0]?.local_path || null, + media: resolved, + type: 'user_album', + }); + } + if (myAlbums.length > 0) { + sectionList.push({ + title: 'My albums', + icon: '📂', + data: [{ albums: myAlbums }], + }); + } + const utilityAlbums = [ { name: 'All Photos', icon: '📸', count: allMedia.length, media: allMedia, type: 'utility' }, { name: 'Hidden', icon: '🙈', count: 0, media: [], type: 'utility' }, @@ -175,7 +205,8 @@ const CollectionsScreen = ({ route }) => { Collections - {sections.length === 0 || (sections.length === 1 && sections[0].data[0].albums[0].count === 0) ? ( + {sections.length === 0 + || !sections.some((sec) => sec.data?.[0]?.albums?.some((a) => (a.count || 0) > 0)) ? ( 📁 No Collections @@ -192,7 +223,7 @@ const CollectionsScreen = ({ route }) => { contentContainerStyle={styles.listContent} stickySectionHeadersEnabled={false} refreshControl={ - { setRefreshing(true); loadCollections(); }} /> + } /> )} diff --git a/src/screens/HomeScreen.js b/src/screens/HomeScreen.js index 70ad2ee..d4fc76e 100644 --- a/src/screens/HomeScreen.js +++ b/src/screens/HomeScreen.js @@ -1,6 +1,6 @@ -import React, { useState, useEffect, useCallback } from 'react'; +import React, { useState, useEffect, useCallback, useMemo } from 'react'; import { - StyleSheet, View, Text, ActivityIndicator, ScrollView, RefreshControl, + StyleSheet, View, Text, ActivityIndicator, ScrollView, RefreshControl, Alert, TouchableOpacity, } from 'react-native'; import { useFocusEffect } from '@react-navigation/native'; import { useTheme } from '../services/ThemeContext'; @@ -12,8 +12,28 @@ import { File } from 'expo-file-system'; import TopBar from '../components/TopBar'; import PhotoGrid from '../components/PhotoGrid'; import MediaViewer from '../components/MediaViewer'; +import SelectionToolbar from '../components/SelectionToolbar'; +import AlbumPickerModal from '../components/AlbumPickerModal'; +import MediaSortControls from '../components/MediaSortControls'; +import MediaMetadataService from '../services/MediaMetadataService'; import { logError } from '../services/ErrorHandler'; -import { TouchableOpacity } from 'react-native'; + +const SERVICE_ICONS = { DEVICE: '📱', GOOGLE_PHOTOS: '📷', ICLOUD_PHOTOS: '🍎', NAS: '💾' }; +const SERVICE_NAMES = { DEVICE: 'On Device', GOOGLE_PHOTOS: 'Google Photos', ICLOUD_PHOTOS: 'iCloud', NAS: 'NAS' }; + +/** Keeps device library timestamps and aligns `created_date` with resolved capture time after merges. */ +const mergeCaptureFields = (target, donor) => { + if (donor.source_service === 'DEVICE' && donor.creationTime != null) { + target.creationTime = donor.creationTime ?? target.creationTime; + } + if (target.creationTime == null && donor.creationTime != null) { + target.creationTime = donor.creationTime; + } + if (donor.modifyTime != null) target.modifyTime = donor.modifyTime ?? target.modifyTime; + const iso = MediaMetadataService.resolveCreatedDateIsoForStorage(target); + if (iso) target.created_date = iso; +}; + const mergeMediaAcrossServices = (items) => { const byFilename = new Map(); const noFilename = []; @@ -36,6 +56,15 @@ const mergeMediaAcrossServices = (items) => { if (!existing.local_path && item.local_path) { existing.local_path = item.local_path; } + existing.width = existing.width || item.width; + existing.height = existing.height || item.height; + if (item.metadata_json) { + existing.metadata_json = MediaMetadataService.mergeMetadataJson( + existing.metadata_json, + item.metadata_json + ); + } + mergeCaptureFields(existing, item); } else { byFilename.set(key, { ...item, sources: [item.source_service] }); } @@ -49,14 +78,48 @@ const mergeMediaAcrossServices = (items) => { if (!existing.sources.includes(src)) existing.sources.push(src); } if (!existing.local_path && item.local_path) existing.local_path = item.local_path; + existing.width = existing.width || item.width; + existing.height = existing.height || item.height; + if (item.metadata_json) { + existing.metadata_json = MediaMetadataService.mergeMetadataJson( + existing.metadata_json, + item.metadata_json + ); + } + mergeCaptureFields(existing, item); } else { bySourceId.set(key, item); } } - return Array.from(bySourceId.values()); + return Array.from(bySourceId.values()).map((row) => { + mergeCaptureFields(row, row); + return row; + }); }; +const buildGoogleAuth = (accountData = {}) => { + const googleAuth = new GoogleAUTH(); + googleAuth.accessToken = accountData.accessToken || null; + googleAuth.refreshToken = accountData.refreshToken || null; + googleAuth.expiresAt = accountData.expiresAt || 0; + return googleAuth; +}; + +const normalizeGoogleMedia = (items = []) => + items.map((item) => { + if (!item.local_path) return item; + try { + const file = new File(item.local_path); + if (!file.exists || file.size === 0) { + return { ...item, local_path: null }; + } + return item; + } catch (_) { + return { ...item, local_path: null }; + } + }); + const HomeScreen = ({ navigation, route }) => { const userId = route?.params?.userId; const { theme } = useTheme(); @@ -75,6 +138,21 @@ const HomeScreen = ({ navigation, route }) => { const [googleSessionId, setGoogleSessionId] = useState(null); const [loadingMore, setLoadingMore] = useState(false); const [importing, setImporting] = useState(false); + const [sortMode, setSortMode] = useState('date_taken'); + const [selectionMode, setSelectionMode] = useState(false); + const [selectedKeys, setSelectedKeys] = useState(() => new Set()); + const [albumModalVisible, setAlbumModalVisible] = useState(false); + const [userAlbums, setUserAlbums] = useState([]); + + const selectedItems = useMemo(() => { + if (!selectionMode || selectedKeys.size === 0) return []; + return filteredMedia.filter((m) => selectedKeys.has(MediaMetadataService.mediaFingerprint(m))); + }, [selectionMode, selectedKeys, filteredMedia]); + + const timelineSections = useMemo(() => { + if (sortMode !== 'date_taken' || !filteredMedia.length) return null; + return MediaMetadataService.groupMediaByDayForTimeline(filteredMedia, 3); + }, [filteredMedia, sortMode]); useFocusEffect( useCallback(() => { @@ -84,9 +162,9 @@ const HomeScreen = ({ navigation, route }) => { useEffect(() => { if (currentUser) loadAllMedia(); - }, [currentUser]); + }, [currentUser, linkedAccounts]); - useEffect(() => { filterMedia(); }, [selectedFilter, allMedia]); + useEffect(() => { filterMedia(); }, [selectedFilter, allMedia, sortMode]); const loadUserData = async () => { try { @@ -94,6 +172,8 @@ const HomeScreen = ({ navigation, route }) => { setCurrentUser(user); const accounts = await DatabaseService.getLinkedAccounts(userId); setLinkedAccounts(accounts); + const albums = await DatabaseService.getUserAlbums(userId); + setUserAlbums(albums); } catch (err) { console.error('Error loading user data:', err); } @@ -108,6 +188,8 @@ const HomeScreen = ({ navigation, route }) => { const deviceResult = await DeviceMediaService.getPhotos(100); if (deviceResult.mediaItems.length) { newMedia.push(...deviceResult.mediaItems); + const chunk = deviceResult.mediaItems.slice(0, 50); + void Promise.all(chunk.map((m) => DatabaseService.saveMediaItem(userId, m))).catch(() => {}); } setDeviceEndCursor(deviceResult.endCursor); setDeviceHasMore(deviceResult.hasNextPage); @@ -118,20 +200,7 @@ const HomeScreen = ({ navigation, route }) => { try { const googleMedia = await DatabaseService.getMediaByService(userId, 'GOOGLE_PHOTOS'); if (googleMedia?.length) { - const validated = googleMedia.map((item) => { - if (item.local_path) { - try { - const f = new File(item.local_path); - if (!f.exists || f.size === 0) { - return { ...item, local_path: null }; - } - } catch (_) { - return { ...item, local_path: null }; - } - } - return item; - }); - newMedia.push(...validated); + newMedia.push(...normalizeGoogleMedia(googleMedia)); } } catch (err) { console.error('DB Google media fetch error:', err); @@ -166,6 +235,11 @@ const HomeScreen = ({ navigation, route }) => { } }; + const refreshMedia = useCallback(() => { + setRefreshing(true); + loadAllMedia(); + }, [loadAllMedia]); + const importGooglePhotos = async () => { const googleAccount = linkedAccounts.find((a) => a.service_type === 'GOOGLE_PHOTOS'); if (!googleAccount) { @@ -176,10 +250,7 @@ const HomeScreen = ({ navigation, route }) => { setImporting(true); try { const data = googleAccount.account_data || {}; - const googleAuth = new GoogleAUTH(); - googleAuth.accessToken = data.accessToken || null; - googleAuth.refreshToken = data.refreshToken || null; - googleAuth.expiresAt = data.expiresAt || 0; + const googleAuth = buildGoogleAuth(data); let result = await googleAuth.pickPhotos(); @@ -199,9 +270,7 @@ const HomeScreen = ({ navigation, route }) => { if (result?.error) { logError('Import Google Photos', result.error.message); } else if (result?.mediaItems?.length) { - for (const item of result.mediaItems) { - await DatabaseService.saveMediaItem(userId, item); - } + await Promise.all(result.mediaItems.map((item) => DatabaseService.saveMediaItem(userId, item))); setGoogleNextPage(result.nextPageToken || null); setGoogleSessionId(result.sessionId || null); @@ -234,8 +303,84 @@ const HomeScreen = ({ navigation, route }) => { (m.sources && m.sources.includes(selectedFilter)) ); } - filtered.sort((a, b) => new Date(b.created_date || 0) - new Date(a.created_date || 0)); - setFilteredMedia(filtered); + setFilteredMedia(MediaMetadataService.sortMediaItems(filtered, sortMode)); + }; + + const resolveNasMetadata = useCallback( + async (item) => { + const nasId = item.remote_path || item.source_id; + if (!nasId || item.source_service !== 'NAS') return null; + const acc = linkedAccounts.find((a) => a.service_type === 'NAS'); + if (!acc?.account_data) return null; + const nas = new NASAUTH(); + const d = acc.account_data; + try { + await nas.authenticate(d.host, d.port, d.username, d.password, d.photoFolder); + const raw = await nas.fetchFileMetadata(nasId); + await nas.revokeAccess(); + if (!raw) return null; + const enriched = MediaMetadataService.enrichNasItem(item, raw); + return MediaMetadataService.parseMetadata(enriched.metadata_json); + } catch (e) { + console.warn('NAS metadata:', e?.message); + try { + await nas.revokeAccess(); + } catch { + /* */ + } + return null; + } + }, + [linkedAccounts] + ); + + const clearSelection = () => { + setSelectionMode(false); + setSelectedKeys(new Set()); + }; + + const toggleSelect = (item) => { + const k = MediaMetadataService.mediaFingerprint(item); + setSelectedKeys((prev) => { + const n = new Set(prev); + if (n.has(k)) n.delete(k); + else n.add(k); + return n; + }); + }; + + const onLongPressItem = (item) => { + setSelectionMode(true); + setSelectedKeys((prev) => new Set(prev).add(MediaMetadataService.mediaFingerprint(item))); + }; + + const addSelectionToAlbum = async (albumId) => { + const items = [...selectedItems]; + for (const m of items) { + await DatabaseService.addMediaToAlbum(userId, albumId, m); + } + Alert.alert('Album', `Added ${items.length} item(s) to album`); + clearSelection(); + setAlbumModalVisible(false); + const albums = await DatabaseService.getUserAlbums(userId); + setUserAlbums(albums); + }; + + const createAlbumAndAdd = async (name) => { + const id = await DatabaseService.createAlbum(userId, name); + if (!id) { + Alert.alert('Error', 'Could not create album'); + return; + } + const items = [...selectedItems]; + for (const m of items) { + await DatabaseService.addMediaToAlbum(userId, id, m); + } + Alert.alert('Album', `Added ${items.length} item(s) to “${name.trim()}”`); + clearSelection(); + setAlbumModalVisible(false); + const albums = await DatabaseService.getUserAlbums(userId); + setUserAlbums(albums); }; const loadMorePhotos = async () => { @@ -259,17 +404,12 @@ const HomeScreen = ({ navigation, route }) => { const googleAccount = linkedAccounts.find((a) => a.service_type === 'GOOGLE_PHOTOS'); if (googleAccount) { const data = googleAccount.account_data || {}; - const googleAuth = new GoogleAUTH(); - googleAuth.accessToken = data.accessToken || null; - googleAuth.refreshToken = data.refreshToken || null; - googleAuth.expiresAt = data.expiresAt || 0; + const googleAuth = buildGoogleAuth(data); const photos = await googleAuth.getMorePhotos(100, googleNextPage, googleSessionId); if (photos?.mediaItems?.length) { moreMedia.push(...photos.mediaItems); - for (const item of photos.mediaItems) { - await DatabaseService.saveMediaItem(userId, item); - } + await Promise.all(photos.mediaItems.map((item) => DatabaseService.saveMediaItem(userId, item))); } setGoogleNextPage(photos?.nextPageToken || null); if (photos?.sessionId) setGoogleSessionId(photos.sessionId); @@ -286,8 +426,8 @@ const HomeScreen = ({ navigation, route }) => { } }; - const getServiceIcon = (type) => ({ DEVICE: '📱', GOOGLE_PHOTOS: '📷', ICLOUD_PHOTOS: '🍎', NAS: '💾' }[type] || '📱'); - const getServiceName = (type) => ({ DEVICE: 'On Device', GOOGLE_PHOTOS: 'Google Photos', ICLOUD_PHOTOS: 'iCloud', NAS: 'NAS' }[type] || type); + const getServiceIcon = (type) => SERVICE_ICONS[type] || SERVICE_ICONS.DEVICE; + const getServiceName = (type) => SERVICE_NAMES[type] || type; const hasGoogleAccount = linkedAccounts.some((a) => a.service_type === 'GOOGLE_PHOTOS'); @@ -336,13 +476,22 @@ const HomeScreen = ({ navigation, route }) => { navigation.navigate('Search')} + onSearchPress={() => navigation.navigate('Search', { userId })} onProfilePress={() => navigation.navigate('Settings', { userId })} onMenuPress={() => navigation.navigate('Settings', { userId })} /> + + + setAlbumModalVisible(true)} + onClear={clearSelection} + /> + {hasGoogleAccount && ( { { setRefreshing(true); loadAllMedia(); }} + onPress={refreshMedia} > Refresh ) : ( { setSelectedMedia(item); setViewerVisible(true); }} + onLongPressItem={onLongPressItem} + onToggleSelect={toggleSelect} + selectionMode={selectionMode} + selectedKeys={selectedKeys} onEndReached={loadMorePhotos} refreshControl={ - { setRefreshing(true); loadAllMedia(); }} /> + } ListFooterComponent={loadingMore ? ( @@ -393,11 +547,20 @@ const HomeScreen = ({ navigation, route }) => { /> )} + setAlbumModalVisible(false)} + albums={userAlbums} + onPickAlbum={(albumId) => addSelectionToAlbum(albumId)} + onCreateAlbum={createAlbumAndAdd} + /> + setViewerVisible(false)} + resolveNasMetadata={resolveNasMetadata} /> ); diff --git a/src/screens/LoginScreen.js b/src/screens/LoginScreen.js index 5f290f0..5a1a609 100644 --- a/src/screens/LoginScreen.js +++ b/src/screens/LoginScreen.js @@ -7,6 +7,7 @@ import { useTheme } from '../services/ThemeContext'; import DatabaseService from '../services/DatabaseService'; const APP_ICON = require('../../assets/icon.png'); +const isBlank = (value) => !value?.trim(); const LoginScreen = ({ navigation }) => { const { theme } = useTheme(); @@ -15,7 +16,7 @@ const LoginScreen = ({ navigation }) => { const [loading, setLoading] = useState(false); const handleLogin = async () => { - if (!identifier.trim() || !password.trim()) { + if (isBlank(identifier) || isBlank(password)) { Alert.alert('Error', 'Please enter your username/email and password'); return; } diff --git a/src/screens/OnboardingScreen.js b/src/screens/OnboardingScreen.js index b482204..2bba23e 100644 --- a/src/screens/OnboardingScreen.js +++ b/src/screens/OnboardingScreen.js @@ -1,4 +1,4 @@ -import React, { useState } from 'react'; +import React, { useState, useEffect } from 'react'; import { StyleSheet, View, Text, ScrollView, TouchableOpacity, Alert, ActivityIndicator, Switch, TextInput, @@ -10,9 +10,27 @@ import NASAUTH from '../AUTH/NASAUTH'; import NASSetupInstructions from '../components/NASSetupInstructions'; import ServiceHealthIndicator from '../components/ServiceHealthIndicator'; +const SERVICES = { + GOOGLE_PHOTOS: { name: 'Google Photos', type: 'GOOGLE_PHOTOS', icon: '📷' }, + ICLOUD_PHOTOS: { name: 'iCloud Photos', type: 'ICLOUD_PHOTOS', icon: '🍎' }, + NAS: { name: 'NAS Storage', type: 'NAS', icon: '💾' }, +}; +const ONBOARDING_STEPS = [ + { title: 'Choose Services', description: 'Select which photo services to connect (1-3)' }, + { title: 'Connect Services', description: 'Set up each selected service' }, + { title: 'Automatic Backup', description: 'Choose where new photos are backed up' }, +]; + const OnboardingScreen = ({ navigation, route }) => { - const { userId } = route.params; + const userId = route?.params?.userId; const { theme } = useTheme(); + + useEffect(() => { + if (userId == null) { + navigation.replace('Login'); + } + }, [userId, navigation]); + const [currentStep, setCurrentStep] = useState(0); const [loading, setLoading] = useState(false); @@ -36,17 +54,11 @@ const OnboardingScreen = ({ navigation, route }) => { const [autoBackupServices, setAutoBackupServices] = useState([]); - const steps = [ - { title: 'Choose Services', description: 'Select which photo services to connect (1-3)' }, - { title: 'Connect Services', description: 'Set up each selected service' }, - { title: 'Automatic Backup', description: 'Choose where new photos are backed up' }, - ]; - const getConnectedServices = () => { const services = []; - if (googleEnabled && googleConnected) services.push({ name: 'Google Photos', type: 'GOOGLE_PHOTOS', icon: '📷' }); - if (iosEnabled && iosConnected) services.push({ name: 'iCloud Photos', type: 'ICLOUD_PHOTOS', icon: '🍎' }); - if (nasEnabled && nasConnected) services.push({ name: 'NAS Storage', type: 'NAS', icon: '💾' }); + if (googleEnabled && googleConnected) services.push(SERVICES.GOOGLE_PHOTOS); + if (iosEnabled && iosConnected) services.push(SERVICES.ICLOUD_PHOTOS); + if (nasEnabled && nasConnected) services.push(SERVICES.NAS); return services; }; @@ -104,14 +116,15 @@ const OnboardingScreen = ({ navigation, route }) => { try { setLoading(true); setNasHealth('checking'); + const parsedNasPort = parseInt(nasPort, 10) || 22; const nasAuth = new NASAUTH(); await nasAuth.authenticate( - nasHost.trim(), parseInt(nasPort) || 22, + nasHost.trim(), parsedNasPort, nasUsername.trim(), nasPassword, nasPhotoFolder.trim() ); await DatabaseService.linkAccount(userId, 'NAS', { host: nasHost.trim(), - port: parseInt(nasPort) || 22, + port: parsedNasPort, username: nasUsername.trim(), photoFolder: nasPhotoFolder.trim(), }); @@ -158,19 +171,27 @@ const OnboardingScreen = ({ navigation, route }) => { } }; + if (userId == null) { + return ( + + + + ); + } + return ( - {steps.map((_, i) => ( + {ONBOARDING_STEPS.map((_, i) => ( ))} - Step {currentStep + 1} of {steps.length} - {steps[currentStep].title} - {steps[currentStep].description} + Step {currentStep + 1} of {ONBOARDING_STEPS.length} + {ONBOARDING_STEPS[currentStep].title} + {ONBOARDING_STEPS[currentStep].description} {currentStep === 0 && ( @@ -332,7 +353,7 @@ const OnboardingScreen = ({ navigation, route }) => { > {loading ? : ( - {currentStep === steps.length - 1 ? 'Get Started' : 'Next'} + {currentStep === ONBOARDING_STEPS.length - 1 ? 'Get Started' : 'Next'} )} diff --git a/src/screens/RegisterScreen.js b/src/screens/RegisterScreen.js index d75cfae..aaba2e9 100644 --- a/src/screens/RegisterScreen.js +++ b/src/screens/RegisterScreen.js @@ -7,6 +7,8 @@ import { useTheme } from '../services/ThemeContext'; import DatabaseService from '../services/DatabaseService'; const APP_ICON = require('../../assets/icon.png'); +const isBlank = (value) => !value?.trim(); +const isEmailValid = (value) => value.includes('@') && value.includes('.'); const RegisterScreen = ({ navigation }) => { const { theme } = useTheme(); @@ -17,11 +19,11 @@ const RegisterScreen = ({ navigation }) => { const [loading, setLoading] = useState(false); const handleRegister = async () => { - if (!username.trim()) { Alert.alert('Error', 'Please enter a username'); return; } + if (isBlank(username)) { Alert.alert('Error', 'Please enter a username'); return; } if (username.trim().length < 3) { Alert.alert('Error', 'Username must be at least 3 characters'); return; } - if (!email.trim()) { Alert.alert('Error', 'Please enter an email'); return; } - if (!email.includes('@') || !email.includes('.')) { Alert.alert('Error', 'Please enter a valid email address'); return; } - if (!password.trim()) { Alert.alert('Error', 'Please enter a password'); return; } + if (isBlank(email)) { Alert.alert('Error', 'Please enter an email'); return; } + if (!isEmailValid(email)) { Alert.alert('Error', 'Please enter a valid email address'); return; } + if (isBlank(password)) { Alert.alert('Error', 'Please enter a password'); return; } if (password.length < 6) { Alert.alert('Error', 'Password must be at least 6 characters'); return; } if (password !== confirmPassword) { Alert.alert('Error', 'Passwords do not match'); return; } diff --git a/src/screens/SearchScreen.js b/src/screens/SearchScreen.js index b434117..0acc496 100644 --- a/src/screens/SearchScreen.js +++ b/src/screens/SearchScreen.js @@ -76,7 +76,9 @@ const SearchScreen = ({ route }) => { 🔍 Search your photos - Search by title or description + + Search by filename and indexed metadata (bag-of-words style matching) + )} diff --git a/src/screens/SettingsScreen.js b/src/screens/SettingsScreen.js index 17ce7b7..86d187d 100644 --- a/src/screens/SettingsScreen.js +++ b/src/screens/SettingsScreen.js @@ -11,6 +11,14 @@ import ServiceHealthIndicator from '../components/ServiceHealthIndicator'; import NASAUTH from '../AUTH/NASAUTH'; import GoogleAUTH from '../AUTH/GoogleAUTH'; +const SERVICE_ICONS = { GOOGLE_PHOTOS: '📷', ICLOUD_PHOTOS: '🍎', NAS: '💾' }; +const SERVICE_NAMES = { GOOGLE_PHOTOS: 'Google Photos', ICLOUD_PHOTOS: 'iCloud Photos', NAS: 'NAS Storage' }; +const AUTO_BACKUP_SERVICES = [ + { type: 'GOOGLE_PHOTOS', name: 'Google Photos', icon: '📷' }, + { type: 'ICLOUD_PHOTOS', name: 'iCloud Photos', icon: '🍎' }, + { type: 'NAS', name: 'NAS Storage', icon: '💾' }, +]; + const SettingsScreen = ({ navigation, route }) => { const userId = route?.params?.userId; const { theme, themeName, setThemeName } = useTheme(); @@ -93,10 +101,14 @@ const SettingsScreen = ({ navigation, route }) => { } }; + const updateServiceHealth = (serviceType, status) => { + setServiceHealth((prev) => ({ ...prev, [serviceType]: status })); + }; + const recheckHealth = async (acc) => { - setServiceHealth((prev) => ({ ...prev, [acc.service_type]: 'checking' })); + updateServiceHealth(acc.service_type, 'checking'); const status = await checkSingleHealth(acc); - setServiceHealth((prev) => ({ ...prev, [acc.service_type]: status })); + updateServiceHealth(acc.service_type, status); }; const toggleService = async (accountId, currentEnabled) => { @@ -188,13 +200,14 @@ const SettingsScreen = ({ navigation, route }) => { setLoading(true); try { const nasAuth = new NASAUTH(); + const parsedNasPort = parseInt(nasPort, 10) || 22; await nasAuth.authenticate( - nasHost.trim(), parseInt(nasPort) || 22, + nasHost.trim(), parsedNasPort, nasUsername.trim(), nasPassword, nasPhotoFolder.trim() ); await DatabaseService.linkAccount(userId, 'NAS', { host: nasHost.trim(), - port: parseInt(nasPort) || 22, + port: parsedNasPort, username: nasUsername.trim(), photoFolder: nasPhotoFolder.trim(), }); @@ -216,9 +229,9 @@ const SettingsScreen = ({ navigation, route }) => { }; const getServiceIcon = (type) => - ({ GOOGLE_PHOTOS: '📷', ICLOUD_PHOTOS: '🍎', NAS: '💾' }[type] || '📱'); + (SERVICE_ICONS[type] || '📱'); const getServiceName = (type) => - ({ GOOGLE_PHOTOS: 'Google Photos', ICLOUD_PHOTOS: 'iCloud Photos', NAS: 'NAS Storage' }[type] || type); + (SERVICE_NAMES[type] || type); const themeOptions = [ { key: 'light', label: 'Light', icon: '☀️', desc: 'Standard light mode' }, @@ -379,11 +392,7 @@ const SettingsScreen = ({ navigation, route }) => { New photos from your device will be automatically uploaded to the selected services. - {[ - { type: 'GOOGLE_PHOTOS', name: 'Google Photos', icon: '📷' }, - { type: 'ICLOUD_PHOTOS', name: 'iCloud Photos', icon: '🍎' }, - { type: 'NAS', name: 'NAS Storage', icon: '💾' }, - ].map((svc) => { + {AUTO_BACKUP_SERVICES.map((svc) => { const isConnected = accounts.some((a) => a.service_type === svc.type && a.is_enabled); return ( { style={[styles.checkRow, { borderBottomWidth: 0 }]} onPress={() => { setAutoBackup([]); - DatabaseService.setAutoBackupServices(userId, []); + DatabaseService.setAutoBackupServices(userId, []).catch((err) => { + console.error('Error saving auto backup:', err); + }); }} activeOpacity={0.7} > diff --git a/src/services/DatabaseService.js b/src/services/DatabaseService.js index 0e950a2..c23cb3a 100644 --- a/src/services/DatabaseService.js +++ b/src/services/DatabaseService.js @@ -1,9 +1,55 @@ import * as SQLite from 'expo-sqlite'; import CredentialEncryption from './CredentialEncryption'; +import MediaMetadataService from './MediaMetadataService'; import { logError } from './ErrorHandler'; let db = null; let initPromise = null; +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 hasValue = (value) => value || value === 0; +const withDecryptedAccount = (row) => ({ ...row, account_data: CredentialEncryption.decrypt(row.account_data) }); +const toNullable = (value) => value || null; +const normalizeMetaString = (itemOrParams) => { + if (!itemOrParams) return null; + if (itemOrParams.metadata && typeof itemOrParams.metadata === 'object') { + return JSON.stringify(itemOrParams.metadata); + } + if (itemOrParams.metadata_json != null && typeof itemOrParams.metadata_json === 'object') { + return JSON.stringify(itemOrParams.metadata_json); + } + return toNullable(itemOrParams.metadata_json); +}; +const buildMediaInsertParams = (itemOrParams) => { + const metaStr = normalizeMetaString(itemOrParams); + const searchText = + itemOrParams.search_text || + MediaMetadataService.buildSearchText( + { + title: itemOrParams.title, + description: itemOrParams.description, + source_service: itemOrParams.sourceService || itemOrParams.source_service, + local_path: itemOrParams.localPath || itemOrParams.local_path, + remote_path: itemOrParams.remotePath || itemOrParams.remote_path, + }, + metaStr + ); + return [ + itemOrParams.userId, + itemOrParams.sourceService || itemOrParams.source_service, + toNullable(itemOrParams.sourceId || itemOrParams.source_id), + toNullable(itemOrParams.title), + toNullable(itemOrParams.description), + toNullable(itemOrParams.localPath || itemOrParams.local_path), + toNullable(itemOrParams.remotePath || itemOrParams.remote_path), + toNullable(itemOrParams.mediaType || itemOrParams.media_type), + toNullable(itemOrParams.createdDate || itemOrParams.created_date), + metaStr, + searchText, + ]; +}; const DatabaseService = { initDatabase: async () => { @@ -61,6 +107,31 @@ const DatabaseService = { try { await db.execAsync("ALTER TABLE users ADD COLUMN auto_backup_services TEXT DEFAULT '[]'"); } catch (_) {} try { await db.execAsync("ALTER TABLE users ADD COLUMN theme_preference TEXT DEFAULT 'light'"); } catch (_) {} + try { await db.execAsync('ALTER TABLE media ADD COLUMN metadata_json TEXT'); } catch (_) {} + try { await db.execAsync('ALTER TABLE media ADD COLUMN search_text TEXT'); } catch (_) {} + + await db.execAsync(` + CREATE TABLE IF NOT EXISTS user_albums ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL, + name TEXT NOT NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY(user_id) REFERENCES users(id) + ); + CREATE TABLE IF NOT EXISTS album_entries ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + album_id INTEGER NOT NULL, + media_fingerprint TEXT NOT NULL, + source_service TEXT, + title_snapshot TEXT, + local_path TEXT, + remote_path TEXT, + metadata_json TEXT, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY(album_id) REFERENCES user_albums(id) ON DELETE CASCADE, + UNIQUE(album_id, media_fingerprint) + ); + `); console.log('Database initialized successfully'); return true; @@ -84,6 +155,11 @@ const DatabaseService = { return true; }, + runMediaInsert: async (params) => { + const result = await db.runAsync(MEDIA_INSERT_SQL, buildMediaInsertParams(params)); + return result.lastInsertRowId; + }, + createUser: async (username, email, password) => { try { if (!(await DatabaseService.ensureDb())) return null; @@ -132,7 +208,7 @@ const DatabaseService = { getUserById: async (userId) => { try { if (!(await DatabaseService.ensureDb())) return null; - if (!userId && userId !== 0) { + if (!hasValue(userId)) { logError('Get User', 'User ID is required'); return null; } @@ -175,7 +251,7 @@ const DatabaseService = { getLinkedAccounts: async (userId) => { try { if (!(await DatabaseService.ensureDb())) return []; - if (!userId && userId !== 0) { + if (!hasValue(userId)) { logError('Get Linked Accounts', 'User ID is required'); return []; } @@ -183,10 +259,7 @@ const DatabaseService = { 'SELECT * FROM linked_accounts WHERE user_id = ? AND is_enabled = 1', [userId] ); - return rows.map((row) => ({ - ...row, - account_data: CredentialEncryption.decrypt(row.account_data), - })); + return rows.map(withDecryptedAccount); } catch (error) { logError('Get Linked Accounts', error); return []; @@ -201,7 +274,7 @@ const DatabaseService = { [userId, serviceType] ); if (!row) return null; - return { ...row, account_data: CredentialEncryption.decrypt(row.account_data) }; + return withDecryptedAccount(row); } catch (error) { logError('Get Linked Account By Service', error); return null; @@ -226,7 +299,7 @@ const DatabaseService = { [userId] ); if (!row) return null; - return { ...row, account_data: CredentialEncryption.decrypt(row.account_data) }; + return withDecryptedAccount(row); } catch (error) { logError('Get Default Service', error); return null; @@ -261,10 +334,7 @@ const DatabaseService = { 'SELECT * FROM linked_accounts WHERE user_id = ?', [userId] ); - return rows.map((row) => ({ - ...row, - account_data: CredentialEncryption.decrypt(row.account_data), - })); + return rows.map(withDecryptedAccount); } catch (error) { logError('Get All Linked Accounts', error); return []; @@ -278,13 +348,17 @@ const DatabaseService = { logError('Add Media', 'userId and sourceService are required'); return null; } - const result = await db.runAsync( - `INSERT INTO media (user_id, source_service, source_id, title, description, local_path, remote_path, media_type, created_date) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, - [userId, sourceService, sourceId || null, title || null, description || null, - localPath || null, remotePath || null, mediaType || null, createdDate || null] - ); - return result.lastInsertRowId; + return await DatabaseService.runMediaInsert({ + userId, + sourceService, + sourceId, + title, + description, + localPath, + remotePath, + mediaType, + createdDate, + }); } catch (error) { logError('Add Media', error); return null; @@ -297,22 +371,59 @@ const DatabaseService = { if (!userId || !item?.source_service) return null; + const incomingMeta = normalizeMetaString(item); + const mergedFromIncoming = incomingMeta + ? MediaMetadataService.mergeMetadataJson('{}', incomingMeta) + : null; + if (item.source_id) { const existing = await db.getFirstAsync( - 'SELECT id FROM media WHERE user_id = ? AND source_id = ?', + 'SELECT * FROM media WHERE user_id = ? AND source_id = ?', [userId, item.source_id] ); - if (existing) return existing.id; + if (existing) { + const mergedMeta = incomingMeta + ? MediaMetadataService.mergeMetadataJson(existing.metadata_json, incomingMeta) + : existing.metadata_json; + const searchText = MediaMetadataService.buildSearchText( + { ...existing, ...item, title: item.title || existing.title }, + mergedMeta + ); + const rowForCapture = { + ...existing, + ...item, + title: item.title || existing.title, + metadata_json: mergedMeta, + creationTime: item.creationTime ?? existing.creationTime, + modifyTime: item.modifyTime ?? existing.modifyTime, + }; + const createdForDb = + MediaMetadataService.resolveCreatedDateIsoForStorage(rowForCapture) + ?? toNullable(item.created_date) + ?? toNullable(existing.created_date); + await db.runAsync( + `UPDATE media SET metadata_json = ?, search_text = ?, + local_path = COALESCE(?, local_path), remote_path = COALESCE(?, remote_path), + title = COALESCE(?, title), created_date = ? + WHERE id = ?`, + [ + mergedMeta, + searchText, + toNullable(item.local_path), + toNullable(item.remote_path), + toNullable(item.title), + createdForDb, + existing.id, + ] + ); + return existing.id; + } } - const result = await db.runAsync( - `INSERT INTO media (user_id, source_service, source_id, title, description, local_path, remote_path, media_type, created_date) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, - [userId, item.source_service, item.source_id || null, item.title || null, - item.description || null, item.local_path || null, item.remote_path || null, - item.media_type || null, item.created_date || null] - ); - return result.lastInsertRowId; + const insertRow = { ...item, userId, metadata_json: mergedFromIncoming }; + const resolvedInsert = MediaMetadataService.resolveCreatedDateIsoForStorage(insertRow); + if (resolvedInsert) insertRow.created_date = resolvedInsert; + return await DatabaseService.runMediaInsert(insertRow); } catch (error) { logError('Save Media Item', error); return null; @@ -322,12 +433,12 @@ const DatabaseService = { getMediaByUser: async (userId) => { try { if (!(await DatabaseService.ensureDb())) return []; - if (!userId && userId !== 0) { + if (!hasValue(userId)) { logError('Get Media', 'User ID is required'); return []; } return await db.getAllAsync( - 'SELECT * FROM media WHERE user_id = ? ORDER BY created_date DESC', + 'SELECT * FROM media WHERE user_id = ? ORDER BY datetime(created_date) DESC', [userId] ); } catch (error) { @@ -352,17 +463,128 @@ const DatabaseService = { searchMedia: async (userId, query) => { try { if (!(await DatabaseService.ensureDb())) return []; - const searchTerm = `%${query}%`; - return await db.getAllAsync( - 'SELECT * FROM media WHERE user_id = ? AND (title LIKE ? OR description LIKE ?) ORDER BY created_date DESC', - [userId, searchTerm, searchTerm] + 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); } catch (error) { logError('Search Media', error); return []; } }, + createAlbum: async (userId, name) => { + try { + if (!(await DatabaseService.ensureDb())) return null; + if (!userId || !name?.trim()) return null; + const result = await db.runAsync( + 'INSERT INTO user_albums (user_id, name) VALUES (?, ?)', + [userId, name.trim()] + ); + return result.lastInsertRowId; + } catch (error) { + logError('Create Album', error); + return null; + } + }, + + getUserAlbums: async (userId) => { + try { + if (!(await DatabaseService.ensureDb())) return []; + return await db.getAllAsync( + 'SELECT * FROM user_albums WHERE user_id = ? ORDER BY datetime(created_at) DESC', + [userId] + ); + } catch (error) { + logError('Get User Albums', error); + return []; + } + }, + + getAlbumEntryCount: async (albumId) => { + try { + if (!(await DatabaseService.ensureDb())) return 0; + const row = await db.getFirstAsync( + 'SELECT COUNT(*) as c FROM album_entries WHERE album_id = ?', + [albumId] + ); + return row?.c ?? 0; + } catch (error) { + logError('Album count', error); + return 0; + } + }, + + addMediaToAlbum: async (userId, albumId, item) => { + try { + if (!(await DatabaseService.ensureDb())) return false; + const album = await db.getFirstAsync( + 'SELECT id FROM user_albums WHERE id = ? AND user_id = ?', + [albumId, userId] + ); + if (!album || !item) return false; + const fp = MediaMetadataService.mediaFingerprint(item); + const metaStr = normalizeMetaString(item) || '{}'; + await db.runAsync( + `INSERT OR REPLACE INTO album_entries + (album_id, media_fingerprint, source_service, title_snapshot, local_path, remote_path, metadata_json) + VALUES (?, ?, ?, ?, ?, ?, ?)`, + [ + albumId, + fp, + item.source_service || null, + item.title || null, + item.local_path || null, + item.remote_path || null, + metaStr, + ] + ); + return true; + } catch (error) { + logError('Add Media To Album', error); + return false; + } + }, + + getAlbumEntries: async (userId, albumId) => { + try { + if (!(await DatabaseService.ensureDb())) return []; + const album = await db.getFirstAsync( + 'SELECT id FROM user_albums WHERE id = ? AND user_id = ?', + [albumId, userId] + ); + if (!album) return []; + return await db.getAllAsync( + 'SELECT * FROM album_entries WHERE album_id = ? ORDER BY datetime(created_at) DESC', + [albumId] + ); + } catch (error) { + logError('Get Album Entries', error); + return []; + } + }, + + removeAlbum: async (userId, albumId) => { + try { + if (!(await DatabaseService.ensureDb())) return; + await db.runAsync( + 'DELETE FROM user_albums WHERE id = ? AND user_id = ?', + [albumId, userId] + ); + } catch (error) { + logError('Remove Album', error); + } + }, + getAutoBackupServices: async (userId) => { try { if (!(await DatabaseService.ensureDb())) return []; diff --git a/src/services/DeviceMediaService.js b/src/services/DeviceMediaService.js index ab03ea2..cf0fd1c 100644 --- a/src/services/DeviceMediaService.js +++ b/src/services/DeviceMediaService.js @@ -1,4 +1,7 @@ import * as MediaLibrary from 'expo-media-library'; +import MediaMetadataService from './MediaMetadataService'; + +const EMPTY_MEDIA_PAGE = { mediaItems: [], endCursor: null, hasNextPage: false }; const DeviceMediaService = { requestPermission: async () => { @@ -13,7 +16,7 @@ const DeviceMediaService = { getPhotos: async (pageSize = 100, after = null, mediaType = 'all') => { const hasPermission = await DeviceMediaService.requestPermission(); if (!hasPermission) { - return { mediaItems: [], endCursor: null, hasNextPage: false }; + return EMPTY_MEDIA_PAGE; } const mediaTypeFilter = @@ -30,21 +33,9 @@ const DeviceMediaService = { const result = await MediaLibrary.getAssetsAsync(options); - const mediaItems = result.assets.map((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: asset.creationTime - ? new Date(asset.creationTime).toISOString() - : null, - width: asset.width, - height: asset.height, - duration: asset.duration || 0, - })); + const mediaItems = await Promise.all( + result.assets.map((a) => MediaMetadataService.deviceAssetToMediaItem(a)) + ); return { mediaItems, @@ -71,11 +62,11 @@ const DeviceMediaService = { getAlbumPhotos: async (albumId, pageSize = 100, after = null) => { const hasPermission = await DeviceMediaService.requestPermission(); if (!hasPermission) { - return { mediaItems: [], endCursor: null, hasNextPage: false }; + return EMPTY_MEDIA_PAGE; } const album = await MediaLibrary.getAlbumAsync(albumId); - if (!album) return { mediaItems: [], endCursor: null, hasNextPage: false }; + if (!album) return EMPTY_MEDIA_PAGE; const options = { first: pageSize, @@ -86,21 +77,9 @@ const DeviceMediaService = { const result = await MediaLibrary.getAssetsAsync(options); - const mediaItems = result.assets.map((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: asset.creationTime - ? new Date(asset.creationTime).toISOString() - : null, - width: asset.width, - height: asset.height, - duration: asset.duration || 0, - })); + const mediaItems = await Promise.all( + result.assets.map((a) => MediaMetadataService.deviceAssetToMediaItem(a)) + ); return { mediaItems,