Give backup album folders distinct names and remove stale entries (closes #103)
check / check (push) Successful in 1m59s
check / check (push) Successful in 1m59s
Within a collection's folder, files whose sanitized titles match (ignoring case) each get their file ID added before the extension, and collections whose sanitized names match get their ID added, so no symlink or JSON replaces another. Names are chosen across all collections, so a scoped run names folders the same as a full one. Each run first removes symlinks into originals/ that no longer belong to a collection, and the folders quak wrote (a sibling JSON with an album ID) for collections that are gone or renamed. Anything else is left alone; a folder still holding user files keeps its JSON. Model: opus-5-5
This commit is contained in:
@@ -40,6 +40,7 @@ import {
|
||||
readFileSync,
|
||||
readlinkSync,
|
||||
rmSync,
|
||||
symlinkSync,
|
||||
writeFileSync,
|
||||
} from "node:fs";
|
||||
import { spawnSync } from "node:child_process";
|
||||
@@ -47,6 +48,7 @@ import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||
|
||||
import { runBackup, type BackupLibrary } from "../../src/backup.js";
|
||||
import { Library } from "../../src/library/index.js";
|
||||
import type { ContentSource } from "../../src/library/content.js";
|
||||
import type { CollectionsPage, FilesPage } from "../../src/client.js";
|
||||
@@ -622,3 +624,219 @@ describe("lib.backup", () => {
|
||||
lib.close();
|
||||
});
|
||||
});
|
||||
|
||||
// The album folders under collections/, driven through `runBackup` with a
|
||||
// stand-in library whose albums a test changes between runs.
|
||||
describe("backup album folders", () => {
|
||||
interface Album {
|
||||
collection: Collection;
|
||||
files: EnteFile[];
|
||||
}
|
||||
|
||||
const libraryOf = (albums: Album[]): BackupLibrary => ({
|
||||
refresh: async () => {},
|
||||
listCollections: () => albums.map((a) => a.collection),
|
||||
listFiles: (id) =>
|
||||
albums.find((a) => a.collection.id === id)?.files ?? [],
|
||||
original: async (fileID) => {
|
||||
const path = join(root, `source-${fileID}`);
|
||||
writeFileSync(path, `original ${fileID}`);
|
||||
return { path };
|
||||
},
|
||||
thumbnail: async () => {
|
||||
throw new Error("no thumbnails in this stand-in");
|
||||
},
|
||||
});
|
||||
|
||||
// Every entry under collections/, one level of directories deep, with each
|
||||
// symlink's target.
|
||||
const tree = (outDir: string): string[] => {
|
||||
const lines: string[] = [];
|
||||
const list = (dir: string, prefix: string): void => {
|
||||
for (const name of readdirSync(dir).sort()) {
|
||||
const path = join(dir, name);
|
||||
const st = lstatSync(path);
|
||||
if (st.isSymbolicLink()) {
|
||||
lines.push(`${prefix}${name} -> ${readlinkSync(path)}`);
|
||||
} else if (st.isDirectory() && prefix === "") {
|
||||
lines.push(`${name}/`);
|
||||
list(path, `${name}/`);
|
||||
} else {
|
||||
lines.push(`${prefix}${name}`);
|
||||
}
|
||||
}
|
||||
};
|
||||
list(join(outDir, "collections"), "");
|
||||
return lines;
|
||||
};
|
||||
|
||||
const albumID = (outDir: string, jsonName: string): number =>
|
||||
JSON.parse(readFileSync(join(outDir, "collections", jsonName), "utf-8"))
|
||||
.id;
|
||||
|
||||
it("gives every file and every album its own name when names repeat", async () => {
|
||||
const outDir = join(root, "backup");
|
||||
const lib = libraryOf([
|
||||
{
|
||||
collection: collection(10, "Trip"),
|
||||
files: [
|
||||
file(1, 10, "IMG_0001.JPG"),
|
||||
file(2, 10, "IMG_0001.JPG"),
|
||||
file(4, 10, "img_0001.jpg"),
|
||||
file(3, 10, "other.jpg"),
|
||||
],
|
||||
},
|
||||
{
|
||||
collection: collection(11, "Trip"),
|
||||
files: [file(3, 11, "other.jpg")],
|
||||
},
|
||||
]);
|
||||
|
||||
const result = await runBackup(lib, { downloadDirectory: outDir });
|
||||
|
||||
expect(result.failed).toBe(0);
|
||||
expect(tree(outDir)).toEqual([
|
||||
"Trip (10)/",
|
||||
"Trip (10)/IMG_0001 (1).JPG -> ../../originals/1.JPG",
|
||||
"Trip (10)/IMG_0001 (2).JPG -> ../../originals/2.JPG",
|
||||
"Trip (10)/img_0001 (4).jpg -> ../../originals/4.jpg",
|
||||
"Trip (10)/other.jpg -> ../../originals/3.jpg",
|
||||
"Trip (10).json",
|
||||
"Trip (11)/",
|
||||
"Trip (11)/other.jpg -> ../../originals/3.jpg",
|
||||
"Trip (11).json",
|
||||
]);
|
||||
expect(albumID(outDir, "Trip (10).json")).toBe(10);
|
||||
expect(albumID(outDir, "Trip (11).json")).toBe(11);
|
||||
});
|
||||
|
||||
it("changes nothing on a second run over an unchanged account", async () => {
|
||||
const outDir = join(root, "backup");
|
||||
const lib = libraryOf([
|
||||
{
|
||||
collection: collection(10, "Trip"),
|
||||
files: [
|
||||
file(1, 10, "IMG_0001.JPG"),
|
||||
file(2, 10, "IMG_0001.JPG"),
|
||||
],
|
||||
},
|
||||
{
|
||||
collection: collection(11, "Trip"),
|
||||
files: [file(3, 11, "other.jpg")],
|
||||
},
|
||||
]);
|
||||
|
||||
await runBackup(lib, { downloadDirectory: outDir });
|
||||
const before = tree(outDir);
|
||||
const second = await runBackup(lib, { downloadDirectory: outDir });
|
||||
|
||||
expect(second.downloaded).toBe(0);
|
||||
expect(second.failed).toBe(0);
|
||||
expect(tree(outDir)).toEqual(before);
|
||||
});
|
||||
|
||||
it("leaves the albums an onlyAlbumNames run skips as they were", async () => {
|
||||
const outDir = join(root, "backup");
|
||||
// "trip" is skipped by the scoped run but its name clashes with the
|
||||
// in-scope "Trip", so "Trip" must keep its ID suffix.
|
||||
const lib = libraryOf([
|
||||
{
|
||||
collection: collection(10, "Trip"),
|
||||
files: [file(1, 10, "a.jpg")],
|
||||
},
|
||||
{
|
||||
collection: collection(11, "trip"),
|
||||
files: [file(2, 11, "b.jpg")],
|
||||
},
|
||||
{
|
||||
collection: collection(12, "Work"),
|
||||
files: [file(3, 12, "c.jpg")],
|
||||
},
|
||||
]);
|
||||
const json = (name: string): string =>
|
||||
readFileSync(join(outDir, "collections", name), "utf-8");
|
||||
|
||||
await runBackup(lib, { downloadDirectory: outDir });
|
||||
const before = tree(outDir);
|
||||
const skippedJSON = [json("trip (11).json"), json("Work.json")];
|
||||
const scoped = await runBackup(lib, {
|
||||
downloadDirectory: outDir,
|
||||
onlyAlbumNames: ["Trip"],
|
||||
});
|
||||
|
||||
expect(scoped.failed).toBe(0);
|
||||
expect(before).toEqual([
|
||||
"Trip (10)/",
|
||||
"Trip (10)/a.jpg -> ../../originals/1.jpg",
|
||||
"Trip (10).json",
|
||||
"Work/",
|
||||
"Work/c.jpg -> ../../originals/3.jpg",
|
||||
"Work.json",
|
||||
"trip (11)/",
|
||||
"trip (11)/b.jpg -> ../../originals/2.jpg",
|
||||
"trip (11).json",
|
||||
]);
|
||||
expect(tree(outDir)).toEqual(before);
|
||||
expect([json("trip (11).json"), json("Work.json")]).toEqual(
|
||||
skippedJSON,
|
||||
);
|
||||
});
|
||||
|
||||
it("removes links and album folders that are gone, and nothing the user added", async () => {
|
||||
const outDir = join(root, "backup");
|
||||
const albums: Album[] = [
|
||||
{
|
||||
collection: collection(10, "Trip"),
|
||||
files: [
|
||||
file(1, 10, "IMG_0001.JPG"),
|
||||
file(2, 10, "IMG_0001.JPG"),
|
||||
file(3, 10, "other.jpg"),
|
||||
],
|
||||
},
|
||||
{
|
||||
collection: collection(12, "Work"),
|
||||
files: [file(5, 12, "a.jpg")],
|
||||
},
|
||||
{
|
||||
collection: collection(13, "Old"),
|
||||
files: [file(5, 13, "a.jpg")],
|
||||
},
|
||||
];
|
||||
const lib = libraryOf(albums);
|
||||
await runBackup(lib, { downloadDirectory: outDir });
|
||||
|
||||
// What the user put in the tree: a note and a symlink of their own in
|
||||
// an album, a note in an album about to be renamed, and a folder quak
|
||||
// did not create.
|
||||
const collectionsDir = join(outDir, "collections");
|
||||
writeFileSync(join(collectionsDir, "Trip", "notes.txt"), "mine");
|
||||
symlinkSync("../elsewhere", join(collectionsDir, "Trip", "mine"));
|
||||
writeFileSync(join(collectionsDir, "Work", "keep.txt"), "mine");
|
||||
mkdirSync(join(collectionsDir, "Mine"));
|
||||
writeFileSync(join(collectionsDir, "Mine", "keep.txt"), "mine");
|
||||
|
||||
// File 2 leaves Trip, Work is renamed Office, Old is deleted.
|
||||
albums[0]!.files.splice(1, 1);
|
||||
albums[1]!.collection = collection(12, "Office");
|
||||
albums.splice(2, 1);
|
||||
const result = await runBackup(lib, { downloadDirectory: outDir });
|
||||
|
||||
expect(result.failed).toBe(0);
|
||||
expect(tree(outDir)).toEqual([
|
||||
"Mine/",
|
||||
"Mine/keep.txt",
|
||||
"Office/",
|
||||
"Office/a.jpg -> ../../originals/5.jpg",
|
||||
"Office.json",
|
||||
"Trip/",
|
||||
"Trip/IMG_0001.JPG -> ../../originals/1.JPG",
|
||||
"Trip/mine -> ../elsewhere",
|
||||
"Trip/notes.txt",
|
||||
"Trip/other.jpg -> ../../originals/3.jpg",
|
||||
"Trip.json",
|
||||
"Work/",
|
||||
"Work/keep.txt",
|
||||
"Work.json",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user