feat(extension): allow importing to new custom collections

This commit is contained in:
Tautvydas Derzinskas committed 2026-10-05 21:06:26 +02:00
1 parent 0325f16c85
commit a5ac824110
4 files changed
+84 -25

No files matched your search

@@ -1,9 +1,28 @@
// Single-model imports only; batch imports get their own auto-named collection.
// The "Add to collection" picker on single-model imports (batch imports get their own auto-named
// collection), and the find-or-create both of them file into.
import type { Collection } from "../../shared/api";
import { api, escapeHtml } from "../runtime";
import { panelQuery } from "../shell";
const NEW_COLLECTION = "__new__";
/** Case-insensitive match on name, created if missing. Null on failure; the import proceeds. */
export async function findOrCreateCollection(name: string | null): Promise<string | null> {
const trimmed = (name || "").trim();
if (!trimmed) return null;
const normalized = trimmed.toLowerCase();
try {
const collections = await api<Collection[]>("GET", "/collections");
const existing = collections.find((c) => !c.system_key && (c.name || "").trim().toLowerCase() === normalized);
if (existing) return existing.id;
return (await api<Collection>("POST", "/collections", { name: trimmed })).id;
} catch {
return null;
}
}
/** Call wireCollectionPicker() once it's rendered. */
export async function collectionPickerHtml(): Promise<string> {
try {
const collections = await api<Collection[]>("GET", "/collections");
@@ -11,16 +30,59 @@ export async function collectionPickerHtml(): Promise<string> {
.filter((c) => !c.system_key)
.map((c) => `<option value="${escapeHtml(c.id)}">${escapeHtml(c.name)}</option>`)
.join("");
// <hr> draws a divider in the dropdown; browsers too old for that drop it.
return `
<label class="tg-label" for="tg-collection">Add to collection (optional)</label>
<select id="tg-collection" class="tg-select"><option value="">No collection</option>${options}</select>
<select id="tg-collection" class="tg-select">
<option value="">No collection</option>
<hr />
${options ? `${options}<hr />` : ""}
<option value="${NEW_COLLECTION}">Create new collection</option>
</select>
<div id="tg-new-collection-row" hidden>
<label class="tg-label" for="tg-new-collection">New collection name</label>
<input id="tg-new-collection" class="tg-input" type="text" maxlength="200" autocomplete="off" />
</div>
`;
} catch {
return ""; // import still works without the picker
}
}
export function selectedCollectionId(): string | null {
/** Shows the name field while "Create new collection" is picked. */
export function wireCollectionPicker(): void {
const select = panelQuery<HTMLSelectElement>("#tg-collection");
return select && select.value ? select.value : null;
const row = panelQuery<HTMLElement>("#tg-new-collection-row");
const input = panelQuery<HTMLInputElement>("#tg-new-collection");
if (!select || !row || !input) return;
select.addEventListener("change", () => {
const creating = select.value === NEW_COLLECTION;
row.hidden = !creating;
input.required = creating;
if (creating) input.focus();
});
}
export type CollectionChoice = { id: string } | { newName: string } | null;
/** Read when the import starts, before SPA navigation can clear the panel. "missing-name" means
* "Create new collection" has no name yet; the field says so and the import should wait. */
export function readCollectionChoice(): CollectionChoice | "missing-name" {
const select = panelQuery<HTMLSelectElement>("#tg-collection");
if (!select || !select.value) return null;
if (select.value !== NEW_COLLECTION) return { id: select.value };
const input = panelQuery<HTMLInputElement>("#tg-new-collection");
const name = input?.value.trim() ?? "";
if (!name) {
input?.reportValidity();
input?.focus();
return "missing-name";
}
return { newName: name };
}
/** A new name files into the collection already called that, if there is one. */
export async function collectionIdFor(choice: CollectionChoice): Promise<string | null> {
if (!choice) return null;
return "id" in choice ? choice.id : findOrCreateCollection(choice.newName);
}
@@ -3,10 +3,11 @@
// it has as many as the page's own data says the collection holds.
// Every not-yet-imported model is queued.
import type { Collection, ImportStatus } from "../../shared/api";
import type { ImportStatus } from "../../shared/api";
import { request } from "../../shared/messages";
import { makerworldModelUrl } from "../../shared/urls";
import { ctx } from "../context";
import { findOrCreateCollection } from "./collectionPicker";
import { NO_MORE_DATA } from "../makerworld/labels";
import { loadMakerworldCollectionTotal } from "../makerworld/pageData";
import { mountScanOverlay } from "../overlays";
@@ -75,21 +76,6 @@ async function scrollToEnd(
return [...ids];
}
/** Case-insensitive match on title, created if missing. Null on failure; the import proceeds. */
async function findOrCreateCollection(name: string | null): Promise<string | null> {
const trimmed = (name || "").trim();
if (!trimmed) return null;
const normalized = trimmed.toLowerCase();
try {
const collections = await api<Collection[]>("GET", "/collections");
const existing = collections.find((c) => !c.system_key && (c.name || "").trim().toLowerCase() === normalized);
if (existing) return existing.id;
return (await api<Collection>("POST", "/collections", { name: trimmed })).id;
} catch {
return null;
}
}
// The first is the default. Longer waits let a big run go unattended under MakerWorld's rate limit.
const STEP_DELAY_CHOICES = [
{ ms: 5_000, label: "5 seconds" },
+14 -4
View File
@@ -13,7 +13,7 @@ import {
} from "../makerworld/pageData";
import { api, escapeHtml } from "../runtime";
import { onPanelAction, panelQuery, panelQueryAll, renderPanel } from "../shell";
import { collectionPickerHtml, selectedCollectionId } from "./collectionPicker";
import { collectionIdFor, collectionPickerHtml, readCollectionChoice, wireCollectionPicker } from "./collectionPicker";
import { errorHtml, statusHtml, successHtml } from "./results";
import { checkingLinkPhrases, importingPhrases, renderFunStatus, updatingPhrases, zipFilesPhrases } from "./funStatus";
@@ -71,7 +71,12 @@ function queueButtonHtml(): string {
/** Queues with the panel's current choices. Picking files out of a zip needs the download now, so
* only the whole-link imports offer it. */
function onQueueAction(): void {
onPanelAction("queue", () => void runQueueImport(selectedProfileScope(), selectedCollectionId()));
onPanelAction("queue", () => {
const choice = readCollectionChoice();
if (choice === "missing-name") return;
const scope = selectedProfileScope();
void collectionIdFor(choice).then((collectionId) => runQueueImport(scope, collectionId));
});
}
export async function loadSingleItem(): Promise<void> {
@@ -110,6 +115,7 @@ export async function loadSingleItem(): Promise<void> {
<button class="tg-btn" type="button" data-action="import">Import</button>
${queueButtonHtml()}
`);
wireCollectionPicker();
onPanelAction("import", () => void runDirectImport());
onQueueAction();
return;
@@ -123,6 +129,7 @@ export async function loadSingleItem(): Promise<void> {
${queueButtonHtml()}
<button class="tg-btn tg-btn--secondary" type="button" data-action="choose-files">Choose files…</button>
`);
wireCollectionPicker();
onPanelAction("import-as-zip", () => void runDirectImport());
onQueueAction();
onPanelAction("choose-files", () => void loadZipEntries());
@@ -189,6 +196,7 @@ async function loadZipEntries(): Promise<void> {
${await collectionPickerHtml()}
<button class="tg-btn" type="button" data-action="import">Import selected</button>
`);
wireCollectionPicker();
onPanelAction("import", () => {
const entries = panelQueryAll<HTMLInputElement>(".tg-entry__checkbox:checked").map((el) => el.value);
if (entries.length) void runDirectImport({ entries });
@@ -197,14 +205,16 @@ async function loadZipEntries(): Promise<void> {
async function runDirectImport(opts?: { entries?: string[] }): Promise<void> {
// Captured up front: SPA navigation clears the context mid-import.
const collectionId = selectedCollectionId();
const choice = readCollectionChoice();
if (choice === "missing-name") return;
const scope = selectedProfileScope();
if (scope !== "url") {
await runProfilesImport(scope, collectionId);
await runProfilesImport(scope, await collectionIdFor(choice));
return;
}
const { url, instanceUrl, classification, title } = ctx();
renderFunStatus(importingPhrases(classification.provider));
const collectionId = await collectionIdFor(choice);
const resolved =
classification.provider === "makerworld" && classification.type === "model"
? await resolveMakerworldDownloadUrl(url).catch(() => null)
+2 -1
View File
@@ -21,7 +21,8 @@
font-weight: 600;
}
.tg-select {
.tg-select,
.tg-input {
@include m.text-input;
margin-bottom: 10px;
}