Names quak builds from server data fit in 255 bytes (closes #205)

The default names of `quak get`, `quak get-thumb`, `downloadFile` and
`downloadThumbnail`, and the album folders of `backup-metadata`, could be
over 255 bytes, which a file system refuses. `shortenKeepingExtension` in
`src/filename.ts` cuts a name short, keeping its extension when it fits,
and can put text right before the extension; the backup's `withID` now
calls it instead of doing the same itself. The `get` defaults use it with
`thumb_` counted. A `backup-metadata` folder keeps its `<id>-` and has the
name after it cut short. `safeExtension` gives `.bin` for an extension of
more than 16 letters and digits, so a save path's name always fits.

Model: opus-5-5
This commit is contained in:
2026-10-18 17:03:14 +00:00
parent 982b878d69
commit fd40714a6d
10 changed files with 290 additions and 27 deletions
+4 -5
View File
@@ -82,6 +82,7 @@ import { describeError } from "./errors.js";
import {
MAX_NAME_BYTES,
sanitizeFileName,
shortenKeepingExtension,
shortenToBytes,
withExtension,
} from "./filename.js";
@@ -305,11 +306,9 @@ const uniqueNames = (
const maxBytes = MAX_NAME_BYTES - (opts.withJSON ? ".json".length : 0);
const withID = (id: number, name: string): string => {
const tag = ` (${id})`;
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;
return opts.beforeExtension
? shortenKeepingExtension(name, maxBytes, tag)
: shortenToBytes(name, maxBytes - Buffer.byteLength(tag)) + tag;
};
const names = entries.map((e) => e.name);
const suffixed = new Set<number>();
+18 -5
View File
@@ -9,7 +9,11 @@
// `metadata.title`, and issue #52 requires that output stay byte-identical, so
// the commands shape their output from the raw `EnteFile` through here.
import { sanitizeFileName } from "./filename.js";
import {
MAX_NAME_BYTES,
sanitizeFileName,
shortenKeepingExtension,
} from "./filename.js";
import type { EnteFile, FileType, Microseconds } from "./model/types.js";
// One row of `quak files --json`.
@@ -34,10 +38,19 @@ export const fileListLine = (file: EnteFile): string =>
`${file.id}\t${file.metadata.fileType}\t${file.metadata.title}`;
// Default output path for `quak get` when `--out` is not given. The title comes
// from the server, so it is sanitized; `--out` is the user's and is used as is.
// from the server, so it is sanitized, and cut short to fit in a file name,
// keeping its extension; `--out` is the user's and is used as is.
export const originalName = (file: EnteFile): string =>
sanitizeFileName(file.metadata.title, `file-${file.id}`);
shortenKeepingExtension(
sanitizeFileName(file.metadata.title, `file-${file.id}`),
MAX_NAME_BYTES,
);
// Default output path for `quak get-thumb` when `--out` is not given.
// Default output path for `quak get-thumb` when `--out` is not given: `thumb_`
// and the title, cut short so that the two fit in a file name.
export const thumbnailName = (file: EnteFile): string =>
`thumb_${originalName(file)}`;
"thumb_" +
shortenKeepingExtension(
sanitizeFileName(file.metadata.title, `file-${file.id}`),
MAX_NAME_BYTES - "thumb_".length,
);
+20 -4
View File
@@ -16,7 +16,13 @@ import {
streamTagFinal,
} from "../crypto/index.js";
import { TruncatedStreamError } from "../errors.js";
import { safeExtension, sanitizeFileName, withExtension } from "../filename.js";
import {
MAX_NAME_BYTES,
safeExtension,
sanitizeFileName,
shortenKeepingExtension,
withExtension,
} from "../filename.js";
import { withRetry } from "../retry.js";
import type { ApiClient } from "../api/client.js";
import type { EnteFile } from "../model/types.js";
@@ -583,9 +589,14 @@ export const downloadFile = async (
onProgress?: ProgressCallback,
): Promise<DownloadResult> => {
// `outPath` is the caller's and is used as is; the title is the server's
// and is sanitized so it can only name a file in the current directory.
// and is sanitized so it can only name a file in the current directory,
// and cut short to fit in a file name, keeping its extension.
const resolvedPath =
outPath ?? sanitizeFileName(file.metadata.title, `file-${file.id}`);
outPath ??
shortenKeepingExtension(
sanitizeFileName(file.metadata.title, `file-${file.id}`),
MAX_NAME_BYTES,
);
const header = fromBase64(file.file.decryptionHeader);
return fetchAndDecrypt(
api,
@@ -604,9 +615,14 @@ export const downloadThumbnail = async (
outPath?: string,
onProgress?: ProgressCallback,
): Promise<DownloadResult> => {
// As in `downloadFile`, with `thumb_` before the title.
const resolvedPath =
outPath ??
`thumb_${sanitizeFileName(file.metadata.title, `file-${file.id}`)}`;
"thumb_" +
shortenKeepingExtension(
sanitizeFileName(file.metadata.title, `file-${file.id}`),
MAX_NAME_BYTES - "thumb_".length,
);
const header = fromBase64(file.thumbnail.decryptionHeader);
return fetchAndDecrypt(
api,
+23 -4
View File
@@ -5,7 +5,7 @@
// used on disk goes through here, so it can only ever name one file inside the
// directory the caller chose: never a path, never `..`, never hidden, never a
// Windows device name. Such a name can still be too long for a file name, and
// `shortenToBytes` cuts one short.
// `shortenToBytes` or `shortenKeepingExtension` cuts one short.
//
// A path the user typed (`--out`, `outPath`) is not passed through here: the
// caller is trusted, the server is not.
@@ -30,11 +30,11 @@ export const sanitizeFileName = (name: string, fallback: string): string => {
return RESERVED_DEVICE_NAME.test(cleaned) ? `_${cleaned}` : cleaned;
};
// The extension of `title` (".jpg"), or ".bin" when it has none or it holds
// anything but letters and digits.
// The extension of `title` (".jpg"), or ".bin" when it has none, it holds
// anything but letters and digits, or it is more than 16 of them long.
export const safeExtension = (title: string): string => {
const ext = extname(title);
return /^\.[A-Za-z0-9]+$/.test(ext) ? ext : ".bin";
return /^\.[A-Za-z0-9]{1,16}$/.test(ext) ? ext : ".bin";
};
// `name` with its extension, if it has one, replaced by `ext` (".mov").
@@ -57,3 +57,22 @@ export const shortenToBytes = (name: string, maxBytes: number): string => {
}
return name.slice(0, end);
};
// `name` cut short to fit in `maxBytes` bytes with `insert` put right before
// its extension (`IMG_0001 (12).jpg`), each cut made as `shortenToBytes`
// makes it. The part before the extension is cut first, so the extension is
// kept whenever it fits. When the extension alone leaves no room for
// `insert`, the extension is cut short too, and the part before it keeps only
// the bytes that cut leaves over. `insert` is never cut. With no `insert`, a
// name that fits comes back unchanged.
export const shortenKeepingExtension = (
name: string,
maxBytes: number,
insert = "",
): string => {
const fullExt = extname(name);
const stem = name.slice(0, name.length - fullExt.length);
const ext = shortenToBytes(fullExt, maxBytes - Buffer.byteLength(insert));
const room = maxBytes - Buffer.byteLength(insert + ext);
return shortenToBytes(stem, room) + insert + ext;
};
+14 -2
View File
@@ -5,7 +5,11 @@ import type { Client } from "./client.js";
import { describeError } from "./errors.js";
import { readExifTags } from "./exif.js";
import type { Library, Photo } from "./library/index.js";
import { sanitizeFileName } from "./filename.js";
import {
MAX_NAME_BYTES,
sanitizeFileName,
shortenToBytes,
} from "./filename.js";
import {
fetchMLDataBatch,
MLDATA_BATCH_SIZE,
@@ -127,7 +131,15 @@ export const runMetadataBackup = async (
const col = lib.getCollection(album.collectionID);
if (!col) continue;
const dirName = `${col.id}-${sanitizeFileName(col.name, "unnamed")}`;
// The album's ID keeps the folders apart, so the name after it can be
// cut short to fit in a file name.
const prefix = `${col.id}-`;
const dirName =
prefix +
shortenToBytes(
sanitizeFileName(col.name, "unnamed"),
MAX_NAME_BYTES - prefix.length,
);
const colDir = join(outDir, "collections", dirName);
mkdirSync(colDir, { recursive: true });