saf + sqlite
This commit is contained in:
@@ -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.
|
||||
|
||||
+2
-1
@@ -24,7 +24,8 @@
|
||||
"favicon": "./assets/favicon.png"
|
||||
},
|
||||
"plugins": [
|
||||
"expo-router"
|
||||
"expo-router",
|
||||
"expo-sqlite"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
+10
-2
@@ -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<string[]>([]);
|
||||
|
||||
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 (
|
||||
|
||||
@@ -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<AuthContextValue | null>(null);
|
||||
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
export type User = {};
|
||||
|
||||
export type AuthContextValue = {
|
||||
user: User | null;
|
||||
};
|
||||
Generated
+21
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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<Folder['uri'],FolderContext>();
|
||||
|
||||
type FolderContext = {
|
||||
syncStatus: 'local' | 'cloud' | 'local-cloud'
|
||||
addedAt: number // UTC DATE
|
||||
folder: Folder
|
||||
}
|
||||
|
||||
export async function saveDirectory(folder: Folder): Promise<boolean> {
|
||||
|
||||
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<FolderContext[]>{
|
||||
|
||||
return new Promise(()=> folders.values())
|
||||
|
||||
}
|
||||
|
||||
export type UserPreferences = {
|
||||
syncMode: 'full' | 'manual' | 'none'
|
||||
}
|
||||
|
||||
export async function getUserPreferences(): Promise<UserPreferences> {
|
||||
|
||||
return new Promise((resolve)=>{
|
||||
resolve({
|
||||
syncMode: 'full'
|
||||
} satisfies UserPreferences)
|
||||
});
|
||||
|
||||
}
|
||||
export type { FolderContext, StoredFile, SyncStatus, UserPreferences } from './sqliteStorage.types';
|
||||
@@ -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<Folder | null> {
|
||||
try {
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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<SQLiteDatabase> {
|
||||
if (database) return database;
|
||||
|
||||
const db = await SQLite.openDatabaseAsync(DATABASE_NAME);
|
||||
await migrateIfNeeded(db);
|
||||
database = db;
|
||||
return db;
|
||||
}
|
||||
|
||||
async function migrateIfNeeded(db: SQLiteDatabase): Promise<void> {
|
||||
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<void> {
|
||||
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<boolean> {
|
||||
await saveFolder(folder);
|
||||
return true;
|
||||
}
|
||||
|
||||
export async function getFolders(): Promise<FolderContext[]> {
|
||||
const db = await getDatabase();
|
||||
const rows = await db.getAllAsync<FolderRow>(
|
||||
'SELECT * FROM folders ORDER BY added_at ASC, name ASC',
|
||||
);
|
||||
return rows.map(toFolderContext);
|
||||
}
|
||||
|
||||
export async function getFolderFolders(parentUri: string): Promise<FolderContext[]> {
|
||||
const db = await getDatabase();
|
||||
const rows = await db.getAllAsync<FolderRow>(
|
||||
'SELECT * FROM folders WHERE parent_uri = ? ORDER BY name ASC',
|
||||
parentUri,
|
||||
);
|
||||
return rows.map(toFolderContext);
|
||||
}
|
||||
|
||||
export async function removeFolder(uri: string): Promise<void> {
|
||||
const db = await getDatabase();
|
||||
await db.runAsync('DELETE FROM folders WHERE uri = ?', uri);
|
||||
}
|
||||
|
||||
export async function saveFile(file: FileEntry, folderUri: string): Promise<void> {
|
||||
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<StoredFile[]> {
|
||||
const db = await getDatabase();
|
||||
const rows = folderUri
|
||||
? await db.getAllAsync<FileRow>(
|
||||
'SELECT * FROM files WHERE folder_uri = ? ORDER BY name ASC',
|
||||
folderUri,
|
||||
)
|
||||
: await db.getAllAsync<FileRow>('SELECT * FROM files ORDER BY name ASC');
|
||||
return rows.map(toStoredFile);
|
||||
}
|
||||
|
||||
export async function removeFile(uri: string): Promise<void> {
|
||||
const db = await getDatabase();
|
||||
await db.runAsync('DELETE FROM files WHERE uri = ?', uri);
|
||||
}
|
||||
|
||||
export async function saveUserPreferences(preferences: UserPreferences): Promise<void> {
|
||||
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<UserPreferences> {
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
Reference in New Issue
Block a user