feat(catalog): queue furniture imports with history and matching file attachments
This commit is contained in:
1 parent
775d14f861
commit
2619bec165
16 files changed
+1016
-232
No files matched your search
@@ -0,0 +1,37 @@
|
|||||||
|
import { withAdmin } from "@/lib/api-handler";
|
||||||
|
import { apiError, apiOk } from "@/lib/api-response";
|
||||||
|
import { validateClassnames } from "@/lib/furni/studio-inspection";
|
||||||
|
import { PERMS } from "@/lib/permission-slugs";
|
||||||
|
import { stageFurnitureAttachment } from "@/lib/services/furni-attachment";
|
||||||
|
export const POST = withAdmin(
|
||||||
|
{ permission: PERMS.ASSETS_IMPORT, maxBodyBytes: 52 * 1024 * 1024 },
|
||||||
|
async (request, ctx) => {
|
||||||
|
const form = await request.formData(),
|
||||||
|
classname = form.get("classname"),
|
||||||
|
file = form.get("file");
|
||||||
|
if (
|
||||||
|
typeof classname !== "string" ||
|
||||||
|
!validateClassnames([classname]) ||
|
||||||
|
!(file instanceof File) ||
|
||||||
|
file.size === 0 ||
|
||||||
|
file.size > 50 * 1024 * 1024
|
||||||
|
)
|
||||||
|
return apiError(
|
||||||
|
"Select a .nitro file for this furniture (maximum 50 MB)",
|
||||||
|
400,
|
||||||
|
);
|
||||||
|
try {
|
||||||
|
const attachmentId = await stageFurnitureAttachment(
|
||||||
|
Buffer.from(await file.arrayBuffer()),
|
||||||
|
classname,
|
||||||
|
ctx.session.user.id,
|
||||||
|
);
|
||||||
|
return apiOk({ attachmentId });
|
||||||
|
} catch (error) {
|
||||||
|
return apiError(
|
||||||
|
error instanceof Error ? error.message : "Invalid .nitro file",
|
||||||
|
400,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,83 @@
|
|||||||
|
import { after } from "next/server";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { withAdmin } from "@/lib/api-handler";
|
||||||
|
import { apiError, apiOk } from "@/lib/api-response";
|
||||||
|
import { validateClassnames } from "@/lib/furni/studio-inspection";
|
||||||
|
import { PERMS } from "@/lib/permission-slugs";
|
||||||
|
import { redis } from "@/lib/redis";
|
||||||
|
import { getSource } from "@/lib/services/clone-sources";
|
||||||
|
import { ImportJobStore } from "@/lib/services/furni-job-store";
|
||||||
|
import { drainFurnitureImports } from "@/lib/services/furni-job-worker";
|
||||||
|
|
||||||
|
const schema = z.object({
|
||||||
|
id: z.uuid().refine((value) => value[14] === "4"),
|
||||||
|
sourceId: z.string().max(100).optional(),
|
||||||
|
translate: z.boolean().default(false),
|
||||||
|
langs: z
|
||||||
|
.array(z.string().regex(/^[a-z]{2,3}$/))
|
||||||
|
.max(25)
|
||||||
|
.optional(),
|
||||||
|
items: z
|
||||||
|
.array(
|
||||||
|
z.object({
|
||||||
|
id: z.number().int().nonnegative(),
|
||||||
|
classname: z.string(),
|
||||||
|
name: z.string().min(1).max(500),
|
||||||
|
description: z.string().max(5000),
|
||||||
|
type: z.enum(["flooritem", "wallitem"]),
|
||||||
|
revision: z.number().int().nonnegative(),
|
||||||
|
category: z.string().max(200),
|
||||||
|
attachmentId: z.uuid().optional(),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.min(1)
|
||||||
|
.max(500),
|
||||||
|
});
|
||||||
|
export const GET = withAdmin(
|
||||||
|
{ permission: PERMS.ASSETS_IMPORT },
|
||||||
|
async (_request, ctx) => {
|
||||||
|
after(drainFurnitureImports);
|
||||||
|
const jobs = (await new ImportJobStore().list())
|
||||||
|
.filter((job) => job.userId === ctx.session.user.id)
|
||||||
|
.reverse()
|
||||||
|
.slice(0, 30);
|
||||||
|
return apiOk({ jobs });
|
||||||
|
},
|
||||||
|
);
|
||||||
|
export const POST = withAdmin(
|
||||||
|
{ permission: PERMS.ASSETS_IMPORT },
|
||||||
|
async (request, ctx) => {
|
||||||
|
const parsed = schema.safeParse(await request.json().catch(() => null));
|
||||||
|
if (
|
||||||
|
!parsed.success ||
|
||||||
|
!validateClassnames(parsed.data.items.map((item) => item.classname))
|
||||||
|
)
|
||||||
|
return apiError(
|
||||||
|
"Invalid furniture import request (maximum 500 items)",
|
||||||
|
400,
|
||||||
|
);
|
||||||
|
if (!redis || (await redis.ping().catch(() => null)) !== "PONG")
|
||||||
|
return apiError(
|
||||||
|
"Background import queue is temporarily unavailable. Please retry shortly.",
|
||||||
|
503,
|
||||||
|
);
|
||||||
|
if (parsed.data.sourceId && !(await getSource(parsed.data.sourceId)))
|
||||||
|
return apiError("Source not found", 404);
|
||||||
|
const store = new ImportJobStore(),
|
||||||
|
body = parsed.data;
|
||||||
|
const createdAt = new Date().toISOString();
|
||||||
|
const unique = [
|
||||||
|
...new Map(body.items.map((item) => [item.classname, item])).values(),
|
||||||
|
];
|
||||||
|
const job = await store.create({
|
||||||
|
...body,
|
||||||
|
userId: ctx.session.user.id,
|
||||||
|
createdAt,
|
||||||
|
updatedAt: createdAt,
|
||||||
|
state: "queued",
|
||||||
|
items: unique.map((item) => ({ ...item, state: "pending" })),
|
||||||
|
});
|
||||||
|
after(drainFurnitureImports);
|
||||||
|
return apiOk({ job });
|
||||||
|
},
|
||||||
|
);
|
||||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -1,4 +1,5 @@
|
|||||||
export interface FurniItem {
|
export interface FurniItem {
|
||||||
|
attachmentId?: string;
|
||||||
id: number;
|
id: number;
|
||||||
classname: string;
|
classname: string;
|
||||||
type: string;
|
type: string;
|
||||||
|
|||||||
@@ -20,3 +20,18 @@ export const onRequestError: Instrumentation.onRequestError = async (
|
|||||||
: null,
|
: null,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export async function register() {
|
||||||
|
if (
|
||||||
|
process.env.NEXT_RUNTIME !== "nodejs" ||
|
||||||
|
process.env.NEXT_PHASE === "phase-production-build"
|
||||||
|
)
|
||||||
|
return;
|
||||||
|
const { drainFurnitureImports } = await import(
|
||||||
|
"@/lib/services/furni-job-worker"
|
||||||
|
);
|
||||||
|
const timer = setInterval(() => {
|
||||||
|
void drainFurnitureImports();
|
||||||
|
}, 30000);
|
||||||
|
timer.unref();
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
export interface ImportJobItem {
|
||||||
|
id: number;
|
||||||
|
classname: string;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
type: string;
|
||||||
|
revision: number;
|
||||||
|
category: string;
|
||||||
|
attachmentId?: string;
|
||||||
|
}
|
||||||
|
export interface ImportJob {
|
||||||
|
id: string;
|
||||||
|
userId: number;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
state: "queued" | "running" | "completed" | "interrupted";
|
||||||
|
sourceId?: string;
|
||||||
|
translate: boolean;
|
||||||
|
langs?: string[];
|
||||||
|
items: Array<
|
||||||
|
ImportJobItem & {
|
||||||
|
state: "pending" | "running" | "done" | "failed";
|
||||||
|
error?: string;
|
||||||
|
warnings?: string[];
|
||||||
|
itemId?: number;
|
||||||
|
}
|
||||||
|
>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import { expect, it } from "vitest";
|
||||||
|
import { validateFurnitureAttachment } from "./furni-attachment";
|
||||||
|
import { createNitroBundle, encodePng } from "./swf/nitro-builder";
|
||||||
|
|
||||||
|
const png = encodePng(1, 1, Buffer.from([1, 2, 3, 255]));
|
||||||
|
it("accepts the matching bundle including color variants sharing a library", () => {
|
||||||
|
const buffer = createNitroBundle({ name: "chair" }, png, "chair");
|
||||||
|
expect(validateFurnitureAttachment(buffer, "chair*2")).toBe(buffer);
|
||||||
|
});
|
||||||
|
it("rejects a similarly named furniture bundle", () => {
|
||||||
|
expect(() =>
|
||||||
|
validateFurnitureAttachment(
|
||||||
|
createNitroBundle({ name: "china_light" }, png, "china_light"),
|
||||||
|
"nft_china_light",
|
||||||
|
),
|
||||||
|
).toThrow("does not belong");
|
||||||
|
});
|
||||||
|
it("rejects a renamed JSON entry with mismatching metadata", () => {
|
||||||
|
expect(() =>
|
||||||
|
validateFurnitureAttachment(
|
||||||
|
createNitroBundle({ name: "china_light" }, png, "nft_china_light"),
|
||||||
|
"nft_china_light",
|
||||||
|
),
|
||||||
|
).toThrow("does not belong");
|
||||||
|
});
|
||||||
|
it("rejects missing texture data", () => {
|
||||||
|
expect(() =>
|
||||||
|
validateFurnitureAttachment(
|
||||||
|
createNitroBundle({ name: "chair" }, Buffer.from("not png"), "chair"),
|
||||||
|
"chair",
|
||||||
|
),
|
||||||
|
).toThrow("not a PNG");
|
||||||
|
});
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import { randomUUID } from "node:crypto";
|
||||||
|
import { promises as fs } from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
import { parseNitroBundle } from "@/lib/services/swf/nitro-builder";
|
||||||
|
import { importRoot, validJobId } from "./furni-job-store";
|
||||||
|
export function validateFurnitureAttachment(buffer: Buffer, classname: string) {
|
||||||
|
if (buffer.length > 50 * 1024 * 1024)
|
||||||
|
throw Error("Maximum file size is 50 MB");
|
||||||
|
const parsed = parseNitroBundle(buffer);
|
||||||
|
const base = classname.split("*")[0];
|
||||||
|
if (
|
||||||
|
parsed.jsonFileName !== `${base}.json` ||
|
||||||
|
(parsed.json.name && parsed.json.name !== base)
|
||||||
|
)
|
||||||
|
throw Error(`This bundle does not belong to ${classname}`);
|
||||||
|
if (
|
||||||
|
!parsed.png
|
||||||
|
.subarray(0, 8)
|
||||||
|
.equals(Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]))
|
||||||
|
)
|
||||||
|
throw Error("The bundle texture is not a PNG");
|
||||||
|
return buffer;
|
||||||
|
}
|
||||||
|
export async function stageFurnitureAttachment(
|
||||||
|
buffer: Buffer,
|
||||||
|
classname: string,
|
||||||
|
userId: number,
|
||||||
|
) {
|
||||||
|
validateFurnitureAttachment(buffer, classname);
|
||||||
|
const id = randomUUID(),
|
||||||
|
root = path.join(importRoot(), "attachments");
|
||||||
|
await fs.mkdir(root, { recursive: true });
|
||||||
|
await fs.writeFile(path.join(root, `${id}.nitro`), buffer);
|
||||||
|
await fs.writeFile(
|
||||||
|
path.join(root, `${id}.json`),
|
||||||
|
JSON.stringify({ classname, userId }),
|
||||||
|
);
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
export async function readFurnitureAttachment(
|
||||||
|
id: string,
|
||||||
|
classname: string,
|
||||||
|
userId: number,
|
||||||
|
) {
|
||||||
|
if (!validJobId(id)) throw Error("Invalid attachment");
|
||||||
|
const root = path.join(importRoot(), "attachments");
|
||||||
|
const meta = JSON.parse(
|
||||||
|
await fs.readFile(path.join(root, `${id}.json`), "utf8"),
|
||||||
|
);
|
||||||
|
if (meta.userId !== userId || meta.classname !== classname)
|
||||||
|
throw Error("Attachment does not belong to this furniture import");
|
||||||
|
return validateFurnitureAttachment(
|
||||||
|
await fs.readFile(path.join(root, `${id}.nitro`)),
|
||||||
|
classname,
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -37,6 +37,7 @@ import {
|
|||||||
} from "@/lib/services/swf-to-nitro";
|
} from "@/lib/services/swf-to-nitro";
|
||||||
import { getRuntimePath } from "@/lib/utils/runtime-path";
|
import { getRuntimePath } from "@/lib/utils/runtime-path";
|
||||||
import type { ImportSingleResult } from "@/types/furni";
|
import type { ImportSingleResult } from "@/types/furni";
|
||||||
|
import { extractFurniIconPng } from "./clone-icon";
|
||||||
import { reserveFurnitureId } from "./furniture-id-reservation";
|
import { reserveFurnitureId } from "./furniture-id-reservation";
|
||||||
|
|
||||||
// Re-export the download helpers now owned by the shared import core.
|
// Re-export the download helpers now owned by the shared import core.
|
||||||
@@ -512,6 +513,8 @@ export async function importSingleFurni(params: {
|
|||||||
skipFurniDataWrite?: boolean;
|
skipFurniDataWrite?: boolean;
|
||||||
updateExisting?: boolean;
|
updateExisting?: boolean;
|
||||||
repairExisting?: boolean;
|
repairExisting?: boolean;
|
||||||
|
/** Validated matching bundle supplied by a queued import. */
|
||||||
|
providedNitro?: Buffer;
|
||||||
onProgress?: (status: string) => void;
|
onProgress?: (status: string) => void;
|
||||||
/** Per-source SWF download base URL (e.g. "https://virtualc.nl/dcr"). */
|
/** Per-source SWF download base URL (e.g. "https://virtualc.nl/dcr"). */
|
||||||
sourceSwfBaseUrl?: string;
|
sourceSwfBaseUrl?: string;
|
||||||
@@ -645,6 +648,10 @@ export async function importSingleFurni(params: {
|
|||||||
const mirrorIconDirs = assetTargets.mirrorDirs.map((d) => d.iconDir);
|
const mirrorIconDirs = assetTargets.mirrorDirs.map((d) => d.iconDir);
|
||||||
const mirrorNitroDirs = assetTargets.mirrorDirs.map((d) => d.nitroDir);
|
const mirrorNitroDirs = assetTargets.mirrorDirs.map((d) => d.nitroDir);
|
||||||
|
|
||||||
|
if (params.providedNitro && !existsSync(nitroPath)) {
|
||||||
|
await fs.writeFile(nitroPath, params.providedNitro, { flag: "wx" });
|
||||||
|
}
|
||||||
|
|
||||||
// ── Download assets in parallel (with retry + validation) ────────
|
// ── Download assets in parallel (with retry + validation) ────────
|
||||||
// Icon strategy matches Nitro's URL template:
|
// Icon strategy matches Nitro's URL template:
|
||||||
// `${hof.furni.url}/icons/%libname%%param%_icon.png`
|
// `${hof.furni.url}/icons/%libname%%param%_icon.png`
|
||||||
@@ -700,11 +707,13 @@ export async function importSingleFurni(params: {
|
|||||||
: tryDownloadCandidates(iconUrls, iconPath, "png", (detail) => {
|
: tryDownloadCandidates(iconUrls, iconPath, "png", (detail) => {
|
||||||
iconFailure = detail;
|
iconFailure = detail;
|
||||||
}),
|
}),
|
||||||
preservingExisting && existsSync(swfPath)
|
params.providedNitro
|
||||||
? Promise.resolve(true)
|
? Promise.resolve(false)
|
||||||
: tryDownloadCandidates(swfUrls, swfPath, "swf", (detail) => {
|
: preservingExisting && existsSync(swfPath)
|
||||||
swfFailure = detail;
|
? Promise.resolve(true)
|
||||||
}),
|
: tryDownloadCandidates(swfUrls, swfPath, "swf", (detail) => {
|
||||||
|
swfFailure = detail;
|
||||||
|
}),
|
||||||
]);
|
]);
|
||||||
let iconOk = iconOkResult;
|
let iconOk = iconOkResult;
|
||||||
|
|
||||||
@@ -744,7 +753,7 @@ export async function importSingleFurni(params: {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!swfOk && !nitroDownloadOk)
|
if (!swfOk && !nitroDownloadOk && !params.providedNitro)
|
||||||
warnings.push(`SWF download failed: ${swfFailure || "unknown cause"}`);
|
warnings.push(`SWF download failed: ${swfFailure || "unknown cause"}`);
|
||||||
|
|
||||||
// ── Convert SWF to Nitro ──────────────────────────────────────────
|
// ── Convert SWF to Nitro ──────────────────────────────────────────
|
||||||
@@ -781,6 +790,19 @@ export async function importSingleFurni(params: {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!iconOk && existsSync(nitroPath)) {
|
||||||
|
try {
|
||||||
|
const icon = extractFurniIconPng(await fs.readFile(nitroPath));
|
||||||
|
if (icon) {
|
||||||
|
await fs.writeFile(iconPath, icon);
|
||||||
|
iconOk = true;
|
||||||
|
warnings.push("Icon extracted from Nitro bundle");
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
warnings.push("Could not extract an icon from the Nitro bundle");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ── Rollback: if conversion/download failed AND no .nitro exists, remove DB record ──
|
// ── Rollback: if conversion/download failed AND no .nitro exists, remove DB record ──
|
||||||
// Also validate that the .nitro file is non-trivial (≥ 128 bytes — a bare
|
// Also validate that the .nitro file is non-trivial (≥ 128 bytes — a bare
|
||||||
// Nitro bundle header is larger than that, anything smaller is corrupt/empty).
|
// Nitro bundle header is larger than that, anything smaller is corrupt/empty).
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import { randomUUID } from "node:crypto";
|
||||||
|
import { promises as fs } from "node:fs";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
import { afterEach, expect, it } from "vitest";
|
||||||
|
import type { ImportJob } from "@/lib/furni/import-job";
|
||||||
|
import { ImportJobStore } from "./furni-job-store";
|
||||||
|
|
||||||
|
const roots: string[] = [];
|
||||||
|
afterEach(async () => {
|
||||||
|
for (const root of roots.splice(0))
|
||||||
|
await fs.rm(root, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
const job = (): ImportJob => ({
|
||||||
|
id: randomUUID(),
|
||||||
|
userId: 4,
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
state: "queued",
|
||||||
|
translate: false,
|
||||||
|
items: [],
|
||||||
|
});
|
||||||
|
async function store() {
|
||||||
|
const root = await fs.mkdtemp(path.join(tmpdir(), "furni-jobs-test-"));
|
||||||
|
roots.push(root);
|
||||||
|
return new ImportJobStore(root);
|
||||||
|
}
|
||||||
|
it("persists history across store instances", async () => {
|
||||||
|
const s = await store(),
|
||||||
|
j = job();
|
||||||
|
await s.create(j);
|
||||||
|
expect(await new ImportJobStore(s.root).read(j.id)).toEqual(j);
|
||||||
|
});
|
||||||
|
it("deduplicates concurrent submissions of the same request", async () => {
|
||||||
|
const s = await store(),
|
||||||
|
j = job();
|
||||||
|
await Promise.all([s.create(j), s.create(j)]);
|
||||||
|
expect(await s.list()).toHaveLength(1);
|
||||||
|
});
|
||||||
|
it("does not overwrite a completed job on a repeated request", async () => {
|
||||||
|
const s = await store(),
|
||||||
|
j = job();
|
||||||
|
await s.create(j);
|
||||||
|
await s.save({ ...j, state: "completed" });
|
||||||
|
expect((await s.create(j)).state).toBe("completed");
|
||||||
|
});
|
||||||
|
it("rejects another user attempting to reuse an ID", async () => {
|
||||||
|
const s = await store(),
|
||||||
|
j = job();
|
||||||
|
await s.create(j);
|
||||||
|
await expect(s.create({ ...j, userId: 8 })).rejects.toThrow(
|
||||||
|
"Request ID already used",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
it("rejects unsafe job paths", async () => {
|
||||||
|
const s = await store();
|
||||||
|
await expect(s.create({ ...job(), id: "../escape" })).rejects.toThrow(
|
||||||
|
"Invalid import ID",
|
||||||
|
);
|
||||||
|
await expect(s.read("../escape")).rejects.toThrow("Invalid import ID");
|
||||||
|
});
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import { randomUUID } from "node:crypto";
|
||||||
|
import { promises as fs } from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
import type { ImportJob } from "@/lib/furni/import-job";
|
||||||
|
export const importRoot = () =>
|
||||||
|
path.join(process.cwd(), "storage", "furniture-imports");
|
||||||
|
export const validJobId = (value: unknown): value is string =>
|
||||||
|
typeof value === "string" &&
|
||||||
|
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(
|
||||||
|
value,
|
||||||
|
);
|
||||||
|
export class ImportJobStore {
|
||||||
|
constructor(readonly root = importRoot()) {}
|
||||||
|
async save(job: ImportJob) {
|
||||||
|
await fs.mkdir(this.root, { recursive: true });
|
||||||
|
const target = path.join(this.root, `${job.id}.json`),
|
||||||
|
tmp = `${target}.${randomUUID()}.tmp`;
|
||||||
|
job.updatedAt = new Date().toISOString();
|
||||||
|
await fs.writeFile(tmp, JSON.stringify(job));
|
||||||
|
await fs.rename(tmp, target);
|
||||||
|
}
|
||||||
|
async create(job: ImportJob) {
|
||||||
|
if (!validJobId(job.id)) throw Error("Invalid import ID");
|
||||||
|
await fs.mkdir(this.root, { recursive: true });
|
||||||
|
const tmp = path.join(this.root, `${randomUUID()}.tmp`);
|
||||||
|
await fs.writeFile(tmp, JSON.stringify(job));
|
||||||
|
try {
|
||||||
|
await fs.link(tmp, path.join(this.root, `${job.id}.json`));
|
||||||
|
return job;
|
||||||
|
} catch (error) {
|
||||||
|
if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error;
|
||||||
|
const existing = await this.read(job.id);
|
||||||
|
if (existing.userId !== job.userId)
|
||||||
|
throw Error("Request ID already used");
|
||||||
|
return existing;
|
||||||
|
} finally {
|
||||||
|
await fs.unlink(tmp);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async read(id: string): Promise<ImportJob> {
|
||||||
|
if (!validJobId(id)) throw Error("Invalid import ID");
|
||||||
|
return JSON.parse(
|
||||||
|
await fs.readFile(path.join(this.root, `${id}.json`), "utf8"),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
async list(): Promise<ImportJob[]> {
|
||||||
|
const files = await fs.readdir(this.root).catch((error) => {
|
||||||
|
if (error.code === "ENOENT") return [];
|
||||||
|
throw error;
|
||||||
|
});
|
||||||
|
const jobs = await Promise.all(
|
||||||
|
files
|
||||||
|
.filter((f) => f.endsWith(".json") && validJobId(f.slice(0, -5)))
|
||||||
|
.map((f) => this.read(f.slice(0, -5))),
|
||||||
|
);
|
||||||
|
return jobs.sort((a, b) => a.createdAt.localeCompare(b.createdAt));
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
Reference in new issue
Block a user