from scratch
This commit is contained in:
@@ -1,168 +0,0 @@
|
||||
import { fileStore } from './fileStore';
|
||||
import { apiClient } from '../api/client';
|
||||
import { ENDPOINTS } from '../constants/api';
|
||||
import { HttpError } from '../types';
|
||||
import type { PendingAction, PendingActionType } from '../types';
|
||||
|
||||
type Listener = () => void;
|
||||
|
||||
const MAX_ATTEMPTS = 5;
|
||||
const BASE_RETRY_DELAY_MS = 2000;
|
||||
|
||||
function genId(): string {
|
||||
return `action_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
|
||||
}
|
||||
|
||||
async function executeAction(action: PendingAction): Promise<void> {
|
||||
const { type, payload, resourceId } = action;
|
||||
|
||||
switch (type) {
|
||||
case 'tag_add': {
|
||||
const { fileId, tags } = payload as { fileId: string; tags: string[] };
|
||||
await apiClient.post(`${ENDPOINTS.RESOURCES}/${fileId}/tags`, { tags });
|
||||
break;
|
||||
}
|
||||
case 'delete': {
|
||||
const { backendId } = payload as { backendId: string };
|
||||
try {
|
||||
await apiClient.delete(`${ENDPOINTS.RESOURCES}/${backendId}`);
|
||||
} catch (err) {
|
||||
if (err instanceof HttpError && err.status === 404) {
|
||||
break;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'move': {
|
||||
const { resourceIds, parentResourceId } = payload as { resourceIds: string[]; parentResourceId: string | null };
|
||||
await apiClient.post(ENDPOINTS.MOVE, { resource_ids: resourceIds, parent_resource_id: parentResourceId });
|
||||
break;
|
||||
}
|
||||
case 'create_folder': {
|
||||
const { name, parentResourceId } = payload as { name: string; parentResourceId?: string };
|
||||
await apiClient.post(ENDPOINTS.FOLDERS, { name, parent_resource_id: parentResourceId });
|
||||
break;
|
||||
}
|
||||
default:
|
||||
throw new Error(`Unknown action type: ${type}`);
|
||||
}
|
||||
}
|
||||
|
||||
class ActionQueue {
|
||||
private processing = false;
|
||||
private listeners = new Set<Listener>();
|
||||
private timer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
subscribe(listener: Listener): () => void {
|
||||
this.listeners.add(listener);
|
||||
return () => { this.listeners.delete(listener); };
|
||||
}
|
||||
|
||||
private notify() {
|
||||
this.listeners.forEach((l) => l());
|
||||
}
|
||||
|
||||
enqueue(type: PendingActionType, payload: Record<string, unknown>, resourceId?: string): PendingAction {
|
||||
const action: PendingAction = {
|
||||
id: genId(),
|
||||
type,
|
||||
payload,
|
||||
status: 'pending',
|
||||
attempts: 0,
|
||||
lastError: null,
|
||||
resourceId: resourceId ?? null,
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
fileStore.insertPendingAction(action);
|
||||
this.notify();
|
||||
return action;
|
||||
}
|
||||
|
||||
async processNext(): Promise<boolean> {
|
||||
const pending = fileStore.getPendingActions();
|
||||
if (pending.length === 0) return false;
|
||||
|
||||
const action = pending[0];
|
||||
|
||||
// Check if a prior delete makes this action obsolete
|
||||
if (action.type !== 'delete' && action.resourceId) {
|
||||
const priorDelete = fileStore.getPendingActions().find(
|
||||
(a) => a.type === 'delete' && a.resourceId === action.resourceId && a.createdAt < action.createdAt
|
||||
);
|
||||
if (priorDelete) {
|
||||
fileStore.markPendingActionObsolete(action.id);
|
||||
this.notify();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await executeAction(action);
|
||||
fileStore.markPendingActionDone(action.id);
|
||||
this.notify();
|
||||
return true;
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
fileStore.markPendingActionError(action.id, msg);
|
||||
|
||||
if (action.attempts + 1 >= MAX_ATTEMPTS) {
|
||||
fileStore.markPendingActionObsolete(action.id);
|
||||
}
|
||||
|
||||
this.notify();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async processAll(): Promise<void> {
|
||||
if (this.processing) return;
|
||||
this.processing = true;
|
||||
|
||||
try {
|
||||
let hasMore = true;
|
||||
while (hasMore) {
|
||||
const pending = fileStore.getPendingActions();
|
||||
if (pending.length === 0) break;
|
||||
|
||||
const success = await this.processNext();
|
||||
if (!success) {
|
||||
// Wait before retrying on error
|
||||
await new Promise((r) => setTimeout(r, BASE_RETRY_DELAY_MS));
|
||||
// Check if there are still pending actions (not just the one we failed on)
|
||||
const remaining = fileStore.getPendingActions();
|
||||
if (remaining.length === 0 || remaining[0].status !== 'pending') break;
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
this.processing = false;
|
||||
fileStore.clearDonePendingActions();
|
||||
this.notify();
|
||||
}
|
||||
}
|
||||
|
||||
isProcessing(): boolean {
|
||||
return this.processing;
|
||||
}
|
||||
|
||||
getPendingCount(): number {
|
||||
return fileStore.getPendingActionsCount();
|
||||
}
|
||||
|
||||
scheduleRetry(delayMs: number = 10_000) {
|
||||
if (this.timer) return;
|
||||
this.timer = setTimeout(() => {
|
||||
this.timer = null;
|
||||
this.processAll();
|
||||
}, delayMs);
|
||||
}
|
||||
|
||||
cancelSchedule() {
|
||||
if (this.timer) {
|
||||
clearTimeout(this.timer);
|
||||
this.timer = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const actionQueue = new ActionQueue();
|
||||
@@ -1,69 +0,0 @@
|
||||
import * as BackgroundTask from 'expo-background-task';
|
||||
import * as TaskManager from 'expo-task-manager';
|
||||
|
||||
const BACKGROUND_UPLOAD_TASK = 'BACKGROUND_UPLOAD';
|
||||
|
||||
TaskManager.defineTask(BACKGROUND_UPLOAD_TASK, async () => {
|
||||
try {
|
||||
const { uploadQueue } = await import('./uploadQueue');
|
||||
const { actionQueue } = await import('./actionQueue');
|
||||
const { apiClient } = await import('../api/client');
|
||||
const { tokenStorage } = await import('../api/secureStorage');
|
||||
|
||||
const pendingUploads = uploadQueue.getPendingCount();
|
||||
const pendingActions = actionQueue.getPendingCount();
|
||||
if (pendingUploads === 0 && pendingActions === 0) {
|
||||
return BackgroundTask.BackgroundTaskResult.Success;
|
||||
}
|
||||
|
||||
const token = await tokenStorage.getAccessToken();
|
||||
if (!token) {
|
||||
return BackgroundTask.BackgroundTaskResult.Success;
|
||||
}
|
||||
apiClient.setAccessToken(token);
|
||||
|
||||
if (pendingUploads > 0) {
|
||||
uploadQueue.retryAll();
|
||||
}
|
||||
if (pendingActions > 0) {
|
||||
actionQueue.processAll();
|
||||
}
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
const check = setInterval(() => {
|
||||
if (uploadQueue.getPendingCount() === 0 && actionQueue.getPendingCount() === 0) {
|
||||
clearInterval(check);
|
||||
resolve();
|
||||
}
|
||||
}, 1000);
|
||||
setTimeout(() => {
|
||||
clearInterval(check);
|
||||
resolve();
|
||||
}, 25000);
|
||||
});
|
||||
|
||||
return BackgroundTask.BackgroundTaskResult.Success;
|
||||
} catch {
|
||||
return BackgroundTask.BackgroundTaskResult.Failed;
|
||||
}
|
||||
});
|
||||
|
||||
let isRegistered = false;
|
||||
|
||||
export async function registerBackgroundUpload() {
|
||||
if (isRegistered) return;
|
||||
isRegistered = true;
|
||||
|
||||
const status = await BackgroundTask.getStatusAsync();
|
||||
|
||||
if (status === BackgroundTask.BackgroundTaskStatus.Restricted) {
|
||||
console.warn('[BackgroundUpload] Permission refusée');
|
||||
return;
|
||||
}
|
||||
|
||||
await BackgroundTask.registerTaskAsync(BACKGROUND_UPLOAD_TASK, {
|
||||
minimumInterval: 15,
|
||||
});
|
||||
|
||||
console.log('[BackgroundUpload] Enregistré (intervalle: 15min)');
|
||||
}
|
||||
@@ -1,98 +0,0 @@
|
||||
import { createMMKV } from 'react-native-mmkv';
|
||||
import { FileDetectedEvent } from '../modules/expo-download-detect';
|
||||
|
||||
const storage = createMMKV({ id: 'vaultdrop-download-registry' });
|
||||
|
||||
const FILES_KEY = 'detected_files';
|
||||
|
||||
export type DownloadRegistryEntry = {
|
||||
id: string;
|
||||
uri: string;
|
||||
name: string;
|
||||
mimeType: string;
|
||||
size: number;
|
||||
createdAt: number;
|
||||
source: string;
|
||||
detectedAt: number;
|
||||
};
|
||||
|
||||
function getAllRaw(): DownloadRegistryEntry[] {
|
||||
const raw = storage.getString(FILES_KEY);
|
||||
if (!raw) return [];
|
||||
return JSON.parse(raw) as DownloadRegistryEntry[];
|
||||
}
|
||||
|
||||
function saveAll(entries: DownloadRegistryEntry[]) {
|
||||
storage.set(FILES_KEY, JSON.stringify(entries));
|
||||
}
|
||||
|
||||
export const downloadRegistry = {
|
||||
getAll(): DownloadRegistryEntry[] {
|
||||
return getAllRaw();
|
||||
},
|
||||
|
||||
getById(id: string): DownloadRegistryEntry | undefined {
|
||||
return getAllRaw().find((e) => e.id === id);
|
||||
},
|
||||
|
||||
add(event: FileDetectedEvent): DownloadRegistryEntry | null {
|
||||
const entries = getAllRaw();
|
||||
if (entries.some((e) => e.id === event.id)) return null;
|
||||
|
||||
const entry: DownloadRegistryEntry = {
|
||||
id: event.id,
|
||||
uri: event.uri,
|
||||
name: event.name,
|
||||
mimeType: event.mimeType,
|
||||
size: event.size,
|
||||
createdAt: event.createdAt,
|
||||
source: event.source,
|
||||
detectedAt: Date.now(),
|
||||
};
|
||||
|
||||
entries.push(entry);
|
||||
saveAll(entries);
|
||||
return entry;
|
||||
},
|
||||
|
||||
addBatch(events: FileDetectedEvent[]): DownloadRegistryEntry[] {
|
||||
const entries = getAllRaw();
|
||||
const existingIds = new Set(entries.map((e) => e.id));
|
||||
const newEntries: DownloadRegistryEntry[] = [];
|
||||
|
||||
for (const event of events) {
|
||||
if (existingIds.has(event.id)) continue;
|
||||
const entry: DownloadRegistryEntry = {
|
||||
id: event.id,
|
||||
uri: event.uri,
|
||||
name: event.name,
|
||||
mimeType: event.mimeType,
|
||||
size: event.size,
|
||||
createdAt: event.createdAt,
|
||||
source: event.source,
|
||||
detectedAt: Date.now(),
|
||||
};
|
||||
newEntries.push(entry);
|
||||
existingIds.add(event.id);
|
||||
}
|
||||
|
||||
if (newEntries.length > 0) {
|
||||
saveAll([...entries, ...newEntries]);
|
||||
}
|
||||
|
||||
return newEntries;
|
||||
},
|
||||
|
||||
remove(id: string) {
|
||||
const entries = getAllRaw().filter((e) => e.id !== id);
|
||||
saveAll(entries);
|
||||
},
|
||||
|
||||
clear() {
|
||||
storage.remove(FILES_KEY);
|
||||
},
|
||||
|
||||
count(): number {
|
||||
return getAllRaw().length;
|
||||
},
|
||||
};
|
||||
@@ -1,757 +0,0 @@
|
||||
import { drizzle } from 'drizzle-orm/expo-sqlite';
|
||||
import * as SQLite from 'expo-sqlite';
|
||||
import { eq, like, or, and, desc, asc, sql, isNull, inArray, isNotNull } from 'drizzle-orm';
|
||||
import { files, fileTags, deletedFiles, pendingActions } from './schema';
|
||||
import type { Tag, PendingAction, PendingActionType, PendingActionStatus } from '../../types';
|
||||
|
||||
const DB_NAME = 'vaultdrop-v3.db';
|
||||
const SCHEMA_VERSION_KEY = 'schema_version';
|
||||
const SCHEMA_VERSION = 5;
|
||||
|
||||
let _db: ReturnType<typeof drizzle> | null = null;
|
||||
let _sqliteDb: SQLite.SQLiteDatabase | null = null;
|
||||
|
||||
export function initDB() {
|
||||
if (_db) return _db;
|
||||
_sqliteDb = SQLite.openDatabaseSync(DB_NAME);
|
||||
_sqliteDb.execSync('PRAGMA journal_mode = WAL;');
|
||||
_sqliteDb.execSync('PRAGMA foreign_keys = ON;');
|
||||
|
||||
const existingVersion = _sqliteDb.getFirstSync<{ version: number }>(
|
||||
`SELECT name as version FROM sqlite_master WHERE type='table' AND name='schema_version'`
|
||||
);
|
||||
|
||||
if (!existingVersion) {
|
||||
createSchema(_sqliteDb);
|
||||
_sqliteDb.execSync(`CREATE TABLE schema_version (version INTEGER PRIMARY KEY);`);
|
||||
_sqliteDb.execSync(`INSERT INTO schema_version (version) VALUES (${SCHEMA_VERSION});`);
|
||||
} else {
|
||||
const versionRow = _sqliteDb.getFirstSync<{ version: number }>(
|
||||
`SELECT version FROM schema_version ORDER BY version DESC LIMIT 1`
|
||||
);
|
||||
const currentVersion = versionRow?.version ?? 0;
|
||||
|
||||
if (currentVersion < SCHEMA_VERSION) {
|
||||
migrate(_sqliteDb, currentVersion, SCHEMA_VERSION);
|
||||
_sqliteDb.execSync(`UPDATE schema_version SET version = ${SCHEMA_VERSION};`);
|
||||
}
|
||||
}
|
||||
|
||||
_db = drizzle(_sqliteDb);
|
||||
return _db;
|
||||
}
|
||||
|
||||
const MIGRATIONS: Array<(db: SQLite.SQLiteDatabase) => void> = [
|
||||
// v5: add thumbnail_local column
|
||||
(db) => {
|
||||
db.execSync(`ALTER TABLE files ADD COLUMN thumbnail_local TEXT;`);
|
||||
},
|
||||
];
|
||||
|
||||
function migrate(db: SQLite.SQLiteDatabase, fromVersion: number, toVersion: number) {
|
||||
for (let v = fromVersion; v < toVersion; v++) {
|
||||
const migration = MIGRATIONS[v - 1];
|
||||
if (migration) migration(db);
|
||||
}
|
||||
}
|
||||
|
||||
function createSchema(db: SQLite.SQLiteDatabase) {
|
||||
db.execSync(`
|
||||
CREATE TABLE IF NOT EXISTS files (
|
||||
id TEXT PRIMARY KEY,
|
||||
backend_id TEXT,
|
||||
name TEXT NOT NULL,
|
||||
mime_type TEXT NOT NULL,
|
||||
size INTEGER NOT NULL DEFAULT 0,
|
||||
source TEXT NOT NULL DEFAULT 'cloud',
|
||||
local_uri TEXT,
|
||||
sync_status TEXT NOT NULL DEFAULT 'cloud',
|
||||
parent_resource_id TEXT,
|
||||
is_folder INTEGER NOT NULL DEFAULT 0,
|
||||
ocr_text TEXT,
|
||||
thumbnail_url TEXT,
|
||||
thumbnail_local TEXT,
|
||||
owner_id TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
last_synced_at TEXT
|
||||
);
|
||||
`);
|
||||
db.execSync(`CREATE INDEX IF NOT EXISTS idx_files_backend_id ON files(backend_id);`);
|
||||
db.execSync(`CREATE INDEX IF NOT EXISTS idx_files_parent_resource_id ON files(parent_resource_id);`);
|
||||
db.execSync(`CREATE INDEX IF NOT EXISTS idx_files_source ON files(source);`);
|
||||
db.execSync(`CREATE INDEX IF NOT EXISTS idx_files_is_folder ON files(is_folder);`);
|
||||
db.execSync(`CREATE INDEX IF NOT EXISTS idx_files_sync_status ON files(sync_status);`);
|
||||
db.execSync(`CREATE INDEX IF NOT EXISTS idx_files_owner_id ON files(owner_id);`);
|
||||
db.execSync(`CREATE TABLE IF NOT EXISTS schema_version (version INTEGER PRIMARY KEY);`);
|
||||
|
||||
db.execSync(`
|
||||
CREATE TABLE IF NOT EXISTS file_tags (
|
||||
id TEXT PRIMARY KEY,
|
||||
file_id TEXT NOT NULL REFERENCES files(id) ON DELETE CASCADE,
|
||||
tag_name TEXT NOT NULL
|
||||
);
|
||||
`);
|
||||
db.execSync(`CREATE INDEX IF NOT EXISTS idx_file_tags_file_id ON file_tags(file_id);`);
|
||||
|
||||
db.execSync(`
|
||||
CREATE TABLE IF NOT EXISTS deleted_files (
|
||||
id TEXT PRIMARY KEY,
|
||||
deleted_at TEXT NOT NULL
|
||||
);
|
||||
`);
|
||||
|
||||
db.execSync(`
|
||||
CREATE TABLE IF NOT EXISTS device_info (
|
||||
id TEXT PRIMARY KEY,
|
||||
server_id TEXT,
|
||||
device_name TEXT NOT NULL DEFAULT '',
|
||||
platform TEXT NOT NULL DEFAULT '',
|
||||
registered_at TEXT
|
||||
);
|
||||
`);
|
||||
|
||||
db.execSync(`
|
||||
CREATE TABLE IF NOT EXISTS pending_actions (
|
||||
id TEXT PRIMARY KEY,
|
||||
type TEXT NOT NULL,
|
||||
payload TEXT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'pending',
|
||||
attempts INTEGER NOT NULL DEFAULT 0,
|
||||
last_error TEXT,
|
||||
resource_id TEXT,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
`);
|
||||
db.execSync(`CREATE INDEX IF NOT EXISTS idx_pending_actions_status ON pending_actions(status);`);
|
||||
db.execSync(`CREATE INDEX IF NOT EXISTS idx_pending_actions_type ON pending_actions(type);`);
|
||||
|
||||
db.execSync(`
|
||||
CREATE VIRTUAL TABLE IF NOT EXISTS resources_fts USING fts5(
|
||||
name,
|
||||
ocr_text,
|
||||
content='files',
|
||||
content_rowid='rowid'
|
||||
);
|
||||
`);
|
||||
|
||||
db.execSync(`
|
||||
CREATE TRIGGER IF NOT EXISTS resources_fts_insert AFTER INSERT ON files BEGIN
|
||||
INSERT INTO resources_fts(rowid, name, ocr_text) VALUES (new.rowid, new.name, new.ocr_text);
|
||||
END;
|
||||
`);
|
||||
db.execSync(`
|
||||
CREATE TRIGGER IF NOT EXISTS resources_fts_delete AFTER DELETE ON files BEGIN
|
||||
INSERT INTO resources_fts(resources_fts, rowid, name, ocr_text) VALUES('delete', old.rowid, old.name, old.ocr_text);
|
||||
END;
|
||||
`);
|
||||
db.execSync(`
|
||||
CREATE TRIGGER IF NOT EXISTS resources_fts_update AFTER UPDATE ON files BEGIN
|
||||
INSERT INTO resources_fts(resources_fts, rowid, name, ocr_text) VALUES('delete', old.rowid, old.name, old.ocr_text);
|
||||
INSERT INTO resources_fts(rowid, name, ocr_text) VALUES (new.rowid, new.name, new.ocr_text);
|
||||
END;
|
||||
`);
|
||||
}
|
||||
|
||||
function getDb() {
|
||||
if (!_db) initDB();
|
||||
return _db!;
|
||||
}
|
||||
|
||||
export type FileRecord = {
|
||||
id: string;
|
||||
backendId: string | null;
|
||||
name: string;
|
||||
mimeType: string;
|
||||
size: number;
|
||||
source: string;
|
||||
localUri: string | null;
|
||||
syncStatus: string;
|
||||
parentResourceId: string | null;
|
||||
isFolder: number;
|
||||
ocrText: string | null;
|
||||
thumbnailUrl: string | null;
|
||||
thumbnailLocal?: string | null;
|
||||
ownerId: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
lastSyncedAt: string | null;
|
||||
tags?: Tag[];
|
||||
};
|
||||
|
||||
type FileRow = {
|
||||
id: string;
|
||||
backendId: string | null;
|
||||
name: string;
|
||||
mimeType: string;
|
||||
size: number;
|
||||
source: string;
|
||||
localUri: string | null;
|
||||
syncStatus: string;
|
||||
parentResourceId: string | null;
|
||||
isFolder: number;
|
||||
ocrText: string | null;
|
||||
thumbnailUrl: string | null;
|
||||
thumbnailLocal: string | null;
|
||||
ownerId: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
lastSyncedAt: string | null;
|
||||
};
|
||||
|
||||
function rowToRecord(row: FileRow, tags?: Tag[]): FileRecord {
|
||||
return {
|
||||
id: row.id,
|
||||
backendId: row.backendId,
|
||||
name: row.name,
|
||||
mimeType: row.mimeType,
|
||||
size: row.size,
|
||||
source: row.source,
|
||||
localUri: row.localUri,
|
||||
syncStatus: row.syncStatus,
|
||||
parentResourceId: row.parentResourceId,
|
||||
isFolder: row.isFolder,
|
||||
ocrText: row.ocrText,
|
||||
thumbnailUrl: row.thumbnailUrl,
|
||||
thumbnailLocal: row.thumbnailLocal,
|
||||
ownerId: row.ownerId,
|
||||
createdAt: row.createdAt,
|
||||
updatedAt: row.updatedAt,
|
||||
lastSyncedAt: row.lastSyncedAt,
|
||||
tags,
|
||||
};
|
||||
}
|
||||
|
||||
function getTagsForFile(fileId: string): Tag[] {
|
||||
const d = getDb();
|
||||
const rows = d.select().from(fileTags).where(eq(fileTags.fileId, fileId)).all();
|
||||
return rows.map((r) => ({ id: r.tagName, tag_name: r.tagName }));
|
||||
}
|
||||
|
||||
function setTagsForFile(fileId: string, tags: Tag[]) {
|
||||
const d = getDb();
|
||||
d.delete(fileTags).where(eq(fileTags.fileId, fileId)).run();
|
||||
if (tags.length === 0) return;
|
||||
d.insert(fileTags).values(
|
||||
tags.map((t) => ({
|
||||
id: `${fileId}_${t.id || t.tag_name}`,
|
||||
fileId,
|
||||
tagName: t.tag_name,
|
||||
})),
|
||||
).run();
|
||||
}
|
||||
|
||||
function upsertRow(file: FileRecord) {
|
||||
const d = getDb();
|
||||
d.insert(files).values({
|
||||
id: file.id,
|
||||
backendId: file.backendId,
|
||||
name: file.name,
|
||||
mimeType: file.mimeType,
|
||||
size: file.size,
|
||||
source: file.source,
|
||||
localUri: file.localUri,
|
||||
syncStatus: file.syncStatus,
|
||||
parentResourceId: file.parentResourceId,
|
||||
isFolder: file.isFolder,
|
||||
ocrText: file.ocrText,
|
||||
thumbnailUrl: file.thumbnailUrl,
|
||||
thumbnailLocal: file.thumbnailLocal ?? null,
|
||||
ownerId: file.ownerId,
|
||||
createdAt: file.createdAt,
|
||||
updatedAt: file.updatedAt,
|
||||
lastSyncedAt: file.lastSyncedAt,
|
||||
}).onConflictDoUpdate({
|
||||
target: files.id,
|
||||
set: {
|
||||
backendId: file.backendId,
|
||||
name: file.name,
|
||||
mimeType: file.mimeType,
|
||||
size: file.size,
|
||||
source: file.source,
|
||||
localUri: file.localUri,
|
||||
syncStatus: file.syncStatus,
|
||||
parentResourceId: file.parentResourceId,
|
||||
isFolder: file.isFolder,
|
||||
ocrText: file.ocrText,
|
||||
thumbnailUrl: file.thumbnailUrl,
|
||||
thumbnailLocal: file.thumbnailLocal ?? null,
|
||||
ownerId: file.ownerId,
|
||||
updatedAt: file.updatedAt,
|
||||
lastSyncedAt: file.lastSyncedAt,
|
||||
},
|
||||
}).run();
|
||||
}
|
||||
|
||||
export const fileStore = {
|
||||
initDB,
|
||||
|
||||
upsert(file: FileRecord) {
|
||||
upsertRow(file);
|
||||
if (file.tags) setTagsForFile(file.id, file.tags);
|
||||
},
|
||||
|
||||
upsertBatch(fileList: FileRecord[]) {
|
||||
const d = getDb();
|
||||
for (const file of fileList) {
|
||||
upsertRow(file);
|
||||
if (file.tags) setTagsForFile(file.id, file.tags);
|
||||
}
|
||||
},
|
||||
|
||||
getById(id: string): FileRecord | null {
|
||||
const d = getDb();
|
||||
const row = d.select().from(files).where(eq(files.id, id)).get() as FileRow | undefined;
|
||||
if (!row) return null;
|
||||
return rowToRecord(row, getTagsForFile(id));
|
||||
},
|
||||
|
||||
getByBackendId(backendId: string): FileRecord | null {
|
||||
const d = getDb();
|
||||
const row = d.select().from(files).where(eq(files.backendId, backendId)).get() as FileRow | undefined;
|
||||
if (!row) return null;
|
||||
return rowToRecord(row, getTagsForFile(row.id));
|
||||
},
|
||||
|
||||
getByLocalUri(localUri: string): FileRecord | null {
|
||||
const d = getDb();
|
||||
const row = d.select().from(files).where(eq(files.localUri, localUri)).get() as FileRow | undefined;
|
||||
if (!row) return null;
|
||||
return rowToRecord(row, getTagsForFile(row.id));
|
||||
},
|
||||
|
||||
getRootFolders(): FileRecord[] {
|
||||
const d = getDb();
|
||||
const rows = d.select().from(files)
|
||||
.where(and(eq(files.isFolder, 1), isNull(files.parentResourceId)))
|
||||
.orderBy(asc(files.name))
|
||||
.all() as FileRow[];
|
||||
return rows.map((r) => rowToRecord(r, getTagsForFile(r.id)));
|
||||
},
|
||||
|
||||
getChildrenByParent(parentId: string): FileRecord[] {
|
||||
const d = getDb();
|
||||
const rows = d.select().from(files)
|
||||
.where(eq(files.parentResourceId, parentId))
|
||||
.orderBy(desc(files.isFolder), desc(files.createdAt))
|
||||
.all() as FileRow[];
|
||||
return rows.map((r) => rowToRecord(r, getTagsForFile(r.id)));
|
||||
},
|
||||
|
||||
getRootFiles(): { files: FileRecord[]; total: number } {
|
||||
const d = getDb();
|
||||
const countRow = d.select({ count: sql<number>`count(*)` })
|
||||
.from(files)
|
||||
.where(isNull(files.parentResourceId))
|
||||
.get();
|
||||
const total = countRow?.count ?? 0;
|
||||
const rows = d.select().from(files)
|
||||
.where(isNull(files.parentResourceId))
|
||||
.orderBy(desc(files.isFolder), desc(files.createdAt))
|
||||
.all() as FileRow[];
|
||||
return {
|
||||
files: rows.map((r) => rowToRecord(r, getTagsForFile(r.id))),
|
||||
total,
|
||||
};
|
||||
},
|
||||
|
||||
getPaginated(page: number, limit: number): { files: FileRecord[]; total: number } {
|
||||
const d = getDb();
|
||||
const offset = (page - 1) * limit;
|
||||
|
||||
const countRow = d.select({ count: sql<number>`count(*)` })
|
||||
.from(files)
|
||||
.where(isNull(files.parentResourceId))
|
||||
.get();
|
||||
const total = countRow?.count ?? 0;
|
||||
|
||||
const rows = d.select().from(files)
|
||||
.where(isNull(files.parentResourceId))
|
||||
.orderBy(desc(files.isFolder), desc(files.createdAt))
|
||||
.limit(limit)
|
||||
.offset(offset)
|
||||
.all() as FileRow[];
|
||||
|
||||
return {
|
||||
files: rows.map((r) => rowToRecord(r, getTagsForFile(r.id))),
|
||||
total,
|
||||
};
|
||||
},
|
||||
|
||||
getAllFolders(): FileRecord[] {
|
||||
const d = getDb();
|
||||
const rows = d.select().from(files)
|
||||
.where(eq(files.isFolder, 1))
|
||||
.orderBy(asc(files.name))
|
||||
.all() as FileRow[];
|
||||
return rows.map((r) => rowToRecord(r, getTagsForFile(r.id)));
|
||||
},
|
||||
|
||||
searchFts(query: string): FileRecord[] {
|
||||
const d = getDb();
|
||||
const sanitized = query.replace(/['"]/g, '').trim();
|
||||
if (!sanitized) return [];
|
||||
const ftsPattern = sanitized.split(/\s+/).map(w => `"${w}"`).join(' OR ');
|
||||
const sqlQuery = `
|
||||
SELECT f.* FROM files f
|
||||
JOIN resources_fts r ON r.rowid = f.rowid
|
||||
WHERE resources_fts MATCH ?
|
||||
ORDER BY rank
|
||||
LIMIT 100
|
||||
`;
|
||||
const sqliteDb = _sqliteDb!;
|
||||
const stmt = sqliteDb.prepareSync(sqlQuery);
|
||||
const result = stmt.executeSync<FileRow>(ftsPattern);
|
||||
const rows: FileRow[] = [];
|
||||
for (const r of result) {
|
||||
rows.push(r as unknown as FileRow);
|
||||
}
|
||||
return rows.map((r) => rowToRecord(r, getTagsForFile(r.id)));
|
||||
},
|
||||
|
||||
search(query: string): FileRecord[] {
|
||||
const d = getDb();
|
||||
const pattern = `%${query}%`;
|
||||
const rows = d.select().from(files)
|
||||
.where(or(like(files.name, pattern), like(files.ocrText, pattern)))
|
||||
.orderBy(desc(files.createdAt))
|
||||
.limit(100)
|
||||
.all() as FileRow[];
|
||||
return rows.map((r) => rowToRecord(r, getTagsForFile(r.id)));
|
||||
},
|
||||
|
||||
mergeFromBackend(backendFiles: Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
mimeType: string;
|
||||
size: number;
|
||||
createdAt: string;
|
||||
updatedAt?: string;
|
||||
ocrText?: string;
|
||||
tags?: Tag[];
|
||||
isFolder: boolean;
|
||||
parentResourceId?: string;
|
||||
thumbnailUrl?: string;
|
||||
ownerId?: string;
|
||||
}>) {
|
||||
const d = getDb();
|
||||
const now = new Date().toISOString();
|
||||
|
||||
d.transaction(() => {
|
||||
for (const bf of backendFiles) {
|
||||
const existing = d.select().from(files).where(eq(files.backendId, bf.id)).get() as FileRow | undefined;
|
||||
|
||||
const recordId = existing?.id ?? bf.id;
|
||||
|
||||
const source = existing && existing.localUri ? 'synced' : 'cloud';
|
||||
const syncStatus = existing && existing.localUri
|
||||
? (existing.syncStatus === 'cloud' ? 'synced' : existing.syncStatus)
|
||||
: 'cloud';
|
||||
|
||||
upsertRow({
|
||||
id: recordId,
|
||||
backendId: bf.id,
|
||||
name: bf.name,
|
||||
mimeType: bf.mimeType,
|
||||
size: bf.size,
|
||||
source,
|
||||
localUri: existing?.localUri ?? null,
|
||||
syncStatus,
|
||||
parentResourceId: bf.parentResourceId ?? null,
|
||||
isFolder: bf.isFolder ? 1 : 0,
|
||||
ocrText: bf.ocrText ?? null,
|
||||
thumbnailUrl: bf.thumbnailUrl ?? null,
|
||||
thumbnailLocal: existing?.thumbnailLocal ?? null,
|
||||
ownerId: bf.ownerId ?? null,
|
||||
createdAt: bf.createdAt,
|
||||
updatedAt: bf.updatedAt ?? now,
|
||||
lastSyncedAt: now,
|
||||
});
|
||||
|
||||
if (bf.tags && bf.tags.length > 0) setTagsForFile(recordId, bf.tags);
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
mergeFromDevice(deviceFiles: Array<{
|
||||
id: string;
|
||||
uri: string;
|
||||
name: string;
|
||||
mimeType: string;
|
||||
size: number;
|
||||
createdAt: string;
|
||||
folderId?: string;
|
||||
}>) {
|
||||
const d = getDb();
|
||||
const now = new Date().toISOString();
|
||||
|
||||
d.transaction(() => {
|
||||
for (const df of deviceFiles) {
|
||||
if (this.isDeleted(df.id)) continue;
|
||||
const existing = d.select().from(files).where(eq(files.id, df.id)).get();
|
||||
if (existing) continue;
|
||||
|
||||
upsertRow({
|
||||
id: df.id,
|
||||
backendId: null,
|
||||
name: df.name,
|
||||
mimeType: df.mimeType,
|
||||
size: df.size,
|
||||
source: 'local',
|
||||
localUri: df.uri,
|
||||
syncStatus: 'local',
|
||||
parentResourceId: df.folderId ?? null,
|
||||
isFolder: 0,
|
||||
ocrText: null,
|
||||
thumbnailUrl: null,
|
||||
thumbnailLocal: null,
|
||||
ownerId: null,
|
||||
createdAt: df.createdAt,
|
||||
updatedAt: now,
|
||||
lastSyncedAt: null,
|
||||
});
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
updatePartial(id: string, updates: Partial<FileRecord>) {
|
||||
const d = getDb();
|
||||
const setFields: Record<string, unknown> = {};
|
||||
if (updates.backendId !== undefined) setFields.backendId = updates.backendId;
|
||||
if (updates.syncStatus !== undefined) setFields.syncStatus = updates.syncStatus;
|
||||
if (updates.localUri !== undefined) setFields.localUri = updates.localUri;
|
||||
if (updates.source !== undefined) setFields.source = updates.source;
|
||||
if (updates.thumbnailUrl !== undefined) setFields.thumbnailUrl = updates.thumbnailUrl;
|
||||
if (updates.thumbnailLocal !== undefined) setFields.thumbnailLocal = updates.thumbnailLocal;
|
||||
if (updates.ocrText !== undefined) setFields.ocrText = updates.ocrText;
|
||||
if (updates.parentResourceId !== undefined) setFields.parentResourceId = updates.parentResourceId;
|
||||
if (updates.name !== undefined) setFields.name = updates.name;
|
||||
if (updates.ownerId !== undefined) setFields.ownerId = updates.ownerId;
|
||||
setFields.updatedAt = new Date().toISOString();
|
||||
|
||||
d.update(files).set(setFields).where(eq(files.id, id)).run();
|
||||
},
|
||||
|
||||
updateSyncStatus(id: string, syncStatus: string) {
|
||||
this.updatePartial(id, { syncStatus });
|
||||
},
|
||||
|
||||
markAsCloudOnly(id: string) {
|
||||
this.updatePartial(id, { syncStatus: 'cloud', localUri: null, source: 'cloud' });
|
||||
},
|
||||
|
||||
setThumbnailUrl(backendId: string, thumbnailUrl: string) {
|
||||
const d = getDb();
|
||||
d.update(files).set({ thumbnailUrl, updatedAt: new Date().toISOString() })
|
||||
.where(eq(files.backendId, backendId)).run();
|
||||
},
|
||||
|
||||
setThumbnailLocal(id: string, thumbnailLocal: string) {
|
||||
const d = getDb();
|
||||
d.update(files).set({ thumbnailLocal, updatedAt: new Date().toISOString() })
|
||||
.where(eq(files.id, id)).run();
|
||||
},
|
||||
|
||||
markDeleted(id: string) {
|
||||
const d = getDb();
|
||||
d.insert(deletedFiles).values({ id, deletedAt: new Date().toISOString() })
|
||||
.onConflictDoUpdate({ target: deletedFiles.id, set: { deletedAt: new Date().toISOString() } })
|
||||
.run();
|
||||
},
|
||||
|
||||
isDeleted(id: string): boolean {
|
||||
const d = getDb();
|
||||
const row = d.select().from(deletedFiles).where(eq(deletedFiles.id, id)).get();
|
||||
return !!row;
|
||||
},
|
||||
|
||||
deleteById(id: string) {
|
||||
const d = getDb();
|
||||
this.markDeleted(id);
|
||||
d.delete(files).where(eq(files.id, id)).run();
|
||||
},
|
||||
|
||||
deleteByBackendId(backendId: string) {
|
||||
const d = getDb();
|
||||
const row = d.select().from(files).where(eq(files.backendId, backendId)).get() as FileRow | undefined;
|
||||
if (row) this.markDeleted(row.id);
|
||||
d.delete(files).where(eq(files.backendId, backendId)).run();
|
||||
},
|
||||
|
||||
clear() {
|
||||
const d = getDb();
|
||||
d.delete(fileTags).run();
|
||||
d.delete(files).run();
|
||||
},
|
||||
|
||||
count(): number {
|
||||
const d = getDb();
|
||||
const row = d.select({ count: sql<number>`count(*)` }).from(files).get();
|
||||
return row?.count ?? 0;
|
||||
},
|
||||
|
||||
countPendingSync(): number {
|
||||
const d = getDb();
|
||||
const row = d.select({ count: sql<number>`count(*)` }).from(files).where(
|
||||
and(
|
||||
or(eq(files.source, 'local'), eq(files.source, 'synced')),
|
||||
isNull(files.backendId),
|
||||
isNotNull(files.localUri),
|
||||
inArray(files.syncStatus, ['local', 'error']),
|
||||
)
|
||||
).get();
|
||||
return row?.count ?? 0;
|
||||
},
|
||||
|
||||
getAllLocal(): FileRecord[] {
|
||||
const d = getDb();
|
||||
const rows = d.select().from(files)
|
||||
.where(or(eq(files.source, 'local'), eq(files.source, 'synced')))
|
||||
.all() as FileRow[];
|
||||
return rows.map((r) => rowToRecord(r, getTagsForFile(r.id)));
|
||||
},
|
||||
|
||||
getLocalDeviceFiles(): FileRecord[] {
|
||||
const d = getDb();
|
||||
const rows = d.select().from(files)
|
||||
.where(and(eq(files.source, 'local'), isNull(files.backendId)))
|
||||
.orderBy(desc(files.createdAt))
|
||||
.all() as FileRow[];
|
||||
return rows.map((r) => rowToRecord(r, getTagsForFile(r.id)));
|
||||
},
|
||||
|
||||
getAllSynced(): FileRecord[] {
|
||||
const d = getDb();
|
||||
const rows = d.select().from(files)
|
||||
.where(eq(files.source, 'synced'))
|
||||
.all() as FileRow[];
|
||||
return rows.map((r) => rowToRecord(r, getTagsForFile(r.id)));
|
||||
},
|
||||
|
||||
getPendingSync(): FileRecord[] {
|
||||
const d = getDb();
|
||||
const rows = d.select().from(files)
|
||||
.where(and(eq(files.syncStatus, 'local'), sql`${files.backendId} IS NULL`))
|
||||
.all() as FileRow[];
|
||||
return rows.map((r) => rowToRecord(r, getTagsForFile(r.id)));
|
||||
},
|
||||
|
||||
getErrorFiles(): FileRecord[] {
|
||||
const d = getDb();
|
||||
const rows = d.select().from(files)
|
||||
.where(eq(files.syncStatus, 'error'))
|
||||
.all() as FileRow[];
|
||||
return rows.map((r) => rowToRecord(r, getTagsForFile(r.id)));
|
||||
},
|
||||
|
||||
resetSyncError(id: string) {
|
||||
this.updatePartial(id, { syncStatus: 'local' });
|
||||
},
|
||||
|
||||
// --- Pending Actions ---
|
||||
|
||||
insertPendingAction(action: PendingAction) {
|
||||
const d = getDb();
|
||||
d.insert(pendingActions).values({
|
||||
id: action.id,
|
||||
type: action.type,
|
||||
payload: JSON.stringify(action.payload),
|
||||
status: action.status,
|
||||
attempts: action.attempts,
|
||||
lastError: action.lastError,
|
||||
resourceId: action.resourceId,
|
||||
createdAt: action.createdAt,
|
||||
}).run();
|
||||
},
|
||||
|
||||
getPendingActions(): PendingAction[] {
|
||||
const d = getDb();
|
||||
const rows = d.select().from(pendingActions)
|
||||
.where(eq(pendingActions.status, 'pending'))
|
||||
.orderBy(asc(pendingActions.createdAt))
|
||||
.all();
|
||||
return rows.map(rowToAction);
|
||||
},
|
||||
|
||||
getPendingActionById(id: string): PendingAction | null {
|
||||
const d = getDb();
|
||||
const row = d.select().from(pendingActions)
|
||||
.where(eq(pendingActions.id, id))
|
||||
.get();
|
||||
return row ? rowToAction(row) : null;
|
||||
},
|
||||
|
||||
getPendingActionsCount(): number {
|
||||
const d = getDb();
|
||||
const row = d.select({ count: sql<number>`count(*)` })
|
||||
.from(pendingActions)
|
||||
.where(eq(pendingActions.status, 'pending'))
|
||||
.get();
|
||||
return row?.count ?? 0;
|
||||
},
|
||||
|
||||
markPendingActionDone(id: string) {
|
||||
const d = getDb();
|
||||
d.update(pendingActions)
|
||||
.set({ status: 'done' })
|
||||
.where(eq(pendingActions.id, id))
|
||||
.run();
|
||||
},
|
||||
|
||||
markPendingActionError(id: string, error: string) {
|
||||
const d = getDb();
|
||||
const row = d.select().from(pendingActions)
|
||||
.where(eq(pendingActions.id, id))
|
||||
.get();
|
||||
if (!row) return;
|
||||
d.update(pendingActions)
|
||||
.set({
|
||||
status: 'error',
|
||||
attempts: row.attempts + 1,
|
||||
lastError: error,
|
||||
})
|
||||
.where(eq(pendingActions.id, id))
|
||||
.run();
|
||||
},
|
||||
|
||||
markPendingActionObsolete(id: string) {
|
||||
const d = getDb();
|
||||
d.update(pendingActions)
|
||||
.set({ status: 'obsolete' })
|
||||
.where(eq(pendingActions.id, id))
|
||||
.run();
|
||||
},
|
||||
|
||||
deletePendingAction(id: string) {
|
||||
const d = getDb();
|
||||
d.delete(pendingActions).where(eq(pendingActions.id, id)).run();
|
||||
},
|
||||
|
||||
clearDonePendingActions() {
|
||||
const d = getDb();
|
||||
d.delete(pendingActions).where(eq(pendingActions.status, 'done')).run();
|
||||
d.delete(pendingActions).where(eq(pendingActions.status, 'obsolete')).run();
|
||||
},
|
||||
};
|
||||
|
||||
function rowToAction(row: {
|
||||
id: string;
|
||||
type: string;
|
||||
payload: string;
|
||||
status: string;
|
||||
attempts: number;
|
||||
lastError: string | null;
|
||||
resourceId: string | null;
|
||||
createdAt: string;
|
||||
}): PendingAction {
|
||||
return {
|
||||
id: row.id,
|
||||
type: row.type as PendingActionType,
|
||||
payload: JSON.parse(row.payload),
|
||||
status: row.status as PendingActionStatus,
|
||||
attempts: row.attempts,
|
||||
lastError: row.lastError,
|
||||
resourceId: row.resourceId,
|
||||
createdAt: row.createdAt,
|
||||
};
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
import { initDB } from './index';
|
||||
|
||||
export function migrateFromLegacy() {
|
||||
initDB();
|
||||
}
|
||||
@@ -1,75 +0,0 @@
|
||||
import { sqliteTable, text, integer, index } from 'drizzle-orm/sqlite-core';
|
||||
|
||||
export const files = sqliteTable(
|
||||
'files',
|
||||
{
|
||||
id: text('id').primaryKey(),
|
||||
backendId: text('backend_id'),
|
||||
name: text('name').notNull(),
|
||||
mimeType: text('mime_type').notNull(),
|
||||
size: integer('size').notNull(),
|
||||
source: text('source').notNull().default('cloud'),
|
||||
localUri: text('local_uri'),
|
||||
syncStatus: text('sync_status').notNull().default('cloud'),
|
||||
parentResourceId: text('parent_resource_id'),
|
||||
isFolder: integer('is_folder').notNull().default(0),
|
||||
ocrText: text('ocr_text'),
|
||||
thumbnailUrl: text('thumbnail_url'),
|
||||
thumbnailLocal: text('thumbnail_local'),
|
||||
ownerId: text('owner_id'),
|
||||
createdAt: text('created_at').notNull(),
|
||||
updatedAt: text('updated_at').notNull(),
|
||||
lastSyncedAt: text('last_synced_at'),
|
||||
},
|
||||
(t) => [
|
||||
index('idx_files_backend_id').on(t.backendId),
|
||||
index('idx_files_parent_resource_id').on(t.parentResourceId),
|
||||
index('idx_files_source').on(t.source),
|
||||
index('idx_files_is_folder').on(t.isFolder),
|
||||
index('idx_files_sync_status').on(t.syncStatus),
|
||||
index('idx_files_owner_id').on(t.ownerId),
|
||||
],
|
||||
);
|
||||
|
||||
export const fileTags = sqliteTable(
|
||||
'file_tags',
|
||||
{
|
||||
id: text('id').primaryKey(),
|
||||
fileId: text('file_id')
|
||||
.notNull()
|
||||
.references(() => files.id, { onDelete: 'cascade' }),
|
||||
tagName: text('tag_name').notNull(),
|
||||
},
|
||||
(t) => [index('idx_file_tags_file_id').on(t.fileId)],
|
||||
);
|
||||
|
||||
export const deletedFiles = sqliteTable('deleted_files', {
|
||||
id: text('id').primaryKey(),
|
||||
deletedAt: text('deleted_at').notNull(),
|
||||
});
|
||||
|
||||
export const deviceInfo = sqliteTable('device_info', {
|
||||
id: text('id').primaryKey(),
|
||||
serverId: text('server_id'),
|
||||
deviceName: text('device_name').notNull().default(''),
|
||||
platform: text('platform').notNull().default(''),
|
||||
registeredAt: text('registered_at'),
|
||||
});
|
||||
|
||||
export const pendingActions = sqliteTable(
|
||||
'pending_actions',
|
||||
{
|
||||
id: text('id').primaryKey(),
|
||||
type: text('type').notNull(),
|
||||
payload: text('payload').notNull(),
|
||||
status: text('status').notNull().default('pending'),
|
||||
attempts: integer('attempts').notNull().default(0),
|
||||
lastError: text('last_error'),
|
||||
resourceId: text('resource_id'),
|
||||
createdAt: text('created_at').notNull(),
|
||||
},
|
||||
(t) => [
|
||||
index('idx_pending_actions_status').on(t.status),
|
||||
index('idx_pending_actions_type').on(t.type),
|
||||
],
|
||||
);
|
||||
@@ -1,20 +0,0 @@
|
||||
import { createMMKV } from 'react-native-mmkv';
|
||||
import { PersistedClient, Persister } from '@tanstack/react-query-persist-client';
|
||||
|
||||
const storage = createMMKV({ id: 'vaultdrop-query-cache' });
|
||||
|
||||
export function createMMKVPersister(): Persister {
|
||||
return {
|
||||
persistClient: async (client: PersistedClient) => {
|
||||
storage.set('query-cache', JSON.stringify(client));
|
||||
},
|
||||
restoreClient: async () => {
|
||||
const raw = storage.getString('query-cache');
|
||||
if (!raw) return undefined;
|
||||
return JSON.parse(raw) as PersistedClient;
|
||||
},
|
||||
removeClient: async () => {
|
||||
storage.remove('query-cache');
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,55 +0,0 @@
|
||||
import { createMMKV } from 'react-native-mmkv';
|
||||
import { ONBOARDING_STEPS, CURRENT_ONBOARDING_VERSION, type OnboardingStep } from '../config/onboarding';
|
||||
import { safDirectory } from './safDirectory';
|
||||
|
||||
const storage = createMMKV({ id: 'vaultdrop-onboarding' });
|
||||
|
||||
const COMPLETED_VERSION_KEY = 'completed_version';
|
||||
const SEEN_STEPS_KEY = 'seen_steps';
|
||||
|
||||
export const onboardingStorage = {
|
||||
getCompletedVersion(): number | undefined {
|
||||
const raw = storage.getNumber(COMPLETED_VERSION_KEY);
|
||||
return raw != null ? raw : undefined;
|
||||
},
|
||||
|
||||
setCompletedVersion(version: number) {
|
||||
storage.set(COMPLETED_VERSION_KEY, version);
|
||||
},
|
||||
|
||||
getSeenSteps(): string[] {
|
||||
const raw = storage.getString(SEEN_STEPS_KEY);
|
||||
if (!raw) return [];
|
||||
return JSON.parse(raw) as string[];
|
||||
},
|
||||
|
||||
markStepSeen(stepId: string) {
|
||||
const seen = this.getSeenSteps();
|
||||
if (!seen.includes(stepId)) {
|
||||
seen.push(stepId);
|
||||
storage.set(SEEN_STEPS_KEY, JSON.stringify(seen));
|
||||
}
|
||||
},
|
||||
|
||||
getPendingSteps(): OnboardingStep[] {
|
||||
const lastVersion = this.getCompletedVersion();
|
||||
const seenIds = this.getSeenSteps();
|
||||
const folders = safDirectory.getAll();
|
||||
|
||||
return ONBOARDING_STEPS.filter((step) => {
|
||||
if (lastVersion != null && step.version <= lastVersion) return false;
|
||||
if (seenIds.includes(step.id)) return false;
|
||||
if (step.condition === 'has_no_folders' && folders.length > 0) return false;
|
||||
return true;
|
||||
});
|
||||
},
|
||||
|
||||
needsOnboarding(): boolean {
|
||||
return this.getPendingSteps().length > 0;
|
||||
},
|
||||
|
||||
reset() {
|
||||
storage.remove(COMPLETED_VERSION_KEY);
|
||||
storage.remove(SEEN_STEPS_KEY);
|
||||
},
|
||||
};
|
||||
@@ -1,179 +0,0 @@
|
||||
import { createMMKV } from 'react-native-mmkv';
|
||||
|
||||
export type SyncMode = 'none' | 'manual' | 'auto';
|
||||
export type SyncGlobalMode = 'off' | 'auto' | 'manual';
|
||||
export type FolderSource = 'saf' | 'media-library' | 'recursive';
|
||||
|
||||
export type StoredFolder = {
|
||||
id: string;
|
||||
uri: string;
|
||||
name: string;
|
||||
visible: boolean;
|
||||
syncMode: SyncMode;
|
||||
syncCellular: boolean;
|
||||
source: FolderSource;
|
||||
albumId?: string;
|
||||
parentUri?: string;
|
||||
};
|
||||
|
||||
const storage = createMMKV({ id: 'vaultdrop-saf' });
|
||||
|
||||
const FOLDERS_KEY = 'saf_folders';
|
||||
const SYNC_GLOBAL_MODE_KEY = 'sync_global_mode';
|
||||
const SYNC_GLOBAL_CELLULAR_KEY = 'sync_global_cellular';
|
||||
const DISCOVERED_KEY = 'folders_discovered';
|
||||
|
||||
function generateId(): string {
|
||||
return `folder_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
|
||||
}
|
||||
|
||||
function getAllRaw(): StoredFolder[] {
|
||||
const raw = storage.getString(FOLDERS_KEY);
|
||||
if (!raw) return [];
|
||||
const parsed = JSON.parse(raw) as StoredFolder[];
|
||||
let migrated = false;
|
||||
for (const f of parsed) {
|
||||
if (f.syncMode === undefined) {
|
||||
(f as any).syncMode = 'none';
|
||||
migrated = true;
|
||||
}
|
||||
if (f.syncCellular === undefined) {
|
||||
(f as any).syncCellular = false;
|
||||
migrated = true;
|
||||
}
|
||||
if (f.source === undefined) {
|
||||
(f as any).source = 'saf';
|
||||
migrated = true;
|
||||
}
|
||||
}
|
||||
if (migrated) saveAll(parsed);
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function saveAll(folders: StoredFolder[]) {
|
||||
storage.set(FOLDERS_KEY, JSON.stringify(folders));
|
||||
}
|
||||
|
||||
export const safDirectory = {
|
||||
getAll(): StoredFolder[] {
|
||||
return getAllRaw();
|
||||
},
|
||||
|
||||
getVisibleFolders(): StoredFolder[] {
|
||||
return getAllRaw().filter((f) => f.visible);
|
||||
},
|
||||
|
||||
addFolder(uri: string, name: string): StoredFolder {
|
||||
const folders = getAllRaw();
|
||||
if (folders.some((f) => f.uri === uri)) {
|
||||
return folders.find((f) => f.uri === uri)!;
|
||||
}
|
||||
const folder: StoredFolder = { id: generateId(), uri, name, visible: true, syncMode: 'none', syncCellular: false, source: 'saf' };
|
||||
folders.push(folder);
|
||||
saveAll(folders);
|
||||
return folder;
|
||||
},
|
||||
|
||||
addMediaFolder(albumId: string, name: string, uri: string): StoredFolder {
|
||||
const folders = getAllRaw();
|
||||
const key = `media://${albumId}`;
|
||||
if (folders.some((f) => f.uri === key)) {
|
||||
return folders.find((f) => f.uri === key)!;
|
||||
}
|
||||
const folder: StoredFolder = {
|
||||
id: generateId(),
|
||||
uri: key,
|
||||
name,
|
||||
visible: true,
|
||||
syncMode: 'none',
|
||||
syncCellular: false,
|
||||
source: 'media-library',
|
||||
albumId,
|
||||
};
|
||||
folders.push(folder);
|
||||
saveAll(folders);
|
||||
return folder;
|
||||
},
|
||||
|
||||
addBatchFolders(folders: Array<{ uri: string; name: string; source?: FolderSource; parentUri?: string }>): StoredFolder[] {
|
||||
const current = getAllRaw();
|
||||
const added: StoredFolder[] = [];
|
||||
for (const f of folders) {
|
||||
if (current.some((existing) => existing.uri === f.uri)) continue;
|
||||
const folder: StoredFolder = {
|
||||
id: generateId(),
|
||||
uri: f.uri,
|
||||
name: f.name,
|
||||
visible: true,
|
||||
syncMode: 'none',
|
||||
syncCellular: false,
|
||||
source: f.source ?? 'recursive',
|
||||
parentUri: f.parentUri,
|
||||
};
|
||||
current.push(folder);
|
||||
added.push(folder);
|
||||
}
|
||||
saveAll(current);
|
||||
return added;
|
||||
},
|
||||
|
||||
getDiscovered(): boolean {
|
||||
return storage.getString(DISCOVERED_KEY) === 'true';
|
||||
},
|
||||
|
||||
setDiscovered() {
|
||||
storage.set(DISCOVERED_KEY, 'true');
|
||||
},
|
||||
|
||||
resetDiscovered() {
|
||||
storage.remove(DISCOVERED_KEY);
|
||||
},
|
||||
|
||||
removeFolder(id: string) {
|
||||
const folders = getAllRaw().filter((f) => f.id !== id);
|
||||
saveAll(folders);
|
||||
},
|
||||
|
||||
toggleVisibility(id: string) {
|
||||
const folders = getAllRaw().map((f) =>
|
||||
f.id === id ? { ...f, visible: !f.visible } : f
|
||||
);
|
||||
saveAll(folders);
|
||||
},
|
||||
|
||||
updateSyncMode(id: string, syncMode: SyncMode) {
|
||||
const folders = getAllRaw().map((f) =>
|
||||
f.id === id ? { ...f, syncMode } : f
|
||||
);
|
||||
saveAll(folders);
|
||||
},
|
||||
|
||||
updateSyncCellular(id: string, syncCellular: boolean) {
|
||||
const folders = getAllRaw().map((f) =>
|
||||
f.id === id ? { ...f, syncCellular } : f
|
||||
);
|
||||
saveAll(folders);
|
||||
},
|
||||
|
||||
getGlobalSyncMode(): SyncGlobalMode {
|
||||
const raw = storage.getString(SYNC_GLOBAL_MODE_KEY);
|
||||
if (raw === 'auto' || raw === 'manual') return raw;
|
||||
return 'off';
|
||||
},
|
||||
|
||||
setGlobalSyncMode(mode: SyncGlobalMode) {
|
||||
storage.set(SYNC_GLOBAL_MODE_KEY, mode);
|
||||
},
|
||||
|
||||
getGlobalSyncCellular(): boolean {
|
||||
return storage.getString(SYNC_GLOBAL_CELLULAR_KEY) === 'true';
|
||||
},
|
||||
|
||||
setGlobalSyncCellular(enabled: boolean) {
|
||||
storage.set(SYNC_GLOBAL_CELLULAR_KEY, enabled ? 'true' : 'false');
|
||||
},
|
||||
|
||||
clear() {
|
||||
storage.remove(FOLDERS_KEY);
|
||||
},
|
||||
};
|
||||
@@ -1,25 +0,0 @@
|
||||
import { manipulateAsync, SaveFormat } from 'expo-image-manipulator';
|
||||
|
||||
const THUMB_SIZE = 128;
|
||||
|
||||
function isGeneratableImage(mimeType: string): boolean {
|
||||
return (mimeType ?? '').toLowerCase().startsWith('image/');
|
||||
}
|
||||
|
||||
export async function generateLocalThumbnail(
|
||||
uri: string | undefined,
|
||||
mimeType: string,
|
||||
): Promise<string | null> {
|
||||
if (!uri || !isGeneratableImage(mimeType)) return null;
|
||||
try {
|
||||
const result = await manipulateAsync(
|
||||
uri,
|
||||
[{ resize: { width: THUMB_SIZE } }],
|
||||
{ format: SaveFormat.JPEG, compress: 0.7, base64: true },
|
||||
);
|
||||
if (!result.base64) return null;
|
||||
return `data:image/jpeg;base64,${result.base64}`;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -1,343 +0,0 @@
|
||||
import { File, UploadType } from 'expo-file-system';
|
||||
import { createMMKV } from 'react-native-mmkv';
|
||||
import { apiClient } from '../api/client';
|
||||
import { API_BASE_URL, ENDPOINTS } from '../constants/api';
|
||||
import { ApiError, UploadError } from '../types';
|
||||
import { fileStore } from './fileStore';
|
||||
|
||||
export type UploadFile = { uri: string; type: string; name: string };
|
||||
export type UploadResult = { name: string; id: string };
|
||||
|
||||
export const UPLOAD_MAX_RETRIES = 3;
|
||||
const BASE_RETRY_DELAY_MS = 1000;
|
||||
|
||||
export const activeUploadUris = new Set<string>();
|
||||
|
||||
export type UploadTaskStatus = 'pending' | 'uploading' | 'done' | 'error';
|
||||
|
||||
export type UploadTask = {
|
||||
id: string;
|
||||
file: UploadFile;
|
||||
status: UploadTaskStatus;
|
||||
progress: number;
|
||||
result?: UploadResult;
|
||||
error?: string;
|
||||
retryCount: number;
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
};
|
||||
|
||||
type Listener = () => void;
|
||||
|
||||
const storage = createMMKV({ id: 'vaultdrop-upload-queue' });
|
||||
const STORAGE_KEY = 'tasks';
|
||||
|
||||
function serialize(task: UploadTask): unknown {
|
||||
return {
|
||||
id: task.id,
|
||||
file: task.file,
|
||||
status: task.status,
|
||||
progress: task.progress,
|
||||
result: task.result ?? null,
|
||||
error: task.error ?? null,
|
||||
retryCount: task.retryCount,
|
||||
createdAt: task.createdAt,
|
||||
updatedAt: task.updatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
function deserialize(data: unknown): UploadTask | null {
|
||||
const d = data as Record<string, unknown>;
|
||||
if (!d || !d.id || !d.file) return null;
|
||||
const file = d.file as Record<string, string>;
|
||||
if (!file.uri || !file.type || !file.name) return null;
|
||||
return {
|
||||
id: d.id as string,
|
||||
file: { uri: file.uri, type: file.type, name: file.name },
|
||||
status: d.status as UploadTaskStatus,
|
||||
progress: d.progress as number,
|
||||
result: d.result ? (d.result as UploadResult) : undefined,
|
||||
error: d.error ? (d.error as string) : undefined,
|
||||
retryCount: (d.retryCount as number) ?? 0,
|
||||
createdAt: d.createdAt as number,
|
||||
updatedAt: d.updatedAt as number,
|
||||
};
|
||||
}
|
||||
|
||||
function saveTasks(tasks: UploadTask[]) {
|
||||
const persistable = tasks
|
||||
.filter((t) => t.status !== 'done')
|
||||
.map(serialize);
|
||||
storage.set(STORAGE_KEY, JSON.stringify(persistable));
|
||||
}
|
||||
|
||||
function loadTasks(): UploadTask[] {
|
||||
try {
|
||||
const raw = storage.getString(STORAGE_KEY);
|
||||
if (!raw) return [];
|
||||
const parsed: unknown[] = JSON.parse(raw);
|
||||
if (!Array.isArray(parsed)) return [];
|
||||
const tasks: UploadTask[] = [];
|
||||
let maxId = 0;
|
||||
for (const item of parsed) {
|
||||
const t = deserialize(item);
|
||||
if (t) {
|
||||
tasks.push(t);
|
||||
const num = parseInt(t.id.replace('upload_', ''), 10);
|
||||
if (num > maxId) maxId = num;
|
||||
}
|
||||
}
|
||||
nextId = maxId;
|
||||
return tasks;
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
let nextId = 0;
|
||||
function genId() {
|
||||
nextId++;
|
||||
return `upload_${Date.now()}_${nextId}`;
|
||||
}
|
||||
|
||||
class UploadQueue {
|
||||
private tasks: UploadTask[] = [];
|
||||
private listeners = new Set<Listener>();
|
||||
private concurrency = 3;
|
||||
private active = 0;
|
||||
private cleanupTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
constructor() {
|
||||
this.tasks = loadTasks();
|
||||
const pendingExist = this.tasks.some(
|
||||
(t) => t.status === 'pending' || t.status === 'uploading',
|
||||
);
|
||||
if (pendingExist) {
|
||||
setTimeout(() => {
|
||||
this.restartUploading();
|
||||
this.processNext();
|
||||
}, 0);
|
||||
}
|
||||
}
|
||||
|
||||
private restartUploading() {
|
||||
for (const task of this.tasks) {
|
||||
if (task.status === 'uploading') {
|
||||
task.status = 'pending';
|
||||
task.progress = 0;
|
||||
task.updatedAt = Date.now();
|
||||
}
|
||||
}
|
||||
this.persist();
|
||||
}
|
||||
|
||||
getTasks(): UploadTask[] {
|
||||
return this.tasks;
|
||||
}
|
||||
|
||||
subscribe(listener: Listener): () => void {
|
||||
this.listeners.add(listener);
|
||||
return () => this.listeners.delete(listener);
|
||||
}
|
||||
|
||||
private notify() {
|
||||
this.listeners.forEach((l) => l());
|
||||
}
|
||||
|
||||
private persist() {
|
||||
saveTasks(this.tasks);
|
||||
}
|
||||
|
||||
enqueue(files: UploadFile[]) {
|
||||
const now = Date.now();
|
||||
for (const file of files) {
|
||||
this.tasks.push({
|
||||
id: genId(),
|
||||
file,
|
||||
status: 'pending',
|
||||
progress: 0,
|
||||
retryCount: 0,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
});
|
||||
}
|
||||
this.persist();
|
||||
this.notify();
|
||||
this.processNext();
|
||||
}
|
||||
|
||||
cancel(id: string) {
|
||||
const task = this.tasks.find((t) => t.id === id);
|
||||
if (!task || task.status === 'done') return;
|
||||
task.status = 'error';
|
||||
task.error = 'Annulé';
|
||||
task.updatedAt = Date.now();
|
||||
this.persist();
|
||||
this.notify();
|
||||
}
|
||||
|
||||
retry(id: string) {
|
||||
const task = this.tasks.find((t) => t.id === id);
|
||||
if (!task || task.status !== 'error') return;
|
||||
task.status = 'pending';
|
||||
task.progress = 0;
|
||||
task.retryCount = 0;
|
||||
task.error = undefined;
|
||||
task.result = undefined;
|
||||
task.updatedAt = Date.now();
|
||||
this.persist();
|
||||
this.notify();
|
||||
this.processNext();
|
||||
}
|
||||
|
||||
retryAll() {
|
||||
for (const task of this.tasks) {
|
||||
if (task.status === 'error') {
|
||||
task.status = 'pending';
|
||||
task.progress = 0;
|
||||
task.retryCount = 0;
|
||||
task.error = undefined;
|
||||
task.result = undefined;
|
||||
task.updatedAt = Date.now();
|
||||
}
|
||||
}
|
||||
this.persist();
|
||||
this.notify();
|
||||
this.processNext();
|
||||
}
|
||||
|
||||
getPendingCount(): number {
|
||||
return this.tasks.filter((t) => t.status === 'pending' || t.status === 'uploading').length;
|
||||
}
|
||||
|
||||
private processNext() {
|
||||
while (this.active < this.concurrency) {
|
||||
const next = this.tasks.find((t) => t.status === 'pending');
|
||||
if (!next) break;
|
||||
this.active++;
|
||||
next.status = 'uploading';
|
||||
next.updatedAt = Date.now();
|
||||
this.persist();
|
||||
this.notify();
|
||||
this.runTask(next);
|
||||
}
|
||||
}
|
||||
|
||||
private async runTask(task: UploadTask) {
|
||||
let willRetry = false;
|
||||
activeUploadUris.add(task.file.uri);
|
||||
try {
|
||||
const fsFile = new File(task.file.uri);
|
||||
const headers: Record<string, string> = {};
|
||||
const token = apiClient.getAccessToken();
|
||||
if (token) {
|
||||
headers['Authorization'] = `Bearer ${token}`;
|
||||
}
|
||||
|
||||
const result = await fsFile.upload(`${API_BASE_URL}${ENDPOINTS.UPLOAD}`, {
|
||||
httpMethod: 'POST',
|
||||
uploadType: UploadType.MULTIPART,
|
||||
fieldName: 'file',
|
||||
mimeType: task.file.type,
|
||||
headers,
|
||||
onProgress: (progress) => {
|
||||
if (progress.totalBytes > 0) {
|
||||
task.progress = Math.round((progress.bytesSent / progress.totalBytes) * 100);
|
||||
this.notify();
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
if (result.status >= 400) {
|
||||
let serverMessage = 'Erreur serveur';
|
||||
try {
|
||||
const body: ApiError = JSON.parse(result.body);
|
||||
serverMessage = body.error?.message || serverMessage;
|
||||
} catch {
|
||||
serverMessage = result.body || serverMessage;
|
||||
}
|
||||
throw new UploadError(task.file.name, result.status, serverMessage);
|
||||
}
|
||||
|
||||
const body = JSON.parse(result.body);
|
||||
const items = body.data ?? body;
|
||||
const item = Array.isArray(items) ? items[0] : items;
|
||||
|
||||
task.status = 'done';
|
||||
task.progress = 100;
|
||||
task.result = item as UploadResult;
|
||||
task.updatedAt = Date.now();
|
||||
this.linkResultToStore(task);
|
||||
this.persist();
|
||||
this.notify();
|
||||
this.scheduleCleanup();
|
||||
} catch (err) {
|
||||
task.retryCount++;
|
||||
if (task.retryCount <= UPLOAD_MAX_RETRIES) {
|
||||
willRetry = true;
|
||||
task.status = 'pending';
|
||||
task.progress = 0;
|
||||
task.error = undefined;
|
||||
task.updatedAt = Date.now();
|
||||
this.persist();
|
||||
this.notify();
|
||||
const delay = BASE_RETRY_DELAY_MS * Math.pow(2, task.retryCount - 1);
|
||||
setTimeout(() => this.processNext(), delay);
|
||||
} else {
|
||||
task.status = 'error';
|
||||
task.error =
|
||||
err instanceof UploadError
|
||||
? `${err.fileName} : ${err.message}`
|
||||
: err instanceof Error
|
||||
? err.message
|
||||
: 'Erreur inconnue';
|
||||
task.error += ` (${task.retryCount} tentative(s))`;
|
||||
task.updatedAt = Date.now();
|
||||
this.persist();
|
||||
this.notify();
|
||||
}
|
||||
} finally {
|
||||
this.active--;
|
||||
activeUploadUris.delete(task.file.uri);
|
||||
this.notify();
|
||||
if (!willRetry) {
|
||||
this.processNext();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private linkResultToStore(task: UploadTask) {
|
||||
try {
|
||||
const backendId = task.result?.id;
|
||||
if (!backendId) return;
|
||||
const entry = fileStore.getByLocalUri(task.file.uri);
|
||||
if (!entry || entry.backendId) return;
|
||||
fileStore.updatePartial(entry.id, {
|
||||
backendId,
|
||||
syncStatus: 'synced',
|
||||
source: 'synced',
|
||||
});
|
||||
} catch {}
|
||||
}
|
||||
|
||||
private scheduleCleanup() {
|
||||
if (this.cleanupTimer) return;
|
||||
this.cleanupTimer = setTimeout(() => {
|
||||
this.cleanupTimer = null;
|
||||
const now = Date.now();
|
||||
const before = this.tasks.length;
|
||||
this.tasks = this.tasks.filter(
|
||||
(t) => t.status !== 'done' || now - t.updatedAt < 5000,
|
||||
);
|
||||
if (this.tasks.length !== before) {
|
||||
this.persist();
|
||||
this.notify();
|
||||
}
|
||||
if (this.tasks.some((t) => t.status === 'done')) {
|
||||
this.scheduleCleanup();
|
||||
}
|
||||
}, 6000);
|
||||
}
|
||||
}
|
||||
|
||||
export const uploadQueue = new UploadQueue();
|
||||
Reference in New Issue
Block a user