feat: add admin job to refetch all user descriptions

This commit is contained in:
Tautvydas Derzinskas committed 2026-10-07 07:52:07 +02:00
1 parent d49d7c9919
commit 0cc27c9eb6
16 files changed
+609 -113

No files matched your search

+26
View File
@@ -24,6 +24,11 @@ import {
} from "../services/importQueueService";
import { toAdminImportJobOut, toImportJobItemOut } from "../dto";
import { authorLinkingSummary, currentAuthorLinkingRun, startAuthorLinking } from "../services/authorLinkingService";
import {
currentDescriptionRefetch,
refetchableCounts,
startDescriptionRefetch,
} from "../services/descriptionRefetchService";
// Mounted at /api/admin (app.ts), so these guards only see admin routes.
const router = Router();
@@ -132,6 +137,27 @@ router.post(
}),
);
router.get(
"/triggers/refetch-descriptions",
asyncHandler(async (_req, res) => {
res.json({ counts: await refetchableCounts(), run: currentDescriptionRefetch() });
}),
);
const refetchSchema = z.object({ user_id: z.string().min(1) });
// Overwrites the user's descriptions, edits included; the UI handles the confirmation.
router.post(
"/triggers/refetch-descriptions",
asyncHandler(async (req, res) => {
const body = parseBody(refetchSchema, req.body);
const user = await prisma.user.findUnique({ where: { id: body.user_id } });
if (!user) throw new HttpError(404, "User not found");
const run = startDescriptionRefetch(req.userId!, user);
if (!run) throw new HttpError(409, "Descriptions are already being refetched");
res.json({ run });
}),
);
// ---- Import queue: every user's batch imports. Start/retry run as the job's owner, with the
// owner's saved MakerWorld cookie. ----
+1 -16
View File
@@ -26,7 +26,7 @@ import { RENDERABLE_MODEL_EXTS } from "../config";
import { estimateDownloadSize, resolvePrintsForDownload, sendPrintsZip } from "../services/downloadZip";
import { systemCollectionKeyForId } from "../services/collectionService";
import { createLog } from "../services/auditLog";
import { fillSourceGaps, refreshDescriptionFromSource } from "../services/fillGapsService";
import { fillSourceGaps } from "../services/fillGapsService";
import { isNormalizable3mf, normalize3mfStatus, normalized3mfFor } from "../services/normalized3mfCache";
import type { Prisma } from "@prisma/client";
@@ -628,21 +628,6 @@ async function fillGaps(req: Request, res: Response): Promise<void> {
}
router.post("/print/:id/fill-gaps", asyncHandler(fillGaps));
/** Replaces the description with the source's current one, formatting and images included. */
router.post(
"/print/:id/refresh-description",
asyncHandler(async (req, res) => {
const sourceUrl = await refreshDescriptionFromSource(req.userId!, req.params.id);
res.json({ print: await printOutById(req.userId!, req.params.id) });
void createLog({
userId: req.userId!,
action: "model_edited",
targetId: req.params.id,
details: { field: "description_refreshed", source_url: sourceUrl },
});
}),
);
// Extension versions up to 1.3.0 call it by its old name.
router.post("/print/:id/reimport", asyncHandler(fillGaps));
+1
View File
@@ -8,6 +8,7 @@ export type LogAction =
| "password_reset"
| "user_invited"
| "authors_linked"
| "descriptions_refetched"
| "model_uploaded"
| "model_imported"
| "import_completed"
+7 -2
View File
@@ -16,7 +16,7 @@ import {
} from "./makerworldCloudApi";
import { resolvePrintablesModel } from "./printablesApi";
import { getThingiverseAccessToken } from "./settingsService";
import { resolveThingiverseThing, ThingiverseAuthError } from "./thingiverseApi";
import { resolveThingiverseThing, ThingiverseAuthError, ThingiverseRateLimitError } from "./thingiverseApi";
// Links models that only know their author by name: first by name matching, then by looking the
// rest up on their site once per distinct creator. Only metadata is fetched.
@@ -25,7 +25,11 @@ const LOOKUP_PROVIDERS = ["makerworld", "thingiverse", "printables"] as const;
type LookupProvider = (typeof LOOKUP_PROVIDERS)[number];
export type LookupProblem =
"thingiverse_no_token" | "thingiverse_token_rejected" | "makerworld_captcha" | "makerworld_login_rejected";
| "thingiverse_no_token"
| "thingiverse_token_rejected"
| "thingiverse_rate_limited"
| "makerworld_captcha"
| "makerworld_login_rejected";
export type AuthorLinkingRun = {
running: boolean;
@@ -132,6 +136,7 @@ export async function runAuthorLinking(run: AuthorLinkingRun): Promise<void> {
if (err instanceof MakerworldCaptchaError) skip("makerworld", "makerworld_captcha");
else if (err instanceof MakerworldAuthError) skip("makerworld", "makerworld_login_rejected");
else if (err instanceof ThingiverseAuthError) skip("thingiverse", "thingiverse_token_rejected");
else if (err instanceof ThingiverseRateLimitError) skip("thingiverse", "thingiverse_rate_limited");
else console.warn(`Couldn't look up the author of model ${candidate.printId}`, err);
}
const saved = author ? await upsertAuthorFromImport(author) : null;
@@ -0,0 +1,139 @@
import { prisma } from "../db";
import { IMPORT_COLLECTION_DELAY_MS } from "../config";
import { maybeSleep } from "../utils/concurrency";
import { createLog } from "./auditLog";
import { localizeDescriptionImages } from "./descriptionImageService";
import { fetchSourceMeta } from "./fillGapsService";
import { buildImportSourceUrl } from "./importService";
import { makerworldCaptchaCooloffActive, MakerworldAuthError, MakerworldCaptchaError } from "./makerworldCloudApi";
import { getThingiverseAccessToken } from "./settingsService";
import { ThingiverseAuthError, ThingiverseRateLimitError } from "./thingiverseApi";
import type { LookupProblem } from "./authorLinkingService";
// Re-reads every imported model's description for one user from its source, replacing the stored
// one, e.g. to bring models imported as plain text up to formatted descriptions with images.
const SOURCE_PROVIDERS = ["makerworld", "thingiverse", "printables"] as const;
type SourceProvider = (typeof SOURCE_PROVIDERS)[number];
export type DescriptionRefetchRun = {
running: boolean;
userId: string;
startedAt: string;
finishedAt: string | null;
total: number;
done: number;
updated: number;
/** The source couldn't be read, or has no description; the stored one is kept. */
failed: number;
problems: LookupProblem[];
};
/** Per user, how many models have a source to refetch from. */
export async function refetchableCounts(): Promise<Record<string, number>> {
const rows = await prisma.print.groupBy({
by: ["userId"],
where: { sourceProvider: { in: [...SOURCE_PROVIDERS] }, sourceExternalId: { not: null } },
_count: { userId: true },
});
return Object.fromEntries(rows.map((row) => [row.userId, row._count.userId]));
}
let current: DescriptionRefetchRun | null = null;
export function currentDescriptionRefetch(): DescriptionRefetchRun | null {
return current;
}
/** Null if a run is already in progress. */
export function startDescriptionRefetch(
adminUserId: string,
user: { id: string; email: string },
): DescriptionRefetchRun | null {
if (current?.running) return null;
current = {
running: true,
userId: user.id,
startedAt: new Date().toISOString(),
finishedAt: null,
total: 0,
done: 0,
updated: 0,
failed: 0,
problems: [],
};
const run = current;
void runDescriptionRefetch(run)
.catch((err) => console.error("Refetch descriptions failed", err))
.finally(() => {
run.running = false;
run.finishedAt = new Date().toISOString();
void createLog({
userId: adminUserId,
action: "descriptions_refetched",
targetId: user.id,
// The log row's user is the admin who ran it; this names whose library was changed.
details: {
email: user.email,
total: run.total,
updated: run.updated,
failed: run.failed,
problems: run.problems,
},
});
});
return run;
}
export async function runDescriptionRefetch(run: DescriptionRefetchRun): Promise<void> {
const prints = await prisma.print.findMany({
where: { userId: run.userId, sourceProvider: { in: [...SOURCE_PROVIDERS] }, sourceExternalId: { not: null } },
orderBy: { createdAt: "asc" },
});
run.total = prints.length;
const skipped = new Set<SourceProvider>();
const skip = (provider: SourceProvider, problem: LookupProblem) => {
skipped.add(provider);
if (!run.problems.includes(problem)) run.problems.push(problem);
};
// Only warned about for sites this user's models actually come from.
const providers = new Set(prints.map((print) => print.sourceProvider));
if (providers.has("thingiverse") && !(await getThingiverseAccessToken())) skip("thingiverse", "thingiverse_no_token");
if (providers.has("makerworld") && makerworldCaptchaCooloffActive()) skip("makerworld", "makerworld_captcha");
for (const print of prints) {
const provider = print.sourceProvider as SourceProvider;
const sourceUrl = buildImportSourceUrl(print.sourceProvider, print.sourceExternalId);
if (skipped.has(provider) || !sourceUrl) {
run.failed++;
run.done++;
continue;
}
try {
// MakerWorld calls are paced inside fetchSourceMeta.
if (provider !== "makerworld") await maybeSleep(IMPORT_COLLECTION_DELAY_MS);
const meta = await fetchSourceMeta(run.userId, print, sourceUrl);
if (meta.description) {
// Re-read, so an edit the owner saved while the run was going counts against what's replaced.
const before = await prisma.print.findUnique({ where: { id: print.id }, select: { notes: true } });
await prisma.print.update({ where: { id: print.id }, data: { notes: meta.description } });
// Images already stored for this model are matched by URL, so they aren't downloaded again.
await localizeDescriptionImages(print.id);
const after = await prisma.print.findUnique({ where: { id: print.id }, select: { notes: true } });
if (after?.notes !== before?.notes) run.updated++;
} else {
run.failed++;
}
} catch (err) {
run.failed++;
if (err instanceof MakerworldCaptchaError) skip("makerworld", "makerworld_captcha");
else if (err instanceof MakerworldAuthError) skip("makerworld", "makerworld_login_rejected");
else if (err instanceof ThingiverseAuthError) skip("thingiverse", "thingiverse_token_rejected");
// Cloudflare's 429 is sticky, so every further Thingiverse request would fail the same way.
else if (err instanceof ThingiverseRateLimitError) skip("thingiverse", "thingiverse_rate_limited");
else console.warn(`Couldn't refetch the description of model ${print.id}`, err);
}
run.done++;
}
}
+12 -24
View File
@@ -30,8 +30,9 @@ export type FillGapsResult = {
remaining: SourceGap[];
};
/** Reads the source's metadata. Nothing is downloaded but the images that fill an empty slot. */
async function fetchSourceMeta(userId: string, print: Print, sourceUrl: string): Promise<ImportedPageMetadata> {
/** Reads the source's metadata. Nothing is downloaded but the images that fill an empty slot.
* Thingiverse's own errors are passed through, so callers can tell a rejected token apart. */
export async function fetchSourceMeta(userId: string, print: Print, sourceUrl: string): Promise<ImportedPageMetadata> {
const provider = print.sourceProvider!;
const externalId = print.sourceExternalId!;
@@ -56,14 +57,7 @@ async function fetchSourceMeta(userId: string, print: Print, sourceUrl: string):
"Thingiverse import isn't configured for this instance yet -- ask an admin to add an Access Token in Admin Settings.",
);
}
let resolved;
try {
resolved = await resolveThingiverseThing(externalId, accessToken);
} catch (err) {
if (err instanceof ThingiverseRateLimitError) throw new HttpError(429, err.message);
if (err instanceof ThingiverseAuthError) throw new HttpError(400, err.message);
throw err;
}
const resolved = await resolveThingiverseThing(externalId, accessToken);
if (!resolved) {
throw new HttpError(
404,
@@ -131,7 +125,14 @@ export async function fillSourceGaps(userId: string, printId: string): Promise<F
// Retries the ones the source lacked last time too: it costs nothing more than the one fetch.
const gaps = emptyGaps(print, print.previewImages);
const meta = await fetchSourceMeta(userId, print, sourceUrl);
let meta: ImportedPageMetadata;
try {
meta = await fetchSourceMeta(userId, print, sourceUrl);
} catch (err) {
if (err instanceof ThingiverseRateLimitError) throw new HttpError(429, err.message);
if (err instanceof ThingiverseAuthError) throw new HttpError(400, err.message);
throw err;
}
const wanted = new Set(gaps);
await fillEmptyMetadata(userId, print, wanted, meta);
if (wanted.has("images")) {
@@ -149,16 +150,3 @@ export async function fillSourceGaps(userId: string, printId: string): Promise<F
await prisma.print.update({ where: { id: print.id }, data: { unfillableGaps: remaining } });
return { source_url: sourceUrl, filled: gaps.filter((gap) => !remaining.includes(gap)), remaining };
}
/** Overwrites the description with the source's, unlike fillSourceGaps. Returns the source URL. */
export async function refreshDescriptionFromSource(userId: string, printId: string): Promise<string> {
const print = await prisma.print.findFirst({ where: { id: printId, userId } });
if (!print) throw new HttpError(404, "Model not found");
const sourceUrl = buildImportSourceUrl(print.sourceProvider, print.sourceExternalId);
if (!sourceUrl) throw new HttpError(400, "This model has no recorded source to refresh its description from");
const meta = await fetchSourceMeta(userId, print, sourceUrl);
if (!meta.description) throw new HttpError(404, "The source has no description for this model");
await prisma.print.update({ where: { id: print.id }, data: { notes: meta.description } });
await localizeDescriptionImages(print.id);
return sourceUrl;
}
+7 -6
View File
@@ -138,14 +138,15 @@ describe("description images", () => {
expect(fs.existsSync(descriptionImagePath(image.id))).toBe(false);
});
it("refreshes the description from the source, images included", async () => {
it("fills an emptied description from the source, formatting and images included", async () => {
await request(app).post(`/api/print/${printId}/meta`).set("Authorization", `Bearer ${token}`).send({ notes: "" });
global.fetch = mockFetch(`<p>Updated <strong>steps</strong></p><img src="${STEP_IMAGE}">`);
const res = await request(app)
.post(`/api/print/${printId}/refresh-description`)
.set("Authorization", `Bearer ${token}`);
const res = await request(app).post(`/api/print/${printId}/fill-gaps`).set("Authorization", `Bearer ${token}`);
expect(res.status).toBe(200);
expect(res.body.filled).toContain("description");
const image = await prisma.descriptionImage.findFirstOrThrow({ where: { printId } });
expect(res.body.print.notes).toBe(`Updated **steps**\n\n![](/description-image/${image.id})`);
const print = await prisma.print.findUniqueOrThrow({ where: { id: printId } });
expect(print.notes).toBe(`Updated **steps**\n\n![](/description-image/${image.id})`);
});
it("drops all stored images when the description is cleared", async () => {
@@ -160,7 +161,7 @@ describe("description images", () => {
// Back again for the next test.
global.fetch = mockFetch(`<img src="${STEP_IMAGE}">`);
await request(app).post(`/api/print/${printId}/refresh-description`).set("Authorization", `Bearer ${token}`);
await request(app).post(`/api/print/${printId}/fill-gaps`).set("Authorization", `Bearer ${token}`);
});
it("removes the stored images with the model", async () => {
+172
View File
@@ -0,0 +1,172 @@
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
import request from "supertest";
import { createApp } from "../src/app";
import { prisma } from "../src/db";
import { setThingiverseAccessToken } from "../src/services/settingsService";
// Administration > Triggers: refetching every imported model's description for one user.
const STEP_IMAGE = "https://media.printables.com/media/prints/refetch-step.gif";
const ONE_PIXEL_GIF_BASE64 = "R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7";
function json(body: unknown): Response {
return new Response(JSON.stringify(body), { status: 200, headers: { "content-type": "application/json" } });
}
function mockPrintables(descriptions: Record<string, string>) {
return vi.fn<(input: RequestInfo | URL, init?: RequestInit) => Promise<Response>>(async (input, init) => {
const url = String(input);
if (url === "https://api.printables.com/graphql/") {
const { variables } = JSON.parse(String(init?.body ?? "{}")) as { variables?: { id?: string } };
const id = variables?.id ?? "";
if (!(id in descriptions)) return json({ data: { print: null } });
return json({
data: { print: { id, name: `Model ${id}`, description: descriptions[id], tags: [], images: [] } },
});
}
if (url === STEP_IMAGE) return new Response(Buffer.from(ONE_PIXEL_GIF_BASE64, "base64"), { status: 200 });
throw new Error(`Unexpected fetch to ${url}`);
}) as unknown as typeof fetch;
}
describe("refetching a user's descriptions", () => {
const app = createApp();
const originalFetch = global.fetch;
let adminToken: string;
let memberToken: string;
let memberId: string;
let memberEmail: string;
beforeAll(async () => {
const stamp = Date.now();
const admin = await request(app)
.post("/api/register")
.send({ displayName: "Refetch Admin", email: `refetch-admin-${stamp}@example.com`, password: "password123" });
await prisma.user.update({ where: { id: admin.body.user.id }, data: { role: "ADMIN" } });
// Tokens carry the role from when they were issued.
const login = await request(app)
.post("/api/login")
.send({ email: `refetch-admin-${stamp}@example.com`, password: "password123" });
adminToken = login.body.token;
const member = await request(app)
.post("/api/register")
.send({ displayName: "Refetch Member", email: `refetch-member-${stamp}@example.com`, password: "password123" });
memberToken = member.body.token;
memberId = member.body.user.id;
memberEmail = member.body.user.email;
});
afterEach(async () => {
global.fetch = originalFetch;
vi.restoreAllMocks();
await setThingiverseAccessToken(null);
});
async function createPrint(name: string, notes: string, externalId: string | null, provider = "printables") {
return prisma.print.create({
data: {
userId: memberId,
name,
nameNormalized: name.toLowerCase(),
notes,
sourceProvider: externalId ? provider : null,
sourceExternalId: externalId,
},
});
}
async function waitForRun() {
for (;;) {
const res = await request(app)
.get("/api/admin/triggers/refetch-descriptions")
.set("Authorization", `Bearer ${adminToken}`);
if (!res.body.run?.running) return res.body;
await new Promise((resolve) => setTimeout(resolve, 100));
}
}
it("is admin-only", async () => {
const res = await request(app)
.post("/api/admin/triggers/refetch-descriptions")
.set("Authorization", `Bearer ${memberToken}`)
.send({ user_id: memberId });
expect(res.status).toBe(403);
});
it("replaces imported descriptions with the source's, formatting and images included", async () => {
const imported = await createPrint("Imported", "Old plain text", "9100001");
const gone = await createPrint("Gone from source", "Kept as it is", "9100002");
const uploaded = await createPrint("Uploaded", "Not from a source", null);
global.fetch = mockPrintables({ "9100001": `<p>Now <strong>formatted</strong></p><img src="${STEP_IMAGE}">` });
const counts = await request(app)
.get("/api/admin/triggers/refetch-descriptions")
.set("Authorization", `Bearer ${adminToken}`);
expect(counts.body.counts[memberId]).toBe(2);
const start = await request(app)
.post("/api/admin/triggers/refetch-descriptions")
.set("Authorization", `Bearer ${adminToken}`)
.send({ user_id: memberId });
expect(start.status).toBe(200);
const { run } = await waitForRun();
expect(run).toMatchObject({ userId: memberId, total: 2, done: 2, updated: 1, failed: 1, problems: [] });
const image = await prisma.descriptionImage.findFirstOrThrow({ where: { printId: imported.id } });
const after = await prisma.print.findMany({ where: { id: { in: [imported.id, gone.id, uploaded.id] } } });
const notesOf = (id: string) => after.find((p) => p.id === id)?.notes;
expect(notesOf(imported.id)).toBe(`Now **formatted**\n\n![](/description-image/${image.id})`);
expect(notesOf(gone.id)).toBe("Kept as it is");
expect(notesOf(uploaded.id)).toBe("Not from a source");
});
it("logs whose descriptions were refetched, not just the admin who ran it", async () => {
// The entry is written fire-and-forget as the run ends, so it may land a moment later.
const findLog = () => prisma.log.findFirst({ where: { action: "descriptions_refetched", targetId: memberId } });
let log = await findLog();
for (let attempt = 0; !log && attempt < 40; attempt++) {
await new Promise((resolve) => setTimeout(resolve, 50));
log = await findLog();
}
expect(log?.details).toMatchObject({ email: memberEmail, total: 2, updated: 1, failed: 1 });
});
it("doesn't download the images again on a second run", async () => {
const fetchMock = mockPrintables({ "9100001": `<p>Now <strong>formatted</strong></p><img src="${STEP_IMAGE}">` });
global.fetch = fetchMock;
await request(app)
.post("/api/admin/triggers/refetch-descriptions")
.set("Authorization", `Bearer ${adminToken}`)
.send({ user_id: memberId });
const { run } = await waitForRun();
// Same description as last time, so nothing changed.
expect(run.updated).toBe(0);
expect(fetchMock.mock.calls.map(([input]) => String(input))).not.toContain(STEP_IMAGE);
});
it("stops asking Thingiverse once it rate-limits, and says so", async () => {
await setThingiverseAccessToken("test-token");
await createPrint("Thing one", "One", "9200001", "thingiverse");
await createPrint("Thing two", "Two", "9200002", "thingiverse");
const fetchMock = vi.fn<(input: RequestInfo | URL, init?: RequestInit) => Promise<Response>>(
async (input, init) => {
if (String(input).startsWith("https://api.thingiverse.com/")) return new Response("", { status: 429 });
return mockPrintables({ "9100001": "<p>Now <strong>formatted</strong></p>" })(input, init);
},
);
global.fetch = fetchMock as unknown as typeof fetch;
await request(app)
.post("/api/admin/triggers/refetch-descriptions")
.set("Authorization", `Bearer ${adminToken}`)
.send({ user_id: memberId });
const { run } = await waitForRun();
expect(run.problems).toEqual(["thingiverse_rate_limited"]);
const thingiverseCalls = fetchMock.mock.calls.filter(([input]) =>
String(input).startsWith("https://api.thingiverse.com/"),
);
expect(thingiverseCalls).toHaveLength(1);
expect(run.failed).toBe(3); // both Things, plus the Printables model the source no longer has
});
});
+39 -1
View File
@@ -20,6 +20,7 @@ export type LogAction =
| "password_reset"
| "user_invited"
| "authors_linked"
| "descriptions_refetched"
| "model_uploaded"
| "model_imported"
| "import_completed"
@@ -49,7 +50,11 @@ export type StorageUsage = {
};
export type AuthorLookupProblem =
"thingiverse_no_token" | "thingiverse_token_rejected" | "makerworld_captcha" | "makerworld_login_rejected";
| "thingiverse_no_token"
| "thingiverse_token_rejected"
| "thingiverse_rate_limited"
| "makerworld_captcha"
| "makerworld_login_rejected";
export type AuthorLinkingRun = {
running: boolean;
@@ -62,6 +67,21 @@ export type AuthorLinkingRun = {
problems: AuthorLookupProblem[];
};
export type DescriptionRefetchRun = {
running: boolean;
userId: string;
startedAt: string;
finishedAt: string | null;
total: number;
done: number;
updated: number;
failed: number;
problems: AuthorLookupProblem[];
};
/** `counts`: per user id, how many models have a source to refetch from. */
export type DescriptionRefetchStatus = { counts: Record<string, number>; run: DescriptionRefetchRun | null };
export type AuthorLinkingStatus = { linkable: number; lookup: number; run: AuthorLinkingRun | null };
/** Any user's batch import, as the admin import queue lists it. */
@@ -126,6 +146,24 @@ export const adminApi = {
return res.json();
},
getDescriptionRefetch: async (): Promise<DescriptionRefetchStatus> => {
const res = await fetch(`${apiBase()}/admin/triggers/refetch-descriptions`, { headers: authHeaders() });
if (res.status === 401) throw new UnauthorizedError();
if (!res.ok) throw new Error(await readErrorMessage(res, "Failed to load description refetching"));
return res.json();
},
startDescriptionRefetch: async (userId: string): Promise<{ run: DescriptionRefetchRun }> => {
const res = await fetch(`${apiBase()}/admin/triggers/refetch-descriptions`, {
method: "POST",
headers: authHeaders({ "Content-Type": "application/json" }),
body: JSON.stringify({ user_id: userId }),
});
if (res.status === 401) throw new UnauthorizedError();
if (!res.ok) throw new Error(await readErrorMessage(res, "Failed to start refetching descriptions"));
return res.json();
},
deleteAllPrintsForUser: async (userId: string): Promise<{ deleted: number }> => {
const res = await fetch(`${apiBase()}/admin/users/${userId}/delete-all-prints`, {
method: "POST",
-13
View File
@@ -378,19 +378,6 @@ export const printsApi = {
return res.json();
},
/** Overwrites the description with the source's current one, formatting and images included. */
refreshDescription: async (id: string): Promise<Print> => {
const res = await fetch(`${apiBase()}/print/${id}/refresh-description`, {
method: "POST",
headers: authHeaders(),
});
if (res.status === 401) throw new UnauthorizedError();
if (!res.ok) {
throw new Error(await readErrorMessage(res, "Refreshing the description failed"));
}
return (await res.json()).print;
},
delete: async (id: string) => {
const res = await fetch(`${apiBase()}/print/${id}`, { method: "DELETE", headers: authHeaders() });
assertOk(res, "Delete print failed");
+16 -8
View File
@@ -443,9 +443,20 @@
"linkAuthorsProblems": {
"thingiverse_no_token": "Thingiverse models were skipped: add a Thingiverse access token in Administration > Settings.",
"thingiverse_token_rejected": "Thingiverse models were skipped: Thingiverse rejected the access token in Administration > Settings.",
"thingiverse_rate_limited": "Thingiverse models were skipped: Thingiverse started blocking requests with a rate limit. It usually clears after a while — try again later.",
"makerworld_captcha": "MakerWorld models were skipped: MakerWorld asked for a CAPTCHA, and blocks further requests for about 2 hours. Try again later.",
"makerworld_login_rejected": "MakerWorld models were skipped: MakerWorld rejected the saved MakerWorld login. Update it in Settings > Imports."
}
},
"refetchTitle": "Refetch model descriptions",
"refetchDesc": "Replaces the description of every model a user imported from MakerWorld, Printables or Thingiverse with the current one from that site, with its formatting and images. Use it to bring models imported before formatted descriptions were supported up to date. Changes the user made to those descriptions are lost. Models whose source is gone keep their description. It runs in the background — MakerWorld requests are spaced out to avoid its CAPTCHA, so a large library can take a while.",
"refetchUserOption": "{{name}} ({{email}}) — {{count}} imported models",
"refetchButton": "Refetch descriptions…",
"refetchConfirm": "Replace the descriptions of {{count}} imported model(s) belonging to {{email}} with the ones from their source sites? Any changes made to those descriptions will be lost.",
"refetchConfirmButton": "Refetch",
"refetchFailed": "Failed to start refetching descriptions.",
"refetchProgress": "Refetching descriptions for {{email}}: {{done}} of {{total}} — {{updated}} updated so far.",
"refetchDone": "Refetched descriptions for {{email}}: {{updated}} of {{total}} changed.",
"refetchFailedCount": "{{count}} couldn't be read from their source and were left as they were."
},
"thingiverse": {
"heading": "Thingiverse Access Token",
@@ -610,9 +621,11 @@
"collection_deleted": "Collection deleted",
"collection_item_added": "Added to collection",
"collection_item_removed": "Removed from collection",
"authors_linked": "Linked missing authors"
"authors_linked": "Linked missing authors",
"descriptions_refetched": "Refetched model descriptions"
},
"authorsLinked": "{{count}} model(s) linked"
"authorsLinked": "{{count}} model(s) linked",
"descriptionsRefetched": "{{email}} — {{updated}} of {{total}} description(s) changed"
},
"rendering": {
"heading": "Rendering",
@@ -1400,11 +1413,6 @@
"fillGapsRemaining": "The source has nothing for: {{remaining}}.",
"fillGapsNothing": "The source has nothing to fetch for these.",
"fillGapsFailed": "Couldn't fetch the missing details. Please try again.",
"refreshDescription": "Refresh description from {{provider}}",
"confirmRefreshDescription": "Replace this model's description with the current one from {{provider}}? Any changes you made to the description will be lost.",
"refreshDescriptionConfirm": "Refresh",
"refreshDescriptionDone": "Description refreshed",
"refreshDescriptionFailed": "Couldn't refresh the description. Please try again.",
"gap": {
"title": "title",
"description": "description",
+16 -8
View File
@@ -445,9 +445,20 @@
"linkAuthorsProblems": {
"thingiverse_no_token": "Thingiverse modeliai praleisti: pridėkite Thingiverse prieigos raktą skiltyje Administravimas > Nustatymai.",
"thingiverse_token_rejected": "Thingiverse modeliai praleisti: Thingiverse atmetė prieigos raktą, nurodytą skiltyje Administravimas > Nustatymai.",
"thingiverse_rate_limited": "Thingiverse modeliai praleisti: Thingiverse pradėjo riboti užklausas. Paprastai tai praeina po kurio laiko — bandykite vėliau.",
"makerworld_captcha": "MakerWorld modeliai praleisti: MakerWorld paprašė CAPTCHA ir maždaug 2 valandoms blokuoja tolesnes užklausas. Bandykite vėliau.",
"makerworld_login_rejected": "MakerWorld modeliai praleisti: MakerWorld atmetė išsaugotą MakerWorld prisijungimą. Atnaujinkite jį skiltyje Nustatymai > Importavimas."
}
},
"refetchTitle": "Iš naujo gauti modelių aprašymus",
"refetchDesc": "Pakeičia kiekvieno naudotojo iš MakerWorld, Printables ar Thingiverse importuoto modelio aprašymą dabartiniu aprašymu iš tos svetainės, su formatavimu ir paveikslėliais. Naudokite tai modeliams, importuotiems prieš atsirandant formatuotų aprašymų palaikymui, atnaujinti. Naudotojo atlikti tų aprašymų pakeitimai bus prarasti. Modeliai, kurių šaltinio nebėra, išlaiko savo aprašymą. Vykdoma fone — MakerWorld užklausos atliekamos su pertraukomis, kad būtų išvengta CAPTCHA, todėl didelė biblioteka gali užtrukti.",
"refetchUserOption": "{{name}} ({{email}}) — importuotų modelių: {{count}}",
"refetchButton": "Gauti aprašymus iš naujo…",
"refetchConfirm": "Pakeisti {{email}} priklausančių importuotų modelių ({{count}}) aprašymus aprašymais iš jų šaltinių? Visi tų aprašymų pakeitimai bus prarasti.",
"refetchConfirmButton": "Gauti iš naujo",
"refetchFailed": "Nepavyko pradėti gauti aprašymų iš naujo.",
"refetchProgress": "Gaunami {{email}} aprašymai: {{done}} iš {{total}} — atnaujinta: {{updated}}.",
"refetchDone": "{{email}} aprašymai gauti iš naujo: pakeista {{updated}} iš {{total}}.",
"refetchFailedCount": "Nepavyko nuskaityti iš šaltinio: {{count}}; jie palikti nepakeisti."
},
"thingiverse": {
"heading": "Thingiverse prieigos raktas",
@@ -612,9 +623,11 @@
"collection_deleted": "Kolekcija ištrinta",
"collection_item_added": "Pridėta į kolekciją",
"collection_item_removed": "Pašalinta iš kolekcijos",
"authors_linked": "Susieti trūkstami autoriai"
"authors_linked": "Susieti trūkstami autoriai",
"descriptions_refetched": "Iš naujo gauti modelių aprašymai"
},
"authorsLinked": "Susieta modelių: {{count}}"
"authorsLinked": "Susieta modelių: {{count}}",
"descriptionsRefetched": "{{email}} — pakeista aprašymų: {{updated}} iš {{total}}"
},
"rendering": {
"heading": "Atvaizdavimas",
@@ -1403,11 +1416,6 @@
"fillGapsRemaining": "Šaltinyje nėra: {{remaining}}.",
"fillGapsNothing": "Šaltinyje šios informacijos nėra.",
"fillGapsFailed": "Nepavyko gauti trūkstamos informacijos. Bandykite dar kartą.",
"refreshDescription": "Atnaujinti aprašymą iš {{provider}}",
"confirmRefreshDescription": "Pakeisti šio modelio aprašymą dabartiniu aprašymu iš {{provider}}? Jūsų atlikti aprašymo pakeitimai bus prarasti.",
"refreshDescriptionConfirm": "Atnaujinti",
"refreshDescriptionDone": "Aprašymas atnaujintas",
"refreshDescriptionFailed": "Nepavyko atnaujinti aprašymo. Bandykite dar kartą.",
"gap": {
"title": "pavadinimas",
"description": "aprašymas",
+7
View File
@@ -34,6 +34,7 @@ const ACTION_COLORS: Record<LogAction, ActionColor> = {
password_reset: "warning",
user_invited: "info",
authors_linked: "success",
descriptions_refetched: "warning",
model_uploaded: "info",
model_imported: "info",
import_completed: "info",
@@ -111,6 +112,12 @@ export default function LogsPage({ onUnauthorized }: Props) {
return typeof d.name === "string" ? d.name : "";
case "user_invited":
return typeof d.email === "string" ? d.email : "";
case "descriptions_refetched":
return t("adminSettings.logs.descriptionsRefetched", {
email: typeof d.email === "string" ? d.email : "",
updated: typeof d.updated === "number" ? d.updated : 0,
total: typeof d.total === "number" ? d.total : 0,
});
case "authors_linked":
return t("adminSettings.logs.authorsLinked", { count: typeof d.linked === "number" ? d.linked : 0 });
case "model_edited":
@@ -17,7 +17,6 @@ import PlaylistAddIcon from "@mui/icons-material/PlaylistAdd";
import PlaylistRemoveIcon from "@mui/icons-material/PlaylistRemove";
import OpenInNewIcon from "@mui/icons-material/OpenInNew";
import AutoFixHighIcon from "@mui/icons-material/AutoFixHigh";
import NotesIcon from "@mui/icons-material/Notes";
import LaunchIcon from "@mui/icons-material/Launch";
import type { SxProps, Theme } from "@mui/material/styles";
import { UnauthorizedError } from "../../api/client";
@@ -25,7 +24,6 @@ import { type Print, printsApi } from "../../api/prints";
import type { AuthUser } from "../../api/auth";
import { collectionsApi } from "../../api/collections";
import { useConfirm } from "../../components/ConfirmProvider";
import { useToast } from "../../components/ToastProvider";
import { importProviderInfo } from "../../constants/importProviders";
import { useDownloadPrint } from "./useDownloadPrint";
import { useOpenInSlicer } from "./useOpenInSlicer";
@@ -74,8 +72,6 @@ export default function ModelActionsMenu({
const [removingFromCollection, setRemovingFromCollection] = useState(false);
const [addToCollectionOpen, setAddToCollectionOpen] = useState(false);
const [fillGapsOpen, setFillGapsOpen] = useState(false);
const [refreshingDescription, setRefreshingDescription] = useState(false);
const showToast = useToast();
const sourceGaps = print.source_gaps ?? [];
// Driven by ?edit=<id> so links and the back button open/close it.
const editOpen = searchParams.get("edit") === print.id;
@@ -132,28 +128,6 @@ export default function ModelActionsMenu({
}
};
const handleRefreshDescription = async (providerLabel: string) => {
closeMenu();
const confirmed = await confirmDialog({
message: t("models:detail.confirmRefreshDescription", { provider: providerLabel }),
confirmLabel: t("models:detail.refreshDescriptionConfirm"),
});
if (!confirmed) return;
setRefreshingDescription(true);
try {
onUpdated?.(await printsApi.refreshDescription(print.id));
showToast({ message: t("models:detail.refreshDescriptionDone") });
} catch (err) {
if (err instanceof UnauthorizedError) {
onUnauthorized?.();
return;
}
showToast({ message: err instanceof Error ? err.message : t("models:detail.refreshDescriptionFailed") });
} finally {
setRefreshingDescription(false);
}
};
const handleDelete = async () => {
closeMenu();
const confirmed = await confirmDialog({
@@ -305,15 +279,6 @@ export default function ModelActionsMenu({
<ListItemText>{t("models:detail.openInProvider", { provider: providerInfo.label })}</ListItemText>
</MenuItem>
)}
{providerInfo && print.source_url && <Divider />}
{providerInfo && print.source_url && (
<MenuItem disabled={refreshingDescription} onClick={() => void handleRefreshDescription(providerInfo.label)}>
<ListItemIcon>
<NotesIcon fontSize="small" />
</ListItemIcon>
<ListItemText>{t("models:detail.refreshDescription", { provider: providerInfo.label })}</ListItemText>
</MenuItem>
)}
{providerInfo && print.source_url && sourceGaps.length > 0 && (
<MenuItem
onClick={() => {
@@ -0,0 +1,164 @@
import React from "react";
import { useTranslation } from "react-i18next";
import Alert from "@mui/material/Alert";
import Box from "@mui/material/Box";
import Button from "@mui/material/Button";
import FormControl from "@mui/material/FormControl";
import InputLabel from "@mui/material/InputLabel";
import LinearProgress from "@mui/material/LinearProgress";
import MenuItem from "@mui/material/MenuItem";
import Paper from "@mui/material/Paper";
import Select from "@mui/material/Select";
import Stack from "@mui/material/Stack";
import Typography from "@mui/material/Typography";
import { UnauthorizedError } from "../../api/client";
import { adminApi, type AdminUser, type DescriptionRefetchStatus } from "../../api/admin";
import { useConfirm } from "../../components/ConfirmProvider";
type Props = {
users: AdminUser[];
onUnauthorized?: () => void;
};
const POLL_MS = 2000;
/** Replaces every imported model's description for one user with the one on its source, e.g. to
* bring models imported as plain text up to formatted descriptions with images. */
export default function RefetchDescriptionsSection({ users, onUnauthorized }: Props) {
const { t } = useTranslation("app");
const confirmDialog = useConfirm();
const [status, setStatus] = React.useState<DescriptionRefetchStatus | null>(null);
const [selectedUserId, setSelectedUserId] = React.useState("");
const [starting, setStarting] = React.useState(false);
const [error, setError] = React.useState<string | null>(null);
const run = status?.run ?? null;
const running = Boolean(run?.running);
const refresh = React.useCallback(async () => {
try {
setStatus(await adminApi.getDescriptionRefetch());
} catch (err) {
if (err instanceof UnauthorizedError) onUnauthorized?.();
}
}, [onUnauthorized]);
React.useEffect(() => {
void refresh();
}, [refresh]);
React.useEffect(() => {
if (!running) return;
const timer = setInterval(() => void refresh(), POLL_MS);
return () => clearInterval(timer);
}, [running, refresh]);
const countOf = (userId: string) => status?.counts[userId] ?? 0;
const selectedUser = users.find((u) => u.id === selectedUserId) ?? null;
const runUser = run ? users.find((u) => u.id === run.userId) : null;
const start = async () => {
if (!selectedUser) return;
const confirmed = await confirmDialog({
message: t("adminSettings.triggers.refetchConfirm", {
count: countOf(selectedUser.id),
email: selectedUser.email,
}),
confirmLabel: t("adminSettings.triggers.refetchConfirmButton"),
});
if (!confirmed) return;
setStarting(true);
setError(null);
try {
const started = await adminApi.startDescriptionRefetch(selectedUser.id);
setStatus((prev) => (prev ? { ...prev, run: started.run } : prev));
} catch (err) {
if (err instanceof UnauthorizedError) onUnauthorized?.();
else setError(err instanceof Error ? err.message : t("adminSettings.triggers.refetchFailed"));
} finally {
setStarting(false);
}
};
return (
<Paper variant="outlined" sx={{ p: 2.5 }}>
<Stack spacing={2}>
<Box>
<Typography variant="subtitle1" fontWeight={600}>
{t("adminSettings.triggers.refetchTitle")}
</Typography>
<Typography variant="body2" color="text.secondary">
{t("adminSettings.triggers.refetchDesc")}
</Typography>
</Box>
{running && run ? (
<Stack spacing={1}>
<Typography variant="body2">
{t("adminSettings.triggers.refetchProgress", {
email: runUser?.email ?? "",
done: run.done,
total: run.total,
updated: run.updated,
})}
</Typography>
<LinearProgress
variant={run.total ? "determinate" : "indeterminate"}
value={run.total ? (run.done / run.total) * 100 : undefined}
/>
</Stack>
) : (
<Stack direction="row" spacing={1.5} alignItems="center" flexWrap="wrap" useFlexGap>
<FormControl size="small" sx={{ minWidth: 300, maxWidth: "100%" }}>
<InputLabel id="refetch-user-select-label">{t("adminSettings.triggers.userLabel")}</InputLabel>
<Select
labelId="refetch-user-select-label"
label={t("adminSettings.triggers.userLabel")}
value={selectedUserId}
onChange={(e) => setSelectedUserId(e.target.value)}
disabled={starting}
>
{users.map((u) => (
<MenuItem key={u.id} value={u.id} disabled={!countOf(u.id)}>
{t("adminSettings.triggers.refetchUserOption", {
name: u.display_name,
email: u.email,
count: countOf(u.id),
})}
</MenuItem>
))}
</Select>
</FormControl>
<Button
variant="contained"
onClick={() => void start()}
disabled={!selectedUser || !countOf(selectedUser.id) || starting}
>
{t("adminSettings.triggers.refetchButton")}
</Button>
</Stack>
)}
{run && !run.running && (
<Alert severity={run.updated > 0 ? "success" : "info"}>
{t("adminSettings.triggers.refetchDone", {
email: runUser?.email ?? "",
updated: run.updated,
total: run.total,
})}
{run.failed > 0 && ` ${t("adminSettings.triggers.refetchFailedCount", { count: run.failed })}`}
</Alert>
)}
{run?.problems.map((problem) => (
<Alert key={problem} severity="warning">
{t(`adminSettings.triggers.linkAuthorsProblems.${problem}`)}
</Alert>
))}
{error && (
<Alert severity="error" onClose={() => setError(null)}>
{error}
</Alert>
)}
</Stack>
</Paper>
);
}
@@ -20,6 +20,7 @@ import TextField from "@mui/material/TextField";
import { UnauthorizedError } from "../../api/client";
import { adminApi, type AdminUser } from "../../api/admin";
import LinkAuthorsSection from "./LinkAuthorsSection";
import RefetchDescriptionsSection from "./RefetchDescriptionsSection";
type Props = {
onUnauthorized?: () => void;
@@ -93,6 +94,7 @@ export default function TriggersPage({ onUnauthorized }: Props) {
return (
<Stack spacing={3}>
<LinkAuthorsSection onUnauthorized={onUnauthorized} />
<RefetchDescriptionsSection users={users} onUnauthorized={onUnauthorized} />
<Paper variant="outlined" sx={{ p: 2.5 }}>
<Stack spacing={2}>
<Box>