1 Commits
Author SHA1 Message Date
sneak 5abf93824d 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 is cut short between two characters by `shortenToBytes` in
`src/filename.ts`, and gets the ID; a folder name leaves room for `.json`.

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

Model: opus-5-5
2026-10-16 00:27:55 +00:00
4 changed files with 45 additions and 78 deletions

No files matched your search

+5 -6
View File
@@ -801,12 +801,11 @@ 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, 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, is cut short between two
with its ID added is cut short between two characters, just far enough that it characters, just far enough that it fits with its ID added, and gets its ID; a
fits: first the part before the ID, and once nothing is left of that, a collection's directory name also leaves room for the `.json` its JSON adds. IDs
symlink's extension. A collection's directory name also leaves room for the never change, so a name stays the same from run to run until such a clash
`.json` its JSON adds. IDs never change, so a name stays the same from run to appears or goes away.
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
+3 -5
View File
@@ -17,11 +17,9 @@ pre-1.0
# Next Step # Next Step
Five bugs are open: https://git.eeqj.de/sneak/quak/issues/196, Three 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/198 and
https://git.eeqj.de/sneak/quak/issues/199, https://git.eeqj.de/sneak/quak/issues/199. The check of quak against a live Ente
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 account, https://git.eeqj.de/sneak/quak/issues/187, needs an account only sneak
has, so it waits on him. has, so it waits on him.
+12 -11
View File
@@ -274,14 +274,13 @@ const rebuildSymlink = (linkPath: string, target: string): void => {
// name is used as is unless another entry would get the same name, ignoring // 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 // 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 // before the extension when `beforeExtension` is set, with the name before it
// before the ID is cut short as far as it must be, and once nothing is left of // cut short as far as it must be to fit. With `withJSON`, each entry also has
// it, so is the extension. With `withJSON`, each entry also has the JSON file // the JSON file `<name>.json` beside it, as an album does: a name then leaves
// `<name>.json` beside it, as an album does: a name then leaves room for // room for `.json`, and one that is another entry's JSON name gets its ID
// `.json`, and one that is another entry's JSON name gets its ID added too. // added too. A name with an ID added can match another entry's own name
// Two names with different IDs added never match, but one can match another // (`IMG (6).JPG`), so this repeats until no name is shared. IDs are stable, so
// entry's own name (`IMG (6).JPG`), so this repeats until no name is shared. // the names are too.
// IDs are stable, so the names are too.
const uniqueNames = ( const uniqueNames = (
entries: { id: number; name: string }[], entries: { id: number; name: string }[],
opts: { beforeExtension?: boolean; withJSON?: boolean }, opts: { beforeExtension?: boolean; withJSON?: boolean },
@@ -289,9 +288,11 @@ const uniqueNames = (
const maxBytes = MAX_NAME_BYTES - (opts.withJSON ? ".json".length : 0); const maxBytes = MAX_NAME_BYTES - (opts.withJSON ? ".json".length : 0);
const withID = (id: number, name: string): string => { const withID = (id: number, name: string): string => {
const tag = ` (${id})`; const tag = ` (${id})`;
const fullExt = opts.beforeExtension ? extname(name) : ""; let ext = opts.beforeExtension ? extname(name) : "";
const stem = name.slice(0, name.length - fullExt.length); // An extension too long to leave room for the ID is cut short with
const ext = shortenToBytes(fullExt, maxBytes - Buffer.byteLength(tag)); // the rest of the name.
if (Buffer.byteLength(tag + ext) > maxBytes) ext = "";
const stem = name.slice(0, name.length - ext.length);
const room = maxBytes - Buffer.byteLength(tag + ext); const room = maxBytes - Buffer.byteLength(tag + ext);
return shortenToBytes(stem, room) + tag + ext; return shortenToBytes(stem, room) + tag + ext;
}; };
+25 -56
View File
@@ -1703,6 +1703,31 @@ describe("backup album folders", () => {
expect(readLedger(outDir).files["5"]).toMatchObject({ attempts: 2 }); 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 // The user has put a file in collections/ with the name of the album
// Trip's folder, so the folder cannot be made. // 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 () => { it("goes on to the next album when an album's folder cannot be made, and lists it as failed", async () => {
@@ -1737,62 +1762,6 @@ 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
// 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 () => { it("changes nothing on a second run over an unchanged account", async () => {
const outDir = join(root, "backup"); const outDir = join(root, "backup");
const lib = libraryOf([ const lib = libraryOf([