1 Commits
Author SHA1 Message Date
sneak 3cabeba249 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 new
`failedAlbums` of the result and of `--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,
gets its ID. A name over 255 bytes (250 for a folder, which leaves room for
`.json`) gets the ID and is cut short between two characters by
`shortenToBytes` in `src/filename.ts`. A link's ID goes right before its
extension.

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

Model: opus-5-5
2026-10-16 02:47:00 +00:00
3 changed files with 25 additions and 14 deletions
+6 -5
View File
@@ -815,11 +815,12 @@ 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 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 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, gets its ID added too. A name 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 with its ID added is cut short between two characters in the part before the ID,
fits: first the part before the ID, and once nothing is left of that, a just far enough that it fits. When a symlink's extension alone leaves no room
symlink's extension. A collection's directory name also leaves room for the for the ID, the extension is cut short and the part before the ID keeps only the
`.json` its JSON adds. IDs never change, so a name stays the same from run to bytes that cut leaves over. A collection's directory name also leaves room for
run until such a clash appears or goes away. 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 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 directory name is taken by a file you put in `collections/`, does not stop the
+6 -4
View File
@@ -276,10 +276,12 @@ const rebuildSymlink = (linkPath: string, target: string): void => {
// case (two names that differ only in case are one entry on a case-insensitive // 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>)`, // file system), or it is too long for a file name; then it gets ` (<id>)`,
// right before the extension when `beforeExtension` is set. To fit, the part // 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 // before the ID is cut short as far as it must be. When the extension alone
// it, so is the extension. With `withJSON`, each entry also has the JSON file // leaves no room for the ID, the extension is cut short and the part before
// `<name>.json` beside it, as an album does: a name then leaves room for // the ID keeps only the bytes that cut leaves over. With `withJSON`, each
// `.json`, and one that is another entry's JSON name gets its ID added too. // 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 // 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. // entry's own name (`IMG (6).JPG`), so this repeats until no name is shared.
// IDs are stable, so the names are too. // IDs are stable, so the names are too.
+13 -5
View File
@@ -1737,27 +1737,35 @@ describe("backup album folders", () => {
expect(readLedger(outDir).files["5"]).toMatchObject({ attempts: 1 }); expect(readLedger(outDir).files["5"]).toMatchObject({ attempts: 1 });
}); });
// The extension of this title is 303 bytes, so it leaves no room for the // The extensions of these titles leave no room for the ID, so each is cut
// ID: nothing is left of the name before the ID, and the extension is cut // short and the name before the ID keeps only the bytes that cut leaves
// short after it. The extension holds a "-", so the original is saved // over. File 1's extension is 303 bytes and is cut to 251, which leaves
// with `.bin`, a name that fits. // none. File 2's is 271 bytes and is cut to 250, since "€" takes three
// bytes, which leaves one, for the "a". Neither extension is only letters
// and digits, so both originals are saved with `.bin`, a name that fits.
it("cuts the extension of a title short when it leaves no room for the ID", async () => { it("cuts the extension of a title short when it leaves no room for the ID", async () => {
const outDir = join(root, "backup"); const outDir = join(root, "backup");
const lib = libraryOf([ const lib = libraryOf([
{ {
collection: collection(10, "Trip"), collection: collection(10, "Trip"),
files: [file(1, 10, `a.${"L".repeat(300)}-x`)], files: [
file(1, 10, `a.${"L".repeat(300)}-x`),
file(2, 10, `a.${"€".repeat(90)}`),
],
}, },
]); ]);
const result = await runBackup(lib, { downloadDirectory: outDir }); const result = await runBackup(lib, { downloadDirectory: outDir });
const link = ` (1).${"L".repeat(250)}`; const link = ` (1).${"L".repeat(250)}`;
const link2 = `a (2).${"€".repeat(83)}`;
expect(Buffer.byteLength(link)).toBe(255); expect(Buffer.byteLength(link)).toBe(255);
expect(Buffer.byteLength(link2)).toBe(255);
expect(result.failed).toBe(0); expect(result.failed).toBe(0);
expect(tree(outDir)).toEqual([ expect(tree(outDir)).toEqual([
"Trip/", "Trip/",
`Trip/${link} -> ${linkTo("1.bin")}`, `Trip/${link} -> ${linkTo("1.bin")}`,
`Trip/${link2} -> ${linkTo("2.bin")}`,
"Trip.json", "Trip.json",
]); ]);
}); });