diff --git a/mobile/AGENTS.md b/mobile/AGENTS.md index 0e6bc80..72dfb11 100644 --- a/mobile/AGENTS.md +++ b/mobile/AGENTS.md @@ -1,3 +1,13 @@ # Expo HAS CHANGED Read the exact versioned docs at https://docs.expo.dev/versions/v57.0.0/ before writing any code. + +# Local storage + +Persistence is SQLite-backed via `services/sqliteStorage.ts` (`expo-sqlite`, database `dot.db`). + +- Tables: `folders`, `files`, `user_preferences` (schema migrated via `PRAGMA user_version`). +- Folder and file per-row sync status: `local` | `cloud` | `local-cloud`. +- `services/localStorage.ts` is a thin re-export of `sqliteStorage` for legacy imports. +- Query usage: `getFiles(folderUri?)`, `getFolders()`, `getFolderFolders(parentUri)`, `saveFolder`, `saveFile`, `saveUserPreferences`, `getUserPreferences`, `removeFolder`, `removeFile`. +- Heavy processing stays server-side; SQLite only persists local metadata/state. diff --git a/mobile/app.json b/mobile/app.json index b9a3ad3..8c51125 100644 --- a/mobile/app.json +++ b/mobile/app.json @@ -24,7 +24,8 @@ "favicon": "./assets/favicon.png" }, "plugins": [ - "expo-router" + "expo-router", + "expo-sqlite" ] } } diff --git a/mobile/app/index.tsx b/mobile/app/index.tsx index b6696ff..e64ffe1 100644 --- a/mobile/app/index.tsx +++ b/mobile/app/index.tsx @@ -1,18 +1,26 @@ import { StatusBar } from 'expo-status-bar'; -import { useState } from 'react'; +import { useEffect, useState } from 'react'; import { Pressable, StyleSheet, Text, View } from 'react-native'; import { pickDirectory } from '../services/safDirectory'; -import { saveDirectory } from '../services/localStorage'; +import { saveDirectory, getFolders } from '../services/localStorage'; export default function Index() { const [folders, setFolders] = useState([]); + useEffect(() => { + (async () => { + const saved = await getFolders(); + setFolders(saved.map((ctx) => ctx.folder.name)); + })(); + }, []); + const handlePickDirectory = async () => { const folder = await pickDirectory(); if ( !folder ) return; await saveDirectory(folder); setFolders((prev) => [...prev, folder.name]); + console.log(folders) }; return ( diff --git a/mobile/context/AuthContext.tsx b/mobile/context/AuthContext.tsx index 9431cf6..79bc249 100644 --- a/mobile/context/AuthContext.tsx +++ b/mobile/context/AuthContext.tsx @@ -1,13 +1,7 @@ -import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react'; +import { createContext, useContext, useEffect, useMemo, useState } from 'react'; import type { ReactNode } from 'react'; import { useSyncDevice } from '../features/syncDevice'; - -export type User = { -}; - -type AuthContextValue = { - user: User | null; -}; +import type { AuthContextValue, User } from './AuthContext.types'; const AuthContext = createContext(null); diff --git a/mobile/context/AuthContext.types.ts b/mobile/context/AuthContext.types.ts new file mode 100644 index 0000000..8b293e8 --- /dev/null +++ b/mobile/context/AuthContext.types.ts @@ -0,0 +1,5 @@ +export type User = {}; + +export type AuthContextValue = { + user: User | null; +}; \ No newline at end of file diff --git a/mobile/package-lock.json b/mobile/package-lock.json index 7e9f98b..8d95735 100644 --- a/mobile/package-lock.json +++ b/mobile/package-lock.json @@ -13,6 +13,7 @@ "expo-file-system": "~57.0.6", "expo-linking": "~57.0.9", "expo-router": "~57.0.20", + "expo-sqlite": "~57.0.2", "expo-status-bar": "~57.0.1", "react": "19.2.3", "react-dom": "19.2.3", @@ -2612,6 +2613,12 @@ "integrity": "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==", "license": "MIT" }, + "node_modules/await-lock": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/await-lock/-/await-lock-2.2.2.tgz", + "integrity": "sha512-aDczADvlvTGajTDjcjpJMqRkOF6Qdz3YbPZm/PyW6tKPkx2hlYBzxMhEywM/tU72HrVZjgl5VCdRuMlA7pZ8Gw==", + "license": "MIT" + }, "node_modules/babel-plugin-polyfill-corejs2": { "version": "0.4.17", "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs2/-/babel-plugin-polyfill-corejs2-0.4.17.tgz", @@ -3762,6 +3769,20 @@ "node": ">=20.16.0" } }, + "node_modules/expo-sqlite": { + "version": "57.0.2", + "resolved": "https://registry.npmjs.org/expo-sqlite/-/expo-sqlite-57.0.2.tgz", + "integrity": "sha512-5KVbT7BQFZlIcQBXKWvwBERK3ODF6PSqct27GQKukZzAjsx2B97R+mwtYqrTUtUbw71VJrQrdr9Yl2OWYC7UpA==", + "license": "MIT", + "dependencies": { + "await-lock": "^2.2.2" + }, + "peerDependencies": { + "expo": "*", + "react": "*", + "react-native": "*" + } + }, "node_modules/expo-status-bar": { "version": "57.0.1", "resolved": "https://registry.npmjs.org/expo-status-bar/-/expo-status-bar-57.0.1.tgz", diff --git a/mobile/package.json b/mobile/package.json index 85dbbd0..d0c44bb 100644 --- a/mobile/package.json +++ b/mobile/package.json @@ -8,6 +8,7 @@ "expo-file-system": "~57.0.6", "expo-linking": "~57.0.9", "expo-router": "~57.0.20", + "expo-sqlite": "~57.0.2", "expo-status-bar": "~57.0.1", "react": "19.2.3", "react-dom": "19.2.3", diff --git a/mobile/services/localStorage.ts b/mobile/services/localStorage.ts index c741d66..79cc347 100644 --- a/mobile/services/localStorage.ts +++ b/mobile/services/localStorage.ts @@ -1,42 +1,14 @@ -import { Folder } from "./safDirectory" +export { + getUserPreferences, + getFolders, + getFolderFolders, + getFiles, + removeFolder, + removeFile, + saveFile, + saveFolder, + saveDirectory, + saveUserPreferences, +} from './sqliteStorage'; -const folders = new Map(); - -type FolderContext = { - syncStatus: 'local' | 'cloud' | 'local-cloud' - addedAt: number // UTC DATE - folder: Folder -} - -export async function saveDirectory(folder: Folder): Promise { - - const ctx = { - syncStatus: 'local', - addedAt: Date.UTC(Date.now()), - folder: folder, - } satisfies FolderContext; - - folders.set(folder.uri,ctx) - - return new Promise(()=> true) -} - -export async function getFolders(): Promise{ - - return new Promise(()=> folders.values()) - -} - -export type UserPreferences = { - syncMode: 'full' | 'manual' | 'none' -} - -export async function getUserPreferences(): Promise { - - return new Promise((resolve)=>{ - resolve({ - syncMode: 'full' - } satisfies UserPreferences) - }); - -} \ No newline at end of file +export type { FolderContext, StoredFile, SyncStatus, UserPreferences } from './sqliteStorage.types'; \ No newline at end of file diff --git a/mobile/services/safDirectory.ts b/mobile/services/safDirectory.ts index 5843146..0536477 100644 --- a/mobile/services/safDirectory.ts +++ b/mobile/services/safDirectory.ts @@ -1,35 +1,11 @@ import { Directory, File, Paths } from 'expo-file-system'; - -export type Folder = { - uri: string; - name: string; - isDirectory: true; - exists?: boolean; -}; - -export type FileEntry = { - uri: string; - name: string; - isDirectory: false; - extension: string; - exists: boolean; - size: number; - type: string; - lastModified: number | null; -}; - -export type DirectoryEntry = Folder | FileEntry; - -export type FolderInfo = { - uri: string; - name: string; - exists: boolean; -}; - -type PickDirectoryOptions = { - recursive?: boolean; - includeRoot?: boolean; -}; +import type { + DirectoryEntry, + FileEntry, + Folder, + FolderInfo, + PickDirectoryOptions, +} from './safDirectory.types'; export async function pickDirectory(initialUri?: string): Promise { try { diff --git a/mobile/services/safDirectory.types.ts b/mobile/services/safDirectory.types.ts new file mode 100644 index 0000000..00a80c5 --- /dev/null +++ b/mobile/services/safDirectory.types.ts @@ -0,0 +1,30 @@ +export type Folder = { + uri: string; + name: string; + isDirectory: true; + exists?: boolean; +}; + +export type FileEntry = { + uri: string; + name: string; + isDirectory: false; + extension: string; + exists: boolean; + size: number; + type: string; + lastModified: number | null; +}; + +export type DirectoryEntry = Folder | FileEntry; + +export type FolderInfo = { + uri: string; + name: string; + exists: boolean; +}; + +export type PickDirectoryOptions = { + recursive?: boolean; + includeRoot?: boolean; +}; \ No newline at end of file diff --git a/mobile/services/sqliteStorage.ts b/mobile/services/sqliteStorage.ts new file mode 100644 index 0000000..8fed016 --- /dev/null +++ b/mobile/services/sqliteStorage.ts @@ -0,0 +1,209 @@ +import * as SQLite from 'expo-sqlite'; +import type { SQLiteDatabase } from 'expo-sqlite'; +import type { FileEntry, Folder } from './safDirectory.types'; +import type { UserPreferences,SyncStatus,FolderContext, FolderRow,StoredFile, FileRow } from './sqliteStorage.types'; + +const DATABASE_NAME = 'dot.db'; +const DATABASE_VERSION = 1; + +const PREFERENCES_KEY = 'user_preferences'; + +let database: SQLiteDatabase | null = null; + +const DEFAULT_PREFERENCES: UserPreferences = { + syncMode: 'full', +}; + +export async function getDatabase(): Promise { + if (database) return database; + + const db = await SQLite.openDatabaseAsync(DATABASE_NAME); + await migrateIfNeeded(db); + database = db; + return db; +} + +async function migrateIfNeeded(db: SQLiteDatabase): Promise { + const row = await db.getFirstAsync<{ user_version: number }>('PRAGMA user_version'); + const currentVersion = row?.user_version ?? 0; + if (currentVersion >= DATABASE_VERSION) return; + + if (currentVersion === 0) { + await db.execAsync(` +PRAGMA journal_mode = 'wal'; +PRAGMA foreign_keys = ON; +CREATE TABLE IF NOT EXISTS folders ( + id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + uri TEXT NOT NULL UNIQUE, + name TEXT NOT NULL, + "exists" INTEGER, + sync_status TEXT NOT NULL DEFAULT 'local', + added_at INTEGER NOT NULL, + parent_uri TEXT +); +CREATE TABLE IF NOT EXISTS files ( + id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + uri TEXT NOT NULL UNIQUE, + name TEXT NOT NULL, + folder_uri TEXT NOT NULL, + extension TEXT, + size INTEGER, + "type" TEXT, + "exists" INTEGER, + last_modified INTEGER, + sync_status TEXT NOT NULL DEFAULT 'local', + added_at INTEGER NOT NULL, + FOREIGN KEY (folder_uri) REFERENCES folders(uri) ON DELETE CASCADE +); +CREATE TABLE IF NOT EXISTS user_preferences ( + "key" TEXT PRIMARY KEY NOT NULL, + "value" TEXT NOT NULL, + updated_at INTEGER NOT NULL +); +`); + } + + await db.execAsync(`PRAGMA user_version = ${DATABASE_VERSION}`); +} + +export async function saveFolder( + folder: Folder, + options?: { parentUri?: string; syncStatus?: SyncStatus }, +): Promise { + const db = await getDatabase(); + const exists = folder.exists === undefined ? null : folder.exists ? 1 : 0; + + await db.runAsync( + `INSERT INTO folders (uri, name, "exists", sync_status, added_at, parent_uri) + VALUES (?, ?, ?, ?, ?, ?) + ON CONFLICT(uri) DO UPDATE SET + name = excluded.name, "exists" = excluded."exists", sync_status = excluded.sync_status`, + folder.uri, + folder.name, + exists, + options?.syncStatus ?? 'local', + Date.now(), + options?.parentUri ?? null, + ); +} + +export async function saveDirectory(folder: Folder): Promise { + await saveFolder(folder); + return true; +} + +export async function getFolders(): Promise { + const db = await getDatabase(); + const rows = await db.getAllAsync( + 'SELECT * FROM folders ORDER BY added_at ASC, name ASC', + ); + return rows.map(toFolderContext); +} + +export async function getFolderFolders(parentUri: string): Promise { + const db = await getDatabase(); + const rows = await db.getAllAsync( + 'SELECT * FROM folders WHERE parent_uri = ? ORDER BY name ASC', + parentUri, + ); + return rows.map(toFolderContext); +} + +export async function removeFolder(uri: string): Promise { + const db = await getDatabase(); + await db.runAsync('DELETE FROM folders WHERE uri = ?', uri); +} + +export async function saveFile(file: FileEntry, folderUri: string): Promise { + const db = await getDatabase(); + const exists = file.exists ? 1 : 0; + + await db.runAsync( + `INSERT INTO files (uri, name, folder_uri, extension, size, "type", "exists", last_modified, added_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(uri) DO UPDATE SET + name = excluded.name, folder_uri = excluded.folder_uri, extension = excluded.extension, + size = excluded.size, "type" = excluded."type", "exists" = excluded."exists", + last_modified = excluded.last_modified`, + file.uri, + file.name, + folderUri, + file.extension ?? null, + file.size, + file.type ?? null, + exists, + file.lastModified, + Date.now(), + ); +} + +export async function getFiles(folderUri?: string): Promise { + const db = await getDatabase(); + const rows = folderUri + ? await db.getAllAsync( + 'SELECT * FROM files WHERE folder_uri = ? ORDER BY name ASC', + folderUri, + ) + : await db.getAllAsync('SELECT * FROM files ORDER BY name ASC'); + return rows.map(toStoredFile); +} + +export async function removeFile(uri: string): Promise { + const db = await getDatabase(); + await db.runAsync('DELETE FROM files WHERE uri = ?', uri); +} + +export async function saveUserPreferences(preferences: UserPreferences): Promise { + const db = await getDatabase(); + await db.runAsync( + `INSERT INTO user_preferences ("key", "value", updated_at) VALUES (?, ?, ?) + ON CONFLICT("key") DO UPDATE SET "value" = excluded."value", updated_at = excluded.updated_at`, + PREFERENCES_KEY, + JSON.stringify(preferences), + Date.now(), + ); +} + +export async function getUserPreferences(): Promise { + const db = await getDatabase(); + const row = await db.getFirstAsync<{ value: string }>( + 'SELECT "value" FROM user_preferences WHERE "key" = ?', + PREFERENCES_KEY, + ); + if (!row) return DEFAULT_PREFERENCES; + + try { + return { ...DEFAULT_PREFERENCES, ...JSON.parse(row.value) } satisfies UserPreferences; + } catch { + return DEFAULT_PREFERENCES; + } +} + +function toFolderContext(row: FolderRow): FolderContext { + return { + syncStatus: row.sync_status, + addedAt: row.added_at, + folder: { + uri: row.uri, + name: row.name, + isDirectory: true, + exists: row.exists === null ? undefined : row.exists === 1, + }, + }; +} + +function toStoredFile(row: FileRow): StoredFile { + return { + uri: row.uri, + name: row.name, + isDirectory: false, + extension: row.extension ?? '', + exists: row.exists === 1, + size: row.size, + type: row.type ?? '', + lastModified: row.last_modified, + folderUri: row.folder_uri, + syncStatus: row.sync_status, + addedAt: row.added_at, + }; +} \ No newline at end of file diff --git a/mobile/services/sqliteStorage.types.ts b/mobile/services/sqliteStorage.types.ts new file mode 100644 index 0000000..527cbd7 --- /dev/null +++ b/mobile/services/sqliteStorage.types.ts @@ -0,0 +1,41 @@ +import type { FileEntry, Folder } from "./safDirectory.types"; + +export type SyncStatus = 'local' | 'cloud' | 'local-cloud'; + +export type FolderContext = { + syncStatus: SyncStatus; + addedAt: number; + folder: Folder; +}; + +export type StoredFile = FileEntry & { + folderUri: string; + syncStatus: SyncStatus; + addedAt: number; +}; + +export type UserPreferences = { + syncMode: 'full' | 'manual' | 'none'; +}; + +export type FolderRow = { + uri: string; + name: string; + exists: number | null; + sync_status: SyncStatus; + added_at: number; + parent_uri: string | null; +}; + +export type FileRow = { + uri: string; + name: string; + folder_uri: string; + extension: string | null; + size: number; + type: string | null; + exists: number; + last_modified: number | null; + sync_status: SyncStatus; + added_at: number; +}; \ No newline at end of file