feat: chrome estensions Thingport grab
No files matched your search
@@ -0,0 +1,72 @@
|
||||
name: Extension release
|
||||
|
||||
# Only runs when extension/ (the Thingport Grab browser extension) or this workflow changes.
|
||||
# Unlike bridge-release.yml, this does NOT mark its release "latest" -- GitHub's "latest release"
|
||||
# is one repo-wide slot, and Bridge's workflow already claims it (make_latest: true) for its own
|
||||
# releases. Publishing here to a second, independently-tagged "latest" would clobber whichever one
|
||||
# ran most recently, breaking the OTHER track's /releases/latest/download/<asset> links. Instead
|
||||
# this always publishes to the same fixed tag, "extension-latest" -- deleting and recreating it
|
||||
# each run -- and the Downloads page links to the tag-scoped path
|
||||
# (.../releases/download/extension-latest/thingport-grab.zip), never the /latest/ alias.
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- "extension/**"
|
||||
- ".github/workflows/extension-release.yml"
|
||||
pull_request:
|
||||
branches: [main]
|
||||
paths:
|
||||
- "extension/**"
|
||||
- ".github/workflows/extension-release.yml"
|
||||
workflow_dispatch: {}
|
||||
|
||||
concurrency:
|
||||
group: extension-release-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Package
|
||||
run: |
|
||||
mkdir -p dist
|
||||
cd extension
|
||||
zip -r ../dist/thingport-grab.zip . -x '*.DS_Store'
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: thingport-grab
|
||||
path: dist/thingport-grab.zip
|
||||
if-no-files-found: error
|
||||
|
||||
release:
|
||||
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
||||
needs: [build]
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: thingport-grab
|
||||
path: dist
|
||||
- name: Move the extension-latest tag to this commit
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: gh release delete extension-latest --yes --cleanup-tag || true
|
||||
- name: Publish release
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
tag_name: extension-latest
|
||||
name: Thingport Grab (latest)
|
||||
body: |
|
||||
Automated build of the Thingport Grab browser extension (see `extension/README.md`)
|
||||
from ${{ github.sha }}.
|
||||
|
||||
Not published to the Chrome Web Store -- unzip `thingport-grab.zip`, then in Chrome:
|
||||
`chrome://extensions` → enable **Developer mode** → **Load unpacked** → select the
|
||||
unzipped folder.
|
||||
files: dist/thingport-grab.zip
|
||||
@@ -7,7 +7,7 @@ import { HttpError } from "../utils/fileUtils";
|
||||
import { normalizeImportUrl } from "../utils/urlUtils";
|
||||
import { parseBody } from "../utils/validate";
|
||||
import { asyncHandler } from "../utils/asyncHandler";
|
||||
import { downloadImportToTemp, findImportedExternalIds, importPrintFromUrl, inspectImportLink } from "../services/importService";
|
||||
import { checkImportStatus, downloadImportToTemp, findImportedExternalIds, importPrintFromUrl, inspectImportLink } from "../services/importService";
|
||||
import { resolveMakerworldCookie } from "../services/importResolvers";
|
||||
import { extractMakerworldBearerToken, MakerworldAuthError, MakerworldCaptchaError } from "../services/makerworldCloudApi";
|
||||
import { fetchMakerworldCollectionEntries, fetchMakerworldCollectionTitle, parseMakerworldCollectionUrl } from "../services/makerworldCollections";
|
||||
@@ -78,6 +78,21 @@ router.post(
|
||||
}),
|
||||
);
|
||||
|
||||
// Deliberately GET + a plain query param, and skips normalizeImportUrl's DNS-resolution SSRF
|
||||
// check (see urlUtils.ts) -- unlike every other import route, this never fetches the URL itself,
|
||||
// so there's nothing to protect against and no reason to pay that latency. Called on every page
|
||||
// load by the Thingport Grab browser extension to decide whether to show its floating icon on a
|
||||
// single-model page (see checkImportStatus's own doc comment for why a listing page always comes
|
||||
// back not-already-imported).
|
||||
router.get(
|
||||
"/import/status",
|
||||
asyncHandler(async (req, res) => {
|
||||
const url = typeof req.query.url === "string" ? req.query.url.trim() : "";
|
||||
if (!url) throw new HttpError(400, "url is required");
|
||||
res.json(await checkImportStatus(req.userId!, url));
|
||||
}),
|
||||
);
|
||||
|
||||
router.post(
|
||||
"/import/zip/entries",
|
||||
asyncHandler(async (req, res) => {
|
||||
|
||||
@@ -455,6 +455,24 @@ export function identifySourceModel(url: string): { provider: string; externalId
|
||||
return null;
|
||||
}
|
||||
|
||||
export type ImportStatus = { recognized: boolean; already_imported: boolean; print_id: string | null };
|
||||
|
||||
/** Cheap "is this page importable, and have I already imported it" check -- unlike
|
||||
* inspectImportLink, this never fetches the provider's page itself, so it's safe to call on
|
||||
* every page load (the Thingport Grab browser extension's floating-icon visibility check on a
|
||||
* single-model page). A URL identifySourceModel doesn't recognize (a collection/Likes listing,
|
||||
* or anything not from a known provider) always reports not-already-imported -- dedup only makes
|
||||
* sense for a single model, never a listing page. */
|
||||
export async function checkImportStatus(userId: string, url: string): Promise<ImportStatus> {
|
||||
const source = identifySourceModel(url);
|
||||
if (!source) return { recognized: false, already_imported: false, print_id: null };
|
||||
const print = await prisma.print.findFirst({
|
||||
where: { userId, sourceProvider: source.provider, sourceExternalId: source.externalId },
|
||||
select: { id: true },
|
||||
});
|
||||
return { recognized: true, already_imported: Boolean(print), print_id: print?.id ?? null };
|
||||
}
|
||||
|
||||
/** The inverse of identifySourceModel above -- rebuilds the original model page URL from the
|
||||
* stable provider + external id a Print was imported with (Print.sourceProvider/
|
||||
* sourceExternalId), for the "Open in {Provider}" link on the model card/detail menus. No raw
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
# Thingport Grab
|
||||
|
||||
A Chrome extension that imports MakerWorld, Thingiverse, and Printables models into your
|
||||
self-hosted Thingport instance without leaving the provider's site. Visiting a model, collection,
|
||||
or Thingiverse Likes page shows a floating Thingport icon; clicking it opens a small panel to pick
|
||||
what to import (and, for a single model, an optional destination collection), then imports it the
|
||||
same way Thingport's own "+ Add > Import" does.
|
||||
|
||||
It talks directly to your Thingport instance's API from the extension's background service worker
|
||||
-- no separate server, no data sent anywhere else.
|
||||
|
||||
## Install (no Chrome Web Store listing)
|
||||
|
||||
This isn't published to the Chrome Web Store, so it installs the same way the
|
||||
[Thingport releases](https://github.com/TautvydasDerzinskas/Thingport/releases) page's other
|
||||
downloads do -- as a developer-mode "unpacked" extension:
|
||||
|
||||
1. Download `thingport-grab.zip` from the in-app Download page (or the
|
||||
[`extension-latest` release](https://github.com/TautvydasDerzinskas/Thingport/releases/tag/extension-latest))
|
||||
and unzip it somewhere permanent (don't delete the folder afterwards -- Chrome loads the
|
||||
extension from it every time it starts).
|
||||
2. Open `chrome://extensions`, turn on **Developer mode** (top right).
|
||||
3. Click **Load unpacked** and select the unzipped folder.
|
||||
4. Click the new Thingport icon in your toolbar, enter your instance's URL and your Thingport
|
||||
login, and save.
|
||||
|
||||
## Setup
|
||||
|
||||
The popup asks for your instance URL (e.g. `https://thingport.example.com`) and your Thingport
|
||||
email/password once. Saving it requests permission to reach that one origin and validates the
|
||||
login before storing anything. After that, the toolbar icon turns from the dark/inactive icon to
|
||||
the color/active one, and the floating icon starts appearing on importable pages. Reopen the popup
|
||||
any time to **Change URL** or check **Disable extension** (unchecked by default) to pause it
|
||||
without losing the saved setup.
|
||||
|
||||
The extension re-authenticates automatically as its session token nears expiry -- there's nothing
|
||||
to keep re-entering day to day. If your password changes or a session gets revoked server-side, the
|
||||
next import attempt will silently re-login with the stored credentials, or surface a clear error if
|
||||
those no longer work.
|
||||
|
||||
## What counts as "importable"
|
||||
|
||||
- A single model page (MakerWorld, a Thingiverse Thing, a Printables Model) -- hidden automatically
|
||||
once you've already imported that exact page.
|
||||
- A MakerWorld collection, a Thingiverse Collection, or a Thingiverse Likes page -- lets you pick
|
||||
which of the listed designs to import in one batch.
|
||||
|
||||
Picking a destination collection is only offered for a single-model import; a batch import instead
|
||||
lands in Thingport's own auto-named collection for that batch (e.g. "Thingiverse Likes"), matching
|
||||
how the web app's own batch imports already work.
|
||||
|
||||
A batch import keeps running on the server even if you close the panel or the tab -- closing it
|
||||
just stops showing progress, it doesn't cancel anything.
|
||||
|
||||
## Build / package
|
||||
|
||||
There's no build step -- `extension/` is loaded directly by Chrome. To produce the distributable
|
||||
zip by hand (CI does this on every push to `main` that touches this folder -- see
|
||||
`.github/workflows/extension-release.yml`):
|
||||
|
||||
```bash
|
||||
cd extension
|
||||
zip -r ../thingport-grab.zip . -x '*.DS_Store'
|
||||
```
|
||||
|
||||
## Regenerating the icons
|
||||
|
||||
The toolbar icon PNGs (`icons/thingport-icon-{color,dark}-{16,32,48,128}.png`) are rendered once
|
||||
from `frontend/src/assets/logos/thingport-icon-{color,dark}.svg` and checked in rather than built
|
||||
on the fly. Regenerate them (e.g. after the source SVGs change) from the repo root:
|
||||
|
||||
```bash
|
||||
node -e "
|
||||
const sharp = require('./backend/node_modules/sharp');
|
||||
const fs = require('fs');
|
||||
const sizes = [16, 32, 48, 128];
|
||||
const jobs = [
|
||||
['frontend/src/assets/logos/thingport-icon-color.svg', 'extension/icons/thingport-icon-color'],
|
||||
['frontend/src/assets/logos/thingport-icon-dark.svg', 'extension/icons/thingport-icon-dark'],
|
||||
];
|
||||
(async () => {
|
||||
for (const [src, outBase] of jobs) {
|
||||
const svg = fs.readFileSync(src);
|
||||
for (const size of sizes) {
|
||||
await sharp(svg, { density: 384 }).resize(size, size).png().toFile(\`\${outBase}-\${size}.png\`);
|
||||
}
|
||||
}
|
||||
})();
|
||||
"
|
||||
```
|
||||
@@ -0,0 +1,161 @@
|
||||
// Service worker: owns the extension's config/auth state and every fetch() call to the user's
|
||||
// Thingport instance. Content scripts and the popup never fetch() directly -- they send a
|
||||
// message here and get a plain JSON result back -- so auth (login + silent re-login on
|
||||
// expiry/401) and the instance URL live in exactly one place. `host_permissions` for the saved
|
||||
// instance origin (requested at setup time, see handleSaveConfig) is what lets this fetch() the
|
||||
// user's self-hosted instance free of that instance's own CORS config.
|
||||
importScripts("common.js");
|
||||
|
||||
const TOKEN_REFRESH_MARGIN_MS = 5 * 60 * 1000;
|
||||
|
||||
async function getStoredConfig() {
|
||||
const keys = Object.values(THINGPORT_STORAGE_KEYS);
|
||||
return chrome.storage.local.get(keys);
|
||||
}
|
||||
|
||||
function isConfigured(config) {
|
||||
return Boolean(config.instanceUrl && config.email && config.password);
|
||||
}
|
||||
|
||||
/** (Re-)authenticates against the stored instance/credentials and persists the resulting token.
|
||||
* Called by ensureToken on first use / near expiry, and again once on a 401 (a password change
|
||||
* or server-side session revocation shouldn't require reopening the popup to recover from). */
|
||||
async function loginAndStoreToken(config) {
|
||||
const res = await fetch(thingportApiUrl(config.instanceUrl, "/login"), {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ email: config.email, password: config.password }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
let message = "Could not sign in to this Thingport instance";
|
||||
try {
|
||||
const body = await res.json();
|
||||
if (body && body.detail) message = body.detail;
|
||||
} catch {
|
||||
// ignore -- keep the generic message
|
||||
}
|
||||
throw new Error(message);
|
||||
}
|
||||
const data = await res.json();
|
||||
const tokenExpiresAt = Date.now() + data.expires_in * 1000;
|
||||
await chrome.storage.local.set({ token: data.token, tokenExpiresAt });
|
||||
return data.token;
|
||||
}
|
||||
|
||||
async function ensureToken(config, { forceRefresh = false } = {}) {
|
||||
if (!forceRefresh && config.token && config.tokenExpiresAt && config.tokenExpiresAt - Date.now() > TOKEN_REFRESH_MARGIN_MS) {
|
||||
return config.token;
|
||||
}
|
||||
return loginAndStoreToken(config);
|
||||
}
|
||||
|
||||
/** Generic authenticated call to the stored instance -- every content-script/popup action goes
|
||||
* through this rather than a bespoke RPC per endpoint, since the backend surface this extension
|
||||
* needs (see the plan this was built from) is otherwise a straight passthrough. `path` is the
|
||||
* API path after `/api` (e.g. "/collections", "/import/jobs/abc123"). */
|
||||
async function apiCall(method, path, body) {
|
||||
const config = await getStoredConfig();
|
||||
if (!isConfigured(config)) throw new Error("Thingport Grab isn't configured yet -- open the extension popup first.");
|
||||
if (config.disabled) throw new Error("Thingport Grab is disabled -- re-enable it from the extension popup.");
|
||||
|
||||
const doFetch = async (token) =>
|
||||
fetch(thingportApiUrl(config.instanceUrl, path), {
|
||||
method,
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
...(body ? { "Content-Type": "application/json" } : {}),
|
||||
},
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
|
||||
let token = await ensureToken(config);
|
||||
let res = await doFetch(token);
|
||||
if (res.status === 401) {
|
||||
token = await ensureToken(config, { forceRefresh: true });
|
||||
res = await doFetch(token);
|
||||
}
|
||||
if (!res.ok) {
|
||||
let message = `Request failed (${res.status})`;
|
||||
try {
|
||||
const errBody = await res.json();
|
||||
if (errBody && errBody.detail) message = errBody.detail;
|
||||
} catch {
|
||||
// ignore -- keep the generic message
|
||||
}
|
||||
throw new Error(message);
|
||||
}
|
||||
if (res.status === 204) return null;
|
||||
return res.json();
|
||||
}
|
||||
|
||||
/** Reflects configured+enabled state on the toolbar icon (color = active, dark = not) -- see
|
||||
* manifest.json's `action.default_icon` for the inactive default this overrides. */
|
||||
async function updateActionIcon() {
|
||||
const config = await getStoredConfig();
|
||||
const active = isConfigured(config) && !config.disabled;
|
||||
const prefix = active ? "thingport-icon-color" : "thingport-icon-dark";
|
||||
await chrome.action.setIcon({
|
||||
path: {
|
||||
16: `icons/${prefix}-16.png`,
|
||||
32: `icons/${prefix}-32.png`,
|
||||
48: `icons/${prefix}-48.png`,
|
||||
128: `icons/${prefix}-128.png`,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// The host permission for the chosen instance origin is requested by popup.js itself, not here --
|
||||
// chrome.permissions.request() must run within the user gesture that triggered it (the popup's
|
||||
// own submit click), which doesn't survive a chrome.runtime.sendMessage hop into this service
|
||||
// worker. By the time SAVE_CONFIG arrives, popup.js has already been granted (or refused, in
|
||||
// which case it never sends this) that permission.
|
||||
async function handleSaveConfig({ instanceUrl, email, password }) {
|
||||
const normalized = thingportNormalizeInstanceUrl(instanceUrl);
|
||||
// Validate before persisting -- a typo'd URL or wrong password shouldn't silently save.
|
||||
await loginAndStoreToken({ instanceUrl: normalized, email, password });
|
||||
await chrome.storage.local.set({ instanceUrl: normalized, email, password, disabled: false });
|
||||
await updateActionIcon();
|
||||
}
|
||||
|
||||
async function handleSetDisabled(disabled) {
|
||||
await chrome.storage.local.set({ disabled: Boolean(disabled) });
|
||||
await updateActionIcon();
|
||||
}
|
||||
|
||||
chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => {
|
||||
(async () => {
|
||||
try {
|
||||
switch (message.type) {
|
||||
case "GET_STATE": {
|
||||
const config = await getStoredConfig();
|
||||
sendResponse({
|
||||
ok: true,
|
||||
data: { configured: isConfigured(config), disabled: Boolean(config.disabled), instanceUrl: config.instanceUrl || "" },
|
||||
});
|
||||
return;
|
||||
}
|
||||
case "SAVE_CONFIG":
|
||||
await handleSaveConfig(message.payload);
|
||||
sendResponse({ ok: true });
|
||||
return;
|
||||
case "SET_DISABLED":
|
||||
await handleSetDisabled(message.payload.disabled);
|
||||
sendResponse({ ok: true });
|
||||
return;
|
||||
case "API_CALL": {
|
||||
const data = await apiCall(message.payload.method, message.payload.path, message.payload.body);
|
||||
sendResponse({ ok: true, data });
|
||||
return;
|
||||
}
|
||||
default:
|
||||
sendResponse({ ok: false, error: `Unknown message type: ${message.type}` });
|
||||
}
|
||||
} catch (err) {
|
||||
sendResponse({ ok: false, error: err instanceof Error ? err.message : String(err) });
|
||||
}
|
||||
})();
|
||||
return true; // keep the message channel open for the async response above
|
||||
});
|
||||
|
||||
chrome.runtime.onInstalled.addListener(() => { void updateActionIcon(); });
|
||||
chrome.runtime.onStartup.addListener(() => { void updateActionIcon(); });
|
||||
@@ -0,0 +1,126 @@
|
||||
// Shared between background.js (service worker, via importScripts) and content.js (content
|
||||
// script, loaded before it in manifest.json's content_scripts.js array) -- plain global
|
||||
// functions/constants rather than ES module import/export so both loading mechanisms work
|
||||
// unmodified.
|
||||
|
||||
const THINGPORT_STORAGE_KEYS = {
|
||||
instanceUrl: "instanceUrl",
|
||||
email: "email",
|
||||
password: "password",
|
||||
disabled: "disabled",
|
||||
token: "token",
|
||||
tokenExpiresAt: "tokenExpiresAt",
|
||||
};
|
||||
|
||||
/** Strips a trailing slash so `${instanceUrl}/api/...` never ends up with a doubled slash,
|
||||
* regardless of whether the user typed one when saving the URL in the popup. */
|
||||
function thingportNormalizeInstanceUrl(raw) {
|
||||
return (raw || "").trim().replace(/\/+$/, "");
|
||||
}
|
||||
|
||||
function thingportApiUrl(instanceUrl, path) {
|
||||
return `${thingportNormalizeInstanceUrl(instanceUrl)}/api${path}`;
|
||||
}
|
||||
|
||||
// The following URL matchers are deliberately duplicated from
|
||||
// frontend/src/components/uploads/useUploadImport.tsx (the web app's own import-link classifier)
|
||||
// and backend/src/services/{makerworldCloudApi,thingiverseApi,printablesApi}.ts (the single-model
|
||||
// URL parsers) rather than shared across the build boundary -- this extension ships as plain,
|
||||
// unbundled JS with no build step, so there's nothing to import them from. Keep both sides in
|
||||
// sync by hand if a provider ever changes its URL shape.
|
||||
|
||||
function thingportIsMakerworldModelUrl(url) {
|
||||
let parsed;
|
||||
try {
|
||||
parsed = new URL(url);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (!parsed.hostname.toLowerCase().endsWith("makerworld.com")) return null;
|
||||
const m = parsed.pathname.match(/\/models?\/(\d+)/i);
|
||||
return m ? { designId: m[1] } : null;
|
||||
}
|
||||
|
||||
function thingportIsMakerworldCollectionUrl(url) {
|
||||
try {
|
||||
const parsed = new URL(url);
|
||||
return parsed.hostname.toLowerCase().endsWith("makerworld.com") && /\/collections\/\d+/i.test(parsed.pathname);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function thingportIsThingiverseThingUrl(url) {
|
||||
let parsed;
|
||||
try {
|
||||
parsed = new URL(url);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
const host = parsed.hostname.toLowerCase();
|
||||
if (host !== "thingiverse.com" && host !== "www.thingiverse.com") return null;
|
||||
const m1 = parsed.pathname.match(/thing:(\d+)/i);
|
||||
if (m1) return { thingId: m1[1] };
|
||||
const m2 = parsed.pathname.match(/\/things\/(\d+)/i);
|
||||
return m2 ? { thingId: m2[1] } : null;
|
||||
}
|
||||
|
||||
function thingportIsThingiverseLikesUrl(url) {
|
||||
try {
|
||||
const parsed = new URL(url);
|
||||
const host = parsed.hostname.toLowerCase();
|
||||
if (host !== "thingiverse.com" && host !== "www.thingiverse.com") return false;
|
||||
return /^\/[^/]+\/likes\/?$/i.test(parsed.pathname);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function thingportIsThingiverseCollectionUrl(url) {
|
||||
try {
|
||||
const parsed = new URL(url);
|
||||
const host = parsed.hostname.toLowerCase();
|
||||
if (host !== "thingiverse.com" && host !== "www.thingiverse.com") return false;
|
||||
return /\/collections\/\d+/i.test(parsed.pathname);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function thingportIsPrintablesModelUrl(url) {
|
||||
let parsed;
|
||||
try {
|
||||
parsed = new URL(url);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
const host = parsed.hostname.toLowerCase();
|
||||
if (host !== "printables.com" && host !== "www.printables.com") return null;
|
||||
const m = parsed.pathname.match(/\/model\/(\d+)/i);
|
||||
return m ? { modelId: m[1] } : null;
|
||||
}
|
||||
|
||||
function thingportIsPrintablesCollectionUrl(url) {
|
||||
try {
|
||||
const parsed = new URL(url);
|
||||
const host = parsed.hostname.toLowerCase();
|
||||
if (host !== "printables.com" && host !== "www.printables.com") return false;
|
||||
return /\/collections\/\d+/i.test(parsed.pathname);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Classifies the current page for the floating icon's behavior. Returns null for anything not
|
||||
* recognized (icon stays hidden). `kind: "single"` pages get the already-imported dedup check
|
||||
* before showing the icon; `kind: "batch"` pages (a listing of many designs) always show it. */
|
||||
function thingportClassifyUrl(url) {
|
||||
if (thingportIsMakerworldCollectionUrl(url)) return { kind: "batch", provider: "makerworld", type: "collection" };
|
||||
if (thingportIsThingiverseLikesUrl(url)) return { kind: "batch", provider: "thingiverse", type: "likes" };
|
||||
if (thingportIsThingiverseCollectionUrl(url)) return { kind: "batch", provider: "thingiverse", type: "collection" };
|
||||
if (thingportIsPrintablesCollectionUrl(url)) return { kind: "batch", provider: "printables", type: "collection" };
|
||||
if (thingportIsMakerworldModelUrl(url)) return { kind: "single", provider: "makerworld", type: "model" };
|
||||
if (thingportIsThingiverseThingUrl(url)) return { kind: "single", provider: "thingiverse", type: "thing" };
|
||||
if (thingportIsPrintablesModelUrl(url)) return { kind: "single", provider: "printables", type: "model" };
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
/* Scoped to the Shadow DOM root content.js creates -- never leaks into, or is affected by, the
|
||||
host provider page's own styles. */
|
||||
|
||||
.tg-fab {
|
||||
all: initial;
|
||||
position: fixed;
|
||||
right: 24px;
|
||||
bottom: 24px;
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
border-radius: 50%;
|
||||
background: #ffffff;
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.25);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
z-index: 2147483000;
|
||||
transition: transform 0.15s ease;
|
||||
}
|
||||
.tg-fab:hover { transform: scale(1.06); }
|
||||
.tg-fab img { width: 34px; height: 34px; pointer-events: none; }
|
||||
|
||||
.tg-panel {
|
||||
all: initial;
|
||||
position: fixed;
|
||||
right: 24px;
|
||||
bottom: 90px;
|
||||
width: 300px;
|
||||
max-height: 70vh;
|
||||
overflow-y: auto;
|
||||
background: #ffffff;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
|
||||
padding: 16px;
|
||||
z-index: 2147483000;
|
||||
font-family: -apple-system, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
||||
font-size: 13px;
|
||||
color: #1f1f1f;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.tg-panel * { box-sizing: border-box; }
|
||||
|
||||
.tg-title { font-weight: 700; font-size: 14px; margin-bottom: 8px; }
|
||||
.tg-hint { color: #6b6b6b; font-size: 12px; margin-bottom: 10px; }
|
||||
.tg-status { font-size: 13px; color: #1f1f1f; padding: 8px 0; }
|
||||
.tg-error { color: #d32f2f; }
|
||||
|
||||
.tg-label { display: block; font-weight: 600; margin: 10px 0 4px; }
|
||||
.tg-select {
|
||||
width: 100%;
|
||||
padding: 6px 8px;
|
||||
border: 1px solid #e2e2e2;
|
||||
border-radius: 6px;
|
||||
font-size: 13px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.tg-entries {
|
||||
max-height: 220px;
|
||||
overflow-y: auto;
|
||||
border: 1px solid #e2e2e2;
|
||||
border-radius: 8px;
|
||||
padding: 4px 8px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.tg-entry {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 6px 0;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
font-size: 12px;
|
||||
}
|
||||
.tg-entry:last-child { border-bottom: none; }
|
||||
.tg-entry span { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.tg-entry-imported { opacity: 0.6; }
|
||||
.tg-badge {
|
||||
flex: none;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
color: #6b6b6b;
|
||||
background: #f0f0f0;
|
||||
border-radius: 4px;
|
||||
padding: 2px 6px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.tg-btn {
|
||||
display: block;
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
padding: 9px 10px;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
background: #00b800;
|
||||
color: #ffffff;
|
||||
font-weight: 600;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
margin-top: 6px;
|
||||
}
|
||||
.tg-btn:hover { background: #00a300; }
|
||||
.tg-btn-secondary { background: transparent; color: #1f1f1f; border: 1px solid #e2e2e2; }
|
||||
.tg-btn-secondary:hover { background: #f5f5f5; }
|
||||
.tg-link { box-sizing: border-box; }
|
||||
@@ -0,0 +1,354 @@
|
||||
// Renders the floating Thingport icon + its import panel on a recognized MakerWorld/
|
||||
// Thingiverse/Printables page. All actual network calls go through background.js's API_CALL
|
||||
// message (see its own doc comment for why) -- this file only classifies the current URL,
|
||||
// walks the user through whatever picking/decisions the import needs, and renders the result.
|
||||
(() => {
|
||||
if (window.__thingportGrabInjected) return;
|
||||
window.__thingportGrabInjected = true;
|
||||
|
||||
const ICON_URL = chrome.runtime.getURL("icons/thingport-icon-color.svg");
|
||||
|
||||
function call(type, payload) {
|
||||
return chrome.runtime.sendMessage({ type, payload });
|
||||
}
|
||||
/** Unwraps background.js's `{ok, data}` / `{ok:false, error}` message-response shape into a
|
||||
* plain resolve-with-data-or-throw call, so every call site below can just `await api(...)`
|
||||
* and try/catch it like a normal fetch. */
|
||||
async function api(method, path, body) {
|
||||
const res = await call("API_CALL", { method, path, body });
|
||||
if (!res.ok) throw new Error(res.error);
|
||||
return res.data;
|
||||
}
|
||||
|
||||
// -- Shell: shadow-rooted host, icon button, panel container ---------------------------------
|
||||
|
||||
let shadowRoot = null;
|
||||
let panelEl = null;
|
||||
let panelOpen = false;
|
||||
/** Set once per page, from the classification + (for a single-model page) whatever
|
||||
* /import/inspect returned -- everything the panel's render functions need to know which
|
||||
* step to show and which endpoints to call. */
|
||||
let context = null;
|
||||
|
||||
async function injectStyles(root) {
|
||||
const res = await fetch(chrome.runtime.getURL("content.css"));
|
||||
const style = document.createElement("style");
|
||||
style.textContent = await res.text();
|
||||
root.appendChild(style);
|
||||
}
|
||||
|
||||
async function mount() {
|
||||
const host = document.createElement("div");
|
||||
host.id = "thingport-grab-host";
|
||||
document.documentElement.appendChild(host);
|
||||
shadowRoot = host.attachShadow({ mode: "open" });
|
||||
await injectStyles(shadowRoot);
|
||||
|
||||
const button = document.createElement("button");
|
||||
button.className = "tg-fab";
|
||||
button.setAttribute("aria-label", "Import to Thingport");
|
||||
button.innerHTML = `<img src="${ICON_URL}" alt="" />`;
|
||||
button.addEventListener("click", togglePanel);
|
||||
shadowRoot.appendChild(button);
|
||||
|
||||
panelEl = document.createElement("div");
|
||||
panelEl.className = "tg-panel";
|
||||
panelEl.hidden = true;
|
||||
shadowRoot.appendChild(panelEl);
|
||||
}
|
||||
|
||||
function togglePanel() {
|
||||
panelOpen = !panelOpen;
|
||||
panelEl.hidden = !panelOpen;
|
||||
if (panelOpen && !panelEl.dataset.loaded) {
|
||||
panelEl.dataset.loaded = "1";
|
||||
void loadPanel();
|
||||
}
|
||||
}
|
||||
|
||||
function renderPanel(html) {
|
||||
panelEl.innerHTML = html;
|
||||
}
|
||||
|
||||
function escapeHtml(value) {
|
||||
return String(value ?? "").replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c]));
|
||||
}
|
||||
|
||||
// -- Collection picker (single-item imports only -- see the plan's scope note) ---------------
|
||||
|
||||
async function collectionOptionsHtml() {
|
||||
try {
|
||||
const collections = await api("GET", "/collections");
|
||||
const opts = collections
|
||||
.filter((c) => !c.system_key)
|
||||
.map((c) => `<option value="${escapeHtml(c.id)}">${escapeHtml(c.name)}</option>`)
|
||||
.join("");
|
||||
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>${opts}</select>
|
||||
`;
|
||||
} catch {
|
||||
return ""; // Collections failed to load -- import still works without the picker.
|
||||
}
|
||||
}
|
||||
|
||||
// -- Single-item flow (Thingiverse Thing / Printables Model skip straight to import; a plain
|
||||
// MakerWorld model link -- and any other generic single link -- goes through /import/inspect
|
||||
// first, mirroring the web app's own useUploadImport.tsx branching exactly) ----------------
|
||||
|
||||
async function loadSingleItem() {
|
||||
renderPanel(`<div class="tg-status">Checking link…</div>`);
|
||||
const { provider, type } = context.classification;
|
||||
const skipInspect = (provider === "thingiverse" && type === "thing") || (provider === "printables" && type === "model");
|
||||
|
||||
let zip = null;
|
||||
if (!skipInspect) {
|
||||
try {
|
||||
const inspect = await api("POST", "/import/inspect", { url: context.url });
|
||||
if (inspect.is_zip) zip = { filename: inspect.filename };
|
||||
} catch (err) {
|
||||
renderPanel(errorHtml(err));
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (!zip) {
|
||||
renderPanel(await readyToImportHtml());
|
||||
bindImportButton(() => runDirectImport());
|
||||
return;
|
||||
}
|
||||
|
||||
renderPanel(await zipChoiceHtml(zip.filename));
|
||||
panelEl.querySelector("[data-action=import-as-zip]").addEventListener("click", () => runDirectImport());
|
||||
panelEl.querySelector("[data-action=choose-files]").addEventListener("click", () => void loadZipEntries());
|
||||
}
|
||||
|
||||
async function readyToImportHtml() {
|
||||
const collectionHtml = await collectionOptionsHtml();
|
||||
return `
|
||||
<div class="tg-title">Import this model</div>
|
||||
${collectionHtml}
|
||||
<button class="tg-btn" data-action="import">Import</button>
|
||||
`;
|
||||
}
|
||||
|
||||
async function zipChoiceHtml(filename) {
|
||||
const collectionHtml = await collectionOptionsHtml();
|
||||
return `
|
||||
<div class="tg-title">Import this model</div>
|
||||
<div class="tg-hint">${escapeHtml(filename)} contains multiple files.</div>
|
||||
${collectionHtml}
|
||||
<button class="tg-btn" data-action="import-as-zip">Import as one model</button>
|
||||
<button class="tg-btn tg-btn-secondary" data-action="choose-files">Choose files…</button>
|
||||
`;
|
||||
}
|
||||
|
||||
async function loadZipEntries() {
|
||||
renderPanel(`<div class="tg-status">Loading files…</div>`);
|
||||
let result;
|
||||
try {
|
||||
result = await api("POST", "/import/zip/entries", { url: context.url });
|
||||
} catch (err) {
|
||||
renderPanel(errorHtml(err));
|
||||
return;
|
||||
}
|
||||
const collectionHtml = await collectionOptionsHtml();
|
||||
const rows = result.entries
|
||||
.map(
|
||||
(entry, i) => `
|
||||
<label class="tg-entry">
|
||||
<input type="checkbox" class="tg-entry-checkbox" value="${escapeHtml(entry)}" checked />
|
||||
<span>${escapeHtml(entry)}</span>
|
||||
</label>
|
||||
`,
|
||||
)
|
||||
.join("");
|
||||
renderPanel(`
|
||||
<div class="tg-title">Choose files to import</div>
|
||||
<div class="tg-entries">${rows}</div>
|
||||
${collectionHtml}
|
||||
<button class="tg-btn" data-action="import">Import selected</button>
|
||||
`);
|
||||
bindImportButton(() => {
|
||||
const entries = [...panelEl.querySelectorAll(".tg-entry-checkbox:checked")].map((el) => el.value);
|
||||
if (!entries.length) return;
|
||||
void runDirectImport({ entries });
|
||||
});
|
||||
}
|
||||
|
||||
function bindImportButton(handler) {
|
||||
panelEl.querySelector("[data-action=import]").addEventListener("click", handler);
|
||||
}
|
||||
|
||||
function selectedCollectionId() {
|
||||
const select = panelEl.querySelector("#tg-collection");
|
||||
return select && select.value ? select.value : null;
|
||||
}
|
||||
|
||||
async function runDirectImport(opts) {
|
||||
const collectionId = selectedCollectionId();
|
||||
renderPanel(`<div class="tg-status">Importing…</div>`);
|
||||
try {
|
||||
const print = opts && opts.entries
|
||||
? await api("POST", "/import/zip", { url: context.url, entries: opts.entries }).then((job) => waitForJobSingleResult(job.job_id))
|
||||
: await api("POST", "/import", { url: context.url });
|
||||
if (collectionId && print && print.id) {
|
||||
await api("POST", `/collection/${collectionId}/items/${print.id}`).catch(() => undefined);
|
||||
}
|
||||
renderPanel(successHtml(print ? `${context.instanceUrl}/models/${print.id}` : `${context.instanceUrl}/models`));
|
||||
} catch (err) {
|
||||
renderPanel(errorHtml(err));
|
||||
}
|
||||
}
|
||||
|
||||
/** /import/zip is a background job even for a single "choose files" selection (it can still
|
||||
* resolve to more than one print) -- polls to completion and returns the single Print DTO
|
||||
* only when exactly one resulted (matching resultPrintId's own semantics), else null. Throws
|
||||
* on an ERROR job so the caller's existing catch block renders the error state instead of
|
||||
* mistaking a failed job for a (null-print) success. */
|
||||
async function waitForJobSingleResult(jobId) {
|
||||
const job = await pollJob(jobId);
|
||||
if (job.status === "ERROR") throw new Error(job.error_message || "Import failed");
|
||||
if (job.result_print_id) return { id: job.result_print_id };
|
||||
return null;
|
||||
}
|
||||
|
||||
// -- Batch flow (a collection/Likes listing page) ---------------------------------------------
|
||||
|
||||
const BATCH_ENTRIES_PATH = {
|
||||
"makerworld:collection": "/import/collection/entries",
|
||||
"thingiverse:likes": "/import/thingiverse-likes/entries",
|
||||
"thingiverse:collection": "/import/thingiverse-collection/entries",
|
||||
"printables:collection": "/import/printables-collection/entries",
|
||||
};
|
||||
const BATCH_START_PATH = {
|
||||
"makerworld:collection": "/import/collection",
|
||||
"thingiverse:likes": "/import/thingiverse-likes",
|
||||
"thingiverse:collection": "/import/thingiverse-collection",
|
||||
"printables:collection": "/import/printables-collection",
|
||||
};
|
||||
const BATCH_ID_FIELD = {
|
||||
"makerworld:collection": "design_ids",
|
||||
"thingiverse:likes": "thing_ids",
|
||||
"thingiverse:collection": "thing_ids",
|
||||
"printables:collection": "model_ids",
|
||||
};
|
||||
|
||||
function batchKey() {
|
||||
return `${context.classification.provider}:${context.classification.type}`;
|
||||
}
|
||||
|
||||
async function loadBatchEntries() {
|
||||
renderPanel(`<div class="tg-status">Loading models…</div>`);
|
||||
let result;
|
||||
try {
|
||||
result = await api("POST", BATCH_ENTRIES_PATH[batchKey()], { url: context.url });
|
||||
} catch (err) {
|
||||
renderPanel(errorHtml(err));
|
||||
return;
|
||||
}
|
||||
const rows = result.entries
|
||||
.map(
|
||||
(entry) => `
|
||||
<label class="tg-entry ${entry.already_imported ? "tg-entry-imported" : ""}">
|
||||
<input type="checkbox" class="tg-entry-checkbox" value="${escapeHtml(entry.design_id)}" ${entry.already_imported ? "" : "checked"} />
|
||||
<span>${escapeHtml(entry.title || entry.design_id)}</span>
|
||||
${entry.already_imported ? '<span class="tg-badge">already imported</span>' : ""}
|
||||
</label>
|
||||
`,
|
||||
)
|
||||
.join("");
|
||||
renderPanel(`
|
||||
<div class="tg-title">${escapeHtml(result.title || "Import models")}</div>
|
||||
<div class="tg-hint">${result.entries.length} models found${result.truncated ? " (more available on the site)" : ""}</div>
|
||||
<div class="tg-entries">${rows}</div>
|
||||
<button class="tg-btn" data-action="import">Import selected</button>
|
||||
`);
|
||||
panelEl.querySelector("[data-action=import]").addEventListener("click", () => void runBatchImport());
|
||||
}
|
||||
|
||||
async function runBatchImport() {
|
||||
const ids = [...panelEl.querySelectorAll(".tg-entry-checkbox:checked")].map((el) => el.value);
|
||||
if (!ids.length) return;
|
||||
renderPanel(`<div class="tg-status">Starting import…</div>`);
|
||||
try {
|
||||
const body = { url: context.url, [BATCH_ID_FIELD[batchKey()]]: ids };
|
||||
const { job_id } = await api("POST", BATCH_START_PATH[batchKey()], body);
|
||||
await pollJobWithProgress(job_id);
|
||||
} catch (err) {
|
||||
renderPanel(errorHtml(err));
|
||||
}
|
||||
}
|
||||
|
||||
async function pollJob(jobId) {
|
||||
// Matches ImportJobContext.tsx's own cadence -- see that file for why 1s.
|
||||
for (;;) {
|
||||
const job = await api("GET", `/import/jobs/${jobId}`);
|
||||
if (job.status !== "RUNNING") return job;
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
}
|
||||
}
|
||||
|
||||
async function pollJobWithProgress(jobId) {
|
||||
for (;;) {
|
||||
const job = await api("GET", `/import/jobs/${jobId}`);
|
||||
if (job.status === "RUNNING") {
|
||||
renderPanel(`<div class="tg-status">Importing ${job.processed} of ${job.total}…</div>`);
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
continue;
|
||||
}
|
||||
if (job.status === "ERROR") {
|
||||
renderPanel(errorHtml(new Error(job.error_message || "Import failed")));
|
||||
return;
|
||||
}
|
||||
const link = job.result_print_id ? `${context.instanceUrl}/models/${job.result_print_id}` : `${context.instanceUrl}/models`;
|
||||
renderPanel(successHtml(link, `Imported ${job.imported} of ${job.total}.`));
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// -- Shared result states -----------------------------------------------------------------
|
||||
|
||||
function successHtml(link, note) {
|
||||
return `
|
||||
<div class="tg-title">Imported!</div>
|
||||
${note ? `<div class="tg-hint">${escapeHtml(note)}</div>` : ""}
|
||||
<a class="tg-btn tg-link" href="${escapeHtml(link)}" target="_blank" rel="noopener noreferrer">Open in Thingport</a>
|
||||
`;
|
||||
}
|
||||
|
||||
function errorHtml(err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
return `<div class="tg-title tg-error">Something went wrong</div><div class="tg-hint">${escapeHtml(message)}</div>`;
|
||||
}
|
||||
|
||||
async function loadPanel() {
|
||||
if (context.classification.kind === "single") await loadSingleItem();
|
||||
else await loadBatchEntries();
|
||||
}
|
||||
|
||||
// -- Entry point: decide whether this page gets the icon at all -------------------------------
|
||||
|
||||
async function init() {
|
||||
const stateRes = await call("GET_STATE");
|
||||
if (!stateRes.ok || !stateRes.data.configured || stateRes.data.disabled) return;
|
||||
|
||||
const classification = thingportClassifyUrl(location.href);
|
||||
if (!classification) return;
|
||||
|
||||
if (classification.kind === "single") {
|
||||
try {
|
||||
const status = await api("GET", `/import/status?url=${encodeURIComponent(location.href)}`);
|
||||
if (status.already_imported) return;
|
||||
} catch {
|
||||
// If the status check fails (instance unreachable, bad credentials, etc.) still show the
|
||||
// icon -- the panel's own error state will surface the real problem when they try it.
|
||||
}
|
||||
}
|
||||
|
||||
context = { url: location.href, instanceUrl: stateRes.data.instanceUrl, classification };
|
||||
await mount();
|
||||
}
|
||||
|
||||
void init();
|
||||
})();
|
||||
|
After Width: | Height: | Size: 2.9 KiB |
|
After Width: | Height: | Size: 354 B |
|
After Width: | Height: | Size: 669 B |
|
After Width: | Height: | Size: 1003 B |
@@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 80 80" fill="none" role="img" aria-labelledby="title">
|
||||
<title id="title">Thingport icon — Color</title>
|
||||
<path d="M35.382 11.301Q40 8.635 44.618 11.301L62.545 21.651Q67.163 24.317 67.163 29.65L67.163 50.35Q67.163 55.682 62.545 58.349L44.618 68.699Q40 71.365 35.382 68.699L17.455 58.349Q12.837 55.683 12.837 50.35L12.837 29.65Q12.837 24.318 17.455 21.651Z" stroke="#1f1f1f" stroke-width="5.27" stroke-linejoin="round" stroke-linecap="round"/>
|
||||
<path fill="#00b800" d="M42.264 37.285Q40 38.592 37.736 37.285L27.286 31.252Q25.022 29.945 27.286 28.637L37.736 22.604Q40 21.297 42.264 22.604L52.714 28.637Q54.978 29.944 52.714 31.252Z"/>
|
||||
<path fill="#00b800" d="M36.517 39.397Q38.781 40.704 38.781 43.318L38.781 55.385Q38.781 57.999 36.517 56.692L26.067 50.659Q23.803 49.352 23.803 46.737L23.803 34.671Q23.803 32.056 26.067 33.363Z"/>
|
||||
<path fill="#00b800" d="M41.219 43.318Q41.219 40.704 43.483 39.397L53.933 33.363Q56.197 32.056 56.197 34.671L56.197 46.737Q56.197 49.352 53.933 50.659L43.483 56.692Q41.219 57.999 41.219 55.385Z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.1 KiB |
|
After Width: | Height: | Size: 2.6 KiB |
|
After Width: | Height: | Size: 341 B |
|
After Width: | Height: | Size: 654 B |
|
After Width: | Height: | Size: 958 B |
@@ -0,0 +1,47 @@
|
||||
{
|
||||
"manifest_version": 3,
|
||||
"name": "Thingport Grab",
|
||||
"version": "1.0.0",
|
||||
"description": "One-click import of MakerWorld, Thingiverse, and Printables models into your self-hosted Thingport instance.",
|
||||
"icons": {
|
||||
"16": "icons/thingport-icon-color-16.png",
|
||||
"32": "icons/thingport-icon-color-32.png",
|
||||
"48": "icons/thingport-icon-color-48.png",
|
||||
"128": "icons/thingport-icon-color-128.png"
|
||||
},
|
||||
"action": {
|
||||
"default_popup": "popup.html",
|
||||
"default_icon": {
|
||||
"16": "icons/thingport-icon-dark-16.png",
|
||||
"32": "icons/thingport-icon-dark-32.png",
|
||||
"48": "icons/thingport-icon-dark-48.png",
|
||||
"128": "icons/thingport-icon-dark-128.png"
|
||||
}
|
||||
},
|
||||
"background": {
|
||||
"service_worker": "background.js"
|
||||
},
|
||||
"permissions": ["storage"],
|
||||
"optional_host_permissions": ["*://*/*"],
|
||||
"content_scripts": [
|
||||
{
|
||||
"matches": [
|
||||
"*://*.thingiverse.com/*",
|
||||
"*://*.makerworld.com/*",
|
||||
"*://*.printables.com/*"
|
||||
],
|
||||
"js": ["common.js", "content.js"],
|
||||
"run_at": "document_idle"
|
||||
}
|
||||
],
|
||||
"web_accessible_resources": [
|
||||
{
|
||||
"resources": ["content.css", "icons/thingport-icon-color.svg"],
|
||||
"matches": [
|
||||
"*://*.thingiverse.com/*",
|
||||
"*://*.makerworld.com/*",
|
||||
"*://*.printables.com/*"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>Thingport Grab</title>
|
||||
<style>
|
||||
:root {
|
||||
color-scheme: light dark;
|
||||
--accent: #00b800;
|
||||
--text: #1f1f1f;
|
||||
--text-muted: #6b6b6b;
|
||||
--border: #e2e2e2;
|
||||
--bg: #ffffff;
|
||||
--danger: #d32f2f;
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
body {
|
||||
width: 300px;
|
||||
margin: 0;
|
||||
padding: 16px;
|
||||
font-family: -apple-system, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
||||
font-size: 13px;
|
||||
color: var(--text);
|
||||
background: var(--bg);
|
||||
}
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
.header img { width: 24px; height: 24px; }
|
||||
.header h1 {
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
margin: 0;
|
||||
}
|
||||
label {
|
||||
display: block;
|
||||
font-weight: 600;
|
||||
margin-bottom: 4px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
label:first-of-type { margin-top: 0; }
|
||||
input[type="text"], input[type="email"], input[type="password"], input[type="url"] {
|
||||
width: 100%;
|
||||
padding: 7px 8px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
font-size: 13px;
|
||||
color: var(--text);
|
||||
background: var(--bg);
|
||||
}
|
||||
input:focus { outline: 2px solid var(--accent); outline-offset: 1px; }
|
||||
button {
|
||||
margin-top: 14px;
|
||||
width: 100%;
|
||||
padding: 8px 10px;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
font-weight: 600;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
}
|
||||
button:disabled { opacity: 0.6; cursor: default; }
|
||||
button.secondary {
|
||||
background: transparent;
|
||||
color: var(--text);
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
.error {
|
||||
color: var(--danger);
|
||||
font-size: 12px;
|
||||
margin-top: 8px;
|
||||
display: none;
|
||||
}
|
||||
.error.visible { display: block; }
|
||||
.configured-url {
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
word-break: break-all;
|
||||
padding: 8px;
|
||||
background: rgba(0, 0, 0, 0.04);
|
||||
border-radius: 6px;
|
||||
}
|
||||
.checkbox-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-top: 14px;
|
||||
}
|
||||
.checkbox-row label { margin: 0; font-weight: 500; }
|
||||
[hidden] { display: none !important; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="header">
|
||||
<img src="icons/thingport-icon-color.svg" alt="" />
|
||||
<h1>Thingport Grab</h1>
|
||||
</div>
|
||||
|
||||
<div id="configured-view" hidden>
|
||||
<label>Instance</label>
|
||||
<div class="configured-url" id="configured-url"></div>
|
||||
<button type="button" class="secondary" id="change-url-btn">Change URL</button>
|
||||
<div class="checkbox-row">
|
||||
<input type="checkbox" id="disabled-checkbox" />
|
||||
<label for="disabled-checkbox">Disable extension</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form id="setup-form">
|
||||
<label for="instance-url">Thingport instance URL</label>
|
||||
<input type="url" id="instance-url" placeholder="https://thingport.example.com" required />
|
||||
<label for="email">Email</label>
|
||||
<input type="email" id="email" required />
|
||||
<label for="password">Password</label>
|
||||
<input type="password" id="password" required />
|
||||
<button type="submit" id="save-btn">Save</button>
|
||||
<button type="button" class="secondary" id="cancel-btn" hidden>Cancel</button>
|
||||
<div class="error" id="error"></div>
|
||||
</form>
|
||||
|
||||
<script src="common.js"></script>
|
||||
<script src="popup.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,115 @@
|
||||
const configuredView = document.getElementById("configured-view");
|
||||
const configuredUrlEl = document.getElementById("configured-url");
|
||||
const disabledCheckbox = document.getElementById("disabled-checkbox");
|
||||
const changeUrlBtn = document.getElementById("change-url-btn");
|
||||
const setupForm = document.getElementById("setup-form");
|
||||
const instanceUrlInput = document.getElementById("instance-url");
|
||||
const emailInput = document.getElementById("email");
|
||||
const passwordInput = document.getElementById("password");
|
||||
const saveBtn = document.getElementById("save-btn");
|
||||
const cancelBtn = document.getElementById("cancel-btn");
|
||||
const errorEl = document.getElementById("error");
|
||||
|
||||
function sendMessage(type, payload) {
|
||||
return chrome.runtime.sendMessage({ type, payload });
|
||||
}
|
||||
|
||||
function showError(message) {
|
||||
errorEl.textContent = message;
|
||||
errorEl.classList.add("visible");
|
||||
}
|
||||
|
||||
function clearError() {
|
||||
errorEl.textContent = "";
|
||||
errorEl.classList.remove("visible");
|
||||
}
|
||||
|
||||
function showConfiguredView(state) {
|
||||
configuredView.hidden = false;
|
||||
setupForm.hidden = true;
|
||||
configuredUrlEl.textContent = state.instanceUrl;
|
||||
disabledCheckbox.checked = state.disabled;
|
||||
}
|
||||
|
||||
function showSetupForm(prefillUrl, prefillEmail) {
|
||||
configuredView.hidden = true;
|
||||
setupForm.hidden = false;
|
||||
cancelBtn.hidden = !prefillUrl;
|
||||
instanceUrlInput.value = prefillUrl || "";
|
||||
emailInput.value = prefillEmail || "";
|
||||
passwordInput.value = "";
|
||||
clearError();
|
||||
instanceUrlInput.focus();
|
||||
}
|
||||
|
||||
async function refresh() {
|
||||
const res = await sendMessage("GET_STATE");
|
||||
if (!res.ok) {
|
||||
showError(res.error);
|
||||
return;
|
||||
}
|
||||
if (res.data.configured) {
|
||||
showConfiguredView(res.data);
|
||||
} else {
|
||||
showSetupForm();
|
||||
}
|
||||
}
|
||||
|
||||
changeUrlBtn.addEventListener("click", async () => {
|
||||
const res = await sendMessage("GET_STATE");
|
||||
showSetupForm(res.ok ? res.data.instanceUrl : "", "");
|
||||
});
|
||||
|
||||
cancelBtn.addEventListener("click", () => { void refresh(); });
|
||||
|
||||
disabledCheckbox.addEventListener("change", async () => {
|
||||
disabledCheckbox.disabled = true;
|
||||
const res = await sendMessage("SET_DISABLED", { disabled: disabledCheckbox.checked });
|
||||
disabledCheckbox.disabled = false;
|
||||
if (!res.ok) {
|
||||
showError(res.error);
|
||||
disabledCheckbox.checked = !disabledCheckbox.checked;
|
||||
}
|
||||
});
|
||||
|
||||
setupForm.addEventListener("submit", async (event) => {
|
||||
event.preventDefault();
|
||||
clearError();
|
||||
|
||||
const normalized = thingportNormalizeInstanceUrl(instanceUrlInput.value);
|
||||
let origin;
|
||||
try {
|
||||
origin = new URL(normalized).origin;
|
||||
} catch {
|
||||
showError("Enter a valid instance URL, e.g. https://thingport.example.com");
|
||||
return;
|
||||
}
|
||||
|
||||
saveBtn.disabled = true;
|
||||
saveBtn.textContent = "Saving…";
|
||||
try {
|
||||
// Must be called directly here, not inside the SAVE_CONFIG message handled by the background
|
||||
// service worker -- chrome.permissions.request() needs the user gesture this submit click
|
||||
// carries, which a runtime.sendMessage hop into the service worker would lose.
|
||||
const granted = await chrome.permissions.request({ origins: [`${origin}/*`] });
|
||||
if (!granted) {
|
||||
showError("Thingport Grab needs permission to reach this instance to work.");
|
||||
return;
|
||||
}
|
||||
const res = await sendMessage("SAVE_CONFIG", {
|
||||
instanceUrl: normalized,
|
||||
email: emailInput.value,
|
||||
password: passwordInput.value,
|
||||
});
|
||||
if (!res.ok) {
|
||||
showError(res.error);
|
||||
return;
|
||||
}
|
||||
await refresh();
|
||||
} finally {
|
||||
saveBtn.disabled = false;
|
||||
saveBtn.textContent = "Save";
|
||||
}
|
||||
});
|
||||
|
||||
void refresh();
|
||||
@@ -0,0 +1,11 @@
|
||||
// Thingport Grab is a Chrome extension (source in /extension, built by
|
||||
// .github/workflows/extension-release.yml) that imports MakerWorld/Thingiverse/Printables models
|
||||
// straight from their own pages -- see extension/README.md. Unlike Bridge's own release (which
|
||||
// GitHub's "latest release" points at), this one publishes to a fixed tag, "extension-latest",
|
||||
// rather than claiming that same repo-wide "latest" slot -- see the release workflow's own
|
||||
// comment for why. That makes this a tag-scoped download path, not the /releases/latest/ alias
|
||||
// bridge.ts uses, but it's equally stable: the workflow moves this tag to point at its newest
|
||||
// build on every release rather than ever making a new one.
|
||||
const EXTENSION_RELEASE_BASE = "https://github.com/TautvydasDerzinskas/Thingport/releases/download/extension-latest";
|
||||
|
||||
export const EXTENSION_DOWNLOAD_URL = `${EXTENSION_RELEASE_BASE}/thingport-grab.zip`;
|
||||
@@ -385,9 +385,25 @@
|
||||
}
|
||||
},
|
||||
"download": {
|
||||
"pageTitle": "Thingport Bridge",
|
||||
"intro": "A small helper app that runs on your computer so \"Open in Slicer\" can hand a model straight to your slicer.",
|
||||
"downloadHeading": "Download",
|
||||
"pageTitle": "Downloads",
|
||||
"intro": "Companion tools that work alongside your Thingport instance.",
|
||||
"extension": {
|
||||
"heading": "Thingport Grab",
|
||||
"intro": "A Chrome extension that imports MakerWorld, Thingiverse, and Printables models straight from their own pages.",
|
||||
"name": "Thingport Grab",
|
||||
"modal": {
|
||||
"heading": "Install Thingport Grab",
|
||||
"step1": "Unzip the downloaded file somewhere permanent -- Chrome loads the extension from this folder every time it starts.",
|
||||
"step2": "Open chrome://extensions in a new tab.",
|
||||
"step3": "Turn on Developer mode (top right).",
|
||||
"step4": "Click \"Load unpacked\" and select the unzipped folder.",
|
||||
"step5": "Click the new Thingport icon in your toolbar and enter your instance URL and login."
|
||||
}
|
||||
},
|
||||
"bridge": {
|
||||
"heading": "Thingport Bridge",
|
||||
"intro": "A small helper app that runs on your computer so \"Open in Slicer\" can hand a model straight to your slicer."
|
||||
},
|
||||
"modal": {
|
||||
"startedNote": "Your download should start automatically. Follow these steps to finish installing:",
|
||||
"windows": {
|
||||
|
||||
@@ -385,9 +385,25 @@
|
||||
}
|
||||
},
|
||||
"download": {
|
||||
"pageTitle": "Thingport Bridge",
|
||||
"intro": "Nedidelė pagalbinė programa, veikianti jūsų kompiuteryje, kad „Atverti pjaustyklėje“ galėtų perduoti modelį tiesiai jūsų pjaustyklei.",
|
||||
"downloadHeading": "Atsisiųsti",
|
||||
"pageTitle": "Atsisiuntimai",
|
||||
"intro": "Papildomi įrankiai, veikiantys kartu su jūsų Thingport aplinka.",
|
||||
"extension": {
|
||||
"heading": "Thingport Grab",
|
||||
"intro": "Chrome plėtinys, importuojantis MakerWorld, Thingiverse ir Printables modelius tiesiai iš jų puslapių.",
|
||||
"name": "Thingport Grab",
|
||||
"modal": {
|
||||
"heading": "Įdiegti Thingport Grab",
|
||||
"step1": "Išarchyvuokite atsisiųstą failą kur nors nuolatinėje vietoje -- Chrome įkelia plėtinį iš šio aplanko kaskart paleidus naršyklę.",
|
||||
"step2": "Naujame skirtuke atidarykite chrome://extensions.",
|
||||
"step3": "Įjunkite Developer mode (viršuje dešinėje).",
|
||||
"step4": "Spustelėkite „Load unpacked“ ir pasirinkite išarchyvuotą aplanką.",
|
||||
"step5": "Spustelėkite naują Thingport piktogramą įrankių juostoje ir įveskite savo aplinkos URL bei prisijungimo duomenis."
|
||||
}
|
||||
},
|
||||
"bridge": {
|
||||
"heading": "Thingport Bridge",
|
||||
"intro": "Nedidelė pagalbinė programa, veikianti jūsų kompiuteryje, kad „Atverti pjaustyklėje“ galėtų perduoti modelį tiesiai jūsų pjaustyklei."
|
||||
},
|
||||
"modal": {
|
||||
"startedNote": "Atsisiuntimas turėtų prasidėti automatiškai. Norėdami baigti diegimą, atlikite šiuos veiksmus:",
|
||||
"windows": {
|
||||
|
||||
@@ -5,6 +5,7 @@ import Paper from "@mui/material/Paper";
|
||||
import Typography from "@mui/material/Typography";
|
||||
import Button from "@mui/material/Button";
|
||||
import Box from "@mui/material/Box";
|
||||
import Divider from "@mui/material/Divider";
|
||||
import Dialog from "@mui/material/Dialog";
|
||||
import DialogTitle from "@mui/material/DialogTitle";
|
||||
import DialogContent from "@mui/material/DialogContent";
|
||||
@@ -16,6 +17,8 @@ import TerminalIcon from "@mui/icons-material/Terminal";
|
||||
import CableIcon from "@mui/icons-material/Cable";
|
||||
import { usePageHeader } from "../../components/Layout/PageHeaderContext";
|
||||
import { BRIDGE_DOWNLOADS, bridgeDownloadUrl, type BridgeDownload } from "../../constants/bridge";
|
||||
import { EXTENSION_DOWNLOAD_URL } from "../../constants/extension";
|
||||
import extensionIcon from "../../assets/logos/thingport-icon-color.svg";
|
||||
|
||||
const OS_ICON = { windows: LaptopWindowsIcon, macos: AppleIcon, linux: TerminalIcon };
|
||||
|
||||
@@ -78,6 +81,7 @@ export default function DownloadPage() {
|
||||
usePageHeader({ title: t("sidebar.downloads") });
|
||||
|
||||
const [installOs, setInstallOs] = useState<BridgeDownload["os"] | null>(null);
|
||||
const [extensionInstallOpen, setExtensionInstallOpen] = useState(false);
|
||||
|
||||
const installSteps: Record<BridgeDownload["os"], InstallStep[]> = {
|
||||
windows: [{ text: t("download.modal.windows.step1") }],
|
||||
@@ -97,18 +101,49 @@ export default function DownloadPage() {
|
||||
|
||||
return (
|
||||
<Stack spacing={3} sx={{ maxWidth: 720 }}>
|
||||
<Stack direction="row" spacing={1.5} alignItems="flex-start">
|
||||
<CableIcon color="primary" sx={{ mt: 0.5 }} />
|
||||
<Box>
|
||||
<Typography variant="h6" fontWeight={600} sx={{ color: (muiTheme) => muiTheme.thingport.headingText }}>
|
||||
{t("download.pageTitle")}
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">{t("download.intro")}</Typography>
|
||||
</Box>
|
||||
</Stack>
|
||||
<Box>
|
||||
<Typography variant="h6" fontWeight={600} sx={{ color: (muiTheme) => muiTheme.thingport.headingText }}>
|
||||
{t("download.pageTitle")}
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">{t("download.intro")}</Typography>
|
||||
</Box>
|
||||
|
||||
<Stack spacing={2}>
|
||||
<Typography variant="subtitle1" fontWeight={600}>{t("download.downloadHeading")}</Typography>
|
||||
<Stack direction="row" spacing={1.5} alignItems="flex-start">
|
||||
<Box component="img" src={extensionIcon} alt="" sx={{ width: 24, height: 24, mt: 0.5 }} />
|
||||
<Box>
|
||||
<Typography variant="subtitle1" fontWeight={600}>{t("download.extension.heading")}</Typography>
|
||||
<Typography variant="body2" color="text.secondary">{t("download.extension.intro")}</Typography>
|
||||
</Box>
|
||||
</Stack>
|
||||
<Paper variant="outlined" sx={{ p: 2.5, maxWidth: 340 }}>
|
||||
<Stack spacing={1.5} alignItems="flex-start">
|
||||
<Box component="img" src={extensionIcon} alt="" sx={{ width: 40, height: 40 }} />
|
||||
<Typography variant="subtitle2" fontWeight={600}>{t("download.extension.name")}</Typography>
|
||||
<Button
|
||||
component="a"
|
||||
href={EXTENSION_DOWNLOAD_URL}
|
||||
variant="outlined"
|
||||
size="small"
|
||||
startIcon={<DownloadIcon fontSize="small" />}
|
||||
onClick={() => setExtensionInstallOpen(true)}
|
||||
>
|
||||
{t("common:download")}
|
||||
</Button>
|
||||
</Stack>
|
||||
</Paper>
|
||||
</Stack>
|
||||
|
||||
<Divider />
|
||||
|
||||
<Stack spacing={2}>
|
||||
<Stack direction="row" spacing={1.5} alignItems="flex-start">
|
||||
<CableIcon color="primary" sx={{ mt: 0.5 }} />
|
||||
<Box>
|
||||
<Typography variant="subtitle1" fontWeight={600}>{t("download.bridge.heading")}</Typography>
|
||||
<Typography variant="body2" color="text.secondary">{t("download.bridge.intro")}</Typography>
|
||||
</Box>
|
||||
</Stack>
|
||||
<Stack direction={{ xs: "column", sm: "row" }} spacing={2}>
|
||||
{BRIDGE_DOWNLOADS.map(({ os, label, asset }) => {
|
||||
const Icon = OS_ICON[os];
|
||||
@@ -155,6 +190,27 @@ export default function DownloadPage() {
|
||||
</>
|
||||
)}
|
||||
</Dialog>
|
||||
|
||||
<Dialog open={extensionInstallOpen} onClose={() => setExtensionInstallOpen(false)} maxWidth="xs" fullWidth>
|
||||
<DialogTitle>{t("download.extension.modal.heading")}</DialogTitle>
|
||||
<DialogContent>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mb: 2.5 }}>
|
||||
{t("download.modal.startedNote")}
|
||||
</Typography>
|
||||
<InstallSteps
|
||||
steps={[
|
||||
{ text: t("download.extension.modal.step1") },
|
||||
{ text: t("download.extension.modal.step2") },
|
||||
{ text: t("download.extension.modal.step3") },
|
||||
{ text: t("download.extension.modal.step4") },
|
||||
{ text: t("download.extension.modal.step5") },
|
||||
]}
|
||||
/>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setExtensionInstallOpen(false)}>{t("common:close")}</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||