import { existsSync, mkdirSync, statSync, symlinkSync, writeFileSync, } from "node:fs"; import { join, relative, extname } from "node:path"; import type { Client } from "./client.js"; import type { EnteFile } from "./model/types.js"; export interface BackupError { fileID: number; title: string; collection: string; error: string; } export interface BackupResult { totalFiles: number; downloaded: number; skipped: number; failed: number; errors: BackupError[]; } export type ProgressCallback = (message: string) => void; const sanitizePath = (name: string): string => name.replace(/[/\\:*?"<>|]/g, "_").replace(/^\.+/, "_"); const originalFileName = (file: EnteFile): string => { const ext = extname(file.metadata.title || "") || ".bin"; return `${file.id}${ext}`; }; export const runBackup = async ( client: Client, outDir: string, onProgress?: ProgressCallback, ): Promise => { const log = onProgress ?? (() => {}); mkdirSync(outDir, { recursive: true }); const originalsDir = join(outDir, "originals"); mkdirSync(originalsDir, { recursive: true }); const collectionsDir = join(outDir, "collections"); mkdirSync(collectionsDir, { recursive: true }); log("Fetching collections..."); const collections = await client.listCollections(); const downloadedIDs = new Set(); let totalFiles = 0; let downloaded = 0; let skipped = 0; let failed = 0; const errors: BackupError[] = []; for (const col of collections) { const colDirName = sanitizePath(col.name || `collection-${col.id}`); const colDir = join(collectionsDir, colDirName); mkdirSync(colDir, { recursive: true }); log(`[${col.name}] Fetching file list...`); const files = await client.listFiles(col.id, col.key); log(`[${col.name}] ${files.length} file(s)`); const collectionMeta: { id: number; name: string; type: string; files: { id: number; metadata: EnteFile["metadata"] }[]; } = { id: col.id, name: col.name, type: col.type, files: [], }; for (const file of files) { totalFiles++; const origName = originalFileName(file); const origPath = join(originalsDir, origName); const linkName = sanitizePath( file.metadata.title || `file-${file.id}`, ); const linkPath = join(colDir, linkName); if (!downloadedIDs.has(file.id)) { if (existsSync(origPath) && statSync(origPath).size > 0) { skipped++; downloadedIDs.add(file.id); } else { try { log(`[${col.name}] Downloading ${linkName}...`); await client.downloadFile(file, origPath); downloaded++; downloadedIDs.add(file.id); } catch (err) { log( `[${col.name}] FAILED ${linkName}: ${err instanceof Error ? err.message : err}`, ); failed++; errors.push({ fileID: file.id, title: file.metadata.title, collection: col.name, error: err instanceof Error ? err.message : String(err), }); continue; } } } // Write per-file metadata JSON alongside the original const metaJsonPath = join(originalsDir, `${file.id}.json`); if (!existsSync(metaJsonPath)) { const fileMeta: Record = { id: file.id, collectionID: file.collectionID, ownerID: file.ownerID, metadata: file.metadata, }; if (file.magicMetadata) { fileMeta.magicMetadata = file.magicMetadata; } if (file.pubMagicMetadata) { fileMeta.pubMagicMetadata = file.pubMagicMetadata; } writeFileSync(metaJsonPath, JSON.stringify(fileMeta, null, 2)); } if (!existsSync(linkPath) && existsSync(origPath)) { const target = relative(colDir, origPath); symlinkSync(target, linkPath); } collectionMeta.files.push({ id: file.id, metadata: file.metadata, }); } writeFileSync( join(collectionsDir, `${colDirName}.json`), JSON.stringify(collectionMeta, null, 2), ); } return { totalFiles, downloaded, skipped, failed, errors }; };