|
|
@@ -1,12 +1,13 @@
|
|
|
import { BrowserWindow } from 'electron'
|
|
|
+import { spawn } from 'child_process'
|
|
|
import * as crypto from 'crypto'
|
|
|
import * as fs from 'fs'
|
|
|
import * as path from 'path'
|
|
|
import { IpcChannels } from '@shared/ipc-channels'
|
|
|
-import type { WallpaperMeta } from '@shared/types'
|
|
|
+import type { BackupProgressEvent, WallpaperMeta } from '@shared/types'
|
|
|
import { getConfiguredBackupPath } from './config.service'
|
|
|
import * as library from './library.service'
|
|
|
-import { readProjectJson, normalizeType, normalizeRating, getDirSize } from './library.service'
|
|
|
+import { normalizeType, normalizeRating, type ProjectJson } from './library.service'
|
|
|
|
|
|
export function getBackupDir(id: string): string {
|
|
|
const backupPath = getConfiguredBackupPath()
|
|
|
@@ -14,9 +15,62 @@ export function getBackupDir(id: string): string {
|
|
|
return path.join(backupPath, id)
|
|
|
}
|
|
|
|
|
|
-export function isBackedUp(id: string): boolean {
|
|
|
+export function resolveBackupDir(wallpaper: WallpaperMeta): string | null {
|
|
|
+ if (wallpaper.source === 'backup' && wallpaper.localPath) return wallpaper.localPath
|
|
|
+ if (wallpaper.backupDir) return wallpaper.backupDir
|
|
|
try {
|
|
|
- return fs.existsSync(getBackupDir(id))
|
|
|
+ return getBackupDir(wallpaper.id)
|
|
|
+ } catch {
|
|
|
+ return null
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+async function isDir(p: string): Promise<boolean> {
|
|
|
+ try {
|
|
|
+ return (await fs.promises.stat(p)).isDirectory()
|
|
|
+ } catch {
|
|
|
+ return false
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+async function exists(p: string): Promise<boolean> {
|
|
|
+ try {
|
|
|
+ await fs.promises.access(p)
|
|
|
+ return true
|
|
|
+ } catch {
|
|
|
+ return false
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+async function readProjectJson(dir: string): Promise<ProjectJson | null> {
|
|
|
+ try {
|
|
|
+ return JSON.parse(await fs.promises.readFile(path.join(dir, 'project.json'), 'utf8')) as ProjectJson
|
|
|
+ } catch {
|
|
|
+ return null
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+export async function hasBackupFiles(dir: string | null): Promise<boolean> {
|
|
|
+ return !!dir && (await isDir(dir)) && (await exists(path.join(dir, 'project.json')))
|
|
|
+}
|
|
|
+
|
|
|
+export async function findBackupProblem(dir: string): Promise<string | null> {
|
|
|
+ if (!(await isDir(dir))) return 'folder is missing'
|
|
|
+ const pj = await readProjectJson(dir)
|
|
|
+ if (!pj) return 'project.json is missing or unreadable'
|
|
|
+ if (!pj.file) return null
|
|
|
+ if (await exists(path.join(dir, pj.file))) return null
|
|
|
+ if (normalizeType(pj.type) === 'scene' && (await fs.promises.readdir(dir)).some((f) => f.endsWith('.pkg'))) {
|
|
|
+ return null
|
|
|
+ }
|
|
|
+ return `${pj.file} is missing`
|
|
|
+}
|
|
|
+
|
|
|
+async function sameFileList(sourcePath: string, backupDir: string): Promise<boolean> {
|
|
|
+ try {
|
|
|
+ const [src, dst] = await Promise.all([walkFiles(sourcePath), walkFiles(backupDir)])
|
|
|
+ const dstSizes = new Map(dst.map((f) => [f.relPath, f.size]))
|
|
|
+ return src.length === dst.length && src.every((f) => dstSizes.get(f.relPath) === f.size)
|
|
|
} catch {
|
|
|
return false
|
|
|
}
|
|
|
@@ -32,49 +86,116 @@ function hashFile(filePath: string): Promise<string> {
|
|
|
})
|
|
|
}
|
|
|
|
|
|
-async function verifyCopy(srcFile: string, destFile: string, expectedSize: number): Promise<void> {
|
|
|
- const destSize = (await fs.promises.stat(destFile)).size
|
|
|
- if (destSize !== expectedSize) {
|
|
|
- throw new Error(
|
|
|
- `Backup verification failed for ${path.basename(destFile)}: expected ${expectedSize} bytes, got ${destSize}`
|
|
|
- )
|
|
|
+async function walkFiles(dirPath: string, baseDir = dirPath): Promise<{ relPath: string; size: number }[]> {
|
|
|
+ const entries = await fs.promises.readdir(dirPath, { withFileTypes: true })
|
|
|
+ const nested = await Promise.all(
|
|
|
+ entries.map(async (entry) => {
|
|
|
+ const full = path.join(dirPath, entry.name)
|
|
|
+ if (entry.isDirectory()) return walkFiles(full, baseDir)
|
|
|
+ if (!entry.isFile()) return []
|
|
|
+ return [{ relPath: path.relative(baseDir, full), size: (await fs.promises.stat(full)).size }]
|
|
|
+ })
|
|
|
+ )
|
|
|
+ return nested.flat()
|
|
|
+}
|
|
|
+
|
|
|
+async function walkDirs(dirPath: string): Promise<string[]> {
|
|
|
+ const entries = await fs.promises.readdir(dirPath, { withFileTypes: true })
|
|
|
+ const nested = await Promise.all(
|
|
|
+ entries.filter((e) => e.isDirectory()).map((e) => walkDirs(path.join(dirPath, e.name)))
|
|
|
+ )
|
|
|
+ return [dirPath, ...nested.flat()]
|
|
|
+}
|
|
|
+
|
|
|
+async function forEachLimit<T>(items: T[], limit: number, fn: (item: T) => Promise<void>): Promise<void> {
|
|
|
+ let next = 0
|
|
|
+ const worker = async (): Promise<void> => {
|
|
|
+ while (next < items.length) await fn(items[next++])
|
|
|
}
|
|
|
- const [srcHash, destHash] = await Promise.all([hashFile(srcFile), hashFile(destFile)])
|
|
|
- if (srcHash !== destHash) {
|
|
|
- throw new Error(`Backup verification failed for ${path.basename(destFile)}: hash mismatch`)
|
|
|
+ await Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker))
|
|
|
+}
|
|
|
+
|
|
|
+async function fsyncPath(p: string): Promise<void> {
|
|
|
+ const handle = await fs.promises.open(p, 'r')
|
|
|
+ try {
|
|
|
+ await handle.sync()
|
|
|
+ } finally {
|
|
|
+ await handle.close()
|
|
|
}
|
|
|
}
|
|
|
|
|
|
-function walkFiles(dirPath: string, baseDir = dirPath): { relPath: string; size: number }[] {
|
|
|
- const results: { relPath: string; size: number }[] = []
|
|
|
- for (const entry of fs.readdirSync(dirPath, { withFileTypes: true })) {
|
|
|
- const full = path.join(dirPath, entry.name)
|
|
|
- if (entry.isDirectory()) {
|
|
|
- results.push(...walkFiles(full, baseDir))
|
|
|
- } else if (entry.isFile()) {
|
|
|
- const rel = path.relative(baseDir, full)
|
|
|
- results.push({ relPath: rel, size: fs.statSync(full).size })
|
|
|
- }
|
|
|
+// fsynced pages are clean, so iflag=nocache can evict them and the hash check reads the disk
|
|
|
+function dropPageCache(dir: string): Promise<void> {
|
|
|
+ return new Promise((resolve) => {
|
|
|
+ const child = spawn(
|
|
|
+ 'find',
|
|
|
+ [dir, '-type', 'f', '-exec', 'sh', '-c',
|
|
|
+ 'for f do dd if="$f" iflag=nocache count=0 status=none; done', 'sh', '{}', '+'],
|
|
|
+ { stdio: 'ignore' }
|
|
|
+ )
|
|
|
+ child.on('error', () => resolve())
|
|
|
+ child.on('close', () => resolve())
|
|
|
+ })
|
|
|
+}
|
|
|
+
|
|
|
+function sendProgress(win: BrowserWindow, progress: BackupProgressEvent): void {
|
|
|
+ if (!win.isDestroyed()) win.webContents.send(IpcChannels.EVENT_BACKUP_PROGRESS, progress)
|
|
|
+}
|
|
|
+
|
|
|
+function percent(done: number, total: number): number {
|
|
|
+ return total > 0 ? Math.round((done / total) * 100) : 100
|
|
|
+}
|
|
|
+
|
|
|
+export async function backupMatchesSource(sourcePath: string, backupDir: string): Promise<boolean> {
|
|
|
+ let src: { relPath: string; size: number }[]
|
|
|
+ let dst: { relPath: string; size: number }[]
|
|
|
+ try {
|
|
|
+ ;[src, dst] = await Promise.all([walkFiles(sourcePath), walkFiles(backupDir)])
|
|
|
+ } catch {
|
|
|
+ return false
|
|
|
+ }
|
|
|
+ if (src.length !== dst.length) return false
|
|
|
+ const dstSizes = new Map(dst.map((f) => [f.relPath, f.size]))
|
|
|
+ if (src.some((f) => dstSizes.get(f.relPath) !== f.size)) return false
|
|
|
+
|
|
|
+ for (const file of src) {
|
|
|
+ const [a, b] = await Promise.all([
|
|
|
+ hashFile(path.join(sourcePath, file.relPath)),
|
|
|
+ hashFile(path.join(backupDir, file.relPath))
|
|
|
+ ])
|
|
|
+ if (a !== b) return false
|
|
|
}
|
|
|
- return results
|
|
|
+ return true
|
|
|
}
|
|
|
|
|
|
export async function backupWallpaper(
|
|
|
id: string,
|
|
|
sourcePath: string,
|
|
|
win: BrowserWindow
|
|
|
-): Promise<void> {
|
|
|
+): Promise<string> {
|
|
|
const destDir = getBackupDir(id)
|
|
|
- const files = walkFiles(sourcePath)
|
|
|
+ const backupRoot = path.dirname(destDir)
|
|
|
+ if (path.resolve(sourcePath) === path.resolve(destDir)) {
|
|
|
+ throw new Error('Wallpaper already lives in the backup folder')
|
|
|
+ }
|
|
|
+
|
|
|
+ const files = await walkFiles(sourcePath)
|
|
|
+ if (!files.some((f) => f.relPath === 'project.json')) {
|
|
|
+ throw new Error('Source folder has no project.json, refusing to back up an incomplete wallpaper')
|
|
|
+ }
|
|
|
const bytesTotal = files.reduce((sum, f) => sum + f.size, 0)
|
|
|
- let bytesCopied = 0
|
|
|
|
|
|
- fs.mkdirSync(destDir, { recursive: true })
|
|
|
+ await fs.promises.mkdir(backupRoot, { recursive: true })
|
|
|
+ const stagingDir = path.join(backupRoot, `.${id}.partial`)
|
|
|
+ const oldDir = path.join(backupRoot, `.${id}.old`)
|
|
|
+ await fs.promises.rm(stagingDir, { recursive: true, force: true })
|
|
|
+ await fs.promises.mkdir(stagingDir)
|
|
|
|
|
|
+ let bytesDone = 0
|
|
|
try {
|
|
|
for (const file of files) {
|
|
|
const srcFile = path.join(sourcePath, file.relPath)
|
|
|
- const destFile = path.join(destDir, file.relPath)
|
|
|
+ const destFile = path.join(stagingDir, file.relPath)
|
|
|
|
|
|
let srcStat: fs.Stats
|
|
|
try {
|
|
|
@@ -86,68 +207,105 @@ export async function backupWallpaper(
|
|
|
throw new Error(`Source file changed since it was scanned, refusing to back up: ${file.relPath}`)
|
|
|
}
|
|
|
|
|
|
- fs.mkdirSync(path.dirname(destFile), { recursive: true })
|
|
|
+ await fs.promises.mkdir(path.dirname(destFile), { recursive: true })
|
|
|
await fs.promises.copyFile(srcFile, destFile)
|
|
|
+ await fsyncPath(destFile)
|
|
|
+ bytesDone += file.size
|
|
|
+ sendProgress(win, {
|
|
|
+ itemId: id,
|
|
|
+ bytesCopied: bytesDone,
|
|
|
+ bytesTotal,
|
|
|
+ percentage: percent(bytesDone, bytesTotal),
|
|
|
+ status: 'copying'
|
|
|
+ })
|
|
|
+ }
|
|
|
+ for (const dir of await walkDirs(stagingDir)) await fsyncPath(dir)
|
|
|
+ await dropPageCache(stagingDir)
|
|
|
|
|
|
- if (!win.isDestroyed()) {
|
|
|
- win.webContents.send(IpcChannels.EVENT_BACKUP_PROGRESS, {
|
|
|
- itemId: id,
|
|
|
- bytesCopied,
|
|
|
- bytesTotal,
|
|
|
- percentage: bytesTotal > 0 ? Math.round((bytesCopied / bytesTotal) * 100) : 0,
|
|
|
- status: 'verifying'
|
|
|
- })
|
|
|
+ bytesDone = 0
|
|
|
+ for (const file of files) {
|
|
|
+ const destFile = path.join(stagingDir, file.relPath)
|
|
|
+ const destSize = (await fs.promises.stat(destFile)).size
|
|
|
+ if (destSize !== file.size) {
|
|
|
+ throw new Error(
|
|
|
+ `Backup verification failed for ${file.relPath}: expected ${file.size} bytes, got ${destSize}`
|
|
|
+ )
|
|
|
}
|
|
|
-
|
|
|
- await verifyCopy(srcFile, destFile, file.size)
|
|
|
- bytesCopied += file.size
|
|
|
-
|
|
|
- if (!win.isDestroyed()) {
|
|
|
- win.webContents.send(IpcChannels.EVENT_BACKUP_PROGRESS, {
|
|
|
- itemId: id,
|
|
|
- bytesCopied,
|
|
|
- bytesTotal,
|
|
|
- percentage: bytesTotal > 0 ? Math.round((bytesCopied / bytesTotal) * 100) : 100,
|
|
|
- status: 'copying'
|
|
|
- })
|
|
|
+ const [srcHash, destHash] = await Promise.all([
|
|
|
+ hashFile(path.join(sourcePath, file.relPath)),
|
|
|
+ hashFile(destFile)
|
|
|
+ ])
|
|
|
+ if (srcHash !== destHash) {
|
|
|
+ throw new Error(`Backup verification failed for ${file.relPath}: hash mismatch`)
|
|
|
}
|
|
|
- }
|
|
|
- } catch (err) {
|
|
|
- try {
|
|
|
- await fs.promises.rm(destDir, { recursive: true, force: true })
|
|
|
- } catch {
|
|
|
- /* ignore */
|
|
|
- }
|
|
|
- if (!win.isDestroyed()) {
|
|
|
- win.webContents.send(IpcChannels.EVENT_BACKUP_PROGRESS, {
|
|
|
+ bytesDone += file.size
|
|
|
+ sendProgress(win, {
|
|
|
itemId: id,
|
|
|
- bytesCopied,
|
|
|
+ bytesCopied: bytesDone,
|
|
|
bytesTotal,
|
|
|
- percentage: 0,
|
|
|
- status: 'error',
|
|
|
- message: (err as Error).message
|
|
|
+ percentage: percent(bytesDone, bytesTotal),
|
|
|
+ status: 'verifying'
|
|
|
})
|
|
|
}
|
|
|
- throw err
|
|
|
- }
|
|
|
|
|
|
- if (!win.isDestroyed()) {
|
|
|
- win.webContents.send(IpcChannels.EVENT_BACKUP_PROGRESS, {
|
|
|
+ await fs.promises.rm(oldDir, { recursive: true, force: true })
|
|
|
+ const hadOld = await exists(destDir)
|
|
|
+ if (hadOld) await fs.promises.rename(destDir, oldDir)
|
|
|
+ await fs.promises.rename(stagingDir, destDir)
|
|
|
+ await fsyncPath(backupRoot)
|
|
|
+ if (hadOld) await fs.promises.rm(oldDir, { recursive: true, force: true })
|
|
|
+ } catch (err) {
|
|
|
+ await fs.promises.rm(stagingDir, { recursive: true, force: true }).catch(() => {})
|
|
|
+ sendProgress(win, {
|
|
|
itemId: id,
|
|
|
- bytesCopied,
|
|
|
+ bytesCopied: bytesDone,
|
|
|
bytesTotal,
|
|
|
- percentage: 100,
|
|
|
- status: 'completed'
|
|
|
+ percentage: 0,
|
|
|
+ status: 'error',
|
|
|
+ message: (err as Error).message
|
|
|
})
|
|
|
+ throw err
|
|
|
}
|
|
|
+
|
|
|
+ sendProgress(win, { itemId: id, bytesCopied: bytesTotal, bytesTotal, percentage: 100, status: 'completed' })
|
|
|
+ return destDir
|
|
|
}
|
|
|
|
|
|
-function buildBackupMeta(
|
|
|
- id: string,
|
|
|
- localPath: string,
|
|
|
- existing?: WallpaperMeta
|
|
|
-): WallpaperMeta {
|
|
|
- const pj = readProjectJson(localPath)
|
|
|
+function isDeletableBackupDir(wallpaper: WallpaperMeta, dir: string): boolean {
|
|
|
+ const resolved = path.resolve(dir)
|
|
|
+ if (wallpaper.backupDir && path.resolve(wallpaper.backupDir) === resolved) return true
|
|
|
+ if (wallpaper.source === 'backup' && path.basename(resolved) === wallpaper.id) return true
|
|
|
+ const backupPath = getConfiguredBackupPath()
|
|
|
+ return !!backupPath && path.dirname(resolved) === path.resolve(backupPath)
|
|
|
+}
|
|
|
+
|
|
|
+export async function removeBackup(id: string): Promise<{ keptDir: string | null }> {
|
|
|
+ const wallpaper = library.getWallpaper(id)
|
|
|
+ if (!wallpaper) return { keptDir: null }
|
|
|
+ const dir = resolveBackupDir(wallpaper)
|
|
|
+ let keptDir: string | null = null
|
|
|
+
|
|
|
+ if (dir && (await exists(dir))) {
|
|
|
+ const isWorkshopCopy =
|
|
|
+ wallpaper.source === 'workshop' && !!wallpaper.localPath && path.resolve(wallpaper.localPath) === path.resolve(dir)
|
|
|
+ if (isWorkshopCopy || !isDeletableBackupDir(wallpaper, dir)) {
|
|
|
+ keptDir = dir
|
|
|
+ console.warn(`[Backup] Not deleting ${dir} for ${id}, only dropping the reference`)
|
|
|
+ } else {
|
|
|
+ await fs.promises.rm(dir, { recursive: true, force: true })
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ if (wallpaper.source === 'backup') {
|
|
|
+ library.deleteWallpaper(id)
|
|
|
+ } else {
|
|
|
+ library.updateWallpaper(id, { backedUp: false, backupDir: undefined })
|
|
|
+ }
|
|
|
+ return { keptDir }
|
|
|
+}
|
|
|
+
|
|
|
+async function buildBackupMeta(id: string, localPath: string, existing?: WallpaperMeta): Promise<WallpaperMeta> {
|
|
|
+ const [pj, files] = await Promise.all([readProjectJson(localPath), walkFiles(localPath)])
|
|
|
const now = Date.now()
|
|
|
return {
|
|
|
id,
|
|
|
@@ -158,7 +316,7 @@ function buildBackupMeta(
|
|
|
previewLocal: pj?.preview ? path.join(localPath, pj.preview) : undefined,
|
|
|
localPath,
|
|
|
file: pj?.file,
|
|
|
- fileSize: getDirSize(localPath),
|
|
|
+ fileSize: files.reduce((sum, f) => sum + f.size, 0),
|
|
|
createdAt: existing?.createdAt ?? now,
|
|
|
updatedAt: now,
|
|
|
subscribed: false,
|
|
|
@@ -167,39 +325,106 @@ function buildBackupMeta(
|
|
|
tags: pj?.tags ?? existing?.tags ?? [],
|
|
|
resolutions: existing?.resolutions,
|
|
|
categories: existing?.categories ?? [],
|
|
|
- backedUp: true
|
|
|
+ backedUp: true,
|
|
|
+ backupDir: localPath
|
|
|
}
|
|
|
}
|
|
|
|
|
|
-export function scanBackupFolder(): { imported: number; skipped: number; linked: number } {
|
|
|
+const SCAN_CONCURRENCY = 4
|
|
|
+
|
|
|
+export async function scanBackupFolder(): Promise<{
|
|
|
+ imported: number
|
|
|
+ skipped: number
|
|
|
+ linked: number
|
|
|
+ removed: number
|
|
|
+ unlinked: number
|
|
|
+ corrupted: string[]
|
|
|
+}> {
|
|
|
+ const result = { imported: 0, skipped: 0, linked: 0, removed: 0, unlinked: 0, corrupted: [] as string[] }
|
|
|
const backupPath = getConfiguredBackupPath()
|
|
|
- if (!backupPath || !fs.existsSync(backupPath)) return { imported: 0, skipped: 0, linked: 0 }
|
|
|
+ if (!backupPath || !(await isDir(backupPath))) return result
|
|
|
+
|
|
|
+ const wallpapers = new Map(library.getAllWallpapers().map((w) => [w.id, w]))
|
|
|
+ const folderIds = (await fs.promises.readdir(backupPath, { withFileTypes: true }))
|
|
|
+ // dot folders are staging copies of an in-progress backup
|
|
|
+ .filter((e) => e.isDirectory() && !e.name.startsWith('.'))
|
|
|
+ .map((e) => e.name)
|
|
|
|
|
|
- let imported = 0
|
|
|
- let skipped = 0
|
|
|
- let linked = 0
|
|
|
+ const problems = new Map<string, Promise<string | null>>()
|
|
|
+ const problemOf = (dir: string): Promise<string | null> => {
|
|
|
+ const key = path.resolve(dir)
|
|
|
+ if (!problems.has(key)) problems.set(key, findBackupProblem(dir))
|
|
|
+ return problems.get(key)!
|
|
|
+ }
|
|
|
+ const isValidFor = async (w: WallpaperMeta, dir: string): Promise<boolean> => {
|
|
|
+ if (await problemOf(dir)) return false
|
|
|
+ if (w.source !== 'workshop' || !w.localPath || !(await isDir(w.localPath))) return true
|
|
|
+ return sameFileList(w.localPath, dir)
|
|
|
+ }
|
|
|
+
|
|
|
+ const upserts: WallpaperMeta[] = []
|
|
|
+ const patches: { id: string; patch: Partial<WallpaperMeta> }[] = []
|
|
|
+ const deletes: string[] = []
|
|
|
|
|
|
- for (const entry of fs.readdirSync(backupPath, { withFileTypes: true })) {
|
|
|
- if (!entry.isDirectory()) continue
|
|
|
+ const referenced = [...wallpapers.values()].filter((w) => (w.source === 'backup' || w.backedUp) && !w.downloading)
|
|
|
+ await forEachLimit(referenced, SCAN_CONCURRENCY, async (w) => {
|
|
|
+ const dir = resolveBackupDir(w)
|
|
|
+ // the drive holding it is not mounted
|
|
|
+ if (dir && !(await isDir(path.dirname(dir)))) return
|
|
|
+ if (dir && (await isValidFor(w, dir))) return
|
|
|
+ const reason = dir ? ((await problemOf(dir)) ?? 'does not match the workshop copy') : 'no backup folder'
|
|
|
+ console.warn(`[Backup] Dropping broken backup of ${w.id} at ${dir}: ${reason}`)
|
|
|
+ if (w.source === 'backup') {
|
|
|
+ deletes.push(w.id)
|
|
|
+ result.removed++
|
|
|
+ } else {
|
|
|
+ patches.push({ id: w.id, patch: { backedUp: false, backupDir: undefined } })
|
|
|
+ result.unlinked++
|
|
|
+ }
|
|
|
+ })
|
|
|
+ const dropped = new Set([...deletes, ...patches.map((p) => p.id)])
|
|
|
|
|
|
- const id = entry.name
|
|
|
- const localBackupPath = path.join(backupPath, id)
|
|
|
- const existing = library.getWallpaper(id)
|
|
|
+ await forEachLimit(folderIds, SCAN_CONCURRENCY, async (id) => {
|
|
|
+ const dir = path.join(backupPath, id)
|
|
|
+ const problem = await problemOf(dir)
|
|
|
+ if (problem) {
|
|
|
+ console.warn(`[Backup] Skipping corrupted backup ${dir}: ${problem}`)
|
|
|
+ result.corrupted.push(id)
|
|
|
+ return
|
|
|
+ }
|
|
|
+ const existing = wallpapers.get(id)
|
|
|
|
|
|
- if (existing && existing.source === 'workshop') {
|
|
|
- if (!existing.backedUp) {
|
|
|
- library.updateWallpaper(id, { backedUp: true })
|
|
|
- linked++
|
|
|
+ if (existing?.source === 'workshop') {
|
|
|
+ if ((existing.backedUp && !dropped.has(id)) || existing.downloading) {
|
|
|
+ result.skipped++
|
|
|
+ } else if (await isValidFor(existing, dir)) {
|
|
|
+ patches.push({ id, patch: { backedUp: true, backupDir: dir } })
|
|
|
+ result.linked++
|
|
|
} else {
|
|
|
- skipped++
|
|
|
+ result.corrupted.push(id)
|
|
|
}
|
|
|
- continue
|
|
|
+ return
|
|
|
}
|
|
|
+ if (
|
|
|
+ existing?.source === 'backup' &&
|
|
|
+ !dropped.has(id) &&
|
|
|
+ existing.fileSize !== undefined &&
|
|
|
+ path.resolve(existing.localPath ?? '') === path.resolve(dir)
|
|
|
+ ) {
|
|
|
+ result.skipped++
|
|
|
+ return
|
|
|
+ }
|
|
|
+ upserts.push(await buildBackupMeta(id, dir, existing))
|
|
|
+ result.imported++
|
|
|
+ })
|
|
|
|
|
|
- const meta = buildBackupMeta(id, localBackupPath, existing ?? undefined)
|
|
|
- library.upsertWallpaper(meta)
|
|
|
- imported++
|
|
|
- }
|
|
|
-
|
|
|
- return { imported, skipped, linked }
|
|
|
+ const reimported = new Set(upserts.map((m) => m.id))
|
|
|
+ library.applyWallpaperChanges({
|
|
|
+ upserts,
|
|
|
+ patches,
|
|
|
+ deletes: deletes.filter((id) => !reimported.has(id))
|
|
|
+ })
|
|
|
+ result.removed -= deletes.filter((id) => reimported.has(id)).length
|
|
|
+ result.corrupted.sort()
|
|
|
+ return result
|
|
|
}
|