mirror of
https://github.com/TautvydasDerzinskas/Thingport.git
synced 2026-10-19 05:10:01 -05:00
feat(extension): allow importing to new custom collections
This commit is contained in:
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" },
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -21,7 +21,8 @@
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.tg-select {
|
||||
.tg-select,
|
||||
.tg-input {
|
||||
@include m.text-input;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
Reference in new issue
Block a user