Initial commit
This commit is contained in:
+102
@@ -0,0 +1,102 @@
|
||||
import path from "node:path";
|
||||
import fs from "node:fs/promises";
|
||||
|
||||
const MAX_BASE_LEN = 120;
|
||||
|
||||
/**
|
||||
* Sanitize a filename for cross-platform safety while preserving most user intent
|
||||
* (unicode, spaces, dashes). Strips characters illegal on Windows + control chars,
|
||||
* trims trailing dots/spaces, falls back to "image" if everything was stripped.
|
||||
*/
|
||||
export function sanitizeFilename(name: string): { base: string; ext: string } {
|
||||
const ext = (path.extname(name) || "").toLowerCase();
|
||||
let base = path.basename(name, ext);
|
||||
|
||||
base = base
|
||||
// disallowed on Windows + path separators
|
||||
.replace(/[<>:"/\\|?*\x00-\x1f]/g, "")
|
||||
// collapse whitespace runs
|
||||
.replace(/\s+/g, " ")
|
||||
// trim trailing dots/spaces (Windows refuses these)
|
||||
.replace(/[.\s]+$/g, "")
|
||||
.trim();
|
||||
|
||||
if (base.length > MAX_BASE_LEN) base = base.slice(0, MAX_BASE_LEN).trim();
|
||||
if (!base) base = "image";
|
||||
|
||||
return { base, ext: ext || ".png" };
|
||||
}
|
||||
|
||||
/**
|
||||
* Find a path under `dirAbs` that matches `${base}${ext}` (or `${base}-N${ext}` on
|
||||
* collision) and atomically reserves it by creating a 0-byte file with the
|
||||
* exclusive-create flag. The caller is expected to overwrite this placeholder
|
||||
* with the real bytes; this avoids a check-then-write race where two
|
||||
* concurrent uploads pick the same "unique" path.
|
||||
*/
|
||||
export async function uniqueFilePath(
|
||||
dirAbs: string,
|
||||
base: string,
|
||||
ext: string,
|
||||
): Promise<string> {
|
||||
const tryPath = (n: number) =>
|
||||
path.join(dirAbs, n === 1 ? `${base}${ext}` : `${base}-${n}${ext}`);
|
||||
for (let i = 1; i < 10_000; i++) {
|
||||
const p = tryPath(i);
|
||||
try {
|
||||
const handle = await fs.open(p, "wx");
|
||||
await handle.close();
|
||||
return p;
|
||||
} catch (e) {
|
||||
// EEXIST → another caller (or prior run) holds this name; try the
|
||||
// next suffix. Anything else (perms, ENOENT on dir) is fatal.
|
||||
if ((e as NodeJS.ErrnoException).code !== "EEXIST") throw e;
|
||||
}
|
||||
}
|
||||
throw new Error(`Could not find unique filename for ${base}${ext} after 10000 tries`);
|
||||
}
|
||||
|
||||
export function dayPartition(d: Date): string {
|
||||
const y = d.getFullYear();
|
||||
const m = String(d.getMonth() + 1).padStart(2, "0");
|
||||
const day = String(d.getDate()).padStart(2, "0");
|
||||
return path.posix.join(String(y), m, day);
|
||||
}
|
||||
|
||||
/**
|
||||
* Letter-bucketed storage path keyed off the first letter of a JAV code.
|
||||
* Codes whose first letter isn't A-Z (null code, digit-prefix, non-Latin)
|
||||
* fall into the `#` fallback bucket so the on-disk layout stays clean.
|
||||
*/
|
||||
const LETTER_RANGES: ReadonlyArray<{ range: string; letters: string }> = [
|
||||
{ range: "A-E", letters: "ABCDE" },
|
||||
{ range: "F-J", letters: "FGHIJ" },
|
||||
{ range: "K-P", letters: "KLMNOP" },
|
||||
{ range: "Q-U", letters: "QRSTU" },
|
||||
{ range: "V-Z", letters: "VWXYZ" },
|
||||
];
|
||||
|
||||
/**
|
||||
* Canonical filename for a cover's grid-preview WebP. Embeds the JAV code
|
||||
* (when known) so the data/thumbs/ folder is browsable by hand:
|
||||
* "DDT-203-2aa9...945f.webp" instead of just "2aa9...945f.webp".
|
||||
* Codes are validated against the same alphabet that codeParser produces
|
||||
* (uppercase letters/digits/dash); anything else is dropped to keep
|
||||
* filenames safe across NTFS/ext4.
|
||||
*/
|
||||
export function canonicalThumbName(code: string | null | undefined, sha: string): string {
|
||||
const safeCode = (code ?? "").trim().toUpperCase().replace(/[^A-Z0-9-]/g, "");
|
||||
return safeCode ? `${safeCode}-${sha}.webp` : `${sha}.webp`;
|
||||
}
|
||||
|
||||
export function letterBucket(code: string | null | undefined): { range: string; letter: string; dirRel: string } {
|
||||
const ch = (code ?? "").trim().charAt(0).toUpperCase();
|
||||
if (!/^[A-Z]$/.test(ch)) {
|
||||
// Fallback bucket is a single level — there's only one possible
|
||||
// "letter" inside `#` so an extra `#/#/` layer would be redundant.
|
||||
return { range: "#", letter: "#", dirRel: "#" };
|
||||
}
|
||||
const r = LETTER_RANGES.find((x) => x.letters.includes(ch));
|
||||
if (!r) return { range: "#", letter: "#", dirRel: "#" };
|
||||
return { range: r.range, letter: ch, dirRel: path.posix.join(r.range, ch) };
|
||||
}
|
||||
Reference in New Issue
Block a user