mirror of
https://github.com/TautvydasDerzinskas/Thingport.git
synced 2026-10-19 05:10:01 -05:00
feat: add author linking admin trigger
This commit is contained in:
1 parent
60eb0b90fe
commit
2a3434f126
13 files changed
+751
-12
No files matched your search
@@ -8,6 +8,7 @@ import { parseBody } from "../utils/validate";
|
||||
import { deleteAllPrintsForUser, getStorageUsage, listLogs, listUsersWithPrintCounts } from "../services/adminService";
|
||||
import { createLog } from "../services/auditLog";
|
||||
import { INVITATION_TTL_DAYS, inviteUser } from "../services/invitationService";
|
||||
import { authorLinkingSummary, currentAuthorLinkingRun, startAuthorLinking } from "../services/authorLinkingService";
|
||||
|
||||
const router = Router();
|
||||
router.use(requireAuth);
|
||||
@@ -105,4 +106,25 @@ router.post(
|
||||
}),
|
||||
);
|
||||
|
||||
// Administration > Triggers > "Link missing authors" (see authorLinkingService.ts), across the
|
||||
// whole instance. The GET says how many models it could link by name (`linkable`) and how many
|
||||
// need their author looked up on their site (`lookup`) -- the trigger is only shown when either
|
||||
// is non-zero -- plus the current or last run, which the page polls while it's running. The POST
|
||||
// starts a run in the background.
|
||||
router.get(
|
||||
"/admin/triggers/link-authors",
|
||||
asyncHandler(async (_req, res) => {
|
||||
res.json({ ...(await authorLinkingSummary()), run: currentAuthorLinkingRun() });
|
||||
}),
|
||||
);
|
||||
|
||||
router.post(
|
||||
"/admin/triggers/link-authors",
|
||||
asyncHandler(async (req, res) => {
|
||||
const run = startAuthorLinking(req.userId!);
|
||||
if (!run) throw new HttpError(409, "Linking is already running");
|
||||
res.json({ run });
|
||||
}),
|
||||
);
|
||||
|
||||
export default router;
|
||||
@@ -5,6 +5,7 @@ export type LogAction =
|
||||
| "user_logged_in"
|
||||
| "user_logged_out"
|
||||
| "user_invited"
|
||||
| "authors_linked"
|
||||
| "model_uploaded"
|
||||
| "model_imported"
|
||||
| "import_completed"
|
||||
|
||||
@@ -0,0 +1,189 @@
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { prisma } from "../db";
|
||||
import { IMPORT_COLLECTION_DELAY_MS, IMPORT_MAKERWORLD_CALL_DELAY_MS } from "../config";
|
||||
import { maybeSleep } from "../utils/concurrency";
|
||||
import { LINKABLE_PRINTS, linkUnattributedPrints, upsertAuthorFromImport } from "./authorService";
|
||||
import { createLog } from "./auditLog";
|
||||
import { fetchMakerworldPageAuthor } from "./importService";
|
||||
import type { ImportedAuthorInfo } from "./importResolvers";
|
||||
import { getUserMakerworldCookie } from "./makerworldCookieService";
|
||||
import {
|
||||
extractMakerworldBearerToken,
|
||||
fetchMakerworldDesignAuthor,
|
||||
makerworldCaptchaCooloffActive,
|
||||
MakerworldAuthError,
|
||||
MakerworldCaptchaError,
|
||||
} from "./makerworldCloudApi";
|
||||
import { resolvePrintablesModel } from "./printablesApi";
|
||||
import { getThingiverseAccessToken } from "./settingsService";
|
||||
import { resolveThingiverseThing, ThingiverseAuthError } from "./thingiverseApi";
|
||||
|
||||
// Administration > Triggers > "Link missing authors", instance-wide: models imported while their
|
||||
// author couldn't be saved (MakerWorld, for a while -- see makerworldCloudApi.ts's
|
||||
// completeMakerworldAuthor) only know the author by name. A run first links every such model
|
||||
// whose name matches a known author (authorService.ts's LINKABLE_PRINTS), then looks the rest up
|
||||
// on the site each came from -- once per distinct creator, not per model, since everything they
|
||||
// made links from that one lookup. Only metadata is fetched, never files.
|
||||
|
||||
const LOOKUP_PROVIDERS = ["makerworld", "thingiverse", "printables"] as const;
|
||||
type LookupProvider = (typeof LOOKUP_PROVIDERS)[number];
|
||||
|
||||
/** Why part of a run was skipped -- shown to the admin, who can usually fix it. */
|
||||
export type LookupProblem = "thingiverse_no_token" | "thingiverse_token_rejected" | "makerworld_captcha" | "makerworld_login_rejected";
|
||||
|
||||
export type AuthorLinkingRun = {
|
||||
running: boolean;
|
||||
startedAt: string;
|
||||
finishedAt: string | null;
|
||||
/** Creators to look up on their sites, and how many have been so far. */
|
||||
toLookUp: number;
|
||||
lookedUp: number;
|
||||
/** Models linked by this run, both by name and from lookups. */
|
||||
linked: number;
|
||||
/** Creators whose site didn't give an author (model gone, page unreachable...). */
|
||||
notFound: number;
|
||||
problems: LookupProblem[];
|
||||
};
|
||||
|
||||
type Candidate = { printId: string; userId: string; provider: LookupProvider; externalId: string; creator: string };
|
||||
|
||||
// One model per creator (per site) that no known author's name matches -- the newest, as the
|
||||
// likeliest still to be online. Ambiguous names are included: a lookup settles who it really is.
|
||||
const LOOKUP_CANDIDATES = Prisma.sql`
|
||||
SELECT DISTINCT ON (p."sourceProvider", lower(trim(p.creator)))
|
||||
p.id AS "printId", p."userId", p."sourceProvider" AS provider, p."sourceExternalId" AS "externalId", p.creator
|
||||
FROM "Print" p
|
||||
WHERE p."authorId" IS NULL AND p.creator IS NOT NULL AND trim(p.creator) <> ''
|
||||
AND p."sourceProvider" IN (${Prisma.join([...LOOKUP_PROVIDERS])}) AND p."sourceExternalId" IS NOT NULL
|
||||
AND p.id NOT IN (SELECT print_id FROM (${LINKABLE_PRINTS}) l)
|
||||
ORDER BY p."sourceProvider", lower(trim(p.creator)), p."createdAt" DESC
|
||||
`;
|
||||
|
||||
/** What the trigger would do now: models it can link by name straight away, and models whose
|
||||
* author it would have to look up (on their sites). The trigger is shown when either is > 0. */
|
||||
export async function authorLinkingSummary(): Promise<{ linkable: number; lookup: number }> {
|
||||
const [row] = await prisma.$queryRaw<{ linkable: number; lookup: number }[]>`
|
||||
SELECT
|
||||
(SELECT count(*)::int FROM (${LINKABLE_PRINTS}) l) AS linkable,
|
||||
(SELECT count(*)::int FROM "Print" p
|
||||
WHERE p."authorId" IS NULL AND p.creator IS NOT NULL AND trim(p.creator) <> ''
|
||||
AND p."sourceProvider" IN (${Prisma.join([...LOOKUP_PROVIDERS])}) AND p."sourceExternalId" IS NOT NULL
|
||||
AND p.id NOT IN (SELECT print_id FROM (${LINKABLE_PRINTS}) l2)) AS lookup
|
||||
`;
|
||||
return { linkable: row?.linkable ?? 0, lookup: row?.lookup ?? 0 };
|
||||
}
|
||||
|
||||
let current: AuthorLinkingRun | null = null;
|
||||
|
||||
/** The current or last run since the server started, for the Triggers page to poll. */
|
||||
export function currentAuthorLinkingRun(): AuthorLinkingRun | null {
|
||||
return current;
|
||||
}
|
||||
|
||||
/** Starts a run in the background; null if one is already running. */
|
||||
export function startAuthorLinking(adminUserId: string): AuthorLinkingRun | null {
|
||||
if (current?.running) return null;
|
||||
current = {
|
||||
running: true,
|
||||
startedAt: new Date().toISOString(),
|
||||
finishedAt: null,
|
||||
toLookUp: 0,
|
||||
lookedUp: 0,
|
||||
linked: 0,
|
||||
notFound: 0,
|
||||
problems: [],
|
||||
};
|
||||
const run = current;
|
||||
void runAuthorLinking(run)
|
||||
.catch((err) => console.error("Link missing authors failed", err))
|
||||
.finally(() => {
|
||||
run.running = false;
|
||||
run.finishedAt = new Date().toISOString();
|
||||
void createLog({
|
||||
userId: adminUserId,
|
||||
action: "authors_linked",
|
||||
details: { linked: run.linked, lookedUp: run.lookedUp, notFound: run.notFound, problems: run.problems },
|
||||
});
|
||||
});
|
||||
return run;
|
||||
}
|
||||
|
||||
/** Models that only know their author by name -- `linked` is how far this has dropped since the
|
||||
* run started, which also counts what saving an author links along the way. */
|
||||
async function countUnlinked(): Promise<number> {
|
||||
return prisma.print.count({ where: { authorId: null, creator: { not: null } } });
|
||||
}
|
||||
|
||||
/** Does the whole run, updating `run` as it goes -- awaited directly by tests. */
|
||||
export async function runAuthorLinking(run: AuthorLinkingRun): Promise<void> {
|
||||
const unlinkedAtStart = await countUnlinked();
|
||||
const updateLinked = async () => {
|
||||
run.linked = unlinkedAtStart - (await countUnlinked());
|
||||
};
|
||||
await linkUnattributedPrints();
|
||||
await updateLinked();
|
||||
|
||||
const candidates = await prisma.$queryRaw<Candidate[]>`${LOOKUP_CANDIDATES}`;
|
||||
run.toLookUp = candidates.length;
|
||||
const skipped = new Set<LookupProvider>();
|
||||
const skip = (provider: LookupProvider, problem: LookupProblem) => {
|
||||
skipped.add(provider);
|
||||
if (!run.problems.includes(problem)) run.problems.push(problem);
|
||||
};
|
||||
const thingiverseToken = await getThingiverseAccessToken();
|
||||
if (!thingiverseToken) skip("thingiverse", "thingiverse_no_token");
|
||||
if (makerworldCaptchaCooloffActive()) skip("makerworld", "makerworld_captcha");
|
||||
|
||||
for (const candidate of candidates) {
|
||||
if (skipped.has(candidate.provider)) {
|
||||
run.lookedUp++;
|
||||
continue;
|
||||
}
|
||||
let author: ImportedAuthorInfo | null = null;
|
||||
try {
|
||||
author = await lookUpAuthor(candidate, thingiverseToken);
|
||||
} catch (err) {
|
||||
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 console.warn(`Couldn't look up the author of model ${candidate.printId}`, err);
|
||||
}
|
||||
const saved = author ? await upsertAuthorFromImport(author) : null;
|
||||
if (saved) {
|
||||
// Saving the author already linked every model its name matches; this also covers the
|
||||
// looked-up model and its siblings when the name the import stored differs from the
|
||||
// author's (e.g. a MakerWorld nickname vs. display name).
|
||||
await prisma.$executeRaw`
|
||||
UPDATE "Print" SET "authorId" = ${saved.id}
|
||||
WHERE "authorId" IS NULL AND "sourceProvider" = ${candidate.provider}
|
||||
AND lower(trim(creator)) = lower(trim(${candidate.creator}))
|
||||
`;
|
||||
await updateLinked();
|
||||
} else {
|
||||
run.notFound++;
|
||||
}
|
||||
run.lookedUp++;
|
||||
}
|
||||
}
|
||||
|
||||
async function lookUpAuthor(candidate: Candidate, thingiverseToken: string | null): Promise<ImportedAuthorInfo | null> {
|
||||
switch (candidate.provider) {
|
||||
case "thingiverse": {
|
||||
await maybeSleep(IMPORT_COLLECTION_DELAY_MS);
|
||||
return (await resolveThingiverseThing(candidate.externalId, thingiverseToken!))?.meta.author ?? null;
|
||||
}
|
||||
case "printables": {
|
||||
await maybeSleep(IMPORT_COLLECTION_DELAY_MS);
|
||||
return (await resolvePrintablesModel(candidate.externalId))?.meta.author ?? null;
|
||||
}
|
||||
case "makerworld": {
|
||||
// The model owner's MakerWorld login, if they saved one, reads MakerWorld's API directly;
|
||||
// otherwise the model page (often behind Cloudflare -- FlareSolverr gets through).
|
||||
const cookie = (await getUserMakerworldCookie(candidate.userId)) ?? process.env.MAKERWORLD_COOKIE ?? null;
|
||||
const bearer = extractMakerworldBearerToken(cookie);
|
||||
return bearer
|
||||
? fetchMakerworldDesignAuthor(candidate.externalId, bearer, IMPORT_MAKERWORLD_CALL_DELAY_MS)
|
||||
: fetchMakerworldPageAuthor(candidate.externalId, cookie, IMPORT_MAKERWORLD_CALL_DELAY_MS);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { prisma } from "../db";
|
||||
import { HttpError } from "../utils/fileUtils";
|
||||
import type { ImportedAuthorInfo } from "./importResolvers";
|
||||
@@ -15,8 +16,9 @@ export function buildAuthorId(provider: string, externalId: string): string {
|
||||
export async function upsertAuthorFromImport(info: ImportedAuthorInfo | null): Promise<Author | null> {
|
||||
if (!info) return null;
|
||||
const id = buildAuthorId(info.provider, info.externalId);
|
||||
let author: Author;
|
||||
try {
|
||||
return await prisma.author.upsert({
|
||||
author = await prisma.author.upsert({
|
||||
where: { id },
|
||||
create: {
|
||||
id,
|
||||
@@ -46,6 +48,40 @@ export async function upsertAuthorFromImport(info: ImportedAuthorInfo | null): P
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
// Models imported before this author had a record -- see linkUnattributedPrints.
|
||||
await linkUnattributedPrints(author.id).catch((err) => console.error("Couldn't link earlier imports to their author", err));
|
||||
return author;
|
||||
}
|
||||
|
||||
/** Models that only know their author by name, each paired with the one author record that
|
||||
* name belongs to. A model gets a plain-text `creator` on every import, but a linked Author only
|
||||
* when the import could build one -- and for a while MakerWorld imports couldn't (see
|
||||
* makerworldCloudApi.ts's completeMakerworldAuthor). The match is by the author's name or
|
||||
* handle, within the model's own provider, ignoring case and a leading "@"; a name two authors
|
||||
* share matches nobody rather than being guessed. (A model whose author was reset has no creator
|
||||
* left to match.) */
|
||||
export const LINKABLE_PRINTS = Prisma.sql`
|
||||
SELECT p2.id AS print_id, min(a.id) AS author_id
|
||||
FROM "Print" p2
|
||||
JOIN "Author" a ON a.provider = p2."sourceProvider"
|
||||
AND (
|
||||
lower(ltrim(trim(a.name), '@')) = lower(ltrim(trim(p2.creator), '@'))
|
||||
OR lower(ltrim(trim(a.handle), '@')) = lower(ltrim(trim(p2.creator), '@'))
|
||||
)
|
||||
WHERE p2."authorId" IS NULL AND p2.creator IS NOT NULL AND trim(p2.creator) <> ''
|
||||
GROUP BY p2.id
|
||||
HAVING count(*) = 1
|
||||
`;
|
||||
|
||||
/** Links models to their author record (see LINKABLE_PRINTS) -- every such model across the
|
||||
* instance, or with `authorId`, only those matching that author: run for each author an import
|
||||
* saves, so earlier imports of theirs get linked right away. Returns how many it linked. */
|
||||
export async function linkUnattributedPrints(authorId: string | null = null): Promise<number> {
|
||||
return prisma.$executeRaw`
|
||||
UPDATE "Print" p SET "authorId" = m.author_id
|
||||
FROM (${LINKABLE_PRINTS}) m
|
||||
WHERE p.id = m.print_id AND (${authorId}::text IS NULL OR m.author_id = ${authorId}::text)
|
||||
`;
|
||||
}
|
||||
|
||||
/** Deletes an Author row once nothing references it any more. Author rows aren't user-scoped --
|
||||
|
||||
@@ -23,12 +23,14 @@ import {
|
||||
resolveMakerworldCookie,
|
||||
resolveMakerworldDownloadUrl,
|
||||
type ImportCookies,
|
||||
type ImportedAuthorInfo,
|
||||
type ImportedPageMetadata,
|
||||
} from "./importResolvers";
|
||||
import {
|
||||
extractMakerworldBearerToken,
|
||||
MakerworldAuthError,
|
||||
MakerworldCaptchaError,
|
||||
makerworldCaptchaCooloffActive,
|
||||
parseMakerworldModelUrl,
|
||||
resolveMakerworldViaCloudApi,
|
||||
completeMakerworldAuthor,
|
||||
@@ -166,6 +168,29 @@ async function fetchWithGuard(url: string, headers: Record<string, string>): Pro
|
||||
|
||||
export type OpenImportResult = { response: Response; finalUrl: string; meta: ImportedPageMetadata };
|
||||
|
||||
/** A MakerWorld design's author from its model page -- the fallback to
|
||||
* makerworldCloudApi.ts's fetchMakerworldDesignAuthor when there's no MakerWorld login to use its
|
||||
* API. The page is often behind Cloudflare's challenge for a server (FlareSolverr gets through);
|
||||
* null when it can't be read. */
|
||||
export async function fetchMakerworldPageAuthor(
|
||||
designId: string,
|
||||
cookie: string | null,
|
||||
paceMs?: number,
|
||||
): Promise<ImportedAuthorInfo | null> {
|
||||
if (makerworldCaptchaCooloffActive()) throw new MakerworldCaptchaError();
|
||||
const url = `https://makerworld.com/en/models/${designId}`;
|
||||
await maybeSleep(paceMs);
|
||||
let res: Response;
|
||||
try {
|
||||
res = await fetchWithGuard(url, { "User-Agent": IMPORT_USER_AGENT, Accept: "*/*", ...makerworldHtmlHeaders(url, cookie) });
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (!isHtmlContentType(res.headers.get("content-type") || "")) return null;
|
||||
const { buffer } = await readCapped(res, IMPORT_HTML_MAX_BYTES);
|
||||
return completeMakerworldAuthor(extractPageMetadata(buffer.toString("utf-8"), "makerworld.com").author, paceMs);
|
||||
}
|
||||
|
||||
type MakerworldCloudShortcut = { downloadUrl: string; meta: ImportedPageMetadata };
|
||||
|
||||
/** Attempts the api.bambulab.com resolution path for a MakerWorld model URL. Returns null
|
||||
|
||||
@@ -269,6 +269,28 @@ export async function completeMakerworldAuthor(
|
||||
};
|
||||
}
|
||||
|
||||
/** Just a design's author, through api.bambulab.com (no Cloudflare there) -- for linking a model
|
||||
* imported before its author could be saved (authorLinkingService.ts), without downloading
|
||||
* anything. Null when the design can't be read; throws MakerworldCaptchaError /
|
||||
* MakerworldAuthError like resolveMakerworldViaCloudApi, so a caller can stop asking. */
|
||||
export async function fetchMakerworldDesignAuthor(
|
||||
designId: string,
|
||||
bearerToken: string,
|
||||
paceMs?: number,
|
||||
): Promise<ImportedAuthorInfo | null> {
|
||||
if (makerworldCaptchaCooloffActive()) throw new MakerworldCaptchaError();
|
||||
await maybeSleep(paceMs);
|
||||
const result = await fetchCloudJson(`${DESIGN_API_BASE}/design/${designId}`, bearerToken);
|
||||
if (!result) return null;
|
||||
if (isCaptchaChallenge(result.data)) {
|
||||
noteCaptchaChallenge();
|
||||
throw new MakerworldCaptchaError();
|
||||
}
|
||||
if (result.status === 401 || result.status === 403) throw new MakerworldAuthError();
|
||||
if (result.status !== 200 || !isRecord(result.data)) return null;
|
||||
return completeMakerworldAuthor(makerworldAuthorFromDesignCreator(result.data.designCreator), paceMs);
|
||||
}
|
||||
|
||||
export type MakerworldGalleryImage = { url: string; filename: string };
|
||||
|
||||
/** The model page's photo gallery -- design.designExtension.design_pictures -- distinct from
|
||||
|
||||
@@ -0,0 +1,260 @@
|
||||
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import request from "supertest";
|
||||
|
||||
// Lookups are paced to spare the sites (MakerWorld's CAPTCHA especially); not here. config.ts
|
||||
// reads these when it loads, so before any import.
|
||||
vi.hoisted(() => {
|
||||
process.env.IMPORT_MAKERWORLD_CALL_DELAY_MS = "0";
|
||||
process.env.IMPORT_COLLECTION_DELAY_MS = "0";
|
||||
});
|
||||
|
||||
import { createApp } from "../src/app";
|
||||
import { prisma } from "../src/db";
|
||||
import { linkUnattributedPrints, upsertAuthorFromImport } from "../src/services/authorService";
|
||||
import { runAuthorLinking, type AuthorLinkingRun } from "../src/services/authorLinkingService";
|
||||
import { getThingiverseAccessToken, setThingiverseAccessToken } from "../src/services/settingsService";
|
||||
import type { ImportedAuthorInfo } from "../src/services/importResolvers";
|
||||
|
||||
// Models imported while their author couldn't be built (MakerWorld, for a while) only know the
|
||||
// author by the plain-text `creator`. Once a record for that author exists -- from a later import,
|
||||
// or already -- they're linked to it, but only when the match is unambiguous.
|
||||
|
||||
const app = createApp();
|
||||
const stamp = Date.now();
|
||||
let userId: string;
|
||||
let memberToken: string;
|
||||
let adminToken: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
const res = await request(app)
|
||||
.post("/api/register")
|
||||
.send({ displayName: "Author Linking Test", email: `author-linking-${stamp}@example.com`, password: "password123" });
|
||||
userId = res.body.user.id;
|
||||
memberToken = res.body.token;
|
||||
const admin = await request(app)
|
||||
.post("/api/register")
|
||||
.send({ displayName: "Author Linking Admin", email: `author-linking-admin-${stamp}@example.com`, password: "password123" });
|
||||
await prisma.user.update({ where: { id: admin.body.user.id }, data: { role: "ADMIN" } });
|
||||
adminToken = (await request(app).post("/api/login").send({ email: `author-linking-admin-${stamp}@example.com`, password: "password123" })).body.token;
|
||||
});
|
||||
|
||||
async function unattributedPrint(label: string, provider: string, creator: string | null) {
|
||||
const name = `${label} ${stamp}`;
|
||||
return prisma.print.create({
|
||||
data: {
|
||||
userId,
|
||||
name,
|
||||
nameNormalized: name.toLowerCase(),
|
||||
creator,
|
||||
sourceProvider: provider,
|
||||
sourceExternalId: `${label}-${stamp}`,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function authorInfo(provider: string, externalId: string, name: string | null, handle: string | null = null): ImportedAuthorInfo {
|
||||
return {
|
||||
provider,
|
||||
externalId: `${externalId}-${stamp}`,
|
||||
name,
|
||||
handle,
|
||||
bio: null,
|
||||
bioTranslated: null,
|
||||
links: [],
|
||||
avatarUrl: null,
|
||||
backgroundUrl: null,
|
||||
};
|
||||
}
|
||||
|
||||
const auth = (token: string) => ({ Authorization: `Bearer ${token}` });
|
||||
const authorOf = async (printId: string) => (await prisma.print.findUniqueOrThrow({ where: { id: printId } })).authorId;
|
||||
|
||||
describe("linking earlier imports to their author", () => {
|
||||
it("links a model once its author is imported, matching the name regardless of case", async () => {
|
||||
const print = await unattributedPrint("case", "makerworld", `mclanesmemories${stamp}`);
|
||||
const author = await upsertAuthorFromImport(authorInfo("makerworld", "case", `McLanesMemories${stamp}`));
|
||||
expect(await authorOf(print.id)).toBe(author!.id);
|
||||
});
|
||||
|
||||
it("matches the handle too, with or without a leading @", async () => {
|
||||
const print = await unattributedPrint("handle", "printables", `@maker_${stamp}`);
|
||||
const author = await upsertAuthorFromImport(authorInfo("printables", "handle", "Display Name", `maker_${stamp}`));
|
||||
expect(await authorOf(print.id)).toBe(author!.id);
|
||||
});
|
||||
|
||||
it("never links across providers", async () => {
|
||||
const print = await unattributedPrint("provider", "thingiverse", `SameName${stamp}`);
|
||||
await upsertAuthorFromImport(authorInfo("makerworld", "provider", `SameName${stamp}`));
|
||||
expect(await authorOf(print.id)).toBeNull();
|
||||
});
|
||||
|
||||
it("leaves a name two known authors share alone rather than guessing", async () => {
|
||||
await upsertAuthorFromImport(authorInfo("makerworld", "twin-a", `Twin${stamp}`));
|
||||
await upsertAuthorFromImport(authorInfo("makerworld", "twin-b", `twin${stamp}`));
|
||||
const print = await unattributedPrint("ambiguous", "makerworld", `Twin${stamp}`);
|
||||
await linkUnattributedPrints();
|
||||
expect(await authorOf(print.id)).toBeNull();
|
||||
});
|
||||
|
||||
it("doesn't touch a model already linked to someone, or one whose author was reset", async () => {
|
||||
const other = await upsertAuthorFromImport(authorInfo("makerworld", "other", `Someone Else ${stamp}`));
|
||||
const linked = await unattributedPrint("linked", "makerworld", `Taken${stamp}`);
|
||||
await prisma.print.update({ where: { id: linked.id }, data: { authorId: other!.id } });
|
||||
// Author reset clears the creator along with the link (see routes/prints.ts).
|
||||
const reset = await unattributedPrint("reset", "makerworld", null);
|
||||
await upsertAuthorFromImport(authorInfo("makerworld", "taken", `Taken${stamp}`));
|
||||
expect(await authorOf(linked.id)).toBe(other!.id);
|
||||
expect(await authorOf(reset.id)).toBeNull();
|
||||
});
|
||||
|
||||
it("links every model an existing author matches when run for all", async () => {
|
||||
const author = await prisma.author.create({
|
||||
data: { id: `makerworld:startup-${stamp}`, provider: "makerworld", externalId: `startup-${stamp}`, name: `Startup Author ${stamp}` },
|
||||
});
|
||||
const print = await unattributedPrint("startup", "makerworld", `Startup Author ${stamp}`);
|
||||
expect(await linkUnattributedPrints()).toBeGreaterThanOrEqual(1);
|
||||
expect(await authorOf(print.id)).toBe(author.id);
|
||||
});
|
||||
});
|
||||
|
||||
const originalFetch = global.fetch;
|
||||
|
||||
function json(body: unknown, status = 200): Response {
|
||||
return new Response(JSON.stringify(body), { status, headers: { "content-type": "application/json" } });
|
||||
}
|
||||
|
||||
/** Answers the listed URLs (by prefix); anything else -- e.g. a lookup for a model some other test
|
||||
* file left behind -- gets a 404, as a model gone from its site would. */
|
||||
function mockFetch(routes: Record<string, () => Response>) {
|
||||
global.fetch = vi.fn<(input: RequestInfo | URL) => Promise<Response>>(async (input) => {
|
||||
const url = String(input);
|
||||
const match = Object.keys(routes).find((prefix) => url.startsWith(prefix));
|
||||
return match ? routes[match]() : new Response("not found", { status: 404 });
|
||||
}) as unknown as typeof fetch;
|
||||
}
|
||||
|
||||
function newRun(): AuthorLinkingRun {
|
||||
return { running: true, startedAt: new Date().toISOString(), finishedAt: null, toLookUp: 0, lookedUp: 0, linked: 0, notFound: 0, problems: [] };
|
||||
}
|
||||
|
||||
describe("looking up authors no known author matches", () => {
|
||||
let previousThingiverseToken: string | null;
|
||||
|
||||
beforeAll(async () => {
|
||||
previousThingiverseToken = await getThingiverseAccessToken();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
global.fetch = originalFetch;
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await setThingiverseAccessToken(previousThingiverseToken);
|
||||
});
|
||||
|
||||
it("fetches a Printables model's author once, linking every model of that creator", async () => {
|
||||
const modelId = String(stamp % 1_000_000_000);
|
||||
const creator = `PrintablesPerson${stamp}`;
|
||||
const first = await unattributedPrint("lookup-p1", "printables", creator);
|
||||
const second = await prisma.print.update({
|
||||
where: { id: (await unattributedPrint("lookup-p2", "printables", creator)).id },
|
||||
data: { sourceExternalId: `other-${stamp}` },
|
||||
});
|
||||
await prisma.print.update({ where: { id: first.id }, data: { sourceExternalId: modelId, createdAt: new Date(Date.now() + 60_000) } });
|
||||
let modelQueries = 0;
|
||||
mockFetch({
|
||||
"https://api.printables.com/graphql/": () => {
|
||||
modelQueries++;
|
||||
return json({
|
||||
data: {
|
||||
print: {
|
||||
id: modelId,
|
||||
name: "Anything",
|
||||
user: { id: `p-${stamp}`, handle: `pperson${stamp}`, publicUsername: `Printables Person ${stamp}`, avatarFilePath: "media/a.png" },
|
||||
stls: [],
|
||||
},
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const run = newRun();
|
||||
await runAuthorLinking(run);
|
||||
const authorId = `printables:p-${stamp}`;
|
||||
// The site's display name differs from the name the import stored -- linked anyway, both models.
|
||||
expect(await authorOf(first.id)).toBe(authorId);
|
||||
expect(await authorOf(second.id)).toBe(authorId);
|
||||
expect(modelQueries).toBe(1);
|
||||
expect(run.linked).toBeGreaterThanOrEqual(2);
|
||||
expect((await prisma.author.findUniqueOrThrow({ where: { id: authorId } })).avatarUrl).toBe("https://media.printables.com/media/a.png");
|
||||
});
|
||||
|
||||
it("uses the model owner's MakerWorld login to read MakerWorld's API", async () => {
|
||||
const designId = String((stamp % 1_000_000_000) + 7);
|
||||
await prisma.user.update({ where: { id: userId }, data: { makerworldCookie: "token=owner-bearer" } });
|
||||
const print = await unattributedPrint("lookup-mw", "makerworld", `mw-nick-${stamp}`);
|
||||
await prisma.print.update({ where: { id: print.id }, data: { sourceExternalId: designId } });
|
||||
let authorization: string | null = null;
|
||||
global.fetch = vi.fn<(input: RequestInfo | URL, init?: RequestInit) => Promise<Response>>(async (input, init) => {
|
||||
const url = String(input);
|
||||
if (url === `https://api.bambulab.com/v1/design-service/design/${designId}`) {
|
||||
authorization = new Headers(init?.headers).get("authorization");
|
||||
return json({ id: Number(designId), designCreator: { uid: 777000 + (stamp % 1000), name: `MW Person ${stamp}`, avatar: "https://public-cdn.bblmw.com/a.png" } });
|
||||
}
|
||||
return new Response("not found", { status: 404 }); // the author profile: behind Cloudflare
|
||||
}) as unknown as typeof fetch;
|
||||
|
||||
await runAuthorLinking(newRun());
|
||||
expect(authorization).toBe("Bearer owner-bearer");
|
||||
expect(await authorOf(print.id)).toBe(`makerworld:${777000 + (stamp % 1000)}`);
|
||||
await prisma.user.update({ where: { id: userId }, data: { makerworldCookie: null } });
|
||||
});
|
||||
|
||||
it("skips Thingiverse without an access token, and says so", async () => {
|
||||
await setThingiverseAccessToken(null);
|
||||
const print = await unattributedPrint("lookup-tv", "thingiverse", `TvPerson${stamp}`);
|
||||
mockFetch({});
|
||||
const run = newRun();
|
||||
await runAuthorLinking(run);
|
||||
expect(run.problems).toContain("thingiverse_no_token");
|
||||
expect(await authorOf(print.id)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Administration > Triggers > Link missing authors", () => {
|
||||
afterEach(() => {
|
||||
global.fetch = originalFetch;
|
||||
});
|
||||
|
||||
it("is admin-only", async () => {
|
||||
expect((await request(app).get("/api/admin/triggers/link-authors").set(auth(memberToken))).status).toBe(403);
|
||||
expect((await request(app).post("/api/admin/triggers/link-authors").set(auth(memberToken))).status).toBe(403);
|
||||
});
|
||||
|
||||
it("says what it can link -- which decides whether it's shown -- then links it in the background", async () => {
|
||||
mockFetch({});
|
||||
const author = await prisma.author.create({
|
||||
data: { id: `thingiverse:trigger-${stamp}`, provider: "thingiverse", externalId: `trigger-${stamp}`, name: `Trigger Author ${stamp}` },
|
||||
});
|
||||
const first = await unattributedPrint("trigger-a", "thingiverse", `Trigger Author ${stamp}`);
|
||||
const second = await unattributedPrint("trigger-b", "thingiverse", `trigger author ${stamp}`);
|
||||
const status = await request(app).get("/api/admin/triggers/link-authors").set(auth(adminToken));
|
||||
expect(status.body.linkable).toBeGreaterThanOrEqual(2);
|
||||
expect(status.body).toHaveProperty("lookup");
|
||||
|
||||
const started = await request(app).post("/api/admin/triggers/link-authors").set(auth(adminToken));
|
||||
expect(started.status).toBe(200);
|
||||
expect(started.body.run.running).toBe(true);
|
||||
expect((await request(app).post("/api/admin/triggers/link-authors").set(auth(adminToken))).status).toBe(409);
|
||||
|
||||
let run = started.body.run;
|
||||
for (let i = 0; i < 100 && run.running; i++) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
run = (await request(app).get("/api/admin/triggers/link-authors").set(auth(adminToken))).body.run;
|
||||
}
|
||||
expect(run.running).toBe(false);
|
||||
expect(run.linked).toBeGreaterThanOrEqual(2);
|
||||
expect(await authorOf(first.id)).toBe(author.id);
|
||||
expect(await authorOf(second.id)).toBe(author.id);
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
import { authHeaders } from "../utils/auth";
|
||||
import { apiBase, assertOk, readErrorMessage } from "./client";
|
||||
import { apiBase, assertOk, readErrorMessage, UnauthorizedError } from "./client";
|
||||
|
||||
export type AdminUser = {
|
||||
id: string;
|
||||
@@ -16,6 +16,7 @@ export type LogAction =
|
||||
| "user_logged_in"
|
||||
| "user_logged_out"
|
||||
| "user_invited"
|
||||
| "authors_linked"
|
||||
| "model_uploaded"
|
||||
| "model_imported"
|
||||
| "import_completed"
|
||||
@@ -43,6 +44,24 @@ export type StorageUsage = {
|
||||
model_count: number;
|
||||
};
|
||||
|
||||
/** Why part of a "Link missing authors" run was skipped. */
|
||||
export type AuthorLookupProblem = "thingiverse_no_token" | "thingiverse_token_rejected" | "makerworld_captcha" | "makerworld_login_rejected";
|
||||
|
||||
export type AuthorLinkingRun = {
|
||||
running: boolean;
|
||||
startedAt: string;
|
||||
finishedAt: string | null;
|
||||
toLookUp: number;
|
||||
lookedUp: number;
|
||||
linked: number;
|
||||
notFound: number;
|
||||
problems: AuthorLookupProblem[];
|
||||
};
|
||||
|
||||
/** `linkable`: models linkable by name right away; `lookup`: models whose author would be looked
|
||||
* up on their site. */
|
||||
export type AuthorLinkingStatus = { linkable: number; lookup: number; run: AuthorLinkingRun | null };
|
||||
|
||||
export const adminApi = {
|
||||
getStorageUsage: async (): Promise<StorageUsage> => {
|
||||
const res = await fetch(`${apiBase()}/admin/storage`, { headers: authHeaders() });
|
||||
@@ -67,6 +86,21 @@ export const adminApi = {
|
||||
return res.json();
|
||||
},
|
||||
|
||||
/** Administration > Triggers > "Link missing authors" (see backend authorLinkingService.ts). */
|
||||
getAuthorLinking: async (): Promise<AuthorLinkingStatus> => {
|
||||
const res = await fetch(`${apiBase()}/admin/triggers/link-authors`, { headers: authHeaders() });
|
||||
if (res.status === 401) throw new UnauthorizedError();
|
||||
if (!res.ok) throw new Error(await readErrorMessage(res, "Failed to check for unlinked authors"));
|
||||
return res.json();
|
||||
},
|
||||
|
||||
startAuthorLinking: async (): Promise<{ run: AuthorLinkingRun }> => {
|
||||
const res = await fetch(`${apiBase()}/admin/triggers/link-authors`, { method: "POST", headers: authHeaders() });
|
||||
if (res.status === 401) throw new UnauthorizedError();
|
||||
if (!res.ok) throw new Error(await readErrorMessage(res, "Failed to start linking authors"));
|
||||
return res.json();
|
||||
},
|
||||
|
||||
deleteAllPrintsForUser: async (userId: string): Promise<{ deleted: number }> => {
|
||||
const res = await fetch(`${apiBase()}/admin/users/${userId}/delete-all-prints`, {
|
||||
method: "POST",
|
||||
|
||||
@@ -358,7 +358,22 @@
|
||||
"confirmWarning": "This permanently deletes all {{count}} model(s) belonging to {{email}}, including their files. This cannot be undone.",
|
||||
"confirmInstructions": "Type {{email}} to confirm.",
|
||||
"confirmButton": "Delete all models",
|
||||
"deleting": "Deleting…"
|
||||
"deleting": "Deleting…",
|
||||
"linkAuthorsTitle": "Link missing authors",
|
||||
"linkAuthorsDesc": "Some imported models show their author only as a name, without the author's profile, avatar or link, because the import couldn't fetch the author at the time. This links each of them to their author: to an author already in Thingport with exactly that name from the same site, or else by looking the author up on the site the model came from. Only details are fetched, never files. It runs for the whole instance, across every user's models, in the background — MakerWorld lookups are spaced out to avoid its CAPTCHA, so they can take a few minutes.",
|
||||
"linkAuthorsButton": "Link missing authors",
|
||||
"linkAuthorsFailed": "Failed to link authors.",
|
||||
"linkAuthorsLinkable": "{{count}} model(s) can be linked right away.",
|
||||
"linkAuthorsLookup": "{{count}} model(s) need their author looked up.",
|
||||
"linkAuthorsProgress": "Looking up authors: {{done}} of {{total}} — {{linked}} model(s) linked so far.",
|
||||
"linkAuthorsDone": "Linked {{linked}} model(s) to their author.",
|
||||
"linkAuthorsNotFound": "The author couldn't be found for {{count}} creator(s) — the model may have been removed from its site, or the site couldn't be reached.",
|
||||
"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.",
|
||||
"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."
|
||||
}
|
||||
},
|
||||
"thingiverse": {
|
||||
"heading": "Thingiverse Access Token",
|
||||
@@ -511,8 +526,10 @@
|
||||
"collection_edited": "Collection edited",
|
||||
"collection_deleted": "Collection deleted",
|
||||
"collection_item_added": "Added to collection",
|
||||
"collection_item_removed": "Removed from collection"
|
||||
}
|
||||
"collection_item_removed": "Removed from collection",
|
||||
"authors_linked": "Linked missing authors"
|
||||
},
|
||||
"authorsLinked": "{{count}} model(s) linked"
|
||||
},
|
||||
"rendering": {
|
||||
"heading": "Rendering",
|
||||
|
||||
@@ -360,7 +360,22 @@
|
||||
"confirmWarning": "Tai negrįžtamai ištrins visus {{count}} modelį(-ius), priklausantį(-čius) {{email}}, įskaitant jų failus. Šio veiksmo anuliuoti negalima.",
|
||||
"confirmInstructions": "Norėdami patvirtinti, įveskite {{email}}.",
|
||||
"confirmButton": "Ištrinti visus modelius",
|
||||
"deleting": "Trinama…"
|
||||
"deleting": "Trinama…",
|
||||
"linkAuthorsTitle": "Susieti trūkstamus autorius",
|
||||
"linkAuthorsDesc": "Kai kurie importuoti modeliai rodo autorių tik vardu, be profilio, avataro ar nuorodos, nes importuojant nepavyko gauti autoriaus duomenų. Šis veiksmas kiekvieną jų susieja su autoriumi: su jau Thingport esančiu tos pačios svetainės autoriumi lygiai tokiu pačiu vardu, o jei tokio nėra – surandant autorių svetainėje, iš kurios modelis importuotas. Gaunami tik duomenys, failai niekada nesisiunčiami. Veiksmas taikomas visam egzemplioriui, visų naudotojų modeliams, ir vyksta fone – MakerWorld užklausos išdėstomos laike, kad nebūtų iškviesta CAPTCHA, todėl gali užtrukti kelias minutes.",
|
||||
"linkAuthorsButton": "Susieti trūkstamus autorius",
|
||||
"linkAuthorsFailed": "Nepavyko susieti autorių.",
|
||||
"linkAuthorsLinkable": "Iš karto galima susieti modelių: {{count}}.",
|
||||
"linkAuthorsLookup": "Modelių, kurių autorių reikia surasti: {{count}}.",
|
||||
"linkAuthorsProgress": "Ieškoma autorių: {{done}} iš {{total}} – kol kas susieta modelių: {{linked}}.",
|
||||
"linkAuthorsDone": "Su autoriumi susieta modelių: {{linked}}.",
|
||||
"linkAuthorsNotFound": "Nepavyko rasti autoriaus kūrėjams: {{count}} – modelis galėjo būti pašalintas iš svetainės arba svetainė nepasiekiama.",
|
||||
"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.",
|
||||
"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."
|
||||
}
|
||||
},
|
||||
"thingiverse": {
|
||||
"heading": "Thingiverse prieigos raktas",
|
||||
@@ -513,8 +528,10 @@
|
||||
"collection_edited": "Kolekcija redaguota",
|
||||
"collection_deleted": "Kolekcija ištrinta",
|
||||
"collection_item_added": "Pridėta į kolekciją",
|
||||
"collection_item_removed": "Pašalinta iš kolekcijos"
|
||||
}
|
||||
"collection_item_removed": "Pašalinta iš kolekcijos",
|
||||
"authors_linked": "Susieti trūkstami autoriai"
|
||||
},
|
||||
"authorsLinked": "Susieta modelių: {{count}}"
|
||||
},
|
||||
"rendering": {
|
||||
"heading": "Atvaizdavimas",
|
||||
|
||||
@@ -31,6 +31,7 @@ const ACTION_COLORS: Record<LogAction, ActionColor> = {
|
||||
user_logged_in: "success",
|
||||
user_logged_out: "default",
|
||||
user_invited: "info",
|
||||
authors_linked: "success",
|
||||
model_uploaded: "info",
|
||||
model_imported: "info",
|
||||
import_completed: "info",
|
||||
@@ -105,6 +106,8 @@ export default function LogsPage({ onUnauthorized }: Props) {
|
||||
return typeof d.name === "string" ? d.name : "";
|
||||
case "user_invited":
|
||||
return typeof d.email === "string" ? d.email : "";
|
||||
case "authors_linked":
|
||||
return t("adminSettings.logs.authorsLinked", { count: typeof d.linked === "number" ? d.linked : 0 });
|
||||
case "model_edited":
|
||||
return typeof d.field === "string" ? t("adminSettings.logs.editedField", { field: d.field }) : "";
|
||||
case "import_completed": {
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
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 LinearProgress from "@mui/material/LinearProgress";
|
||||
import Paper from "@mui/material/Paper";
|
||||
import Stack from "@mui/material/Stack";
|
||||
import Typography from "@mui/material/Typography";
|
||||
import { UnauthorizedError } from "../../api/client";
|
||||
import { adminApi, type AuthorLinkingStatus } from "../../api/admin";
|
||||
|
||||
type Props = {
|
||||
onUnauthorized?: () => void;
|
||||
};
|
||||
|
||||
const POLL_MS = 2000;
|
||||
|
||||
/** "Link missing authors": links imported models that only know their author by name, across the
|
||||
* whole instance -- by name to authors already known, otherwise by looking the author up on the
|
||||
* site the model came from (see backend authorLinkingService.ts). Runs in the background; this
|
||||
* polls while it does. Only shown while there's something to link, or a run to report on. */
|
||||
export default function LinkAuthorsSection({ onUnauthorized }: Props) {
|
||||
const { t } = useTranslation("app");
|
||||
const [status, setStatus] = React.useState<AuthorLinkingStatus | null>(null);
|
||||
const [starting, setStarting] = React.useState(false);
|
||||
const [error, setError] = React.useState<string | null>(null);
|
||||
const running = Boolean(status?.run?.running);
|
||||
|
||||
const refresh = React.useCallback(async () => {
|
||||
try {
|
||||
setStatus(await adminApi.getAuthorLinking());
|
||||
} 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 start = async () => {
|
||||
setStarting(true);
|
||||
setError(null);
|
||||
try {
|
||||
const { run } = await adminApi.startAuthorLinking();
|
||||
setStatus((prev) => (prev ? { ...prev, run } : prev));
|
||||
} catch (err) {
|
||||
if (err instanceof UnauthorizedError) onUnauthorized?.();
|
||||
else setError(err instanceof Error ? err.message : t("adminSettings.triggers.linkAuthorsFailed"));
|
||||
} finally {
|
||||
setStarting(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (!status) return null;
|
||||
const { linkable, lookup, run } = status;
|
||||
if (!linkable && !lookup && !run) return null;
|
||||
|
||||
return (
|
||||
<Paper variant="outlined" sx={{ p: 2.5 }}>
|
||||
<Stack spacing={2}>
|
||||
<Box>
|
||||
<Typography variant="subtitle1" fontWeight={600}>{t("adminSettings.triggers.linkAuthorsTitle")}</Typography>
|
||||
<Typography variant="body2" color="text.secondary">{t("adminSettings.triggers.linkAuthorsDesc")}</Typography>
|
||||
</Box>
|
||||
|
||||
{run?.running ? (
|
||||
<Stack spacing={1}>
|
||||
<Typography variant="body2">
|
||||
{t("adminSettings.triggers.linkAuthorsProgress", { done: run.lookedUp, total: run.toLookUp, linked: run.linked })}
|
||||
</Typography>
|
||||
<LinearProgress
|
||||
variant={run.toLookUp ? "determinate" : "indeterminate"}
|
||||
value={run.toLookUp ? (run.lookedUp / run.toLookUp) * 100 : undefined}
|
||||
/>
|
||||
</Stack>
|
||||
) : (
|
||||
(linkable > 0 || lookup > 0) && (
|
||||
<Stack direction="row" spacing={1.5} alignItems="center" flexWrap="wrap" useFlexGap>
|
||||
<Button variant="contained" onClick={() => void start()} disabled={starting}>
|
||||
{t("adminSettings.triggers.linkAuthorsButton")}
|
||||
</Button>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{[
|
||||
linkable > 0 ? t("adminSettings.triggers.linkAuthorsLinkable", { count: linkable }) : null,
|
||||
lookup > 0 ? t("adminSettings.triggers.linkAuthorsLookup", { count: lookup }) : null,
|
||||
].filter(Boolean).join(" ")}
|
||||
</Typography>
|
||||
</Stack>
|
||||
)
|
||||
)}
|
||||
|
||||
{run && !run.running && (
|
||||
<Alert severity={run.linked > 0 ? "success" : "info"}>
|
||||
{t("adminSettings.triggers.linkAuthorsDone", { linked: run.linked })}
|
||||
{run.notFound > 0 && ` ${t("adminSettings.triggers.linkAuthorsNotFound", { count: run.notFound })}`}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -19,15 +19,16 @@ import DialogActions from "@mui/material/DialogActions";
|
||||
import TextField from "@mui/material/TextField";
|
||||
import { UnauthorizedError } from "../../api/client";
|
||||
import { adminApi, type AdminUser } from "../../api/admin";
|
||||
import LinkAuthorsSection from "./LinkAuthorsSection";
|
||||
|
||||
type Props = {
|
||||
onUnauthorized?: () => void;
|
||||
};
|
||||
|
||||
/** Admin-only "trigger" actions -- one-off, high-blast-radius operations run against another
|
||||
* user's data. Today there's a single trigger (delete all of a user's models); this file
|
||||
* intentionally stays a plain list of Paper sections rather than a generic "trigger registry"
|
||||
* until there's a second one to justify the abstraction. */
|
||||
/** Admin-only "trigger" actions -- one-off operations run against other users' data or the whole
|
||||
* instance: linking imported models to their authors (shown only when some can be), and
|
||||
* deleting all of one user's models. A plain list of Paper sections rather than a generic
|
||||
* "trigger registry" -- two don't justify the abstraction. */
|
||||
export default function TriggersPage({ onUnauthorized }: Props) {
|
||||
const { t } = useTranslation(["app", "common"]);
|
||||
const [users, setUsers] = React.useState<AdminUser[]>([]);
|
||||
@@ -91,6 +92,7 @@ export default function TriggersPage({ onUnauthorized }: Props) {
|
||||
|
||||
return (
|
||||
<Stack spacing={3}>
|
||||
<LinkAuthorsSection onUnauthorized={onUnauthorized} />
|
||||
<Paper variant="outlined" sx={{ p: 2.5 }}>
|
||||
<Stack spacing={2}>
|
||||
<Box>
|
||||
|
||||
Reference in new issue
Block a user