A live photo's default names fit, and no cut name starts with a dot
check / check (push) Canceled after 0s
check / check (push) Canceled after 0s
`defaultFileName` in `src/filename.ts` now gives the name a file is saved under when the caller gives no path, and `originalName`, `thumbnailName`, `downloadFile` and `downloadThumbnail` all call it. For a live photo's image and video it puts the part's own extension in before cutting the name short, so those names fit too; a path from `--out` or `outPath` is kept as given. `shortenKeepingExtension` keeps the first character before the extension, cutting a long extension short to leave room for it, so a cut name no longer starts with a dot. The backup's links for such a title change to match. TODO.md names the open bug and describes what the code does. Model: opus-5-5
This commit is contained in:
@@ -687,24 +687,26 @@ Without `--out`, `get` writes the file to the working directory under its title,
|
|||||||
and `get-thumb` writes the thumbnail there under `thumb_` followed by the title,
|
and `get-thumb` writes the thumbnail there under `thumb_` followed by the title,
|
||||||
each with unsafe characters replaced. A name over 255 bytes, which most file
|
each with unsafe characters replaced. A name over 255 bytes, which most file
|
||||||
systems refuse, is cut short between two characters before its extension, so the
|
systems refuse, is cut short between two characters before its extension, so the
|
||||||
extension is kept whenever it fits.
|
extension is kept whenever it fits. An extension too long to leave room for the
|
||||||
|
name's first character is cut short too, so the name never starts with a dot.
|
||||||
|
|
||||||
`get` and `get-thumb` resolve the file by ID directly, so `--collection` is
|
`get` and `get-thumb` resolve the file by ID directly, so `--collection` is
|
||||||
accepted for backward compatibility but ignored. For a live photo, `get` writes
|
accepted for backward compatibility but ignored. For a live photo, `get` writes
|
||||||
its image and its video, each named after the title with its own extension, as
|
its image and its video, each named after the title with its own extension, as
|
||||||
Ente's clients name them (`IMG_0001.heic` and `IMG_0001.mov`). With
|
Ente's clients name them (`IMG_0001.heic` and `IMG_0001.mov`), and each name is
|
||||||
`--out PATH`, the image is written to `PATH` and the video beside it, with
|
cut short as above once its own extension is in it. With `--out PATH`, the image
|
||||||
`PATH`'s name and the video's extension; a `PATH` with the video's extension is
|
is written to `PATH` and the video beside it, with `PATH`'s name and the video's
|
||||||
refused. `backup-metadata --exif` (alias `--all`) additionally fetches each
|
extension; a `PATH` with the video's extension is refused.
|
||||||
file's original through the cache and records, from it or a live photo's image,
|
`backup-metadata --exif` (alias `--all`) additionally fetches each file's
|
||||||
its XMP metadata, its EXIF metadata and, for a JPEG, its dimensions. EXIF is
|
original through the cache and records, from it or a live photo's image, its XMP
|
||||||
read with [exifreader](https://github.com/mattiasw/ExifReader) from any image
|
metadata, its EXIF metadata and, for a JPEG, its dimensions. EXIF is read with
|
||||||
format it reads, JPEG, HEIC/HEIF, AVIF, PNG and WebP among them. The record's
|
[exifreader](https://github.com/mattiasw/ExifReader) from any image format it
|
||||||
`exif` field is exifreader's EXIF tag output: each tag by name, with its
|
reads, JPEG, HEIC/HEIF, AVIF, PNG and WebP among them. The record's `exif` field
|
||||||
`value`, `description` and `computed` value. An EXIF block exifreader finds but
|
is exifreader's EXIF tag output: each tag by name, with its `value`,
|
||||||
reads no tag from is recorded, base64, as `exifRaw`, with the reason in
|
`description` and `computed` value. An EXIF block exifreader finds but reads no
|
||||||
`exifError`. `collections`, `files`, `backup`, `helper list-missing-thumbnails`
|
tag from is recorded, base64, as `exifRaw`, with the reason in `exifError`.
|
||||||
and `helper fix-missing-thumbnails` take `--json` for machine-readable output.
|
`collections`, `files`, `backup`, `helper list-missing-thumbnails` and
|
||||||
|
`helper fix-missing-thumbnails` take `--json` for machine-readable output.
|
||||||
|
|
||||||
`backup --verify` also hashes the originals already in the backup and replaces
|
`backup --verify` also hashes the originals already in the backup and replaces
|
||||||
any that do not match the content hash Ente records; one it cannot replace stays
|
any that do not match the content hash Ente records; one it cannot replace stays
|
||||||
@@ -864,11 +866,12 @@ 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 in the part before the ID,
|
with its ID added is cut short between two characters in the part before the ID,
|
||||||
just far enough that it fits. When a symlink's extension alone leaves no room
|
just far enough that it fits. When a symlink's extension leaves no room for the
|
||||||
for the ID, the extension is cut short and the part before the ID keeps only the
|
ID and the first character of the name, the extension is cut short, and the part
|
||||||
bytes that cut leaves over. A collection's directory name also leaves room for
|
before the ID keeps that character and any bytes the cut leaves over. A
|
||||||
the `.json` its JSON adds. IDs never change, so a name stays the same from run
|
collection's directory name also leaves room for the `.json` its JSON adds. IDs
|
||||||
to run until such a clash appears or goes away.
|
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
|
||||||
|
|||||||
@@ -17,9 +17,11 @@ pre-1.0
|
|||||||
|
|
||||||
# Next Step
|
# Next Step
|
||||||
|
|
||||||
No bug is open. The check of quak against a live Ente account,
|
No bug is open. Two changes sneak asked for are open:
|
||||||
https://git.eeqj.de/sneak/quak/issues/187, needs an account only sneak has, so
|
https://git.eeqj.de/sneak/quak/issues/211 and
|
||||||
it waits on him.
|
https://git.eeqj.de/sneak/quak/issues/212. The check of quak against a live Ente
|
||||||
|
account, https://git.eeqj.de/sneak/quak/issues/187, needs an account only sneak
|
||||||
|
has, so it waits on him.
|
||||||
|
|
||||||
Tagging and releases are decided by sneak alone, and happen only when he
|
Tagging and releases are decided by sneak alone, and happen only when he
|
||||||
declares one.
|
declares one.
|
||||||
@@ -27,13 +29,17 @@ declares one.
|
|||||||
# Completed Steps
|
# Completed Steps
|
||||||
|
|
||||||
- 2026-10-18: Names quak builds from an album name or a title fit in a file name
|
- 2026-10-18: Names quak builds from an album name or a title fit in a file name
|
||||||
(issue 205). `shortenKeepingExtension` in `src/filename.ts` cuts a name short
|
(https://git.eeqj.de/sneak/quak/issues/205). `shortenKeepingExtension` in
|
||||||
to a number of bytes, keeping its extension when it fits, and can put text
|
`src/filename.ts` cuts a name short to a number of bytes, keeping its
|
||||||
right before the extension; the backup's `withID` uses it for an ID. The
|
extension when it fits and always its first character, and can put text right
|
||||||
default names of `quak get`, `quak get-thumb`, `downloadFile` and
|
before the extension; the backup's `withID` uses it for an ID.
|
||||||
`downloadThumbnail` are cut short with it to 255 bytes, `thumb_` included.
|
`defaultFileName` there gives the name a file is saved under when the caller
|
||||||
`backup-metadata` cuts an album's name short so that its folder,
|
gives no path: a prefix such as `thumb_` and the title with unsafe characters
|
||||||
`<id>-<name>`, fits in 255 bytes. `safeExtension` gives `.bin` for an
|
replaced, cut short with it to 255 bytes, and, for a live photo's image or
|
||||||
|
video, with that part's own extension before the cut. `quak get`,
|
||||||
|
`quak get-thumb`, `downloadFile` and `downloadThumbnail` use it without
|
||||||
|
`--out` or `outPath`. `backup-metadata` cuts an album's name short so that its
|
||||||
|
folder, `<id>-<name>`, fits in 255 bytes. `safeExtension` gives `.bin` for an
|
||||||
extension of more than 16 letters and digits, so a save path's name always
|
extension of more than 16 letters and digits, so a save path's name always
|
||||||
fits.
|
fits.
|
||||||
|
|
||||||
|
|||||||
+4
-3
@@ -290,9 +290,10 @@ 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. When the extension alone
|
// before the ID is cut short as far as it must be. When the extension leaves
|
||||||
// leaves no room for the ID, the extension is cut short and the part before
|
// no room for the ID and the first character of the name, the extension is
|
||||||
// the ID keeps only the bytes that cut leaves over. With `withJSON`, each
|
// cut short, and the part before the ID keeps that character and any bytes
|
||||||
|
// the cut leaves over (see `shortenKeepingExtension`). With `withJSON`, each
|
||||||
// entry also has the JSON file `<name>.json` beside it, as an album does: a
|
// 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
|
// name then leaves room for `.json`, and one that is another entry's JSON name
|
||||||
// gets its ID added too.
|
// gets its ID added too.
|
||||||
|
|||||||
+8
-4
@@ -332,8 +332,8 @@ export const getCommand = async (
|
|||||||
const result = await photo.original();
|
const result = await photo.original();
|
||||||
// Default name is the file's own title, as the pre-library CLI used
|
// Default name is the file's own title, as the pre-library CLI used
|
||||||
// (not the editedName-preferring projection title) (issue #52).
|
// (not the editedName-preferring projection title) (issue #52).
|
||||||
const outPath = opts.out ?? originalName(file);
|
|
||||||
if (result.videoPath === undefined) {
|
if (result.videoPath === undefined) {
|
||||||
|
const outPath = opts.out ?? originalName(file);
|
||||||
copyFileSync(result.path, outPath);
|
copyFileSync(result.path, outPath);
|
||||||
ctx.stderr.write(`${result.bytes} bytes -> ${outPath}\n`);
|
ctx.stderr.write(`${result.bytes} bytes -> ${outPath}\n`);
|
||||||
return 0;
|
return 0;
|
||||||
@@ -341,9 +341,13 @@ export const getCommand = async (
|
|||||||
// A live photo is written as its image and its video, each named after
|
// A live photo is written as its image and its video, each named after
|
||||||
// the title with its own extension, as Ente's clients name them. With
|
// the title with its own extension, as Ente's clients name them. With
|
||||||
// --out, the image goes there and the video beside it.
|
// --out, the image goes there and the video beside it.
|
||||||
const imageOut =
|
const imageExt = extname(result.path);
|
||||||
opts.out ?? withExtension(outPath, extname(result.path));
|
const videoExt = extname(result.videoPath);
|
||||||
const videoOut = withExtension(outPath, extname(result.videoPath));
|
const imageOut = opts.out ?? originalName(file, imageExt);
|
||||||
|
const videoOut =
|
||||||
|
opts.out === undefined
|
||||||
|
? originalName(file, videoExt)
|
||||||
|
: withExtension(opts.out, videoExt);
|
||||||
if (imageOut.toLowerCase() === videoOut.toLowerCase()) {
|
if (imageOut.toLowerCase() === videoOut.toLowerCase()) {
|
||||||
ctx.stderr.write(
|
ctx.stderr.write(
|
||||||
`File ${fileID} is a live photo, and its video would also be written to ${imageOut}\n`,
|
`File ${fileID} is a live photo, and its video would also be written to ${imageOut}\n`,
|
||||||
|
|||||||
+8
-19
@@ -9,11 +9,7 @@
|
|||||||
// `metadata.title`, and issue #52 requires that output stay byte-identical, so
|
// `metadata.title`, and issue #52 requires that output stay byte-identical, so
|
||||||
// the commands shape their output from the raw `EnteFile` through here.
|
// the commands shape their output from the raw `EnteFile` through here.
|
||||||
|
|
||||||
import {
|
import { defaultFileName } from "./filename.js";
|
||||||
MAX_NAME_BYTES,
|
|
||||||
sanitizeFileName,
|
|
||||||
shortenKeepingExtension,
|
|
||||||
} from "./filename.js";
|
|
||||||
import type { EnteFile, FileType, Microseconds } from "./model/types.js";
|
import type { EnteFile, FileType, Microseconds } from "./model/types.js";
|
||||||
|
|
||||||
// One row of `quak files --json`.
|
// One row of `quak files --json`.
|
||||||
@@ -37,20 +33,13 @@ export const fileListRow = (file: EnteFile): FileListRow => ({
|
|||||||
export const fileListLine = (file: EnteFile): string =>
|
export const fileListLine = (file: EnteFile): string =>
|
||||||
`${file.id}\t${file.metadata.fileType}\t${file.metadata.title}`;
|
`${file.id}\t${file.metadata.fileType}\t${file.metadata.title}`;
|
||||||
|
|
||||||
// Default output path for `quak get` when `--out` is not given. The title comes
|
// Default output path for `quak get` when `--out` is not given, made from the
|
||||||
// from the server, so it is sanitized, and cut short to fit in a file name,
|
// title (see `defaultFileName`), with `ext` in place of its extension for a
|
||||||
// keeping its extension; `--out` is the user's and is used as is.
|
// live photo's image or video; `--out` is the user's and is used as is.
|
||||||
export const originalName = (file: EnteFile): string =>
|
export const originalName = (file: EnteFile, ext?: string): string =>
|
||||||
shortenKeepingExtension(
|
defaultFileName(file.metadata.title, file.id, { ext });
|
||||||
sanitizeFileName(file.metadata.title, `file-${file.id}`),
|
|
||||||
MAX_NAME_BYTES,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Default output path for `quak get-thumb` when `--out` is not given: `thumb_`
|
// 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.
|
// and the title.
|
||||||
export const thumbnailName = (file: EnteFile): string =>
|
export const thumbnailName = (file: EnteFile): string =>
|
||||||
"thumb_" +
|
defaultFileName(file.metadata.title, file.id, { prefix: "thumb_" });
|
||||||
shortenKeepingExtension(
|
|
||||||
sanitizeFileName(file.metadata.title, `file-${file.id}`),
|
|
||||||
MAX_NAME_BYTES - "thumb_".length,
|
|
||||||
);
|
|
||||||
|
|||||||
+27
-26
@@ -16,13 +16,7 @@ import {
|
|||||||
streamTagFinal,
|
streamTagFinal,
|
||||||
} from "../crypto/index.js";
|
} from "../crypto/index.js";
|
||||||
import { TruncatedStreamError } from "../errors.js";
|
import { TruncatedStreamError } from "../errors.js";
|
||||||
import {
|
import { defaultFileName, safeExtension, withExtension } from "../filename.js";
|
||||||
MAX_NAME_BYTES,
|
|
||||||
safeExtension,
|
|
||||||
sanitizeFileName,
|
|
||||||
shortenKeepingExtension,
|
|
||||||
withExtension,
|
|
||||||
} from "../filename.js";
|
|
||||||
import { withRetry } from "../retry.js";
|
import { withRetry } from "../retry.js";
|
||||||
import type { ApiClient } from "../api/client.js";
|
import type { ApiClient } from "../api/client.js";
|
||||||
import type { EnteFile } from "../model/types.js";
|
import type { EnteFile } from "../model/types.js";
|
||||||
@@ -371,10 +365,14 @@ const openPart = async (
|
|||||||
// name starts with `image` as the image and the one whose name starts with
|
// name starts with `image` as the image and the one whose name starts with
|
||||||
// `video` as the video, and refuses a ZIP holding a second of either. It is
|
// `video` as the video, and refuses a ZIP holding a second of either. It is
|
||||||
// written unpacked: each part is named `destination` with the extension
|
// written unpacked: each part is named `destination` with the extension
|
||||||
// replaced by its own entry's, and the two must differ ignoring case. When the
|
// replaced by its own entry's, and the two must differ ignoring case. When
|
||||||
// file records a hash, `<imageHash>:<videoHash>` must match it, each over that
|
// `defaultName` is set, `destination` is the name `defaultFileName` made from
|
||||||
// part's own bytes. Only then are the image, then the video, renamed into
|
// the title rather than a path the caller gave, and each part is named as
|
||||||
// place; on any failure neither is stored.
|
// `defaultFileName` names it with its own extension, so that the name is cut
|
||||||
|
// short to fit with that extension. When the file records a hash,
|
||||||
|
// `<imageHash>:<videoHash>` must match it, each over that part's own bytes.
|
||||||
|
// Only then are the image, then the video, renamed into place; on any failure
|
||||||
|
// neither is stored.
|
||||||
//
|
//
|
||||||
// The ZIP is chosen by its uploader and may expand enormously, so each part is
|
// The ZIP is chosen by its uploader and may expand enormously, so each part is
|
||||||
// written as it decompresses and never held, and the ZIP is refused once the
|
// written as it decompresses and never held, and the ZIP is refused once the
|
||||||
@@ -394,6 +392,7 @@ const decryptLivePhoto = async (
|
|||||||
key: Uint8Array,
|
key: Uint8Array,
|
||||||
onProgress: ProgressCallback | undefined,
|
onProgress: ProgressCallback | undefined,
|
||||||
file: EnteFile,
|
file: EnteFile,
|
||||||
|
defaultName: boolean,
|
||||||
): Promise<DownloadResult> => {
|
): Promise<DownloadResult> => {
|
||||||
const sliceSize = 4096;
|
const sliceSize = 4096;
|
||||||
const dir = dirname(destination);
|
const dir = dirname(destination);
|
||||||
@@ -492,8 +491,12 @@ const decryptLivePhoto = async (
|
|||||||
`live photo's image and video have the same extension, ${video.ext}`,
|
`live photo's image and video have the same extension, ${video.ext}`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
const path = withExtension(destination, image.ext);
|
const partPath = (ext: string): string =>
|
||||||
const videoPath = withExtension(destination, video.ext);
|
defaultName
|
||||||
|
? defaultFileName(file.metadata.title, file.id, { ext })
|
||||||
|
: withExtension(destination, ext);
|
||||||
|
const path = partPath(image.ext);
|
||||||
|
const videoPath = partPath(video.ext);
|
||||||
for (const part of parts) {
|
for (const part of parts) {
|
||||||
await part.handle.sync();
|
await part.handle.sync();
|
||||||
await part.handle.close();
|
await part.handle.close();
|
||||||
@@ -537,6 +540,9 @@ const decryptLivePhoto = async (
|
|||||||
// and then removes its own temp file, and only the attempt that reaches
|
// and then removes its own temp file, and only the attempt that reaches
|
||||||
// TAG_FINAL renames one into place, so a download that needed three tries still
|
// TAG_FINAL renames one into place, so a download that needed three tries still
|
||||||
// performs exactly one rename over the destination.
|
// performs exactly one rename over the destination.
|
||||||
|
//
|
||||||
|
// `defaultName` says `destination` is the name `defaultFileName` made, not a
|
||||||
|
// path the caller gave (see `decryptLivePhoto`).
|
||||||
const fetchAndDecrypt = async (
|
const fetchAndDecrypt = async (
|
||||||
api: ApiClient,
|
api: ApiClient,
|
||||||
openStream: () => Promise<ReadableStream<Uint8Array>>,
|
openStream: () => Promise<ReadableStream<Uint8Array>>,
|
||||||
@@ -545,6 +551,7 @@ const fetchAndDecrypt = async (
|
|||||||
destination: string,
|
destination: string,
|
||||||
onProgress?: ProgressCallback,
|
onProgress?: ProgressCallback,
|
||||||
original?: EnteFile,
|
original?: EnteFile,
|
||||||
|
defaultName = false,
|
||||||
): Promise<DownloadResult> =>
|
): Promise<DownloadResult> =>
|
||||||
withRetry(async () => {
|
withRetry(async () => {
|
||||||
const stream = await openStream();
|
const stream = await openStream();
|
||||||
@@ -557,6 +564,7 @@ const fetchAndDecrypt = async (
|
|||||||
key,
|
key,
|
||||||
onProgress,
|
onProgress,
|
||||||
original,
|
original,
|
||||||
|
defaultName,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
const bytesWritten = await decryptToTemp(
|
const bytesWritten = await decryptToTemp(
|
||||||
@@ -588,15 +596,11 @@ export const downloadFile = async (
|
|||||||
outPath?: string,
|
outPath?: string,
|
||||||
onProgress?: ProgressCallback,
|
onProgress?: ProgressCallback,
|
||||||
): Promise<DownloadResult> => {
|
): Promise<DownloadResult> => {
|
||||||
// `outPath` is the caller's and is used as is; the title is the server's
|
// `outPath` is the caller's and is used as is. Without it, the file is
|
||||||
// and is sanitized so it can only name a file in the current directory,
|
// written in the current directory under the name `defaultFileName` makes
|
||||||
// and cut short to fit in a file name, keeping its extension.
|
// from the title, which is the server's.
|
||||||
const resolvedPath =
|
const resolvedPath =
|
||||||
outPath ??
|
outPath ?? defaultFileName(file.metadata.title, file.id);
|
||||||
shortenKeepingExtension(
|
|
||||||
sanitizeFileName(file.metadata.title, `file-${file.id}`),
|
|
||||||
MAX_NAME_BYTES,
|
|
||||||
);
|
|
||||||
const header = fromBase64(file.file.decryptionHeader);
|
const header = fromBase64(file.file.decryptionHeader);
|
||||||
return fetchAndDecrypt(
|
return fetchAndDecrypt(
|
||||||
api,
|
api,
|
||||||
@@ -606,6 +610,7 @@ export const downloadFile = async (
|
|||||||
resolvedPath,
|
resolvedPath,
|
||||||
onProgress,
|
onProgress,
|
||||||
file,
|
file,
|
||||||
|
outPath === undefined,
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -618,11 +623,7 @@ export const downloadThumbnail = async (
|
|||||||
// As in `downloadFile`, with `thumb_` before the title.
|
// As in `downloadFile`, with `thumb_` before the title.
|
||||||
const resolvedPath =
|
const resolvedPath =
|
||||||
outPath ??
|
outPath ??
|
||||||
"thumb_" +
|
defaultFileName(file.metadata.title, file.id, { prefix: "thumb_" });
|
||||||
shortenKeepingExtension(
|
|
||||||
sanitizeFileName(file.metadata.title, `file-${file.id}`),
|
|
||||||
MAX_NAME_BYTES - "thumb_".length,
|
|
||||||
);
|
|
||||||
const header = fromBase64(file.thumbnail.decryptionHeader);
|
const header = fromBase64(file.thumbnail.decryptionHeader);
|
||||||
return fetchAndDecrypt(
|
return fetchAndDecrypt(
|
||||||
api,
|
api,
|
||||||
|
|||||||
+38
-5
@@ -6,6 +6,8 @@
|
|||||||
// directory the caller chose: never a path, never `..`, never hidden, never a
|
// 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
|
// Windows device name. Such a name can still be too long for a file name, and
|
||||||
// `shortenToBytes` or `shortenKeepingExtension` cuts one short.
|
// `shortenToBytes` or `shortenKeepingExtension` cuts one short.
|
||||||
|
// `defaultFileName` does both for the name a file is saved under when the
|
||||||
|
// caller gives no path.
|
||||||
//
|
//
|
||||||
// A path the user typed (`--out`, `outPath`) is not passed through here: the
|
// A path the user typed (`--out`, `outPath`) is not passed through here: the
|
||||||
// caller is trusted, the server is not.
|
// caller is trusted, the server is not.
|
||||||
@@ -61,10 +63,12 @@ export const shortenToBytes = (name: string, maxBytes: number): string => {
|
|||||||
// `name` cut short to fit in `maxBytes` bytes with `insert` put right before
|
// `name` cut short to fit in `maxBytes` bytes with `insert` put right before
|
||||||
// its extension (`IMG_0001 (12).jpg`), each cut made as `shortenToBytes`
|
// 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
|
// 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
|
// kept whenever it fits, but that part always keeps its first character:
|
||||||
// `insert`, the extension is cut short too, and the part before it keeps only
|
// when the extension leaves no room for `insert` and that character, the
|
||||||
// the bytes that cut leaves over. `insert` is never cut. With no `insert`, a
|
// extension is cut short too, and the part before it keeps the bytes that cut
|
||||||
// name that fits comes back unchanged.
|
// leaves over. So a name that does not start with a dot never comes back
|
||||||
|
// starting with one, which would hide it. `insert` is never cut. With no
|
||||||
|
// `insert`, a name that fits comes back unchanged.
|
||||||
export const shortenKeepingExtension = (
|
export const shortenKeepingExtension = (
|
||||||
name: string,
|
name: string,
|
||||||
maxBytes: number,
|
maxBytes: number,
|
||||||
@@ -72,7 +76,36 @@ export const shortenKeepingExtension = (
|
|||||||
): string => {
|
): string => {
|
||||||
const fullExt = extname(name);
|
const fullExt = extname(name);
|
||||||
const stem = name.slice(0, name.length - fullExt.length);
|
const stem = name.slice(0, name.length - fullExt.length);
|
||||||
const ext = shortenToBytes(fullExt, maxBytes - Buffer.byteLength(insert));
|
const first = Array.from(stem)[0] ?? "";
|
||||||
|
const ext = shortenToBytes(
|
||||||
|
fullExt,
|
||||||
|
maxBytes - Buffer.byteLength(insert + first),
|
||||||
|
);
|
||||||
const room = maxBytes - Buffer.byteLength(insert + ext);
|
const room = maxBytes - Buffer.byteLength(insert + ext);
|
||||||
return shortenToBytes(stem, room) + insert + ext;
|
return shortenToBytes(stem, room) + insert + ext;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// The name a file is saved under in the working directory when the caller
|
||||||
|
// gives no path (`quak get` without `--out`, `downloadFile` without
|
||||||
|
// `outPath`): `prefix` (`thumb_` for a thumbnail), then the file's title with
|
||||||
|
// unsafe characters replaced, or `file-<id>` when the title is empty, cut
|
||||||
|
// short so that the whole fits in a file name, keeping the title's extension
|
||||||
|
// whenever it fits. `ext` (".mov"), when given, takes the place of the
|
||||||
|
// title's extension before the name is cut short; a live photo's image and
|
||||||
|
// video are each named with their own.
|
||||||
|
export const defaultFileName = (
|
||||||
|
title: string,
|
||||||
|
fileID: number,
|
||||||
|
opts: { prefix?: string; ext?: string } = {},
|
||||||
|
): string => {
|
||||||
|
const { prefix = "", ext } = opts;
|
||||||
|
const safe = sanitizeFileName(title, `file-${fileID}`);
|
||||||
|
const name = ext === undefined ? safe : withExtension(safe, ext);
|
||||||
|
return (
|
||||||
|
prefix +
|
||||||
|
shortenKeepingExtension(
|
||||||
|
name,
|
||||||
|
MAX_NAME_BYTES - Buffer.byteLength(prefix),
|
||||||
|
)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|||||||
+10
-11
@@ -1795,12 +1795,11 @@ describe("backup album folders", () => {
|
|||||||
expect(readLedger(outDir).files["5"]).toMatchObject({ attempts: 1 });
|
expect(readLedger(outDir).files["5"]).toMatchObject({ attempts: 1 });
|
||||||
});
|
});
|
||||||
|
|
||||||
// The extensions of these titles leave no room for the ID, so each is cut
|
// The extensions of these titles leave no room for the ID and the "a"
|
||||||
// short and the name before the ID keeps only the bytes that cut leaves
|
// before them, so each is cut short to 250 bytes, which leaves one, for
|
||||||
// over. File 1's extension is 303 bytes and is cut to 251, which leaves
|
// the "a". File 1's extension is 303 bytes and file 2's 271. Neither
|
||||||
// none. File 2's is 271 bytes and is cut to 250, since "€" takes three
|
// extension is only letters and digits, so both originals are saved with
|
||||||
// bytes, which leaves one, for the "a". Neither extension is only letters
|
// `.bin`, a name that fits.
|
||||||
// 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([
|
||||||
@@ -1815,7 +1814,7 @@ describe("backup album folders", () => {
|
|||||||
|
|
||||||
const result = await runBackup(lib, { downloadDirectory: outDir });
|
const result = await runBackup(lib, { downloadDirectory: outDir });
|
||||||
|
|
||||||
const link = ` (1).${"L".repeat(250)}`;
|
const link = `a (1).${"L".repeat(249)}`;
|
||||||
const link2 = `a (2).${"€".repeat(83)}`;
|
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(Buffer.byteLength(link2)).toBe(255);
|
||||||
@@ -1830,9 +1829,9 @@ describe("backup album folders", () => {
|
|||||||
|
|
||||||
// Files 1 and 3 share a title, so each gets its ID before the
|
// 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
|
// 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
|
// room for the ID and the "x" before it. Were the ID put at the end of
|
||||||
// short, its link would have file 1's name, and the link made second
|
// file 2's title cut short, its link would have file 1's name, and the
|
||||||
// would replace the first.
|
// link made second would replace the first.
|
||||||
it("keeps names unique when a title's extension leaves no room for the ID", async () => {
|
it("keeps names unique when a title's extension leaves no room for the ID", async () => {
|
||||||
const outDir = join(root, "backup");
|
const outDir = join(root, "backup");
|
||||||
const shared = `x.${"Q".repeat(245)} (2)`;
|
const shared = `x.${"Q".repeat(245)} (2)`;
|
||||||
@@ -1852,8 +1851,8 @@ describe("backup album folders", () => {
|
|||||||
expect(result.failed).toBe(0);
|
expect(result.failed).toBe(0);
|
||||||
expect(tree(outDir)).toEqual([
|
expect(tree(outDir)).toEqual([
|
||||||
"Trip/",
|
"Trip/",
|
||||||
`Trip/ (2).${"Q".repeat(250)} -> ${linkTo("2.bin")}`,
|
|
||||||
`Trip/x (1).${"Q".repeat(245)} (2) -> ${linkTo("1.bin")}`,
|
`Trip/x (1).${"Q".repeat(245)} (2) -> ${linkTo("1.bin")}`,
|
||||||
|
`Trip/x (2).${"Q".repeat(249)} -> ${linkTo("2.bin")}`,
|
||||||
`Trip/x (3).${"Q".repeat(245)} (2) -> ${linkTo("3.bin")}`,
|
`Trip/x (3).${"Q".repeat(245)} (2) -> ${linkTo("3.bin")}`,
|
||||||
"Trip.json",
|
"Trip.json",
|
||||||
]);
|
]);
|
||||||
|
|||||||
@@ -187,6 +187,9 @@ const FETCHED_BOTH_ALBUMS =
|
|||||||
"Fetched the files of 1 of 2 collections\n" +
|
"Fetched the files of 1 of 2 collections\n" +
|
||||||
"Fetched the files of 2 of 2 collections\n";
|
"Fetched the files of 2 of 2 collections\n";
|
||||||
|
|
||||||
|
// The same for an account with one album.
|
||||||
|
const FETCHED_ONE_ALBUM = "Fetched the files of 1 of 1 collections\n";
|
||||||
|
|
||||||
// Collects everything written to it.
|
// Collects everything written to it.
|
||||||
class Output {
|
class Output {
|
||||||
text = "";
|
text = "";
|
||||||
@@ -605,7 +608,7 @@ describe("names from an album name or a title over 255 bytes", () => {
|
|||||||
expect(Buffer.byteLength(name)).toBe(255);
|
expect(Buffer.byteLength(name)).toBe(255);
|
||||||
expect(code).toBe(0);
|
expect(code).toBe(0);
|
||||||
expect(readdirSync(dir)).toEqual([name]);
|
expect(readdirSync(dir)).toEqual([name]);
|
||||||
expect(stderr.text).toBe(`7 bytes -> ${name}\n`);
|
expect(stderr.text).toBe(FETCHED_ONE_ALBUM + `7 bytes -> ${name}\n`);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("get-thumb cuts the title short so that thumb_ and it fit in 255 bytes, and exits 0", async () => {
|
it("get-thumb cuts the title short so that thumb_ and it fit in 255 bytes, and exits 0", async () => {
|
||||||
@@ -617,7 +620,7 @@ describe("names from an album name or a title over 255 bytes", () => {
|
|||||||
expect(Buffer.byteLength(name)).toBe(255);
|
expect(Buffer.byteLength(name)).toBe(255);
|
||||||
expect(code).toBe(0);
|
expect(code).toBe(0);
|
||||||
expect(readdirSync(dir)).toEqual([name]);
|
expect(readdirSync(dir)).toEqual([name]);
|
||||||
expect(stderr.text).toBe(`3 bytes -> ${name}\n`);
|
expect(stderr.text).toBe(FETCHED_ONE_ALBUM + `3 bytes -> ${name}\n`);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("backup-metadata cuts the album name short so that its folder's name fits in 255 bytes, and exits 0", async () => {
|
it("backup-metadata cuts the album name short so that its folder's name fits in 255 bytes, and exits 0", async () => {
|
||||||
@@ -659,12 +662,12 @@ describe("names from an album name or a title over 255 bytes", () => {
|
|||||||
// one live photo, 300, downloaded through the real download layer
|
// one live photo, 300, downloaded through the real download layer
|
||||||
// (test/live-photo.ts).
|
// (test/live-photo.ts).
|
||||||
describe("a live photo", () => {
|
describe("a live photo", () => {
|
||||||
// What `get` prints on stderr while its refresh fetches Vacation's files.
|
const livePhotoClient = async (
|
||||||
const FETCHED_ONE_ALBUM = "Fetched the files of 1 of 1 collections\n";
|
image = IMAGE,
|
||||||
|
title = "IMG_0300.HEIC",
|
||||||
const livePhotoClient = async (image = IMAGE): Promise<Client> => {
|
): Promise<Client> => {
|
||||||
const { file: live, body } = await asLivePhoto(
|
const { file: live, body } = await asLivePhoto(
|
||||||
file(300, 1, "IMG_0300.HEIC"),
|
file(300, 1, title),
|
||||||
livePhotoZip({ "image.heic": image, "video.mov": VIDEO }),
|
livePhotoZip({ "image.heic": image, "video.mov": VIDEO }),
|
||||||
livePhotoHash(image, VIDEO),
|
livePhotoHash(image, VIDEO),
|
||||||
);
|
);
|
||||||
@@ -712,6 +715,44 @@ describe("a live photo", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Run `get` without `--out` in a new directory for a live photo titled
|
||||||
|
// `title`, and return its exit code and the names it wrote.
|
||||||
|
const getInNewDirectory = async (
|
||||||
|
title: string,
|
||||||
|
): Promise<{ code: number; names: string[] }> => {
|
||||||
|
const ctx = context(await livePhotoClient(IMAGE, title));
|
||||||
|
const dir = mkdtempSync(join(root, "cwd-"));
|
||||||
|
const previous = process.cwd();
|
||||||
|
process.chdir(dir);
|
||||||
|
try {
|
||||||
|
const code = await getCommand(ctx, "300", {});
|
||||||
|
return { code, names: readdirSync(dir).sort() };
|
||||||
|
} finally {
|
||||||
|
process.chdir(previous);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const imageName = `${"t".repeat(250)}.heic`;
|
||||||
|
const videoName = `${"t".repeat(251)}.mov`;
|
||||||
|
|
||||||
|
// The title fits, but ".heic" is one byte longer than ".jpg", so the
|
||||||
|
// image's name is cut short once its extension is in it.
|
||||||
|
it("get cuts its image's name short for a 255-byte title ending in .jpg, and exits 0", async () => {
|
||||||
|
const title = `${"t".repeat(251)}.jpg`;
|
||||||
|
|
||||||
|
const result = await getInNewDirectory(title);
|
||||||
|
|
||||||
|
expect(Buffer.byteLength(title)).toBe(255);
|
||||||
|
expect(Buffer.byteLength(imageName)).toBe(255);
|
||||||
|
expect(Buffer.byteLength(videoName)).toBe(255);
|
||||||
|
expect(result).toEqual({ code: 0, names: [imageName, videoName] });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("get cuts the names of its image and video short for a 300-byte title with no extension, and exits 0", async () => {
|
||||||
|
const result = await getInNewDirectory("t".repeat(300));
|
||||||
|
|
||||||
|
expect(result).toEqual({ code: 0, names: [imageName, videoName] });
|
||||||
|
});
|
||||||
|
|
||||||
it("get --out writes the image there and the video beside it", async () => {
|
it("get --out writes the image there and the video beside it", async () => {
|
||||||
const out = join(root, "photo.jpg");
|
const out = join(root, "photo.jpg");
|
||||||
const video = join(root, "photo.mov");
|
const video = join(root, "photo.mov");
|
||||||
|
|||||||
@@ -1923,6 +1923,53 @@ describe("downloadFile live photos", () => {
|
|||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// A file system refuses a file name over 255 bytes. Each name is cut
|
||||||
|
// short once its own extension is in it.
|
||||||
|
const imageName = `${"t".repeat(250)}.heic`;
|
||||||
|
const videoName = `${"t".repeat(251)}.mov`;
|
||||||
|
|
||||||
|
// The title fits, but ".heic" is one byte longer than ".jpg".
|
||||||
|
it("cuts the image's name short for a 255-byte title ending in .jpg when no outPath is given", async () => {
|
||||||
|
const title = `${"t".repeat(251)}.jpg`;
|
||||||
|
const t = setup(livePhotoZip(), { ...livePhoto, title });
|
||||||
|
|
||||||
|
const result = await inDirectory(t.dir, () =>
|
||||||
|
downloadFile(t.api, t.file),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(Buffer.byteLength(title)).toBe(255);
|
||||||
|
expect(Buffer.byteLength(imageName)).toBe(255);
|
||||||
|
expect(Buffer.byteLength(videoName)).toBe(255);
|
||||||
|
expect(result.path).toBe(imageName);
|
||||||
|
expect(result.videoPath).toBe(videoName);
|
||||||
|
expect(readdirSync(t.dir).sort()).toEqual([imageName, videoName]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("cuts the names of the image and the video short for a 300-byte title with no extension when no outPath is given", async () => {
|
||||||
|
const t = setup(livePhotoZip(), {
|
||||||
|
...livePhoto,
|
||||||
|
title: "t".repeat(300),
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await inDirectory(t.dir, () =>
|
||||||
|
downloadFile(t.api, t.file),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.path).toBe(imageName);
|
||||||
|
expect(result.videoPath).toBe(videoName);
|
||||||
|
expect(readdirSync(t.dir).sort()).toEqual([imageName, videoName]);
|
||||||
|
});
|
||||||
|
|
||||||
|
// The caller's path is used as is; only the extension is each part's own.
|
||||||
|
it("keeps an outPath too long for a file name as given", async () => {
|
||||||
|
const t = setup(livePhotoZip(), livePhoto);
|
||||||
|
const outPath = join(t.dir, `${"t".repeat(251)}.jpg`);
|
||||||
|
|
||||||
|
await expect(downloadFile(t.api, t.file, outPath)).rejects.toThrow(
|
||||||
|
/ENAMETOOLONG/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it("gives each part its own extension from the ZIP, letters and digits only", async () => {
|
it("gives each part its own extension from the ZIP, letters and digits only", async () => {
|
||||||
const zip = livePhotoZip({ "image.JPG": IMAGE, "video.m-4v": VIDEO });
|
const zip = livePhotoZip({ "image.JPG": IMAGE, "video.m-4v": VIDEO });
|
||||||
const t = setup(zip, livePhoto);
|
const t = setup(zip, livePhoto);
|
||||||
|
|||||||
@@ -15,6 +15,7 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
defaultFileName,
|
||||||
safeExtension,
|
safeExtension,
|
||||||
sanitizeFileName,
|
sanitizeFileName,
|
||||||
shortenKeepingExtension,
|
shortenKeepingExtension,
|
||||||
@@ -141,9 +142,17 @@ describe("shortenKeepingExtension", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("cuts the extension short when it alone does not fit", () => {
|
// A name cut down to its extension would start with a dot, and so be
|
||||||
|
// hidden.
|
||||||
|
it("keeps the first character before the extension, cutting the extension short to leave room for it", () => {
|
||||||
expect(shortenKeepingExtension(`b.${"e".repeat(300)}`, 255)).toBe(
|
expect(shortenKeepingExtension(`b.${"e".repeat(300)}`, 255)).toBe(
|
||||||
`.${"e".repeat(254)}`,
|
`b.${"e".repeat(253)}`,
|
||||||
|
);
|
||||||
|
expect(shortenKeepingExtension(`ab.${"e".repeat(300)}`, 255)).toBe(
|
||||||
|
`a.${"e".repeat(253)}`,
|
||||||
|
);
|
||||||
|
expect(shortenKeepingExtension(`é.${"e".repeat(300)}`, 255)).toBe(
|
||||||
|
`é.${"e".repeat(252)}`,
|
||||||
);
|
);
|
||||||
// "€" takes three bytes, so the extension is cut to 253 and the
|
// "€" takes three bytes, so the extension is cut to 253 and the
|
||||||
// part before it keeps the two bytes left over, for the "a".
|
// part before it keeps the two bytes left over, for the "a".
|
||||||
@@ -161,6 +170,50 @@ describe("shortenKeepingExtension", () => {
|
|||||||
).toBe(`${"t".repeat(247)} (7).jpg`);
|
).toBe(`${"t".repeat(247)} (7).jpg`);
|
||||||
expect(
|
expect(
|
||||||
shortenKeepingExtension(`b.${"e".repeat(300)}`, 255, " (7)"),
|
shortenKeepingExtension(`b.${"e".repeat(300)}`, 255, " (7)"),
|
||||||
).toBe(` (7).${"e".repeat(250)}`);
|
).toBe(`b (7).${"e".repeat(249)}`);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("defaultFileName", () => {
|
||||||
|
it("is the title when it is safe and fits", () => {
|
||||||
|
expect(defaultFileName("IMG_0001.HEIC", 42)).toBe("IMG_0001.HEIC");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("replaces unsafe characters, and is file-<id> for an empty title", () => {
|
||||||
|
expect(defaultFileName("../x.jpg", 42)).toBe("__x.jpg");
|
||||||
|
expect(defaultFileName("", 42)).toBe("file-42");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("cuts a title over 255 bytes short, keeping its extension", () => {
|
||||||
|
expect(defaultFileName(`${"t".repeat(296)}.jpg`, 42)).toBe(
|
||||||
|
`${"t".repeat(251)}.jpg`,
|
||||||
|
);
|
||||||
|
expect(defaultFileName(`b.${"e".repeat(300)}`, 42)).toBe(
|
||||||
|
`b.${"e".repeat(253)}`,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("puts `prefix` before the title and leaves room for it", () => {
|
||||||
|
expect(defaultFileName("beach.jpg", 42, { prefix: "thumb_" })).toBe(
|
||||||
|
"thumb_beach.jpg",
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
defaultFileName(`${"t".repeat(296)}.jpg`, 42, { prefix: "thumb_" }),
|
||||||
|
).toBe(`thumb_${"t".repeat(245)}.jpg`);
|
||||||
|
});
|
||||||
|
|
||||||
|
// A live photo's image and video are each named with their own
|
||||||
|
// extension, and the name is cut short once that extension is in it.
|
||||||
|
it("puts `ext` in place of the title's extension before cutting the name short", () => {
|
||||||
|
expect(defaultFileName("IMG_0001.HEIC", 42, { ext: ".mov" })).toBe(
|
||||||
|
"IMG_0001.mov",
|
||||||
|
);
|
||||||
|
expect(defaultFileName("", 42, { ext: ".mov" })).toBe("file-42.mov");
|
||||||
|
expect(
|
||||||
|
defaultFileName(`${"t".repeat(251)}.jpg`, 42, { ext: ".heic" }),
|
||||||
|
).toBe(`${"t".repeat(250)}.heic`);
|
||||||
|
expect(defaultFileName("t".repeat(300), 42, { ext: ".heic" })).toBe(
|
||||||
|
`${"t".repeat(250)}.heic`,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user