mirror of
https://github.com/TautvydasDerzinskas/Thingport.git
synced 2026-10-21 22:22:24 -05:00
feat: pick mapped provider categories by name
Each provider gets a Manage button that opens its categories as chips, by name, instead of a field of ids. The names come from a catalog checked in with the app, refreshed by backend/scripts/syncProviderCategories.ts. Stored values keep their shape.
This commit is contained in:
1 parent
0aff9e9a9e
commit
87db3f0ff0
9 files changed
+8687
-131
No files matched your search
@@ -0,0 +1,195 @@
|
||||
// Refreshes frontend/src/components/categoryMapping/providerCategories.json: every provider's own category tree, as id, name and parent, so
|
||||
// the category mapping dialog can show names instead of ids. Run from backend/ when a site changes its categories:
|
||||
//
|
||||
// npx tsx scripts/syncProviderCategories.ts
|
||||
//
|
||||
// Thingiverse and MyMiniFactory list their categories only with an API key, so it reads this instance's (from the
|
||||
// database in backend/.env, as the app does). The rest are public.
|
||||
|
||||
import { writeFileSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { browserFetch } from "../src/utils/browserFetch";
|
||||
import { getMyminifactoryApiKey, getThingiverseAccessToken } from "../src/services/settingsService";
|
||||
import { prisma } from "../src/db";
|
||||
|
||||
type Entry = { id: string; name: string; parent: string | null };
|
||||
type Node = Record<string, unknown>;
|
||||
|
||||
const OUT = path.resolve(__dirname, "../../frontend/src/components/categoryMapping/providerCategories.json");
|
||||
|
||||
const isNode = (value: unknown): value is Node => !!value && typeof value === "object" && !Array.isArray(value);
|
||||
const nodes = (value: unknown): Node[] => (Array.isArray(value) ? value.filter(isNode) : []);
|
||||
const text = (value: unknown) => (typeof value === "string" ? value.trim() : "");
|
||||
|
||||
/** Walks a nested tree, depth first, in the site's own order; ids seen before are skipped. */
|
||||
function flatten(
|
||||
roots: Node[],
|
||||
read: (node: Node) => { id: unknown; name: unknown; children: unknown },
|
||||
parent: string | null = null,
|
||||
out: Entry[] = [],
|
||||
seen = new Set<string>(),
|
||||
): Entry[] {
|
||||
for (const node of roots) {
|
||||
const { id, name, children } = read(node);
|
||||
const key = id == null ? "" : String(id);
|
||||
const label = text(name);
|
||||
if (!key || !label || seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
out.push({ id: key, name: label, parent });
|
||||
flatten(nodes(children), read, key, out, seen);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
async function json(
|
||||
url: string,
|
||||
init: { method?: "GET" | "POST"; body?: string; headers?: Record<string, string> } = {},
|
||||
) {
|
||||
const res = await browserFetch(url, { ...init, headers: { Accept: "application/json", ...init.headers } });
|
||||
if (!res.ok) throw new Error(`${url}: HTTP ${res.status}`);
|
||||
return res.json() as Promise<unknown>;
|
||||
}
|
||||
|
||||
async function nextData(url: string): Promise<Node> {
|
||||
const res = await browserFetch(url, { headers: { Accept: "text/html" } });
|
||||
const html = await res.text();
|
||||
const m = html.match(/<script id="__NEXT_DATA__"[^>]*>([\s\S]*?)<\/script>/);
|
||||
if (!m) throw new Error(`${url}: no page data (HTTP ${res.status})`);
|
||||
return JSON.parse(m[1]) as Node;
|
||||
}
|
||||
|
||||
const FETCHERS: Record<string, () => Promise<Entry[]>> = {
|
||||
async makerworld() {
|
||||
const data = await nextData("https://makerworld.com/en/3d-models");
|
||||
const tree = (data.props as Node)?.pageProps as Node;
|
||||
return flatten(nodes((tree.categoryTree as Node)?.children), (n) => ({
|
||||
id: n.id,
|
||||
name: n.name,
|
||||
children: n.children,
|
||||
}));
|
||||
},
|
||||
async thingiverse() {
|
||||
const token = await getThingiverseAccessToken();
|
||||
if (!token) throw new Error("no Thingiverse App Token on this instance");
|
||||
const headers = { Authorization: `Bearer ${token}` };
|
||||
const top = nodes(await json("https://api.thingiverse.com/categories", { headers }));
|
||||
const out: Entry[] = [];
|
||||
for (const category of top) {
|
||||
const id = String(category.id);
|
||||
out.push({ id, name: text(category.name), parent: null });
|
||||
const detail = await json(`https://api.thingiverse.com/categories/${text(category.slug)}`, { headers });
|
||||
for (const child of nodes(isNode(detail) ? detail.children : null)) {
|
||||
out.push({ id: String(child.id), name: text(child.name), parent: id });
|
||||
}
|
||||
}
|
||||
return out;
|
||||
},
|
||||
async printables() {
|
||||
const data = await json("https://api.printables.com/graphql/", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ query: "{ categories { id name path { id } } }" }),
|
||||
});
|
||||
const categories = nodes(((data as Node).data as Node)?.categories);
|
||||
return categories.map((c) => {
|
||||
const trail = nodes(c.path);
|
||||
return {
|
||||
id: String(c.id),
|
||||
name: text(c.name),
|
||||
parent: trail.length > 1 ? String(trail[trail.length - 2].id) : null,
|
||||
};
|
||||
});
|
||||
},
|
||||
async myminifactory() {
|
||||
const key = await getMyminifactoryApiKey();
|
||||
if (!key) throw new Error("no MyMiniFactory API key on this instance");
|
||||
// Pages its top-level categories, 30 at a time unless asked for more.
|
||||
const data = await json(
|
||||
`https://www.myminifactory.com/api/v2/categories?key=${encodeURIComponent(key)}&per_page=1000`,
|
||||
);
|
||||
return flatten(nodes((data as Node).items), (n) => ({
|
||||
id: n.id,
|
||||
name: n.name,
|
||||
children: isNode(n.children) ? n.children.items : n.children,
|
||||
}));
|
||||
},
|
||||
async snapmaker() {
|
||||
const data = await json("https://api-models.snapmaker.com/api/model/category/tree");
|
||||
// Its one root, "Categories", isn't a category of its own.
|
||||
const root = nodes((data as Node).data)[0];
|
||||
return flatten(nodes(root?.children), (n) => ({ id: n.id, name: n.nameKey, children: n.children }));
|
||||
},
|
||||
async makeronline() {
|
||||
const data = await json("https://www.makeronline.com/api/category/options");
|
||||
return flatten(nodes((data as Node).data), (n) => ({ id: n.id, name: n.name, children: n.children }));
|
||||
},
|
||||
async crealitycloud() {
|
||||
const data = await json("https://www.crealitycloud.com/api/cxy/v2/common/categoryList", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Origin: "https://www.crealitycloud.com",
|
||||
__cxy_app_id_: "cxy-gen2",
|
||||
__cxy_app_ver_: "7.3.28",
|
||||
__cxy_app_ch_: "Chrome 152.0.0.0",
|
||||
__cxy_brand_: "creality",
|
||||
__cxy_platform_: "2",
|
||||
__cxy_os_lang_: "0",
|
||||
__cxy_os_ver_: "macOS 10.15.7",
|
||||
__cxy_timezone_: "0",
|
||||
__cxy_duid_: randomUUID(),
|
||||
__cxy_requestid_: randomUUID(),
|
||||
},
|
||||
body: JSON.stringify({ type: 7 }),
|
||||
});
|
||||
const list = ((data as Node).result as Node)?.list;
|
||||
return flatten(nodes(list), (n) => ({ id: n.id, name: n.en_name || n.name, children: n.children }));
|
||||
},
|
||||
async nexprint() {
|
||||
const data = await json("https://www.nexprint.com/gateway/api/v1/model-library-server/model-classification/tree", {
|
||||
headers: { "client-id": "Nexprint", "user-lang": "en" },
|
||||
});
|
||||
return flatten(nodes((data as Node).data), (n) => ({
|
||||
id: n.id,
|
||||
name: n.classificationNameEn || n.name,
|
||||
children: n.children,
|
||||
}));
|
||||
},
|
||||
async thangs() {
|
||||
// Every page carries the tree; a missing one is the lightest.
|
||||
const data = await nextData("https://thangs.com/3d-models");
|
||||
const appData = (data.props as Node)?.appData as Node;
|
||||
const root = (appData?.["categories/root"] as Node)?.categories;
|
||||
const out: Entry[] = [];
|
||||
for (const category of nodes(root)) {
|
||||
const name = text(category.name);
|
||||
out.push({ id: name, name, parent: null });
|
||||
for (const sub of Array.isArray(category.subcategories) ? category.subcategories : []) {
|
||||
if (typeof sub === "string" && sub !== "All") out.push({ id: `${name}/${sub}`, name: sub, parent: name });
|
||||
}
|
||||
}
|
||||
return out;
|
||||
},
|
||||
};
|
||||
|
||||
(async () => {
|
||||
const catalog: Record<string, Entry[]> = {};
|
||||
let failed = false;
|
||||
for (const [provider, fetch] of Object.entries(FETCHERS)) {
|
||||
try {
|
||||
catalog[provider] = await fetch();
|
||||
console.log(`${provider}: ${catalog[provider].length} categories`);
|
||||
} catch (err) {
|
||||
failed = true;
|
||||
console.error(`${provider}: failed --`, err instanceof Error ? err.message : err);
|
||||
}
|
||||
}
|
||||
await prisma.$disconnect();
|
||||
if (failed) {
|
||||
console.error("Not written: every provider has to come through, or its names would be lost.");
|
||||
process.exit(1);
|
||||
}
|
||||
writeFileSync(OUT, `${JSON.stringify(catalog, null, 2)}\n`);
|
||||
console.log(`Wrote ${path.relative(process.cwd(), OUT)}`);
|
||||
})();
|
||||
@@ -8,8 +8,9 @@ Each model can sit in one **category** or one **folder**, both nested as deep as
|
||||
top of the Models page.
|
||||
|
||||
- **Categories** sort models by what they are, such as Tools > Measuring or Toys & Games > Puzzles. Imported models are
|
||||
placed by the source site's own category, through the provider category IDs each of your categories matches. The
|
||||
optional [AI categorization](../guides/ai.md) can sort the rest.
|
||||
placed by the source site's own category: each of your categories lists, per site, which of that site's categories
|
||||
land in it. Change them from a category's details, with **Manage** next to each site, by picking the site's
|
||||
categories by name. The optional [AI categorization](../guides/ai.md) can sort the rest.
|
||||
- **Folders** are your own tree, the way you'd lay it out on disk. [Uploading a folder](../guides/upload-folders.md)
|
||||
keeps its structure as folders.
|
||||
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import Box from "@mui/material/Box";
|
||||
import Button from "@mui/material/Button";
|
||||
import Divider from "@mui/material/Divider";
|
||||
import Stack from "@mui/material/Stack";
|
||||
import Typography from "@mui/material/Typography";
|
||||
import TuneIcon from "@mui/icons-material/Tune";
|
||||
import { IMPORT_PROVIDER_INFO } from "../../constants/importProviders";
|
||||
import { useDisabledProviders } from "../../hooks/useDisabledProviders";
|
||||
import type { ImportProvider } from "../../api/settings";
|
||||
import { ProviderLogo } from "../Layout/ConfigurationDialog/providerLogos";
|
||||
import ProviderCategoryPicker from "./ProviderCategoryPicker";
|
||||
import { indexCatalog, joinCatIds, loadProviderCatalog, splitCatIds, type CatalogCategory } from "./providerCatalog";
|
||||
|
||||
/** The providers a category can take imports from, in the order the rows show them. */
|
||||
export const MAPPED_PROVIDERS = [
|
||||
"makerworld",
|
||||
"printables",
|
||||
"thingiverse",
|
||||
"myminifactory",
|
||||
"snapmaker",
|
||||
"makeronline",
|
||||
"crealitycloud",
|
||||
"nexprint",
|
||||
"thangs",
|
||||
] as const satisfies readonly ImportProvider[];
|
||||
export type MappedProvider = (typeof MAPPED_PROVIDERS)[number];
|
||||
|
||||
type Props = {
|
||||
/** Each provider's stored ids, ";"-separated. */
|
||||
value: Record<MappedProvider, string>;
|
||||
onChange: (provider: MappedProvider, value: string) => void;
|
||||
/** The Thingport category being mapped. */
|
||||
categoryName: string;
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
/** One row per provider: what its imports bring into this category, by name, and a button to change it. Providers an
|
||||
* admin turned off are hidden; their mapping is kept. */
|
||||
export default function ProviderCategoryMappings({ value, onChange, categoryName, disabled }: Props) {
|
||||
const { t } = useTranslation("models");
|
||||
const disabledProviders = useDisabledProviders();
|
||||
const [indexes, setIndexes] = useState<Record<string, Map<string, CatalogCategory>> | null>(null);
|
||||
const [editing, setEditing] = useState<MappedProvider | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
loadProviderCatalog()
|
||||
.then((catalog) => {
|
||||
if (!active) return;
|
||||
setIndexes(Object.fromEntries(Object.entries(catalog).map(([id, entries]) => [id, indexCatalog(entries)])));
|
||||
})
|
||||
// The names are a nicety here: without them the rows still count, and the picker says what went wrong.
|
||||
.catch(() => undefined);
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const shown = MAPPED_PROVIDERS.filter((provider) => !disabledProviders.has(provider));
|
||||
|
||||
return (
|
||||
<>
|
||||
<Box sx={{ border: 1, borderColor: "divider", borderRadius: "12px", overflow: "hidden" }}>
|
||||
{shown.map((provider, i) => {
|
||||
const ids = splitCatIds(value[provider]);
|
||||
const names = ids.map(
|
||||
(id) => indexes?.[provider]?.get(id)?.name ?? t("categories.manager.mapping.unknown", { id }),
|
||||
);
|
||||
const name = IMPORT_PROVIDER_INFO[provider].label;
|
||||
return (
|
||||
<Box key={provider}>
|
||||
{i > 0 && <Divider />}
|
||||
<Stack direction="row" alignItems="center" spacing={1.5} sx={{ px: 1.5, py: 1 }}>
|
||||
<Box sx={{ color: "text.primary", flexShrink: 0 }}>
|
||||
<ProviderLogo id={provider} size={22} />
|
||||
</Box>
|
||||
<Box sx={{ minWidth: 0, flex: 1 }}>
|
||||
<Typography variant="body2" fontWeight={600} noWrap>
|
||||
{name}
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="caption"
|
||||
color={ids.length ? "text.secondary" : "text.disabled"}
|
||||
noWrap
|
||||
component="p"
|
||||
title={names.join(", ")}
|
||||
>
|
||||
{ids.length ? names.join(", ") : t("categories.manager.mapping.none")}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Button
|
||||
size="small"
|
||||
variant={ids.length ? "contained" : "outlined"}
|
||||
disableElevation
|
||||
startIcon={<TuneIcon fontSize="small" />}
|
||||
onClick={() => setEditing(provider)}
|
||||
disabled={disabled}
|
||||
aria-label={t("categories.manager.mapping.manageLabel", { provider: name, count: ids.length })}
|
||||
sx={{ flexShrink: 0, whiteSpace: "nowrap" }}
|
||||
>
|
||||
{t("categories.manager.mapping.manage", { count: ids.length })}
|
||||
</Button>
|
||||
</Stack>
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
{editing && (
|
||||
<ProviderCategoryPicker
|
||||
provider={editing}
|
||||
providerName={IMPORT_PROVIDER_INFO[editing].label}
|
||||
categoryName={categoryName}
|
||||
selected={splitCatIds(value[editing])}
|
||||
onClose={() => setEditing(null)}
|
||||
onDone={(ids) => {
|
||||
onChange(editing, joinCatIds(ids));
|
||||
setEditing(null);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,240 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import Box from "@mui/material/Box";
|
||||
import Button from "@mui/material/Button";
|
||||
import Chip from "@mui/material/Chip";
|
||||
import Dialog from "@mui/material/Dialog";
|
||||
import DialogActions from "@mui/material/DialogActions";
|
||||
import DialogContent from "@mui/material/DialogContent";
|
||||
import DialogTitle from "@mui/material/DialogTitle";
|
||||
import InputAdornment from "@mui/material/InputAdornment";
|
||||
import Skeleton from "@mui/material/Skeleton";
|
||||
import Stack from "@mui/material/Stack";
|
||||
import TextField from "@mui/material/TextField";
|
||||
import Typography from "@mui/material/Typography";
|
||||
import { keyframes } from "@mui/material/styles";
|
||||
import CheckIcon from "@mui/icons-material/Check";
|
||||
import SearchIcon from "@mui/icons-material/Search";
|
||||
import { ProviderLogo, type ProviderId } from "../Layout/ConfigurationDialog/providerLogos";
|
||||
import { SectionLabel } from "../Layout/ConfigurationDialog/parts";
|
||||
import { indexCatalog, loadProviderCatalog, type CatalogCategory } from "./providerCatalog";
|
||||
|
||||
type Props = {
|
||||
provider: ProviderId;
|
||||
providerName: string;
|
||||
/** The Thingport category being mapped, for the title. */
|
||||
categoryName: string;
|
||||
selected: string[];
|
||||
onClose: () => void;
|
||||
onDone: (ids: string[]) => void;
|
||||
};
|
||||
|
||||
const appear = keyframes`
|
||||
from { opacity: 0; transform: scale(0.9); }
|
||||
to { opacity: 1; transform: none; }
|
||||
`;
|
||||
|
||||
const chipMotion = {
|
||||
transition: "background-color 150ms ease, color 150ms ease, border-color 150ms ease",
|
||||
"@media (prefers-reduced-motion: reduce)": { transition: "none", animation: "none" },
|
||||
} as const;
|
||||
|
||||
/** A provider's categories as chips to pick, grouped under their top-level ones, with search. Picks are kept here
|
||||
* until Done; ids the catalog doesn't know (a category the site has since dropped) stay picked, shown by id. */
|
||||
export default function ProviderCategoryPicker({
|
||||
provider,
|
||||
providerName,
|
||||
categoryName,
|
||||
selected: initial,
|
||||
onClose,
|
||||
onDone,
|
||||
}: Props) {
|
||||
const { t } = useTranslation(["models", "common"]);
|
||||
const [index, setIndex] = useState<Map<string, CatalogCategory> | null>(null);
|
||||
const [failed, setFailed] = useState(false);
|
||||
const [selected, setSelected] = useState(initial);
|
||||
const [query, setQuery] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
loadProviderCatalog()
|
||||
.then((catalog) => active && setIndex(indexCatalog(catalog[provider] ?? [])))
|
||||
.catch(() => active && setFailed(true));
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, [provider]);
|
||||
|
||||
const picked = new Set(selected);
|
||||
const toggle = (id: string) =>
|
||||
setSelected((current) => (current.includes(id) ? current.filter((x) => x !== id) : [...current, id]));
|
||||
|
||||
// Top-level categories in the site's order, each with everything under it that matches the search. A top-level
|
||||
// one that matches brings all of its own.
|
||||
const groups = useMemo(() => {
|
||||
if (!index) return [];
|
||||
const needle = query.trim().toLowerCase();
|
||||
const matches = (category: CatalogCategory) => category.path.join(" ").toLowerCase().includes(needle);
|
||||
const all = [...index.values()];
|
||||
return all
|
||||
.filter((category) => category.id === category.root)
|
||||
.flatMap((root) => {
|
||||
const children = all.filter((category) => category.root === root.id && category.id !== root.id);
|
||||
const shown = !needle || matches(root) ? children : children.filter(matches);
|
||||
return !needle || matches(root) || shown.length ? [{ root, children: shown }] : [];
|
||||
});
|
||||
}, [index, query]);
|
||||
|
||||
const labelOf = (id: string) => {
|
||||
const category = index?.get(id);
|
||||
return category ? category.name : t("categories.manager.mapping.unknown", { id });
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open onClose={onClose} fullWidth maxWidth="sm" scroll="paper">
|
||||
<DialogTitle sx={{ pb: 1 }}>
|
||||
<Stack direction="row" alignItems="center" spacing={1.25}>
|
||||
<ProviderLogo id={provider} size={22} />
|
||||
<Box sx={{ minWidth: 0 }}>
|
||||
<Typography variant="subtitle1" fontWeight={600} noWrap>
|
||||
{t("categories.manager.mapping.pickerTitle", { provider: providerName })}
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary" component="p" noWrap>
|
||||
{t("categories.manager.mapping.pickerSubtitle", { provider: providerName, category: categoryName })}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Stack>
|
||||
<TextField
|
||||
size="small"
|
||||
fullWidth
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder={t("categories.manager.mapping.search", { provider: providerName }) ?? undefined}
|
||||
// oxlint-disable-next-line jsx-a11y/no-autofocus
|
||||
autoFocus
|
||||
sx={{ mt: 1.5 }}
|
||||
slotProps={{
|
||||
input: {
|
||||
startAdornment: (
|
||||
<InputAdornment position="start">
|
||||
<SearchIcon fontSize="small" />
|
||||
</InputAdornment>
|
||||
),
|
||||
},
|
||||
htmlInput: { "aria-label": t("categories.manager.mapping.search", { provider: providerName }) },
|
||||
}}
|
||||
/>
|
||||
</DialogTitle>
|
||||
<DialogContent dividers>
|
||||
{selected.length > 0 && (
|
||||
<Box sx={{ mb: 2 }}>
|
||||
<SectionLabel>{t("categories.manager.mapping.selected", { count: selected.length })}</SectionLabel>
|
||||
<Stack direction="row" flexWrap="wrap" gap={0.75}>
|
||||
{selected.map((id) => (
|
||||
<Chip
|
||||
key={id}
|
||||
size="small"
|
||||
color="primary"
|
||||
label={labelOf(id)}
|
||||
title={index?.get(id)?.path.join(" › ")}
|
||||
onDelete={() => toggle(id)}
|
||||
sx={{ ...chipMotion, animation: `${appear} 150ms ease` }}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{failed ? (
|
||||
<Typography variant="body2" color="error">
|
||||
{t("categories.manager.mapping.loadFailed")}
|
||||
</Typography>
|
||||
) : !index ? (
|
||||
<Stack spacing={2} aria-busy="true">
|
||||
{[0, 1, 2].map((row) => (
|
||||
<Box key={row}>
|
||||
<Skeleton variant="text" width="30%" />
|
||||
<Stack direction="row" gap={0.75} flexWrap="wrap">
|
||||
{[64, 88, 72, 96, 56].map((width) => (
|
||||
<Skeleton key={width} variant="rounded" width={width} height={24} sx={{ borderRadius: 4 }} />
|
||||
))}
|
||||
</Stack>
|
||||
</Box>
|
||||
))}
|
||||
</Stack>
|
||||
) : groups.length === 0 ? (
|
||||
<Typography variant="body2" color="text.secondary" sx={{ py: 3, textAlign: "center" }}>
|
||||
{query.trim()
|
||||
? t("categories.manager.mapping.noMatches", { query: query.trim() })
|
||||
: t("categories.manager.mapping.empty", { provider: providerName })}
|
||||
</Typography>
|
||||
) : (
|
||||
<Stack spacing={2}>
|
||||
{groups.map(({ root, children }) => (
|
||||
<Box key={root.id}>
|
||||
<CategoryChip label={root.name} selected={picked.has(root.id)} onClick={() => toggle(root.id)} strong />
|
||||
{children.length > 0 && (
|
||||
<Stack
|
||||
direction="row"
|
||||
flexWrap="wrap"
|
||||
gap={0.75}
|
||||
sx={{ mt: 0.75, pl: 1.5, borderLeft: 2, borderColor: "divider" }}
|
||||
>
|
||||
{children.map((category) => (
|
||||
<CategoryChip
|
||||
key={category.id}
|
||||
label={category.name}
|
||||
// Deeper ones say what they sit under.
|
||||
title={category.path.length > 2 ? category.path.slice(1).join(" › ") : undefined}
|
||||
selected={picked.has(category.id)}
|
||||
onClick={() => toggle(category.id)}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
)}
|
||||
</Box>
|
||||
))}
|
||||
</Stack>
|
||||
)}
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setSelected([])} disabled={!selected.length} sx={{ mr: "auto" }}>
|
||||
{t("categories.manager.mapping.clearAll")}
|
||||
</Button>
|
||||
<Button onClick={onClose}>{t("common:cancel")}</Button>
|
||||
<Button variant="contained" onClick={() => onDone(selected)}>
|
||||
{t("categories.manager.mapping.done")}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function CategoryChip({
|
||||
label,
|
||||
title,
|
||||
selected,
|
||||
onClick,
|
||||
strong = false,
|
||||
}: {
|
||||
label: string;
|
||||
title?: string;
|
||||
selected: boolean;
|
||||
onClick: () => void;
|
||||
strong?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<Chip
|
||||
size="small"
|
||||
label={label}
|
||||
title={title}
|
||||
clickable
|
||||
onClick={onClick}
|
||||
aria-pressed={selected}
|
||||
color={selected ? "primary" : "default"}
|
||||
variant={selected ? "filled" : "outlined"}
|
||||
icon={selected ? <CheckIcon /> : undefined}
|
||||
sx={{ ...chipMotion, ...(strong && { fontWeight: 700 }) }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
// Every provider's own category tree, generated by backend/scripts/syncProviderCategories.ts. Loaded only when the
|
||||
// mapping is edited: it's large, MyMiniFactory's alone near a thousand.
|
||||
|
||||
export type CatalogEntry = { id: string; name: string; parent: string | null };
|
||||
export type Catalog = Record<string, CatalogEntry[]>;
|
||||
|
||||
/** One of a provider's categories, with where it sits: its top-level category, and its names from the top. */
|
||||
export type CatalogCategory = CatalogEntry & { root: string; path: string[] };
|
||||
|
||||
let loading: Promise<Catalog> | null = null;
|
||||
|
||||
export function loadProviderCatalog(): Promise<Catalog> {
|
||||
loading ??= import("./providerCategories.json").then((module) => module.default as Catalog);
|
||||
return loading;
|
||||
}
|
||||
|
||||
/** A provider's categories by id, each knowing its top-level category and path. */
|
||||
export function indexCatalog(entries: CatalogEntry[]): Map<string, CatalogCategory> {
|
||||
const byId = new Map(entries.map((entry) => [entry.id, entry]));
|
||||
const index = new Map<string, CatalogCategory>();
|
||||
for (const entry of entries) {
|
||||
const path: string[] = [];
|
||||
let current: CatalogEntry | undefined = entry;
|
||||
let root = entry.id;
|
||||
// Guards against a loop in the data, which would never end.
|
||||
for (let depth = 0; current && depth < 10; depth++) {
|
||||
path.unshift(current.name);
|
||||
root = current.id;
|
||||
current = current.parent ? byId.get(current.parent) : undefined;
|
||||
}
|
||||
index.set(entry.id, { ...entry, root, path });
|
||||
}
|
||||
return index;
|
||||
}
|
||||
|
||||
/** The stored form, "800;71;1001" (or Thangs' category paths), as ids. */
|
||||
export function splitCatIds(value: string): string[] {
|
||||
return [
|
||||
...new Set(
|
||||
value
|
||||
.split(";")
|
||||
.map((id) => id.trim())
|
||||
.filter(Boolean),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
export function joinCatIds(ids: string[]): string {
|
||||
return ids.join(";");
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -2768,20 +2768,24 @@
|
||||
"metaDialogTitle": "Details — {{name}}",
|
||||
"metaTitleLabel": "Meta title",
|
||||
"metaDescriptionLabel": "Meta description",
|
||||
"makerworldCatIdLabel": "MakerWorld category IDs",
|
||||
"thingiverseCatIdLabel": "Thingiverse category IDs",
|
||||
"printablesCatIdLabel": "Printables category IDs",
|
||||
"myminifactoryCatIdLabel": "MyMiniFactory category IDs",
|
||||
"snapmakerCatIdLabel": "Snapmaker Space category IDs",
|
||||
"makeronlineCatIdLabel": "MakerOnline category IDs",
|
||||
"crealitycloudCatIdLabel": "Creality Cloud category IDs",
|
||||
"nexprintCatIdLabel": "NexPrint category IDs",
|
||||
"thangsCatIdLabel": "Thangs category paths",
|
||||
"thangsCatPathsPlaceholder": "Toys & Games/Puzzles;Toys & Games",
|
||||
"thangsCatPathsHelp": "Thangs names categories rather than numbering them: separate paths with ;",
|
||||
"catIdsPlaceholder": "e.g. 800;71;1001",
|
||||
"catIdsHelp": "Separate multiple IDs with \";\" -- matches if an import's category is any of these",
|
||||
"dragToMove": "Drag to move"
|
||||
"dragToMove": "Drag to move",
|
||||
"mapping": {
|
||||
"heading": "Source categories",
|
||||
"help": "Models imported from these categories on each site land in \"{{category}}\". Pick them with Manage.",
|
||||
"none": "None",
|
||||
"manage": "Manage ({{count}})",
|
||||
"manageLabel": "Manage {{provider}} categories, {{count}} picked",
|
||||
"pickerTitle": "{{provider}} categories",
|
||||
"pickerSubtitle": "Imports from the ones you pick land in \"{{category}}\"",
|
||||
"search": "Search {{provider}} categories",
|
||||
"selected": "Picked ({{count}})",
|
||||
"clearAll": "Clear all",
|
||||
"done": "Done",
|
||||
"noMatches": "No categories match \"{{query}}\".",
|
||||
"empty": "No categories known for {{provider}}.",
|
||||
"loadFailed": "Couldn't load the category list. Close this and try again.",
|
||||
"unknown": "Unknown #{{id}}"
|
||||
}
|
||||
}
|
||||
},
|
||||
"view": {
|
||||
|
||||
@@ -2843,20 +2843,24 @@
|
||||
"metaDialogTitle": "Informacija — {{name}}",
|
||||
"metaTitleLabel": "Meta pavadinimas",
|
||||
"metaDescriptionLabel": "Meta aprašymas",
|
||||
"makerworldCatIdLabel": "MakerWorld kategorijų ID",
|
||||
"thingiverseCatIdLabel": "Thingiverse kategorijų ID",
|
||||
"printablesCatIdLabel": "Printables kategorijų ID",
|
||||
"myminifactoryCatIdLabel": "MyMiniFactory kategorijų ID",
|
||||
"snapmakerCatIdLabel": "Snapmaker Space kategorijų ID",
|
||||
"makeronlineCatIdLabel": "MakerOnline kategorijų ID",
|
||||
"crealitycloudCatIdLabel": "Creality Cloud kategorijų ID",
|
||||
"nexprintCatIdLabel": "NexPrint kategorijų ID",
|
||||
"thangsCatIdLabel": "Thangs kategorijų keliai",
|
||||
"thangsCatPathsPlaceholder": "Toys & Games/Puzzles;Toys & Games",
|
||||
"thangsCatPathsHelp": "Thangs kategorijas įvardija, o ne numeruoja: kelius atskirkite ;",
|
||||
"catIdsPlaceholder": "pvz. 800;71;1001",
|
||||
"catIdsHelp": "Kelis ID atskirkite „;“ -- atitiks, jei importo kategorija sutampa su bet kuriuo iš jų",
|
||||
"dragToMove": "Vilkite, kad perkeltumėte"
|
||||
"dragToMove": "Vilkite, kad perkeltumėte",
|
||||
"mapping": {
|
||||
"heading": "Šaltinių kategorijos",
|
||||
"help": "Iš šių kiekvienos svetainės kategorijų importuoti modeliai pateks į „{{category}}“. Pasirinkite jas mygtuku Tvarkyti.",
|
||||
"none": "Nėra",
|
||||
"manage": "Tvarkyti ({{count}})",
|
||||
"manageLabel": "Tvarkyti {{provider}} kategorijas, pasirinkta {{count}}",
|
||||
"pickerTitle": "{{provider}} kategorijos",
|
||||
"pickerSubtitle": "Importai iš pasirinktų pateks į „{{category}}“",
|
||||
"search": "Ieškoti {{provider}} kategorijų",
|
||||
"selected": "Pasirinkta ({{count}})",
|
||||
"clearAll": "Išvalyti viską",
|
||||
"done": "Atlikta",
|
||||
"noMatches": "Jokia kategorija neatitinka „{{query}}“.",
|
||||
"empty": "{{provider}} kategorijų sąrašas nežinomas.",
|
||||
"loadFailed": "Nepavyko įkelti kategorijų sąrašo. Uždarykite ir bandykite dar kartą.",
|
||||
"unknown": "Nežinoma #{{id}}"
|
||||
}
|
||||
}
|
||||
},
|
||||
"view": {
|
||||
|
||||
@@ -11,6 +11,11 @@ import Button from "@mui/material/Button";
|
||||
import Alert from "@mui/material/Alert";
|
||||
import CircularProgress from "@mui/material/CircularProgress";
|
||||
import type { Category, CategoryMetaInput } from "../../api/categories";
|
||||
import HelpTip from "../../components/HelpTip";
|
||||
import { SectionLabel } from "../../components/Layout/ConfigurationDialog/parts";
|
||||
import ProviderCategoryMappings, {
|
||||
type MappedProvider,
|
||||
} from "../../components/categoryMapping/ProviderCategoryMappings";
|
||||
|
||||
type Props = {
|
||||
category: Category;
|
||||
@@ -20,22 +25,24 @@ type Props = {
|
||||
onSave: (meta: CategoryMetaInput) => Promise<void>;
|
||||
};
|
||||
|
||||
/** Blank fields clear the metadata. Imports whose site category id matches any listed id
|
||||
* (";"-separated) land in this category. Validation errors show inline and keep the edits. */
|
||||
/** Blank fields clear the metadata. Imports whose site category matches one picked for its provider land in this
|
||||
* category. Validation errors show inline and keep the edits. */
|
||||
export default function CategoryMetaDialog({ category, showSiteCatIds, onClose, onSave }: Props) {
|
||||
const { t } = useTranslation(["models", "common"]);
|
||||
const untitledLabel = t("models:categories.untitled");
|
||||
const [title, setTitle] = useState(category.meta_title ?? "");
|
||||
const [description, setDescription] = useState(category.meta_description ?? "");
|
||||
const [makerworldCatIds, setMakerworldCatIds] = useState(category.makerworld_cat_ids);
|
||||
const [thingiverseCatIds, setThingiverseCatIds] = useState(category.thingiverse_cat_ids);
|
||||
const [printablesCatIds, setPrintablesCatIds] = useState(category.printables_cat_ids);
|
||||
const [myminifactoryCatIds, setMyminifactoryCatIds] = useState(category.myminifactory_cat_ids);
|
||||
const [snapmakerCatIds, setSnapmakerCatIds] = useState(category.snapmaker_cat_ids);
|
||||
const [makeronlineCatIds, setMakeronlineCatIds] = useState(category.makeronline_cat_ids);
|
||||
const [crealitycloudCatIds, setCrealitycloudCatIds] = useState(category.crealitycloud_cat_ids);
|
||||
const [nexprintCatIds, setNexprintCatIds] = useState(category.nexprint_cat_ids);
|
||||
const [thangsCatIds, setThangsCatIds] = useState(category.thangs_cat_ids);
|
||||
const [catIds, setCatIds] = useState<Record<MappedProvider, string>>({
|
||||
makerworld: category.makerworld_cat_ids,
|
||||
thingiverse: category.thingiverse_cat_ids,
|
||||
printables: category.printables_cat_ids,
|
||||
myminifactory: category.myminifactory_cat_ids,
|
||||
snapmaker: category.snapmaker_cat_ids,
|
||||
makeronline: category.makeronline_cat_ids,
|
||||
crealitycloud: category.crealitycloud_cat_ids,
|
||||
nexprint: category.nexprint_cat_ids,
|
||||
thangs: category.thangs_cat_ids,
|
||||
});
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
@@ -46,15 +53,15 @@ export default function CategoryMetaDialog({ category, showSiteCatIds, onClose,
|
||||
await onSave({
|
||||
metaTitle: title.trim() || null,
|
||||
metaDescription: description.trim() || null,
|
||||
makerworldCatIds,
|
||||
thingiverseCatIds,
|
||||
printablesCatIds,
|
||||
myminifactoryCatIds,
|
||||
snapmakerCatIds,
|
||||
makeronlineCatIds,
|
||||
crealitycloudCatIds,
|
||||
nexprintCatIds,
|
||||
thangsCatIds,
|
||||
makerworldCatIds: catIds.makerworld,
|
||||
thingiverseCatIds: catIds.thingiverse,
|
||||
printablesCatIds: catIds.printables,
|
||||
myminifactoryCatIds: catIds.myminifactory,
|
||||
snapmakerCatIds: catIds.snapmaker,
|
||||
makeronlineCatIds: catIds.makeronline,
|
||||
crealitycloudCatIds: catIds.crealitycloud,
|
||||
nexprintCatIds: catIds.nexprint,
|
||||
thangsCatIds: catIds.thangs,
|
||||
});
|
||||
onClose();
|
||||
} catch (err) {
|
||||
@@ -90,87 +97,17 @@ export default function CategoryMetaDialog({ category, showSiteCatIds, onClose,
|
||||
disabled={saving}
|
||||
/>
|
||||
{showSiteCatIds && (
|
||||
// Two to a row, one column on a phone.
|
||||
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", sm: "1fr 1fr" }, gap: 2 }}>
|
||||
<TextField
|
||||
label={t("models:categories.manager.makerworldCatIdLabel")}
|
||||
placeholder={t("models:categories.manager.catIdsPlaceholder") ?? undefined}
|
||||
helperText={t("models:categories.manager.catIdsHelp")}
|
||||
fullWidth
|
||||
value={makerworldCatIds}
|
||||
onChange={(e) => setMakerworldCatIds(e.target.value)}
|
||||
disabled={saving}
|
||||
/>
|
||||
<TextField
|
||||
label={t("models:categories.manager.thingiverseCatIdLabel")}
|
||||
placeholder={t("models:categories.manager.catIdsPlaceholder") ?? undefined}
|
||||
helperText={t("models:categories.manager.catIdsHelp")}
|
||||
fullWidth
|
||||
value={thingiverseCatIds}
|
||||
onChange={(e) => setThingiverseCatIds(e.target.value)}
|
||||
disabled={saving}
|
||||
/>
|
||||
<TextField
|
||||
label={t("models:categories.manager.printablesCatIdLabel")}
|
||||
placeholder={t("models:categories.manager.catIdsPlaceholder") ?? undefined}
|
||||
helperText={t("models:categories.manager.catIdsHelp")}
|
||||
fullWidth
|
||||
value={printablesCatIds}
|
||||
onChange={(e) => setPrintablesCatIds(e.target.value)}
|
||||
disabled={saving}
|
||||
/>
|
||||
<TextField
|
||||
label={t("models:categories.manager.myminifactoryCatIdLabel")}
|
||||
placeholder={t("models:categories.manager.catIdsPlaceholder") ?? undefined}
|
||||
helperText={t("models:categories.manager.catIdsHelp")}
|
||||
fullWidth
|
||||
value={myminifactoryCatIds}
|
||||
onChange={(e) => setMyminifactoryCatIds(e.target.value)}
|
||||
disabled={saving}
|
||||
/>
|
||||
<TextField
|
||||
label={t("models:categories.manager.snapmakerCatIdLabel")}
|
||||
placeholder={t("models:categories.manager.catIdsPlaceholder") ?? undefined}
|
||||
helperText={t("models:categories.manager.catIdsHelp")}
|
||||
fullWidth
|
||||
value={snapmakerCatIds}
|
||||
onChange={(e) => setSnapmakerCatIds(e.target.value)}
|
||||
disabled={saving}
|
||||
/>
|
||||
<TextField
|
||||
label={t("models:categories.manager.makeronlineCatIdLabel")}
|
||||
placeholder={t("models:categories.manager.catIdsPlaceholder") ?? undefined}
|
||||
helperText={t("models:categories.manager.catIdsHelp")}
|
||||
fullWidth
|
||||
value={makeronlineCatIds}
|
||||
onChange={(e) => setMakeronlineCatIds(e.target.value)}
|
||||
disabled={saving}
|
||||
/>
|
||||
<TextField
|
||||
label={t("models:categories.manager.crealitycloudCatIdLabel")}
|
||||
placeholder={t("models:categories.manager.catIdsPlaceholder") ?? undefined}
|
||||
helperText={t("models:categories.manager.catIdsHelp")}
|
||||
fullWidth
|
||||
value={crealitycloudCatIds}
|
||||
onChange={(e) => setCrealitycloudCatIds(e.target.value)}
|
||||
disabled={saving}
|
||||
/>
|
||||
<TextField
|
||||
label={t("models:categories.manager.nexprintCatIdLabel")}
|
||||
placeholder={t("models:categories.manager.catIdsPlaceholder") ?? undefined}
|
||||
helperText={t("models:categories.manager.catIdsHelp")}
|
||||
fullWidth
|
||||
value={nexprintCatIds}
|
||||
onChange={(e) => setNexprintCatIds(e.target.value)}
|
||||
disabled={saving}
|
||||
/>
|
||||
<TextField
|
||||
label={t("models:categories.manager.thangsCatIdLabel")}
|
||||
placeholder={t("models:categories.manager.thangsCatPathsPlaceholder") ?? undefined}
|
||||
helperText={t("models:categories.manager.thangsCatPathsHelp")}
|
||||
fullWidth
|
||||
value={thangsCatIds}
|
||||
onChange={(e) => setThangsCatIds(e.target.value)}
|
||||
<Box>
|
||||
<SectionLabel>
|
||||
{t("models:categories.manager.mapping.heading")}
|
||||
<HelpTip
|
||||
text={t("models:categories.manager.mapping.help", { category: category.name || untitledLabel })}
|
||||
/>
|
||||
</SectionLabel>
|
||||
<ProviderCategoryMappings
|
||||
value={catIds}
|
||||
onChange={(provider, value) => setCatIds((current) => ({ ...current, [provider]: value }))}
|
||||
categoryName={category.name || untitledLabel}
|
||||
disabled={saving}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
Reference in new issue
Block a user