Compare commits

..
1 Commits
Author SHA1 Message Date
sneak 0fbc6f4776 An album whose folder cannot be made no longer stops the backup (closes #197)
check / check (push) Canceled after 0s
Each album's folder, links and JSON are written inside one try. A failure
prints `FAILED album <name>: <reason>`, lists the album's ID in the result's
new `failedAlbums`, and so in `--json`, and makes `quak backup` exit 1. The
other albums and `failures.json` are still written.

An album whose folder would have another album's JSON name, ignoring case
(`x.json` beside `x`), gets its ID added. A folder or link name over 255
bytes gets the ID and is cut short between two characters by
`shortenToBytes` in `src/filename.ts`; a folder name leaves room for
`.json`. A link's ID stays right before its extension, which is cut short
only once nothing is left before the ID, so no two names with IDs match.

Apart from its call to `uniqueNames`, the album loop is only indented into
the try.

Model: opus-5-5
2026-10-16 01:03:58 +00:00
4 changed files with 78 additions and 45 deletions
+6 -5
View File
@@ -801,11 +801,12 @@ become `IMG_0001 (12345).JPG` and `IMG_0001 (12346).JPG`. A collection whose
directory would have the name of another collection's JSON, again ignoring case,
gets its ID added as well: beside an album `x`, whose JSON is `x.json`, an album
named `x.json` gets the directory `x.json (12)/`. A name too long for a file
name, which most file systems limit to 255 bytes, is cut short between two
characters, just far enough that it fits with its ID added, and gets its ID; a
collection's directory name also leaves room for the `.json` its JSON adds. IDs
never change, so a name stays the same from run to run until such a clash
appears or goes away.
name, which most file systems limit to 255 bytes, gets its ID added too. A name
with its ID added is cut short between two characters, just far enough that it
fits: first the part before the ID, and once nothing is left of that, a
symlink's extension. A collection's directory name also leaves room for the
`.json` its JSON adds. IDs never change, so a name stays the same from run to
run until such a clash appears or goes away.
A collection whose directory or JSON cannot be written, such as one whose
directory name is taken by a file you put in `collections/`, does not stop the
+5 -3
View File
@@ -17,9 +17,11 @@ pre-1.0
# Next Step
Three bugs are open: https://git.eeqj.de/sneak/quak/issues/196,
https://git.eeqj.de/sneak/quak/issues/198 and
https://git.eeqj.de/sneak/quak/issues/199. The check of quak against a live Ente
Five bugs are open: https://git.eeqj.de/sneak/quak/issues/196,
https://git.eeqj.de/sneak/quak/issues/198,
https://git.eeqj.de/sneak/quak/issues/199,
https://git.eeqj.de/sneak/quak/issues/205 and
https://git.eeqj.de/sneak/quak/issues/206. The check of quak against a live Ente
account, https://git.eeqj.de/sneak/quak/issues/187, needs an account only sneak
has, so it waits on him.
+11 -12
View File
@@ -274,13 +274,14 @@ const rebuildSymlink = (linkPath: string, target: string): void => {
// name is used as is unless another entry would get the same name, ignoring
// case (two names that differ only in case are one entry on a case-insensitive
// file system), or it is too long for a file name; then it gets ` (<id>)`,
// before the extension when `beforeExtension` is set, with the name before it
// cut short as far as it must be to fit. With `withJSON`, each entry also has
// the JSON file `<name>.json` beside it, as an album does: a name then leaves
// room for `.json`, and one that is another entry's JSON name gets its ID
// added too. A name with an ID added can match another entry's own name
// (`IMG (6).JPG`), so this repeats until no name is shared. IDs are stable, so
// the names are too.
// right before the extension when `beforeExtension` is set. To fit, the part
// before the ID is cut short as far as it must be, and once nothing is left of
// it, so is the extension. With `withJSON`, each entry also has the JSON file
// `<name>.json` beside it, as an album does: a name then leaves room for
// `.json`, and one that is another entry's JSON name gets its ID added too.
// Two names with different IDs added never match, but one can match another
// entry's own name (`IMG (6).JPG`), so this repeats until no name is shared.
// IDs are stable, so the names are too.
const uniqueNames = (
entries: { id: number; name: string }[],
opts: { beforeExtension?: boolean; withJSON?: boolean },
@@ -288,11 +289,9 @@ const uniqueNames = (
const maxBytes = MAX_NAME_BYTES - (opts.withJSON ? ".json".length : 0);
const withID = (id: number, name: string): string => {
const tag = ` (${id})`;
let ext = opts.beforeExtension ? extname(name) : "";
// An extension too long to leave room for the ID is cut short with
// the rest of the name.
if (Buffer.byteLength(tag + ext) > maxBytes) ext = "";
const stem = name.slice(0, name.length - ext.length);
const fullExt = opts.beforeExtension ? extname(name) : "";
const stem = name.slice(0, name.length - fullExt.length);
const ext = shortenToBytes(fullExt, maxBytes - Buffer.byteLength(tag));
const room = maxBytes - Buffer.byteLength(tag + ext);
return shortenToBytes(stem, room) + tag + ext;
};
+56 -25
View File
@@ -1703,31 +1703,6 @@ describe("backup album folders", () => {
expect(readLedger(outDir).files["5"]).toMatchObject({ attempts: 2 });
});
// The extension of this title is 303 bytes, so the ID cannot go before
// it, and the whole title is cut short with the ID at its end. The
// extension holds a "-", so the original is saved with `.bin`, a name
// that fits.
it("cuts a title short, extension and all, when its extension leaves no room for the ID", async () => {
const outDir = join(root, "backup");
const lib = libraryOf([
{
collection: collection(10, "Trip"),
files: [file(1, 10, `a.${"L".repeat(300)}-x`)],
},
]);
const result = await runBackup(lib, { downloadDirectory: outDir });
const link = `a.${"L".repeat(249)} (1)`;
expect(Buffer.byteLength(link)).toBe(255);
expect(result.failed).toBe(0);
expect(tree(outDir)).toEqual([
"Trip/",
`Trip/${link} -> ${linkTo("1.bin")}`,
"Trip.json",
]);
});
// The user has put a file in collections/ with the name of the album
// Trip's folder, so the folder cannot be made.
it("goes on to the next album when an album's folder cannot be made, and lists it as failed", async () => {
@@ -1762,6 +1737,62 @@ describe("backup album folders", () => {
expect(readLedger(outDir).files["5"]).toMatchObject({ attempts: 1 });
});
// The extension of this title is 303 bytes, so it leaves no room for the
// ID: nothing is left of the name before the ID, and the extension is cut
// short after it. The extension holds a "-", so the original is saved
// with `.bin`, a name that fits.
it("cuts the extension of a title short when it leaves no room for the ID", async () => {
const outDir = join(root, "backup");
const lib = libraryOf([
{
collection: collection(10, "Trip"),
files: [file(1, 10, `a.${"L".repeat(300)}-x`)],
},
]);
const result = await runBackup(lib, { downloadDirectory: outDir });
const link = ` (1).${"L".repeat(250)}`;
expect(Buffer.byteLength(link)).toBe(255);
expect(result.failed).toBe(0);
expect(tree(outDir)).toEqual([
"Trip/",
`Trip/${link} -> ${linkTo("1.bin")}`,
"Trip.json",
]);
});
// Files 1 and 3 share a title, so each gets its ID before the
// extension. File 2's title is too long, and its extension leaves no
// room for the ID. Were the ID put at the end of file 2's title cut
// short, its link would have file 1's name, and the link made second
// would replace the first.
it("keeps names unique when a title's extension leaves no room for the ID", async () => {
const outDir = join(root, "backup");
const shared = `x.${"Q".repeat(245)} (2)`;
const lib = libraryOf([
{
collection: collection(10, "Trip"),
files: [
file(1, 10, shared),
file(2, 10, `x (1).${"Q".repeat(250)}-`),
file(3, 10, shared),
],
},
]);
const result = await runBackup(lib, { downloadDirectory: outDir });
expect(result.failed).toBe(0);
expect(tree(outDir)).toEqual([
"Trip/",
`Trip/ (2).${"Q".repeat(250)} -> ${linkTo("2.bin")}`,
`Trip/x (1).${"Q".repeat(245)} (2) -> ${linkTo("1.bin")}`,
`Trip/x (3).${"Q".repeat(245)} (2) -> ${linkTo("3.bin")}`,
"Trip.json",
]);
});
it("changes nothing on a second run over an unchanged account", async () => {
const outDir = join(root, "backup");
const lib = libraryOf([