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

No files matched your search

+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, 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, is cut short between two name, which most file systems limit to 255 bytes, gets its ID added too. A name
characters, just far enough that it fits with its ID added, and gets its ID; a with its ID added is cut short between two characters, just far enough that it
collection's directory name also leaves room for the `.json` its JSON adds. IDs fits: first the part before the ID, and once nothing is left of that, a
never change, so a name stays the same from run to run until such a clash symlink's extension. A collection's directory name also leaves room for the
appears or goes away. `.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
+5 -3
View File
@@ -17,9 +17,11 @@ pre-1.0
# Next Step # Next Step
Three bugs are open: https://git.eeqj.de/sneak/quak/issues/196, Five 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/198,
https://git.eeqj.de/sneak/quak/issues/199. The check of quak against a live Ente 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 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.
+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 // 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>)`,
// before the extension when `beforeExtension` is set, with the name before it // right before the extension when `beforeExtension` is set. To fit, the part
// cut short as far as it must be to fit. With `withJSON`, each entry also has // before the ID is cut short as far as it must be, and once nothing is left of
// the JSON file `<name>.json` beside it, as an album does: a name then leaves // it, so is the extension. With `withJSON`, each entry also has the JSON file
// room for `.json`, and one that is another entry's JSON name gets its ID // `<name>.json` beside it, as an album does: a name then leaves room for
// added too. A name with an ID added can match another entry's own name // `.json`, and one that is another entry's JSON name gets its ID added too.
// (`IMG (6).JPG`), so this repeats until no name is shared. IDs are stable, so // Two names with different IDs added never match, but one can match another
// the names are too. // 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 = ( const uniqueNames = (
entries: { id: number; name: string }[], entries: { id: number; name: string }[],
opts: { beforeExtension?: boolean; withJSON?: boolean }, opts: { beforeExtension?: boolean; withJSON?: boolean },
@@ -288,11 +289,9 @@ 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})`;
let ext = opts.beforeExtension ? extname(name) : ""; const fullExt = opts.beforeExtension ? extname(name) : "";
// An extension too long to leave room for the ID is cut short with const stem = name.slice(0, name.length - fullExt.length);
// the rest of the name. const ext = shortenToBytes(fullExt, maxBytes - Buffer.byteLength(tag));
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;
}; };
+56 -25
View File
@@ -1703,31 +1703,6 @@ 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 () => {
@@ -1762,6 +1737,62 @@ 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([