from scratch

This commit is contained in:
m
2026-09-09 18:23:01 +02:00
parent c7467ea8f9
commit 2ba837bcb0
87 changed files with 721 additions and 15218 deletions
-168
View File
@@ -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();
-69
View File
@@ -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)');
}
-98
View File
@@ -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;
},
};
-757
View File
@@ -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,
};
}
-5
View File
@@ -1,5 +0,0 @@
import { initDB } from './index';
export function migrateFromLegacy() {
initDB();
}
-75
View File
@@ -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),
],
);
-20
View File
@@ -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');
},
};
}
-55
View File
@@ -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);
},
};
-179
View File
@@ -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);
},
};
-25
View File
@@ -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;
}
}
-343
View File
@@ -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();