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:
+4
-5
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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 });
|
||||
|
||||
|
||||
Reference in New Issue
Block a user