Compare commits
No commits in common. "main" and "phase-2-formats-viewer" have entirely different histories.
main
...
phase-2-fo
57 changed files with 173 additions and 10305 deletions
|
|
@ -1,11 +0,0 @@
|
|||
{
|
||||
"version": "0.0.1",
|
||||
"configurations": [
|
||||
{
|
||||
"name": "printhive",
|
||||
"runtimeExecutable": "npm",
|
||||
"runtimeArgs": ["start"],
|
||||
"port": 3100
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -12,8 +12,6 @@ data
|
|||
.DS_Store
|
||||
npm-debug.log*
|
||||
Dockerfile
|
||||
Dockerfile.check
|
||||
.dockerignore
|
||||
docker-compose.yml
|
||||
extracted
|
||||
models
|
||||
|
|
|
|||
13
.env.example
13
.env.example
|
|
@ -2,18 +2,5 @@
|
|||
DATABASE_URL=postgres://USER@localhost:5432/stl_library
|
||||
PORT=3000
|
||||
|
||||
# Docker only: where data lives on the host. Both default to ./data beside
|
||||
# docker-compose.yml. DATA_DIR takes the model files, thumbnails and previews.
|
||||
DATA_DIR=/Volumes/Data/printhive
|
||||
# The database is set separately, because Postgres needs a filesystem with real
|
||||
# ownership and permissions — leave it on local disk if DATA_DIR is exFAT, FAT
|
||||
# or a network share.
|
||||
PGDATA_DIR=/Volumes/Data/printhive/pgdata
|
||||
|
||||
# Where tools/backup.sh writes (default ./backups) and how many days of
|
||||
# database dumps it keeps (default 14). Best on a different disk from DATA_DIR.
|
||||
BACKUP_DIR=/Volumes/Data/printhive-backups
|
||||
KEEP_DAYS=14
|
||||
|
||||
# Thingiverse import — get a free app token at https://www.thingiverse.com/developers
|
||||
THINGIVERSE_TOKEN=
|
||||
|
|
|
|||
|
|
@ -16,11 +16,6 @@ jobs:
|
|||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Lint and test
|
||||
# Built, not mounted: this job runs in a container, so a host bind mount
|
||||
# of $PWD arrives empty. A failure here stops the image being built.
|
||||
run: docker build -f Dockerfile.check -t printhive-check .
|
||||
|
||||
- name: Log in to the Forgejo container registry
|
||||
# REGISTRY_TOKEN = a Forgejo access token with write:package scope, added
|
||||
# as a repo Actions secret. The auto GITHUB_TOKEN can't push packages.
|
||||
|
|
|
|||
2
.gitignore
vendored
2
.gitignore
vendored
|
|
@ -10,5 +10,3 @@ glb/
|
|||
tools/venv/
|
||||
__pycache__/
|
||||
extracted/
|
||||
models/
|
||||
backups/
|
||||
|
|
|
|||
10
Dockerfile
10
Dockerfile
|
|
@ -18,19 +18,11 @@ RUN python3 -m venv /app/tools/venv \
|
|||
|
||||
# Node deps (cached unless package files change).
|
||||
COPY package.json package-lock.json ./
|
||||
RUN npm ci --omit=dev \
|
||||
# semantic search only needs onnxruntime's Linux binaries (saves ~130 MB)
|
||||
&& rm -rf node_modules/onnxruntime-node/bin/napi-v3/darwin node_modules/onnxruntime-node/bin/napi-v3/win32
|
||||
RUN npm ci --omit=dev
|
||||
|
||||
# App source.
|
||||
COPY . .
|
||||
|
||||
# The embedding model for semantic search (~34 MB), fetched at build time so the
|
||||
# server never has to download it.
|
||||
ENV MODELS_DIR=/app/models
|
||||
# (No process.exit: forcing an exit while onnxruntime's threads are up aborts node.)
|
||||
RUN node -e "require('./lib/embeddingModel').embed(['test']).then(() => console.log('embedding model ready'), (err) => { console.error(err); process.exitCode = 1; })"
|
||||
|
||||
ENV NODE_ENV=production \
|
||||
PORT=3000
|
||||
EXPOSE 3000
|
||||
|
|
|
|||
|
|
@ -1,11 +0,0 @@
|
|||
# Lint and tests, run in CI. A build context rather than a bind mount, because
|
||||
# the CI job runs inside a container of its own — $PWD there means nothing to
|
||||
# the Docker daemon, so a mounted folder arrives empty.
|
||||
FROM node:22-bookworm-slim
|
||||
WORKDIR /src
|
||||
|
||||
COPY package.json package-lock.json ./
|
||||
RUN npm ci --no-audit --no-fund
|
||||
|
||||
COPY . .
|
||||
RUN npm run lint && npm test
|
||||
76
README.md
76
README.md
|
|
@ -8,14 +8,12 @@ A self-hosted web app for organizing your 3D-printing files (STL, 3MF, OBJ, FBX,
|
|||
- **Import from URL**:
|
||||
- **Thingiverse** — downloads files automatically via the official API (needs a free app token).
|
||||
- **Printables** — downloads files automatically via its public GraphQL API (no auth needed for free models).
|
||||
- **MakerWorld** — downloads files via FlareSolverr (solves Cloudflare) + your account token. If files can't be downloaded the import says why, and when MakerWorld refuses the token (they expire) Printhive asks for a fresh one and retries.
|
||||
- **MakerWorld** — downloads files via FlareSolverr (solves Cloudflare) + your account token.
|
||||
- **cubee3d (Hive)** and any other URL — imports title/description/preview image; you add the files manually.
|
||||
- **In-browser 3D viewer** (Three.js) for STL, OBJ (with its MTL colours/textures), FBX (with textures), STEP/STP (OpenCascade WASM) and Rhino 3DM, and for 3MF via a server-side **trimesh GLB conversion** that streams even huge Bambu/MakerWorld projects.
|
||||
- **Slicer projects plate by plate** — Bambu Studio / OrcaSlicer 3MFs open on plate 1 with the slicer's plate images as a switcher, parts shown in their filament colours, and a Filaments list; negative/modifier parts are hidden.
|
||||
- **Archives as folders** — ZIP, 7Z, RAR and TAR/.tar.gz uploads are indexed in the background; browse what's inside and preview or download any file without unpacking. Models inside archives count for duplicate detection.
|
||||
- **Pictures, PDFs & readmes** — keep them with a model and view them in the app.
|
||||
- **Open in slicer** — pick your slicer once per browser (Settings → Slicer). Bambu Studio and OrcaSlicer open the file straight from a 10-minute download link (`bambustudioopen://…`, `orcaslicer://open?file=…`), so the server must be reachable at the address the browser uses; Bambu Studio asks before opening files that aren't from MakerWorld. PrusaSlicer (which only accepts Printables/Thingiverse links) and Cura get a normal download, and any other slicer with an open-link can be added as a template. Opening a 3MF project in a different slicer from the one that saved it asks first. Works for files inside archives too.
|
||||
- **Drag out** — drag a model card (its file, or a zip of all its files) or any file row, including files inside archives, onto your desktop or a folder to save it. Uses Chromium's DownloadURL drag, so it needs Chrome, Edge, Brave or similar.
|
||||
- **Auto thumbnails** made in the background: an embedded 3MF/plate preview, else a picture that came with the model (or one inside its archive), else a server-side render of the STL/OBJ/3MF; the browser renders FBX/STEP/3DM ones.
|
||||
- **License & commercial-use tracking** — captured per source, with a "sellable / non-commercial" indicator (a guide; verify before selling).
|
||||
- **Collections** — group models (a model can be in many); rename/delete without losing models.
|
||||
|
|
@ -23,12 +21,6 @@ A self-hosted web app for organizing your 3D-printing files (STL, 3MF, OBJ, FBX,
|
|||
- **Notes** — free-form notes per model, saved as you type.
|
||||
- **Search, filters & sorting** — search across names, descriptions, notes, tags, file names and designers; filter by format, source and license; include *or exclude* tags (match any/all); sort and group by source or format. Press <kbd>/</kbd> to search.
|
||||
- **Duplicate finder** — every file gets a SHA-256 content hash plus (for STL/OBJ/3MF) a geometry fingerprint, computed in the background. Models sharing files are grouped as *exact copies* or *same shape* (renamed, re-exported, converted — or mirrored, so check those), with a recommended copy to keep. Keeping one merges the others' tags, collections and notes into it and moves them to the trash; groups can be dismissed as "not duplicates".
|
||||
- **Watch a MakerWorld collection** — paste a collection link (private ones work, using your MakerWorld token) and Printhive checks it on a schedule you choose, importing models added to it into a collection of the same name here, optionally AI-tagging the new ones. Each check reads the whole collection (paging through MakerWorld's own load-more call, up to ~640 models) and imports up to 15 of the new ones, spaced out, returning shortly after if more are waiting; models you remove on MakerWorld stay in your library. Each watch shows when it last ran, what it imported, and why a check failed — with a one-click token update when MakerWorld refuses your token. A check imports each model's page — name, description, picture, licence — but not its files, so a large collection arrives quickly; models are listed as still without files and you fetch the ones you want with "Get files". MakerWorld refuses downloads fired off in quick succession (a few seconds apart, as a check would), but spacing them out is fine — 67 models in a row downloaded about 20 seconds apart without a single refusal. After a run of downloads MakerWorld may also ask you to prove you're not a robot: the Watched page says so and Printhive stops downloading — checks still import new models, whose files wait — until you solve it on MakerWorld and press "I've solved it".
|
||||
- **AI tags & descriptions** — bring your own key for DeepSeek (the default), OpenRouter, OpenAI or any OpenAI-compatible server (Ollama, LM Studio). Tag the whole library, only models not yet tagged, or a single model from its page. Each model's name, file names and (optionally) thumbnail are sent; tags are only ever added and descriptions only filled where empty unless you ask. Optionally it also gives models with unclear names (file-name leftovers, typos, site boilerplate) a clearer one; the old name is kept and can be restored with Undo on the model page. It can also suggest collections (reusing yours where they fit): suggestions wait on the AI page, grouped by name, until you create/add or dismiss them. Each run can do any mix of tags & descriptions, names and collections, and a reply that isn't the JSON asked for is retried once. Runs show live progress, tokens and cost, can be stopped and resumed, stop at an optional spending limit, and pause (rather than keep spending) if the server restarts. "Save & test connection" in Settings checks the key, model and whether it accepts images.
|
||||
- **Search by meaning** — switch the search box to *Meaning* to find models by what they are ("wall hook" finds a "coat hanger bracket"). Uses a small embedding model (bge-small-en-v1.5, ~34 MB) that runs on the server, so it's free and private; the index updates in the background as models change.
|
||||
- **Automatic tags** — strict whole-word rules over names, file names and archive contents (part types, mechanisms, materials, techniques) plus your own "word → tag" rules. Switch it off (optionally removing its tags) or re-apply it any time. Tags you add yourself are never changed; AI and rule tags are marked on the model page.
|
||||
- **Filament & what's printable** — keep the spools you own (material, colour, brand, grams left) on the **Filament** page. Bambu Studio / OrcaSlicer projects name the filament in each slot, which Printhive reads once per model in the background, so cards and the model page say whether you can print it now and what you're short of, and the toolbar can show only what's printable. Each slot is matched to a spool by material *and* colour — a four-colour print needs those four colours, and one spool can't fill two slots. Near shades count as the same colour; the model page lists every slot, which spool fills it, and the nearest thing you own when none does. Models that aren't slicer projects don't name their filaments, and say so rather than being called unprintable. The slot list can be edited on the model page when the project's own list is wrong or you plan to print it differently — set a slot to **any colour** when the shade doesn't matter, and it's filled by any spool of that material. Edited lists are marked as yours and left alone by the background reader; "Use the project's list" puts it back. Adding a spool can start from Bambu Lab's own filament list — pick "PLA Basic — Jade White (10100)" and the material, colour and code fill themselves — and slot colours are named where they match it. That list is included by kind permission of [3D Filament Profiles](https://3dfilamentprofiles.com), whose terms otherwise reserve it; ask them before reusing `db/bambu-filaments.json` elsewhere.
|
||||
- **Printers** — add as many Bambu Lab printers as you have on the Printers page, each with an optional nickname (the model is shown when it's blank), a model picked from the lineup with a drawing of its shape, its AMS type, and its address, serial number and access code. Printhive stays connected to each printer and its card shows what it's doing live: state, the file printing, progress, time left and finish time, layer, nozzle, bed and chamber temperatures, the speed setting, and any error or HMS codes. The drawing colours in the spools loaded in the AMS, with every slot listed underneath. "Test & sync" asks a printer for a full report straight away. "Camera" shows the chamber camera on a P1 or A1 series printer — about a picture a second, connected only while you're watching and never recorded. The connection only listens — Printhive never sends a printer a command, other than asking for a full report — and access codes stay on your server. Spools a printer reads from their RFID tags are added to the Filament page by themselves, within seconds of going in: named and coded from Bambu's filament list, shown with their tag ID, matched to any you'd already added by hand rather than duplicated, and marked as taken out when they leave the printer. Grams left are filled in from an AMS, AMS 2 Pro or AMS HT; an AMS lite only knows full or empty, so those are left to you. Spools without a tag can't be told apart from one another, so they're listed for you to add by hand.
|
||||
- **Trash** — deleting moves a model to the trash; restore it, delete it forever, or empty the trash (only then are files removed from disk).
|
||||
- **Settings** — API tokens / FlareSolverr URL stored in the database (with `.env` fallback), secrets masked.
|
||||
|
||||
|
|
@ -55,60 +47,7 @@ A self-hosted web app for organizing your 3D-printing files (STL, 3MF, OBJ, FBX,
|
|||
```
|
||||
4. Open http://localhost:3000
|
||||
|
||||
Run the unit tests with `npm test`, ESLint with `npm run lint`, or both with `npm run check`. CI runs both before
|
||||
building the image, so a lint error stops a broken image being pushed. The lint rules are deliberately thin — their
|
||||
job is the class of mistake tests can't catch, chiefly a name that doesn't exist until the line finally runs.
|
||||
|
||||
### Where your data lives
|
||||
|
||||
With Docker, everything sits in folders on the host — `./data` beside `docker-compose.yml` unless `.env` says
|
||||
otherwise (`DATA_DIR` for the files, `PGDATA_DIR` for the database):
|
||||
|
||||
| Folder | Holds |
|
||||
|---|---|
|
||||
| `$PGDATA_DIR` | The database: models, tags, collections, watches, spools, printers, settings (tokens and printer access codes live here) |
|
||||
| `$DATA_DIR/uploads` | Your model files, as uploaded or downloaded |
|
||||
| `$DATA_DIR/thumbnails` | Generated pictures, including plate images |
|
||||
| `$DATA_DIR/glb` | Converted 3D previews for the viewer |
|
||||
| `$DATA_DIR/extracted` | Files unpacked from archives — a cache |
|
||||
|
||||
The database is set apart from the rest because Postgres needs a filesystem with real ownership and permissions —
|
||||
ext4, xfs, APFS and the like. exFAT, FAT and most network shares won't do, so keep `PGDATA_DIR` on local disk if the
|
||||
files live on one of those.
|
||||
|
||||
A backup needs the database (or a `pg_dump`) and `uploads`; the other three rebuild themselves. Everything else in
|
||||
the container is replaced on each update, so nothing outside these folders is kept.
|
||||
|
||||
### Backups
|
||||
|
||||
`tools/backup.sh` takes a dated dump of the database and mirrors the model files, leaving Printhive running:
|
||||
|
||||
```bash
|
||||
./tools/backup.sh
|
||||
```
|
||||
|
||||
It writes to `./backups` unless `BACKUP_DIR` says otherwise: `backups/db/printhive-YYYY-MM-DD.dump` (kept for
|
||||
`KEEP_DAYS`, 14 by default) and `backups/uploads`, an rsync mirror of the files — matching what's there now,
|
||||
deletions included, rather than a history. Thumbnails, previews and extracted archives are skipped, as they rebuild.
|
||||
Each dump is read back with `pg_restore -l` before it replaces the day's file, so a failed run leaves the older
|
||||
backups alone and exits non-zero.
|
||||
|
||||
Nightly, at 3:30am — `crontab -e`, with the path to your own copy:
|
||||
|
||||
```
|
||||
30 3 * * * cd /path/to/printhive && ./tools/backup.sh >> backups/backup.log 2>&1
|
||||
```
|
||||
|
||||
Docker has to be running for it to work, and the backups are best kept on a different disk from `DATA_DIR`.
|
||||
|
||||
To restore the database into a running stack:
|
||||
|
||||
```bash
|
||||
docker compose exec -T db pg_restore -U printhive -d printhive --clean --if-exists < backups/db/printhive-2026-01-01.dump
|
||||
```
|
||||
|
||||
Upgrading from a version that used Docker named volumes: stop the stack, run `tools/volumes-to-folders.sh` to copy the
|
||||
data across, then start it again. The script only copies — the old volumes stay until you remove them yourself.
|
||||
Run the unit tests with `npm test`.
|
||||
|
||||
## Configuration
|
||||
|
||||
|
|
@ -120,19 +59,8 @@ Settings live in the database (editable in the app's **Settings** menu) and fall
|
|||
| FlareSolverr URL | `FLARESOLVERR_URL` | Read Cloudflare-protected sites (MakerWorld) |
|
||||
| MakerWorld auth token | `MAKERWORLD_TOKEN` | MakerWorld downloads (bearer token from your session) |
|
||||
| Printables auth token | `PRINTABLES_TOKEN` | Optional; account-gated Printables files |
|
||||
| AI provider | `AI_PROVIDER` | `deepseek` (default), `openrouter`, `openai` or `custom` |
|
||||
| AI API key | `AI_API_KEY` | Your provider key (not needed for a local server) |
|
||||
| AI model | `AI_MODEL` | Defaults to `deepseek-flash` for DeepSeek |
|
||||
| AI API address | `AI_BASE_URL` | Defaults per provider; e.g. `http://localhost:11434/v1` for Ollama |
|
||||
| AI prices | `AI_PRICE_INPUT`, `AI_PRICE_OUTPUT` | $ per million tokens, only for showing costs (DeepSeek defaults to its peak rates) |
|
||||
| Send thumbnails | `AI_SEND_IMAGES` | `false` to tag from names and files only |
|
||||
|
||||
Printers are added on the **Printers** page instead — as many as you like, each with a nickname, model, AMS type,
|
||||
address, serial number and access code. Access codes stay on the server. A printer set up in Settings by an earlier
|
||||
version moves there automatically; the old `PRINTER_*` environment variables are no longer read.
|
||||
|
||||
## Notes
|
||||
|
||||
- Auto-download from MakerWorld/Printables relies on their private/session APIs and can break if they change; failed/partial imports can be recovered with "Download files from source".
|
||||
- The embedding model for search is downloaded into `./models` on first use locally; the Docker image ships with it. It holds ~150 MB of RAM while in use and is released after 10 idle minutes.
|
||||
- The commercial-use badge is a best-effort read of the license — always confirm on the source page before selling.
|
||||
|
|
|
|||
|
|
@ -1,334 +0,0 @@
|
|||
{
|
||||
"source": "https://3dfilamentprofiles.com/filaments/bambu-lab",
|
||||
"permission": "Copied with the site owner's permission, granted on request 2026-09-17. Their terms otherwise prohibit copying, scraping and redistribution — ask them before reusing this file anywhere else.",
|
||||
"collected": "2026-09-17",
|
||||
"count": 325,
|
||||
"note": "Bambu Lab filaments: material, product type, colour name, Bambu filament code (what the AMS reports), and the colour(s) shown. Multi-colour filaments list each colour; a few entries have no published code.",
|
||||
"filaments": [
|
||||
{"material":"ABS","type":"Basic","name":"Azure","code":"40601","colours":["#489FDF"]},
|
||||
{"material":"ABS","type":"Basic","name":"Bambu Green","code":"40500","colours":["#00AE42"]},
|
||||
{"material":"ABS","type":"Basic","name":"Beige","code":"40401","colours":["#DFD1A7"]},
|
||||
{"material":"ABS","type":"Basic","name":"Black","code":"40101","colours":["#000000"]},
|
||||
{"material":"ABS","type":"Basic","name":"Blue","code":"40600","colours":["#0A2CA5"]},
|
||||
{"material":"ABS","type":"Basic","name":"Grey","code":"10103","colours":["#8E9089"]},
|
||||
{"material":"ABS","type":"Basic","name":"Lavender","code":"40701","colours":["#7248BD"]},
|
||||
{"material":"ABS","type":"Basic","name":"Mint","code":"40501","colours":["#ADF4DC"]},
|
||||
{"material":"ABS","type":"Basic","name":"Navy Blue","code":"40602","colours":["#0C2340"]},
|
||||
{"material":"ABS","type":"Basic","name":"Olive","code":"40502","colours":["#789D4A"]},
|
||||
{"material":"ABS","type":"Basic","name":"Orange","code":"40300","colours":["#FF6A13"]},
|
||||
{"material":"ABS","type":"Basic","name":"Purple","code":"40700","colours":["#AF1685"]},
|
||||
{"material":"ABS","type":"Basic","name":"Red","code":"40200","colours":["#D32941"]},
|
||||
{"material":"ABS","type":"Basic","name":"Silver","code":"40102","colours":["#87909A"]},
|
||||
{"material":"ABS","type":"Basic","name":"Tangerine Yellow","code":"40402","colours":["#FFC72C"]},
|
||||
{"material":"ABS","type":"Basic","name":"White","code":"40100","colours":["#FFFFFF"]},
|
||||
{"material":"ABS","type":"Basic","name":"Yellow","code":"40400","colours":["#FFFF20"]},
|
||||
{"material":"ABS","type":"GF","name":"Black","code":"41101","colours":["#000000"]},
|
||||
{"material":"ABS","type":"GF","name":"Blue","code":"41600","colours":["#0C3B95"]},
|
||||
{"material":"ABS","type":"GF","name":"Gray","code":"41102","colours":["#C6C6C6"]},
|
||||
{"material":"ABS","type":"GF","name":"Green","code":"41500","colours":["#61BF36"]},
|
||||
{"material":"ABS","type":"GF","name":"Orange","code":"41300","colours":["#F48438"]},
|
||||
{"material":"ABS","type":"GF","name":"Red","code":"41200","colours":["#E83100"]},
|
||||
{"material":"ABS","type":"GF","name":"White","code":"41100","colours":["#FFFFFF"]},
|
||||
{"material":"ABS","type":"GF","name":"Yellow","code":"41400","colours":["#FFE133"]},
|
||||
{"material":"ASA","type":"Aero","name":"White","code":"46100","colours":["#EFF2DE"]},
|
||||
{"material":"ASA","type":"Basic","name":"Black","code":"45101","colours":["#000000"]},
|
||||
{"material":"ASA","type":"Basic","name":"Blue","code":"45600","colours":["#2140B4"]},
|
||||
{"material":"ASA","type":"Basic","name":"Gray","code":"45102","colours":["#8A949E"]},
|
||||
{"material":"ASA","type":"Basic","name":"Green","code":"45500","colours":["#00A6A0"]},
|
||||
{"material":"ASA","type":"Basic","name":"Red","code":"45200","colours":["#E02928"]},
|
||||
{"material":"ASA","type":"Basic","name":"White","code":"45100","colours":["#FFFAF2"]},
|
||||
{"material":"ASA","type":"CF","name":"Black","code":"46101","colours":["#000000"]},
|
||||
{"material":"HIPS","type":"Support","name":"Support for ABS White","code":"66100","colours":["#FFFFFF"]},
|
||||
{"material":"PA6","type":"CF","name":"Black","code":"72100","colours":["#000000"]},
|
||||
{"material":"PA6","type":"GF","name":"Black","code":"72104","colours":["#000000"]},
|
||||
{"material":"PA6","type":"GF","name":"Blue","code":"72600","colours":["#75AED8"]},
|
||||
{"material":"PA6","type":"GF","name":"Brown","code":"72800","colours":["#5B492F"]},
|
||||
{"material":"PA6","type":"GF","name":"Gray","code":"72103","colours":["#353533"]},
|
||||
{"material":"PA6","type":"GF","name":"Lime","code":"72500","colours":["#C5ED48"]},
|
||||
{"material":"PA6","type":"GF","name":"Orange","code":"72200","colours":["#FF4800"]},
|
||||
{"material":"PA6","type":"GF","name":"White","code":"72102","colours":["#EAEAE4"]},
|
||||
{"material":"PA6","type":"GF","name":"Yellow","code":"72400","colours":["#FFCE00"]},
|
||||
{"material":"PAHT","type":"CF","name":"Black","code":"70100","colours":["#000000"]},
|
||||
{"material":"PAHT","type":"Support","name":"Support for PA/PET","code":"65500","colours":["#C0DF16"]},
|
||||
{"material":"PC","type":"Basic","name":"Black","code":"60101","colours":["#000000"]},
|
||||
{"material":"PC","type":"Basic","name":"Clear Black","code":"60102","colours":["#5A5161"]},
|
||||
{"material":"PC","type":"Basic","name":"Transparent","code":"60103","colours":["#BFC3C7"]},
|
||||
{"material":"PC","type":"Basic","name":"White","code":"60100","colours":["#FFFFFF"]},
|
||||
{"material":"PC","type":"FR","name":"Black","code":"63100","colours":["#000000"]},
|
||||
{"material":"PC","type":"FR","name":"Gray","code":"63102","colours":["#A8A8AA"]},
|
||||
{"material":"PC","type":"FR","name":"White","code":"63101","colours":["#FFFFFF"]},
|
||||
{"material":"PET","type":"CF","name":"Black","code":"71100","colours":["#000000"]},
|
||||
{"material":"PETG","type":"Basic","name":"Black","code":"30101","colours":["#000000"]},
|
||||
{"material":"PETG","type":"Basic","name":"Black","code":"30105","colours":["#000000"]},
|
||||
{"material":"PETG","type":"Basic","name":"Blue","code":"30600","colours":["#001489"]},
|
||||
{"material":"PETG","type":"Basic","name":"Blue Gray","code":"30601","colours":["#2E4F66"]},
|
||||
{"material":"PETG","type":"Basic","name":"Dark Beige","code":"30403","colours":["#DBC8B6"]},
|
||||
{"material":"PETG","type":"Basic","name":"Dark Brown","code":"30800","colours":["#4F2C1D"]},
|
||||
{"material":"PETG","type":"Basic","name":"Gold","code":"30401","colours":["#F6D86A"]},
|
||||
{"material":"PETG","type":"Basic","name":"Gray","code":"30102","colours":["#ADB1B2"]},
|
||||
{"material":"PETG","type":"Basic","name":"Gray","code":"30107","colours":["#7F7E83"]},
|
||||
{"material":"PETG","type":"Basic","name":"Green","code":"30500","colours":["#009639"]},
|
||||
{"material":"PETG","type":"Basic","name":"Green","code":"30502","colours":["#009639"]},
|
||||
{"material":"PETG","type":"Basic","name":"Lake Blue","code":"30602","colours":["#0069B1"]},
|
||||
{"material":"PETG","type":"Basic","name":"Lime Green","code":"30501","colours":["#7CD82B"]},
|
||||
{"material":"PETG","type":"Basic","name":"Misty Blue","code":"30108","colours":["#688197"]},
|
||||
{"material":"PETG","type":"Basic","name":"Nature","code":"30104","colours":["#D7D7D7"]},
|
||||
{"material":"PETG","type":"Basic","name":"Navy Blue","code":"30604","colours":["#0086D6"]},
|
||||
{"material":"PETG","type":"Basic","name":"Orange","code":"30300","colours":["#FF671F"]},
|
||||
{"material":"PETG","type":"Basic","name":"Orange","code":"30301","colours":["#FF671F"]},
|
||||
{"material":"PETG","type":"Basic","name":"Pine Green","code":"30503","colours":["#034638"]},
|
||||
{"material":"PETG","type":"Basic","name":"Purple","code":"30700","colours":["#9E007E"]},
|
||||
{"material":"PETG","type":"Basic","name":"Red","code":"30200","colours":["#D6001C"]},
|
||||
{"material":"PETG","type":"Basic","name":"Red","code":"30201","colours":["#D6001C"]},
|
||||
{"material":"PETG","type":"Basic","name":"Reflex Blue","code":"30603","colours":["#001489"]},
|
||||
{"material":"PETG","type":"Basic","name":"Translucent","code":"30103","colours":["#FFFFFF"]},
|
||||
{"material":"PETG","type":"Basic","name":"White","code":"30100","colours":["#EDF0F2"]},
|
||||
{"material":"PETG","type":"Basic","name":"White","code":"30106","colours":["#FFFFFF"]},
|
||||
{"material":"PETG","type":"Basic","name":"Yellow","code":"30400","colours":["#FCE300"]},
|
||||
{"material":"PETG","type":"Basic","name":"Yellow","code":"30402","colours":["#FCE300"]},
|
||||
{"material":"PETG","type":"CF","name":"Black","code":"31100","colours":["#000000"]},
|
||||
{"material":"PETG","type":"CF","name":"Brick Red","code":"31200","colours":["#9F332A"]},
|
||||
{"material":"PETG","type":"CF","name":"Indigo Blue","code":"31600","colours":["#324585"]},
|
||||
{"material":"PETG","type":"CF","name":"Malachite Green","code":"31500","colours":["#16B08E"]},
|
||||
{"material":"PETG","type":"CF","name":"Titan Gray","code":"31101","colours":["#565656"]},
|
||||
{"material":"PETG","type":"CF","name":"Violet Purple","code":"31700","colours":["#583061"]},
|
||||
{"material":"PETG","type":"HF","name":"Black","code":"33102","colours":["#000000"]},
|
||||
{"material":"PETG","type":"HF","name":"Blue","code":"33600","colours":["#002E96"]},
|
||||
{"material":"PETG","type":"HF","name":"Cream","code":"33401","colours":["#F9DFB9"]},
|
||||
{"material":"PETG","type":"HF","name":"Dark Gray","code":"33103","colours":["#515151"]},
|
||||
{"material":"PETG","type":"HF","name":"Forest Green","code":"33502","colours":["#39541A"]},
|
||||
{"material":"PETG","type":"HF","name":"Gray","code":"33101","colours":["#ADB1B2"]},
|
||||
{"material":"PETG","type":"HF","name":"Green","code":"33500","colours":["#00AE42"]},
|
||||
{"material":"PETG","type":"HF","name":"Lake Blue","code":"33601","colours":["#1F79E5"]},
|
||||
{"material":"PETG","type":"HF","name":"Lime Green","code":"33501","colours":["#6EE53C"]},
|
||||
{"material":"PETG","type":"HF","name":"Orange","code":"33300","colours":["#F75403"]},
|
||||
{"material":"PETG","type":"HF","name":"Peanut Brown","code":"33801","colours":["#875718"]},
|
||||
{"material":"PETG","type":"HF","name":"Red","code":"33200","colours":["#EB3A3A"]},
|
||||
{"material":"PETG","type":"HF","name":"White","code":"33100","colours":["#FFFFFF"]},
|
||||
{"material":"PETG","type":"HF","name":"Yellow","code":"33400","colours":["#FFD00B"]},
|
||||
{"material":"PETG","type":"Translucent","name":"Brown","code":"32800","colours":["#C9A381"]},
|
||||
{"material":"PETG","type":"Translucent","name":"Clear","code":"32101","colours":["#EBEBEB"]},
|
||||
{"material":"PETG","type":"Translucent","name":"Gray","code":"32100","colours":["#8E8E8E"]},
|
||||
{"material":"PETG","type":"Translucent","name":"Light Blue","code":"32600","colours":["#61B0FF"]},
|
||||
{"material":"PETG","type":"Translucent","name":"Olive","code":"32500","colours":["#748C45"]},
|
||||
{"material":"PETG","type":"Translucent","name":"Orange","code":"32300","colours":["#FF911A"]},
|
||||
{"material":"PETG","type":"Translucent","name":"Pink","code":"32200","colours":["#F9C1BD"]},
|
||||
{"material":"PETG","type":"Translucent","name":"Purple","code":"32700","colours":["#D6ABFF"]},
|
||||
{"material":"PETG","type":"Translucent","name":"Teal","code":"32501","colours":["#77EDD7"]},
|
||||
{"material":"PLA","type":"Aero","name":"Black","code":"14103","colours":["#000000"]},
|
||||
{"material":"PLA","type":"Aero","name":"Gray","code":"14104","colours":["#CDCECA"]},
|
||||
{"material":"PLA","type":"Aero","name":"White","code":"14102","colours":["#FFFFFF"]},
|
||||
{"material":"PLA","type":"Basic","name":"Bambu Green","code":"10501","colours":["#00AE42"]},
|
||||
{"material":"PLA","type":"Basic","name":"Beige","code":"10201","colours":["#F7E6DE"]},
|
||||
{"material":"PLA","type":"Basic","name":"Black","code":"10101","colours":["#000000"]},
|
||||
{"material":"PLA","type":"Basic","name":"Blue","code":"10600","colours":["#0A2989"]},
|
||||
{"material":"PLA","type":"Basic","name":"Blue","code":"10601","colours":["#0A2989"]},
|
||||
{"material":"PLA","type":"Basic","name":"Blue Gray","code":"10602","colours":["#5B6579"]},
|
||||
{"material":"PLA","type":"Basic","name":"Bright Green","code":"10503","colours":["#BECF00"]},
|
||||
{"material":"PLA","type":"Basic","name":"Bronze","code":"10801","colours":["#847D48"]},
|
||||
{"material":"PLA","type":"Basic","name":"Brown","code":"10800","colours":["#9D432C"]},
|
||||
{"material":"PLA","type":"Basic","name":"Cobalt Blue","code":"10604","colours":["#0056B8"]},
|
||||
{"material":"PLA","type":"Basic","name":"Cocoa Brown","code":"10802","colours":["#6F5034"]},
|
||||
{"material":"PLA","type":"Basic","name":"Cyan","code":"10603","colours":["#0086D6"]},
|
||||
{"material":"PLA","type":"Basic","name":"Dark Gray","code":"10105","colours":["#545454"]},
|
||||
{"material":"PLA","type":"Basic","name":"Gold","code":"10401","colours":["#E4BD68"]},
|
||||
{"material":"PLA","type":"Basic","name":"Gray","code":"10103","colours":["#8E9089"]},
|
||||
{"material":"PLA","type":"Basic","name":"Green","code":"10500","colours":["#002914"]},
|
||||
{"material":"PLA","type":"Basic","name":"Hot Pink","code":"10204","colours":["#F5547C"]},
|
||||
{"material":"PLA","type":"Basic","name":"Indigo Purple","code":"10701","colours":["#482960"]},
|
||||
{"material":"PLA","type":"Basic","name":"Jade White","code":"10100","colours":["#FFFFFF"]},
|
||||
{"material":"PLA","type":"Basic","name":"Light Gray","code":"10104","colours":["#D1D3D5"]},
|
||||
{"material":"PLA","type":"Basic","name":"Magenta","code":"10202","colours":["#EC008C"]},
|
||||
{"material":"PLA","type":"Basic","name":"Maroon Red","code":"10205","colours":["#9D2235"]},
|
||||
{"material":"PLA","type":"Basic","name":"Mistletoe Green","code":"10502","colours":["#3F8E43"]},
|
||||
{"material":"PLA","type":"Basic","name":"Orange","code":"10300","colours":["#FF6A13"]},
|
||||
{"material":"PLA","type":"Basic","name":"Pink","code":"10203","colours":["#F55A74"]},
|
||||
{"material":"PLA","type":"Basic","name":"Pumpkin Orange","code":"10301","colours":["#FF9016"]},
|
||||
{"material":"PLA","type":"Basic","name":"Purple","code":"10700","colours":["#5E43B7"]},
|
||||
{"material":"PLA","type":"Basic","name":"Red","code":"10200","colours":["#C12E1F"]},
|
||||
{"material":"PLA","type":"Basic","name":"Silver","code":"10102","colours":["#A6A9AA"]},
|
||||
{"material":"PLA","type":"Basic","name":"Sunflower Yellow","code":"10402","colours":["#FEC600"]},
|
||||
{"material":"PLA","type":"Basic","name":"Turquoise","code":"10605","colours":["#00B1B7"]},
|
||||
{"material":"PLA","type":"Basic","name":"Yellow","code":"10400","colours":["#F4EE2A"]},
|
||||
{"material":"PLA","type":"CF","name":"Black","code":"14100","colours":["#000000"]},
|
||||
{"material":"PLA","type":"CF","name":"Burgundy Red","code":"14200","colours":["#951E23"]},
|
||||
{"material":"PLA","type":"CF","name":"Iris Purple","code":"14700","colours":["#69398E"]},
|
||||
{"material":"PLA","type":"CF","name":"Jeans Blue","code":"14600","colours":["#6E88BC"]},
|
||||
{"material":"PLA","type":"CF","name":"Lava Gray","code":"14101","colours":["#4D5054"]},
|
||||
{"material":"PLA","type":"CF","name":"Matcha Green","code":"14500","colours":["#5C9748"]},
|
||||
{"material":"PLA","type":"CF","name":"Royal Blue","code":"14601","colours":["#2842AD"]},
|
||||
{"material":"PLA","type":"CMYK","name":"Lithophane Bundle","code":"","colours":["#00FFFF","#FFFF00","#FF00FF","#FFFFFF"]},
|
||||
{"material":"PLA","type":"Galaxy","name":"Brown","code":"13203","colours":["#684A43"]},
|
||||
{"material":"PLA","type":"Galaxy","name":"Green","code":"13503","colours":["#3B665E"]},
|
||||
{"material":"PLA","type":"Galaxy","name":"Nebula","code":"13504","colours":["#424379"]},
|
||||
{"material":"PLA","type":"Galaxy","name":"Purple","code":"13602","colours":["#594177"]},
|
||||
{"material":"PLA","type":"Glow","name":"Glow Blue","code":"15600","colours":["#7AC0E9"]},
|
||||
{"material":"PLA","type":"Glow","name":"Glow Green","code":"15500","colours":["#A1FFAC"]},
|
||||
{"material":"PLA","type":"Glow","name":"Glow Orange","code":"15300","colours":["#FF9D5B"]},
|
||||
{"material":"PLA","type":"Glow","name":"Glow Pink","code":"15200","colours":["#F17B8F"]},
|
||||
{"material":"PLA","type":"Glow","name":"Glow Yellow","code":"15400","colours":["#F8FF80"]},
|
||||
{"material":"PLA","type":"Gradient","name":"Arctic Whisper","code":"10900","colours":["#9CDBD9","#FFFFFF"]},
|
||||
{"material":"PLA","type":"Gradient","name":"Blueberry Bubblegum","code":"10905","colours":["#6FCAEF","#8573DD"]},
|
||||
{"material":"PLA","type":"Gradient","name":"Cotton Candy Cloud","code":"10907","colours":["#E7C1D5","#8EC9E9"]},
|
||||
{"material":"PLA","type":"Gradient","name":"Dusk Glare","code":"10906","colours":["#ED6022","#FFCBA6"]},
|
||||
{"material":"PLA","type":"Gradient","name":"Mint Lime","code":"10904","colours":["#4EC83A","#D7FE9E"]},
|
||||
{"material":"PLA","type":"Gradient","name":"Ocean to Meadow","code":"10902","colours":["#307FE2","#54FF9B"]},
|
||||
{"material":"PLA","type":"Gradient","name":"Pink Citrus","code":"10903","colours":["#E4555E","#FCDAD2"]},
|
||||
{"material":"PLA","type":"Gradient","name":"Solar Breeze","code":"10901","colours":["#FC776A","#FFF7F5"]},
|
||||
{"material":"PLA","type":"Lite","name":"Beige","code":"16700","colours":["#F7E6DE"]},
|
||||
{"material":"PLA","type":"Lite","name":"Black","code":"16100","colours":["#000000"]},
|
||||
{"material":"PLA","type":"Lite","name":"Blue","code":"16601","colours":["#004EA8"]},
|
||||
{"material":"PLA","type":"Lite","name":"Cocoa Brown","code":"","colours":["#8E3C06"]},
|
||||
{"material":"PLA","type":"Lite","name":"Cyan","code":"16600","colours":["#00FFFF"]},
|
||||
{"material":"PLA","type":"Lite","name":"Dark Gray","code":"16102","colours":["#6F6E6D"]},
|
||||
{"material":"PLA","type":"Lite","name":"Gray","code":"16101","colours":["#9FA19F"]},
|
||||
{"material":"PLA","type":"Lite","name":"Green","code":"16501","colours":["#00BB31"]},
|
||||
{"material":"PLA","type":"Lite","name":"Orange","code":"16301","colours":["#FF671F"]},
|
||||
{"material":"PLA","type":"Lite","name":"Red","code":"16200","colours":["#FF0000"]},
|
||||
{"material":"PLA","type":"Lite","name":"Sunflower Yellow","code":"16401","colours":["#FFB549"]},
|
||||
{"material":"PLA","type":"Lite","name":"White","code":"16103","colours":["#FFFEF7"]},
|
||||
{"material":"PLA","type":"Lite","name":"Yellow","code":"16400","colours":["#FFD834"]},
|
||||
{"material":"PLA","type":"Marble","name":"Red Granite","code":"13201","colours":["#AD4E38"]},
|
||||
{"material":"PLA","type":"Marble","name":"White Marble","code":"13103","colours":["#F7F3F0"]},
|
||||
{"material":"PLA","type":"Matte","name":"Apple Green","code":"11502","colours":["#C2E189"]},
|
||||
{"material":"PLA","type":"Matte","name":"Ash Gray","code":"11102","colours":["#9B9EA0"]},
|
||||
{"material":"PLA","type":"Matte","name":"Bone White","code":"11103","colours":["#CBC6B8"]},
|
||||
{"material":"PLA","type":"Matte","name":"Caramel","code":"11803","colours":["#AE835B"]},
|
||||
{"material":"PLA","type":"Matte","name":"Charcoal","code":"11101","colours":["#000000"]},
|
||||
{"material":"PLA","type":"Matte","name":"Dark Blue","code":"11602","colours":["#042F56"]},
|
||||
{"material":"PLA","type":"Matte","name":"Dark Brown","code":"11801","colours":["#7D6556"]},
|
||||
{"material":"PLA","type":"Matte","name":"Dark Chocolate","code":"11802","colours":["#4D3324"]},
|
||||
{"material":"PLA","type":"Matte","name":"Dark Green","code":"11501","colours":["#68724D"]},
|
||||
{"material":"PLA","type":"Matte","name":"Dark Red","code":"11202","colours":["#BB3D43"]},
|
||||
{"material":"PLA","type":"Matte","name":"Desert Tan","code":"11401","colours":["#E8DBB7"]},
|
||||
{"material":"PLA","type":"Matte","name":"Grass Green","code":"11500","colours":["#61C680"]},
|
||||
{"material":"PLA","type":"Matte","name":"Ice Blue","code":"11601","colours":["#A3D8E1"]},
|
||||
{"material":"PLA","type":"Matte","name":"Ivory White","code":"11100","colours":["#FFFFFF"]},
|
||||
{"material":"PLA","type":"Matte","name":"Latte Brown","code":"11800","colours":["#D3B7A7"]},
|
||||
{"material":"PLA","type":"Matte","name":"Lemon Yellow","code":"11400","colours":["#F7D959"]},
|
||||
{"material":"PLA","type":"Matte","name":"Lilac Purple","code":"11700","colours":["#AE96D4"]},
|
||||
{"material":"PLA","type":"Matte","name":"Mandarin Orange","code":"11300","colours":["#F99963"]},
|
||||
{"material":"PLA","type":"Matte","name":"Marine Blue","code":"11600","colours":["#0078BF"]},
|
||||
{"material":"PLA","type":"Matte","name":"Nardo Gray","code":"11104","colours":["#757575"]},
|
||||
{"material":"PLA","type":"Matte","name":"Plum","code":"11204","colours":["#950051"]},
|
||||
{"material":"PLA","type":"Matte","name":"Sakura Pink","code":"11201","colours":["#E8AFCF"]},
|
||||
{"material":"PLA","type":"Matte","name":"Scarlet Red","code":"11200","colours":["#DE4343"]},
|
||||
{"material":"PLA","type":"Matte","name":"Sky Blue","code":"11603","colours":["#56B7E6"]},
|
||||
{"material":"PLA","type":"Matte","name":"Terracotta","code":"11203","colours":["#B15533"]},
|
||||
{"material":"PLA","type":"Metal","name":"Cobalt Blue Metallic","code":"13600","colours":["#39699E"]},
|
||||
{"material":"PLA","type":"Metal","name":"Copper Brown Metallic","code":"13800","colours":["#AA6443"]},
|
||||
{"material":"PLA","type":"Metal","name":"Iridium Gold Metallic","code":"13400","colours":["#B39B84"]},
|
||||
{"material":"PLA","type":"Metal","name":"Iron Gray Metallic","code":"13100","colours":["#43403D"]},
|
||||
{"material":"PLA","type":"Metal","name":"Oxide Green Metallic","code":"13500","colours":["#1D7C6A"]},
|
||||
{"material":"PLA","type":"Pure","name":"Absolute Black","code":"17101","colours":["#000000"]},
|
||||
{"material":"PLA","type":"Pure","name":"Apricot","code":"17300","colours":["#FFB673"]},
|
||||
{"material":"PLA","type":"Pure","name":"Baby Blue","code":"17600","colours":["#A4DBE8"]},
|
||||
{"material":"PLA","type":"Pure","name":"Milky Pink","code":"17200","colours":["#F7CED7"]},
|
||||
{"material":"PLA","type":"Pure","name":"Pure White","code":"17100","colours":["#FFFFFF"]},
|
||||
{"material":"PLA","type":"Silk","name":"Blue","code":"13601","colours":["#147BD1"]},
|
||||
{"material":"PLA","type":"Silk","name":"Copper","code":"13300","colours":["#5E4B3C"]},
|
||||
{"material":"PLA","type":"Silk","name":"Gold","code":"13401","colours":["#E5B03D"]},
|
||||
{"material":"PLA","type":"Silk","name":"Green","code":"13502","colours":["#4CE4A0"]},
|
||||
{"material":"PLA","type":"Silk","name":"Pink","code":"13202","colours":["#EEB1C1"]},
|
||||
{"material":"PLA","type":"Silk","name":"Purple","code":"13701","colours":["#580490"]},
|
||||
{"material":"PLA","type":"Silk","name":"Silver","code":"13104","colours":["#ACB4B5"]},
|
||||
{"material":"PLA","type":"Silk","name":"White","code":"13105","colours":["#FFFFFF"]},
|
||||
{"material":"PLA","type":"Silk Multi-Color","name":"Aurora Purple","code":"13909","colours":["#7F3696","#006EC9"]},
|
||||
{"material":"PLA","type":"Silk Multi-Color","name":"Blue Hawaii (Blue-Green)","code":"13904","colours":["#60A4E8","#4CE4A0"]},
|
||||
{"material":"PLA","type":"Silk Multi-Color","name":"Dawn Radiance","code":"13912","colours":["#EC984C","#6CD4BC","#A66EB9","#D87694"]},
|
||||
{"material":"PLA","type":"Silk Multi-Color","name":"Gilded Rose (Pink-Gold)","code":"13901","colours":["#FF9425","#FCA2BF"]},
|
||||
{"material":"PLA","type":"Silk Multi-Color","name":"Midnight Blaze (Blue-Red)","code":"13902","colours":["#0047BB","#7D1B49"]},
|
||||
{"material":"PLA","type":"Silk Multi-Color","name":"Mystic Magenta (Purple+Green)","code":"13913","colours":["#720062","#3A913F"]},
|
||||
{"material":"PLA","type":"Silk Multi-Color","name":"Neon City (Blue-Magenta)","code":"13903","colours":["#0047BB","#BB22A3"]},
|
||||
{"material":"PLA","type":"Silk Multi-Color","name":"Phantom Blue (Blue-Black)","code":"13916","colours":["#000000","#00629B"]},
|
||||
{"material":"PLA","type":"Silk Multi-Color","name":"South Beach","code":"13906","colours":["#F772A4","#00918B"]},
|
||||
{"material":"PLA","type":"Silk Multi-Color","name":"Velvet Eclipse (Black-Red)","code":"13905","colours":["#000000","#C80000"]},
|
||||
{"material":"PLA","type":"Silk+","name":"Baby Blue","code":"13603","colours":["#A8C6EE"]},
|
||||
{"material":"PLA","type":"Silk+","name":"Blue","code":"13604","colours":["#008BDA"]},
|
||||
{"material":"PLA","type":"Silk+","name":"Candy Green","code":"13506","colours":["#018814"]},
|
||||
{"material":"PLA","type":"Silk+","name":"Candy Red","code":"13205","colours":["#D02727"]},
|
||||
{"material":"PLA","type":"Silk+","name":"Champagne","code":"13404","colours":["#F3CFB2"]},
|
||||
{"material":"PLA","type":"Silk+","name":"Gold","code":"13405","colours":["#F4A925"]},
|
||||
{"material":"PLA","type":"Silk+","name":"Mint","code":"13507","colours":["#96DCB9"]},
|
||||
{"material":"PLA","type":"Silk+","name":"Pink","code":"13207","colours":["#F7ADA6"]},
|
||||
{"material":"PLA","type":"Silk+","name":"Purple","code":"13702","colours":["#8671CB"]},
|
||||
{"material":"PLA","type":"Silk+","name":"Rose Gold","code":"13206","colours":["#BA9594"]},
|
||||
{"material":"PLA","type":"Silk+","name":"Silver","code":"13109","colours":["#C8C8C8"]},
|
||||
{"material":"PLA","type":"Silk+","name":"Titan Gray","code":"13108","colours":["#5F6367"]},
|
||||
{"material":"PLA","type":"Silk+","name":"White","code":"13110","colours":["#FFFFFF"]},
|
||||
{"material":"PLA","type":"Sparkle","name":"Alpine Green Sparkle","code":"13501","colours":["#3F5443"]},
|
||||
{"material":"PLA","type":"Sparkle","name":"Classic Gold Sparkle","code":"13402","colours":["#CEA629"]},
|
||||
{"material":"PLA","type":"Sparkle","name":"Crimson Red Sparkle","code":"13200","colours":["#792B36"]},
|
||||
{"material":"PLA","type":"Sparkle","name":"Onyx Black Sparkle","code":"13101","colours":["#2D2B28"]},
|
||||
{"material":"PLA","type":"Sparkle","name":"Royal Purple Sparkle","code":"13700","colours":["#483D8B"]},
|
||||
{"material":"PLA","type":"Sparkle","name":"Slate Gray Sparkle","code":"13102","colours":["#8E9089"]},
|
||||
{"material":"PLA","type":"Support","name":"Support for PLA Black","code":"65101","colours":["#000000"]},
|
||||
{"material":"PLA","type":"Support","name":"Support for PLA White","code":"65100","colours":["#FFFFFF"]},
|
||||
{"material":"PLA","type":"Support","name":"Support for PLA White","code":"65104","colours":["#FFFFFF"]},
|
||||
{"material":"PLA","type":"Support","name":"Support for PLA/PETG Black","code":"65103","colours":["#000000"]},
|
||||
{"material":"PLA","type":"Support","name":"Support for PLA/PETG Nature","code":"65102","colours":["#FBF5E4"]},
|
||||
{"material":"PLA","type":"Tough","name":"Black","code":"","colours":["#000000"]},
|
||||
{"material":"PLA","type":"Tough","name":"Cream White","code":"","colours":["#FAFAFA"]},
|
||||
{"material":"PLA","type":"Tough","name":"Gray","code":"12102","colours":["#515A6C"]},
|
||||
{"material":"PLA","type":"Tough","name":"Lavender-Blue","code":"12700","colours":["#6667AB"]},
|
||||
{"material":"PLA","type":"Tough","name":"Light-Blue","code":"12600","colours":["#0085AD"]},
|
||||
{"material":"PLA","type":"Tough","name":"Orange","code":"12300","colours":["#FF7F41"]},
|
||||
{"material":"PLA","type":"Tough","name":"Pine Green","code":"12500","colours":["#00482B"]},
|
||||
{"material":"PLA","type":"Tough","name":"Silver","code":"12103","colours":["#9D9D9D"]},
|
||||
{"material":"PLA","type":"Tough","name":"Vermilion-Red","code":"12200","colours":["#DD3C22"]},
|
||||
{"material":"PLA","type":"Tough","name":"White","code":"12100","colours":["#FFFFFF"]},
|
||||
{"material":"PLA","type":"Tough","name":"Yellow","code":"12400","colours":["#FEDB00"]},
|
||||
{"material":"PLA","type":"Tough+","name":"Black","code":"12104","colours":["#000000"]},
|
||||
{"material":"PLA","type":"Tough+","name":"Cyan","code":"12601","colours":["#009BD9"]},
|
||||
{"material":"PLA","type":"Tough+","name":"Gray","code":"12105","colours":["#AFB1AE"]},
|
||||
{"material":"PLA","type":"Tough+","name":"Orange","code":"12301","colours":["#DC3A27"]},
|
||||
{"material":"PLA","type":"Tough+","name":"Silver","code":"12106","colours":["#959698"]},
|
||||
{"material":"PLA","type":"Tough+","name":"White","code":"12107","colours":["#FFFFFF"]},
|
||||
{"material":"PLA","type":"Tough+","name":"Yellow","code":"12401","colours":["#F4D53F"]},
|
||||
{"material":"PLA","type":"Translucent","name":"Blue","code":"13611","colours":["#0047BB"]},
|
||||
{"material":"PLA","type":"Translucent","name":"Cherry Pink","code":"13211","colours":["#F5B6CD"]},
|
||||
{"material":"PLA","type":"Translucent","name":"Ice Blue","code":"13610","colours":["#B8CDE9"]},
|
||||
{"material":"PLA","type":"Translucent","name":"Lavender","code":"13711","colours":["#B8ACD6"]},
|
||||
{"material":"PLA","type":"Translucent","name":"Light Jade","code":"13510","colours":["#96D8AF"]},
|
||||
{"material":"PLA","type":"Translucent","name":"Mellow Yellow","code":"13410","colours":["#F5DBAB"]},
|
||||
{"material":"PLA","type":"Translucent","name":"Orange","code":"13301","colours":["#F74E02"]},
|
||||
{"material":"PLA","type":"Translucent","name":"Purple","code":"13710","colours":["#8344B0"]},
|
||||
{"material":"PLA","type":"Translucent","name":"Red","code":"13210","colours":["#B50011"]},
|
||||
{"material":"PLA","type":"Translucent","name":"Teal","code":"13612","colours":["#009FA1"]},
|
||||
{"material":"PLA","type":"Wood","name":"Black Walnut","code":"13107","colours":["#4F3F24"]},
|
||||
{"material":"PLA","type":"Wood","name":"Classic Birch","code":"13505","colours":["#918669"]},
|
||||
{"material":"PLA","type":"Wood","name":"Clay Brown","code":"13801","colours":["#995F11"]},
|
||||
{"material":"PLA","type":"Wood","name":"Ochre Yellow","code":"13403","colours":["#C98935"]},
|
||||
{"material":"PLA","type":"Wood","name":"Rosewood","code":"13204","colours":["#4C241C"]},
|
||||
{"material":"PLA","type":"Wood","name":"White Oak","code":"13106","colours":["#D6CCA3"]},
|
||||
{"material":"PPA","type":"CF","name":"Black","code":"73100","colours":["#000000"]},
|
||||
{"material":"PPS","type":"CF","name":"Black","code":"76100","colours":["#000000"]},
|
||||
{"material":"PVA","type":"Support","name":"Clear","code":"66400","colours":["#F0F1A8"]},
|
||||
{"material":"TPU","type":"68D (For AMS)","name":"Black","code":"53101","colours":["#000000"]},
|
||||
{"material":"TPU","type":"68D (For AMS)","name":"Blue","code":"53600","colours":["#5898DD"]},
|
||||
{"material":"TPU","type":"68D (For AMS)","name":"Gray","code":"53102","colours":["#939393"]},
|
||||
{"material":"TPU","type":"68D (For AMS)","name":"Neon Green","code":"53500","colours":["#90FF1A"]},
|
||||
{"material":"TPU","type":"68D (For AMS)","name":"Red","code":"53200","colours":["#ED0000"]},
|
||||
{"material":"TPU","type":"68D (For AMS)","name":"White","code":"53100","colours":["#FFFFFF"]},
|
||||
{"material":"TPU","type":"68D (For AMS)","name":"Yellow","code":"53400","colours":["#F9EF41"]},
|
||||
{"material":"TPU","type":"85A","name":"Black","code":"51107","colours":["#000000"]},
|
||||
{"material":"TPU","type":"85A","name":"Flesh","code":"51201","colours":["#FFF2D5"]},
|
||||
{"material":"TPU","type":"85A","name":"Light Cyan","code":"51500","colours":["#C3E2D6"]},
|
||||
{"material":"TPU","type":"85A","name":"Lime Green","code":"51501","colours":["#EAF28F"]},
|
||||
{"material":"TPU","type":"85A","name":"Neon Orange","code":"51305","colours":["#F68B1B"]},
|
||||
{"material":"TPU","type":"90A","name":"Black","code":"51103","colours":["#000000"]},
|
||||
{"material":"TPU","type":"90A","name":"Blaze","code":"51901","colours":["#F1AAA8","#D21B3C"]},
|
||||
{"material":"TPU","type":"90A","name":"Cocoa Brown","code":"51800","colours":["#5C4738"]},
|
||||
{"material":"TPU","type":"90A","name":"Crystal Blue","code":"51601","colours":["#7EB4E1"]},
|
||||
{"material":"TPU","type":"90A","name":"Frozen","code":"51900","colours":["#FFFFFF","#40B6E4"]},
|
||||
{"material":"TPU","type":"90A","name":"Grape Jelly","code":"51700","colours":["#D6ABFF"]},
|
||||
{"material":"TPU","type":"90A","name":"Quicksilver","code":"51106","colours":["#9EA2A2"]},
|
||||
{"material":"TPU","type":"90A","name":"White","code":"51105","colours":["#FFFFFF"]},
|
||||
{"material":"TPU","type":"95A HF/High Speed","name":"Black","code":"51100","colours":["#000000"]},
|
||||
{"material":"TPU","type":"95A HF/High Speed","name":"Blue","code":"51600","colours":["#0072CE"]},
|
||||
{"material":"TPU","type":"95A HF/High Speed","name":"Gray","code":"51101","colours":["#898D8D"]},
|
||||
{"material":"TPU","type":"95A HF/High Speed","name":"Red","code":"51200","colours":["#C8102E"]},
|
||||
{"material":"TPU","type":"95A HF/High Speed","name":"White","code":"51102","colours":["#FFFFFF"]},
|
||||
{"material":"TPU","type":"95A HF/High Speed","name":"Yellow","code":"51400","colours":["#F3E600"]}
|
||||
]
|
||||
}
|
||||
180
db/schema.sql
180
db/schema.sql
|
|
@ -154,183 +154,3 @@ CREATE TABLE IF NOT EXISTS duplicate_dismissals (
|
|||
signature TEXT PRIMARY KEY,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
-- Where a model's tag came from: user (typed in), rule (automatic tagging) or ai.
|
||||
-- Rule and AI passes only add or remove their own tags; yours are never touched.
|
||||
ALTER TABLE model_tags ADD COLUMN IF NOT EXISTS source TEXT NOT NULL DEFAULT 'user';
|
||||
|
||||
-- Automatic tagging: fingerprint of the rules and names the rule tags were last
|
||||
-- worked out from, so a model is re-checked only when one of those changes.
|
||||
ALTER TABLE models ADD COLUMN IF NOT EXISTS autotag_key TEXT;
|
||||
|
||||
-- Semantic search: a local embedding of the model's text, and the fingerprint of
|
||||
-- the text (and embedding model) it was made from.
|
||||
ALTER TABLE models ADD COLUMN IF NOT EXISTS embedding REAL[];
|
||||
ALTER TABLE models ADD COLUMN IF NOT EXISTS embedding_key TEXT;
|
||||
|
||||
-- AI tagging runs, kept so they can be watched, stopped and resumed.
|
||||
ALTER TABLE models ADD COLUMN IF NOT EXISTS ai_tagged_at TIMESTAMPTZ;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ai_runs (
|
||||
id SERIAL PRIMARY KEY,
|
||||
status TEXT NOT NULL DEFAULT 'running', -- running | paused | stopped | done | failed
|
||||
provider TEXT,
|
||||
model TEXT,
|
||||
options JSONB NOT NULL DEFAULT '{}', -- { overwriteDescriptions, maxCost, pricesKnown }
|
||||
total INTEGER NOT NULL DEFAULT 0,
|
||||
done INTEGER NOT NULL DEFAULT 0,
|
||||
failed INTEGER NOT NULL DEFAULT 0,
|
||||
prompt_tokens BIGINT NOT NULL DEFAULT 0,
|
||||
completion_tokens BIGINT NOT NULL DEFAULT 0,
|
||||
cost_usd NUMERIC(12, 6) NOT NULL DEFAULT 0,
|
||||
error TEXT,
|
||||
note TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
finished_at TIMESTAMPTZ
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ai_run_items (
|
||||
run_id INTEGER NOT NULL REFERENCES ai_runs(id) ON DELETE CASCADE,
|
||||
model_id INTEGER NOT NULL REFERENCES models(id) ON DELETE CASCADE,
|
||||
status TEXT NOT NULL DEFAULT 'pending', -- pending | done | error
|
||||
error TEXT,
|
||||
tags TEXT[],
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
PRIMARY KEY (run_id, model_id)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_ai_run_items_status ON ai_run_items(run_id, status);
|
||||
|
||||
-- Your own automatic tagging rules: a word or phrase in a name -> a tag.
|
||||
CREATE TABLE IF NOT EXISTS tag_rules (
|
||||
id SERIAL PRIMARY KEY,
|
||||
phrase TEXT NOT NULL,
|
||||
tag TEXT NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
UNIQUE (phrase, tag)
|
||||
);
|
||||
|
||||
-- AI renaming: the name a model had before AI first renamed it (for undo), and
|
||||
-- what each run renamed.
|
||||
ALTER TABLE models ADD COLUMN IF NOT EXISTS original_name TEXT;
|
||||
ALTER TABLE ai_run_items ADD COLUMN IF NOT EXISTS renamed_from TEXT;
|
||||
ALTER TABLE ai_run_items ADD COLUMN IF NOT EXISTS renamed_to TEXT;
|
||||
|
||||
-- Collections the AI suggested for a model, waiting for you to accept or dismiss.
|
||||
-- Dismissed ones are kept so the same idea isn't suggested for that model again.
|
||||
CREATE TABLE IF NOT EXISTS collection_suggestions (
|
||||
model_id INTEGER NOT NULL REFERENCES models(id) ON DELETE CASCADE,
|
||||
name TEXT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'pending', -- pending | accepted | dismissed
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_collection_suggestions_model_name ON collection_suggestions(model_id, lower(name));
|
||||
ALTER TABLE ai_run_items ADD COLUMN IF NOT EXISTS collections TEXT[];
|
||||
|
||||
-- MakerWorld collections watched for new models (lib/collectionWatch.js).
|
||||
CREATE TABLE IF NOT EXISTS watched_collections (
|
||||
id SERIAL PRIMARY KEY,
|
||||
site TEXT NOT NULL DEFAULT 'makerworld',
|
||||
source_id TEXT NOT NULL, -- the collection's id on the site
|
||||
url TEXT NOT NULL,
|
||||
title TEXT NOT NULL DEFAULT '', -- its name there, refreshed on each check
|
||||
project_id INTEGER REFERENCES projects(id) ON DELETE SET NULL, -- where new models land
|
||||
ai_tag BOOLEAN NOT NULL DEFAULT false,
|
||||
fetch_files BOOLEAN NOT NULL DEFAULT false, -- download files during a check, not just metadata
|
||||
interval_hours INTEGER NOT NULL DEFAULT 6,
|
||||
enabled BOOLEAN NOT NULL DEFAULT true,
|
||||
last_checked_at TIMESTAMPTZ,
|
||||
next_check_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
last_status TEXT, -- ok | error
|
||||
last_error TEXT,
|
||||
last_error_reason TEXT, -- makerworld_token: offer the token prompt
|
||||
last_added INTEGER NOT NULL DEFAULT 0,
|
||||
listed INTEGER, -- models the page listed at the last check
|
||||
total INTEGER, -- models the site says the collection holds
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
UNIQUE (site, source_id)
|
||||
);
|
||||
|
||||
-- What a watch has already seen, so a model isn't imported (or retried) twice.
|
||||
CREATE TABLE IF NOT EXISTS watched_collection_items (
|
||||
watch_id INTEGER NOT NULL REFERENCES watched_collections(id) ON DELETE CASCADE,
|
||||
design_id TEXT NOT NULL,
|
||||
model_id INTEGER REFERENCES models(id) ON DELETE SET NULL,
|
||||
title TEXT NOT NULL DEFAULT '',
|
||||
status TEXT NOT NULL, -- imported | partial (no files yet) | existing | error
|
||||
error TEXT,
|
||||
seen_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
PRIMARY KEY (watch_id, design_id)
|
||||
);
|
||||
|
||||
ALTER TABLE watched_collections ADD COLUMN IF NOT EXISTS fetch_files BOOLEAN NOT NULL DEFAULT false;
|
||||
|
||||
-- ------------------------------------------------------------------ filament
|
||||
-- The spools you own. Weights in grams; remaining is what's left on the spool,
|
||||
-- null when you haven't said. ams_slot is filled in later by AMS sync, and
|
||||
-- stays null for spools logged by hand.
|
||||
CREATE TABLE IF NOT EXISTS filament_spools (
|
||||
id SERIAL PRIMARY KEY,
|
||||
material TEXT NOT NULL, -- PLA, PETG, ABS, TPU…
|
||||
colour TEXT NOT NULL DEFAULT '', -- what you call it, e.g. "Bambu Matte Ivory"
|
||||
colour_hex TEXT, -- #RRGGBB when known, to match a project's colours
|
||||
brand TEXT NOT NULL DEFAULT '',
|
||||
total_g INTEGER, -- spool weight when new
|
||||
remaining_g INTEGER, -- what's left; null = unknown
|
||||
ams_slot TEXT, -- "<printer>/<unit>/<slot>" once AMS sync lands
|
||||
code TEXT, -- maker's filament code (Bambu 10101 etc.), what the AMS reports
|
||||
notes TEXT NOT NULL DEFAULT '',
|
||||
date_added TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
date_modified TIMESTAMPTZ
|
||||
);
|
||||
|
||||
-- What a model asks for, read once from its 3MF project rather than on every
|
||||
-- page: [{ type, colour }]. A background pass fills these in, the way
|
||||
-- thumbnails and fingerprints are done, because the reader shells out to Python.
|
||||
ALTER TABLE models ADD COLUMN IF NOT EXISTS filaments JSONB;
|
||||
ALTER TABLE models ADD COLUMN IF NOT EXISTS filaments_status TEXT; -- pending | done | none | error
|
||||
ALTER TABLE filament_spools ADD COLUMN IF NOT EXISTS code TEXT;
|
||||
-- A Bambu spool's RFID identity and where it was last seen, so a printer's
|
||||
-- report finds the same spool again instead of adding it twice.
|
||||
ALTER TABLE filament_spools ADD COLUMN IF NOT EXISTS rfid_uuid TEXT;
|
||||
ALTER TABLE filament_spools ADD COLUMN IF NOT EXISTS tag_uid TEXT;
|
||||
ALTER TABLE filament_spools ADD COLUMN IF NOT EXISTS product_code TEXT;
|
||||
ALTER TABLE filament_spools ADD COLUMN IF NOT EXISTS printer_serial TEXT;
|
||||
ALTER TABLE filament_spools ADD COLUMN IF NOT EXISTS last_seen_at TIMESTAMPTZ;
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS filament_spools_rfid ON filament_spools (rfid_uuid) WHERE rfid_uuid IS NOT NULL;
|
||||
|
||||
-- ------------------------------------------------------------------ printers
|
||||
-- The printers you've added. access_code is read by the server only.
|
||||
CREATE TABLE IF NOT EXISTS printers (
|
||||
id SERIAL PRIMARY KEY,
|
||||
nickname TEXT, -- shown instead of the model when set
|
||||
model TEXT, -- A1, P1S, … or Other
|
||||
host TEXT,
|
||||
serial TEXT,
|
||||
access_code TEXT,
|
||||
ams_type TEXT, -- none | ams_lite | ams | ams_2_pro | ams_ht
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
date_modified TIMESTAMPTZ
|
||||
);
|
||||
|
||||
-- What each printer last reported loaded, slot by slot, for its card.
|
||||
ALTER TABLE printers ADD COLUMN IF NOT EXISTS loaded JSONB;
|
||||
ALTER TABLE printers ADD COLUMN IF NOT EXISTS loaded_at TIMESTAMPTZ;
|
||||
|
||||
-- The one printer that used to live in Settings becomes the first entry here.
|
||||
INSERT INTO printers (host, serial, access_code)
|
||||
SELECT h.value, s.value, c.value
|
||||
FROM settings h
|
||||
JOIN settings s ON s.key = 'printer_serial'
|
||||
JOIN settings c ON c.key = 'printer_access_code'
|
||||
WHERE h.key = 'printer_host'
|
||||
AND coalesce(h.value, '') <> '' AND coalesce(s.value, '') <> '' AND coalesce(c.value, '') <> ''
|
||||
AND NOT EXISTS (SELECT 1 FROM printers);
|
||||
|
||||
-- …and its copy in Settings goes, so the access code isn't kept twice — but
|
||||
-- only once that printer is safely here.
|
||||
DELETE FROM settings
|
||||
WHERE key IN ('printer_host', 'printer_serial', 'printer_access_code')
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM printers p
|
||||
JOIN settings s ON s.key = 'printer_serial' AND s.value = p.serial
|
||||
);
|
||||
|
|
|
|||
|
|
@ -6,11 +6,7 @@ services:
|
|||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-printhive}
|
||||
POSTGRES_DB: printhive
|
||||
volumes:
|
||||
# Set separately from DATA_DIR: Postgres needs a filesystem with real
|
||||
# ownership and permissions (ext4, xfs, APFS...), which exFAT, FAT and
|
||||
# most network shares don't provide. Keep it on local disk if the files
|
||||
# live somewhere like that.
|
||||
- ${PGDATA_DIR:-/Volumes/Data/printhive/pgdata}:/var/lib/postgresql/data
|
||||
- pgdata:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U printhive -d printhive"]
|
||||
interval: 5s
|
||||
|
|
@ -37,14 +33,10 @@ services:
|
|||
MAKERWORLD_TOKEN: ${MAKERWORLD_TOKEN:-}
|
||||
PRINTABLES_TOKEN: ${PRINTABLES_TOKEN:-}
|
||||
volumes:
|
||||
# Absolute on purpose: a relative path resolves against whatever folder
|
||||
# runs compose, which isn't this one when a stack manager deploys it —
|
||||
# and an empty folder makes a working install look wiped.
|
||||
# uploads and pgdata hold everything that matters; the other two rebuild.
|
||||
- ${DATA_DIR:-/Volumes/Data/printhive}/uploads:/app/uploads
|
||||
- ${DATA_DIR:-/Volumes/Data/printhive}/thumbnails:/app/thumbnails
|
||||
- ${DATA_DIR:-/Volumes/Data/printhive}/glb:/app/glb
|
||||
- ${DATA_DIR:-/Volumes/Data/printhive}/extracted:/app/extracted # cache of files unpacked from archives
|
||||
- uploads:/app/uploads
|
||||
- thumbnails:/app/thumbnails
|
||||
- glb:/app/glb
|
||||
- extracted:/app/extracted # cache of files unpacked from archives
|
||||
# No host port published — Traefik auto-discovers the app on the shared network.
|
||||
networks:
|
||||
- default # talk to the db
|
||||
|
|
@ -60,6 +52,13 @@ services:
|
|||
- "traefik.http.routers.printhive.tls.certresolver=${TRAEFIK_CERTRESOLVER:-le}"
|
||||
- "traefik.http.services.printhive.loadbalancer.server.port=3000"
|
||||
|
||||
volumes:
|
||||
pgdata:
|
||||
uploads:
|
||||
thumbnails:
|
||||
glb:
|
||||
extracted:
|
||||
|
||||
networks:
|
||||
default:
|
||||
# The existing Traefik network (the `proxy` network Traefik runs on).
|
||||
|
|
|
|||
|
|
@ -1,45 +0,0 @@
|
|||
// Lint rules, kept to the mistakes tests can't catch — chiefly a name that
|
||||
// doesn't exist, which only shows up when that line finally runs.
|
||||
const js = require('@eslint/js');
|
||||
const globals = require('globals');
|
||||
|
||||
module.exports = [
|
||||
{ ignores: ['node_modules/**', 'uploads/**', 'thumbnails/**', 'glb/**', 'extracted/**', 'models/**', 'data/**', 'backups/**', 'tools/venv/**'] },
|
||||
|
||||
// The server: Node, CommonJS.
|
||||
{
|
||||
files: ['**/*.js'],
|
||||
languageOptions: {
|
||||
ecmaVersion: 2023,
|
||||
sourceType: 'commonjs',
|
||||
globals: { ...globals.node },
|
||||
},
|
||||
rules: {
|
||||
...js.configs.recommended.rules,
|
||||
'no-unused-vars': ['error', { argsIgnorePattern: '^_|^next$', caughtErrors: 'none' }],
|
||||
},
|
||||
},
|
||||
|
||||
// The browser: plain scripts sharing globals across files, plus one module.
|
||||
{
|
||||
files: ['public/**/*.js'],
|
||||
languageOptions: {
|
||||
sourceType: 'script',
|
||||
globals: {
|
||||
...globals.browser,
|
||||
ModelViewer: 'readonly', // viewer.js hangs it on window
|
||||
PRINTER_MODELS: 'readonly', // printer-art.js does the same
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ['public/viewer.js'],
|
||||
languageOptions: { sourceType: 'module' },
|
||||
},
|
||||
|
||||
// Tests.
|
||||
{
|
||||
files: ['test/**/*.js'],
|
||||
languageOptions: { globals: { ...globals.node } },
|
||||
},
|
||||
];
|
||||
378
lib/ai.js
378
lib/ai.js
|
|
@ -1,378 +0,0 @@
|
|||
// AI tags, descriptions, names and collection ideas through any OpenAI-compatible
|
||||
// chat API: DeepSeek by default, or OpenRouter, OpenAI, or a local server such as
|
||||
// Ollama or LM Studio. The API key stays on the server.
|
||||
|
||||
const PROVIDERS = {
|
||||
deepseek: { label: 'DeepSeek', baseUrl: 'https://api.deepseek.com', model: 'deepseek-flash', prices: { input: 0.3, output: 1.2 } },
|
||||
openrouter: { label: 'OpenRouter', baseUrl: 'https://openrouter.ai/api/v1', model: '' },
|
||||
openai: { label: 'OpenAI', baseUrl: 'https://api.openai.com/v1', model: '' },
|
||||
custom: { label: 'Other (OpenAI-compatible)', baseUrl: '', model: '' },
|
||||
};
|
||||
|
||||
// The system prompt is assembled from the parts a run asks for.
|
||||
const BASE_PROMPT = `You catalogue 3D-printable models in a maker's library. Reply with JSON only, as one object containing exactly the fields described below.`;
|
||||
|
||||
const DESCRIBE_PROMPT = `
|
||||
"tags": 3 to 8 short lowercase tags someone would search for: what the object is, what it's for, its theme or style, and print traits (print-in-place, articulated, multicolor, needs supports) only when evident. Prefer singular nouns. When a tag already used in the library fits, use it instead of a new synonym. Never use generic tags like "3d print", "model", "stl" or "3mf".
|
||||
"description": one or two plain sentences on what the object is and what it's for. Don't invent sizes, print settings or claims.`;
|
||||
|
||||
const NAME_PROMPT = `
|
||||
"name": the model's name as a clear title. If the current name already says clearly what the model is, return it exactly as it is. Otherwise write a short title-case name (under 60 characters) saying what it is, using the picture and details: drop file-name leftovers (underscores, stray spaces, version numbers like "v4", "final", file extensions), fix typos, and remove site boilerplate like "Free 3D Print Model - MakerWorld". Keep designer, character or product names that are part of what it is.`;
|
||||
|
||||
const COLLECTIONS_PROMPT = `
|
||||
"collections": 0 to 3 collections this model belongs in: broad groups someone would browse a library by, such as "Bookends & Book Holders", "Desk Organizers", "Pokémon" or "Halloween", never a name for this one model. When one of the library's existing collections fits, use its name exactly as written. Return [] when nothing fits well.`;
|
||||
|
||||
const GENERIC_TAGS = new Set([
|
||||
'3d print', '3d printing', '3d printed', '3d printable', '3d model', 'model', 'print', 'printable', 'file',
|
||||
'stl', '3mf', 'obj', 'step', 'stp', 'gcode',
|
||||
]);
|
||||
|
||||
class AiError extends Error {
|
||||
constructor(message, status, kind = 'http') {
|
||||
super(message);
|
||||
this.status = status;
|
||||
this.kind = kind; // http | network | timeout
|
||||
}
|
||||
}
|
||||
|
||||
// The provider answered, but not with the JSON asked for.
|
||||
class ReplyError extends Error {}
|
||||
|
||||
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
// Cuts text to at most max UTF-16 units without splitting an emoji in half. A lone
|
||||
// half (surrogate) is still sent as JSON, but some APIs (DeepSeek) refuse to parse it.
|
||||
function clip(text, max) {
|
||||
const s = String(text);
|
||||
if (s.length <= max) return s;
|
||||
const cut = s.slice(0, max);
|
||||
return /[\uD800-\uDBFF]$/.test(cut) ? cut.slice(0, -1) : cut;
|
||||
}
|
||||
|
||||
// Settings values (ai_provider, ai_base_url, …) -> a client config, filling the
|
||||
// gaps from the provider's preset.
|
||||
function configFrom(values = {}) {
|
||||
const provider = PROVIDERS[values.ai_provider] ? values.ai_provider : 'deepseek';
|
||||
const preset = PROVIDERS[provider];
|
||||
const price = (value, fallback) => (value !== undefined && value !== null && value !== '' && Number.isFinite(Number(value))
|
||||
? Number(value) : fallback ?? null);
|
||||
return {
|
||||
provider,
|
||||
label: preset.label,
|
||||
baseUrl: String(values.ai_base_url || preset.baseUrl || '').trim().replace(/\/+$/, ''),
|
||||
apiKey: values.ai_api_key || '',
|
||||
model: String(values.ai_model || preset.model || '').trim(),
|
||||
sendImages: values.ai_send_images !== 'false',
|
||||
prices: {
|
||||
input: price(values.ai_price_input, preset.prices && preset.prices.input),
|
||||
output: price(values.ai_price_output, preset.prices && preset.prices.output),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// What still needs filling in before AI can run, or null.
|
||||
function missingConfig(config) {
|
||||
if (!config.baseUrl) return 'Add the API address in Settings → AI.';
|
||||
if (!config.model) return 'Choose a model in Settings → AI.';
|
||||
// Local servers (Ollama, LM Studio) usually don't need a key.
|
||||
if (!config.apiKey && config.provider !== 'custom') return 'Add your API key in Settings → AI.';
|
||||
return null;
|
||||
}
|
||||
|
||||
// "120 × 80 × 40 mm" from the biggest file's bounding box, if known.
|
||||
function modelSize(model) {
|
||||
const dims = (model.files || [])
|
||||
.map((f) => f.bbox && [f.bbox.x ?? f.bbox.width, f.bbox.y ?? f.bbox.depth, f.bbox.z ?? f.bbox.height].map(Number))
|
||||
.filter((d) => d && d.every((n) => Number.isFinite(n) && n > 0))
|
||||
.sort((a, b) => b[0] * b[1] * b[2] - a[0] * a[1] * a[2])[0];
|
||||
return dims ? `${dims.map((n) => Math.round(n)).join(' × ')} mm` : null;
|
||||
}
|
||||
|
||||
function modelSummary(model, knownTags = []) {
|
||||
const lines = [`Name: ${model.name}`];
|
||||
const files = (model.files || []).map((f) => f.originalName).filter(Boolean);
|
||||
if (files.length) lines.push(`Files: ${files.slice(0, 30).join(', ')}`);
|
||||
if (model.entryNames && model.entryNames.length) lines.push(`Inside archives: ${model.entryNames.slice(0, 40).join(', ')}`);
|
||||
if (model.source_site) lines.push(`Source: ${model.source_site}${model.designer ? `, by ${model.designer.name}` : ''}`);
|
||||
const size = modelSize(model);
|
||||
if (size) lines.push(`Size: ${size}`);
|
||||
if (model.tags && model.tags.length) lines.push(`Tags so far: ${model.tags.join(', ')}`);
|
||||
if (model.description && model.description.trim()) lines.push(`Description so far: ${clip(model.description.trim(), 800)}`);
|
||||
if (knownTags.length) lines.push(`Tags already used in this library: ${knownTags.join(', ')}`);
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
// Which fields to ask for: describe (tags + description, the default), a clearer
|
||||
// name, and collection ideas.
|
||||
function buildMessages(model, {
|
||||
image = null, knownTags = [], describe = true, suggestName = false, suggestCollections = false, collectionNames = [],
|
||||
} = {}) {
|
||||
let text = modelSummary(model, describe ? knownTags : []);
|
||||
if (suggestCollections) {
|
||||
text += collectionNames.length
|
||||
? `\nExisting collections in this library: ${collectionNames.join(', ')}`
|
||||
: '\nThis library has no collections yet.';
|
||||
}
|
||||
// An example object helps JSON mode (DeepSeek can otherwise send back an empty reply).
|
||||
const example = {};
|
||||
if (describe) Object.assign(example, { tags: ['wall hook', 'headphone holder'], description: 'A wall hook for hanging headphones.' });
|
||||
if (suggestName) example.name = 'Headphone Wall Hook';
|
||||
if (suggestCollections) example.collections = ['Desk Organizers'];
|
||||
const system = BASE_PROMPT
|
||||
+ (describe ? DESCRIBE_PROMPT : '')
|
||||
+ (suggestName ? NAME_PROMPT : '')
|
||||
+ (suggestCollections ? COLLECTIONS_PROMPT : '')
|
||||
+ `\nThe format, shown for a different model: ${JSON.stringify(example)}`;
|
||||
// Stored names and descriptions may already hold a broken emoji; send a stand-in instead.
|
||||
text = text.toWellFormed();
|
||||
const content = image
|
||||
? [{ type: 'text', text }, { type: 'image_url', image_url: { url: image } }]
|
||||
: text;
|
||||
return [{ role: 'system', content: system }, { role: 'user', content }];
|
||||
}
|
||||
|
||||
function normalizeTags(value) {
|
||||
const list = Array.isArray(value) ? value : typeof value === 'string' ? value.split(',') : [];
|
||||
const tags = [];
|
||||
for (const raw of list) {
|
||||
const tag = clip(String(raw).toLowerCase()
|
||||
.replace(/^#+/, '')
|
||||
.replace(/[_\s]+/g, ' ')
|
||||
.replace(/[^\p{L}\p{N} +&'-]/gu, '')
|
||||
.trim(), 40)
|
||||
.trim();
|
||||
if (tag && !GENERIC_TAGS.has(tag) && !tags.includes(tag)) tags.push(tag);
|
||||
}
|
||||
return tags.slice(0, 8);
|
||||
}
|
||||
|
||||
const comparable = (s) => String(s || '').toLowerCase().replace(/[^\p{L}\p{N}]+/gu, '');
|
||||
const stripQuotes = (s) => String(s).replace(/^["'“”‘’]+|["'“”‘’]+$/g, '');
|
||||
|
||||
// A suggested name, tidied; null when there isn't one or it's the current name
|
||||
// written differently.
|
||||
function cleanName(value, currentName) {
|
||||
if (typeof value !== 'string') return null;
|
||||
const name = clip(stripQuotes(value)
|
||||
.replace(/\.(stl|3mf|obj|fbx|step|stp|3dm|gcode|zip|7z|rar)$/i, '')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim(), 80)
|
||||
.trim();
|
||||
return name && comparable(name) !== comparable(currentName) ? name : null;
|
||||
}
|
||||
|
||||
// Up to 3 collection names, tidied, without repeats that differ only in case or punctuation.
|
||||
function normalizeCollections(value) {
|
||||
const list = Array.isArray(value) ? value : typeof value === 'string' ? value.split(',') : [];
|
||||
const names = [];
|
||||
for (const raw of list) {
|
||||
const name = clip(stripQuotes(raw).replace(/\s+/g, ' ').trim(), 60).trim();
|
||||
if (name && !names.some((n) => comparable(n) === comparable(name))) names.push(name);
|
||||
}
|
||||
return names.slice(0, 3);
|
||||
}
|
||||
|
||||
// The JSON object in a reply, allowing for code fences, notes around it, or braces
|
||||
// in a note after it; null when there isn't one.
|
||||
function extractJson(content) {
|
||||
const text = String(content || '').replace(/```(?:json)?/gi, '').trim();
|
||||
const candidates = [text];
|
||||
const first = text.indexOf('{');
|
||||
const last = text.lastIndexOf('}');
|
||||
if (first >= 0 && last > first) candidates.push(text.slice(first, last + 1));
|
||||
if (first >= 0) {
|
||||
// The first complete {...}, reading past braces inside strings.
|
||||
let depth = 0;
|
||||
let inString = false;
|
||||
let escaped = false;
|
||||
for (let i = first; i < text.length; i++) {
|
||||
const ch = text[i];
|
||||
if (inString) {
|
||||
if (escaped) escaped = false;
|
||||
else if (ch === '\\') escaped = true;
|
||||
else if (ch === '"') inString = false;
|
||||
} else if (ch === '"') inString = true;
|
||||
else if (ch === '{') depth += 1;
|
||||
else if (ch === '}' && --depth === 0) { candidates.push(text.slice(first, i + 1)); break; }
|
||||
}
|
||||
}
|
||||
for (const candidate of candidates) {
|
||||
try {
|
||||
const data = JSON.parse(candidate);
|
||||
if (data && typeof data === 'object' && !Array.isArray(data)) return data;
|
||||
} catch { /* try the next */ }
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Says what came back instead of the JSON, so a failure can be understood.
|
||||
function badReplyMessage(content, finishReason) {
|
||||
const text = String(content || '').replace(/\s+/g, ' ').trim();
|
||||
if (!text) return 'The AI reply was empty, not the JSON asked for';
|
||||
const cutOff = finishReason === 'length' ? ' (it was cut off)' : '';
|
||||
return `The AI reply wasn’t the JSON asked for${cutOff}. It began: “${clip(text, 120)}${text.length > 120 ? '…' : ''}”`;
|
||||
}
|
||||
|
||||
// The model's reply -> { tags, description, name, collections }. Tolerates code
|
||||
// fences or prose around the JSON. Only the fields asked for are read: name when
|
||||
// { currentName } is given, collections when { collections: true }.
|
||||
function parseReply(content, options = {}) {
|
||||
const { describe = true, collections = false } = options;
|
||||
const data = extractJson(content);
|
||||
if (!data) throw new ReplyError(badReplyMessage(content, options.finishReason));
|
||||
const tags = describe ? normalizeTags(data.tags) : [];
|
||||
const description = describe && typeof data.description === 'string' ? clip(data.description.trim(), 600) : '';
|
||||
if (describe && !tags.length && !description) throw new ReplyError('The AI reply had no tags or description');
|
||||
return {
|
||||
tags,
|
||||
description,
|
||||
name: Object.prototype.hasOwnProperty.call(options, 'currentName') ? cleanName(data.name, options.currentName) : null,
|
||||
collections: collections ? normalizeCollections(data.collections) : [],
|
||||
};
|
||||
}
|
||||
|
||||
function usageOf(usage) {
|
||||
return {
|
||||
prompt: Math.max(0, Math.round(Number(usage && usage.prompt_tokens) || 0)),
|
||||
completion: Math.max(0, Math.round(Number(usage && usage.completion_tokens) || 0)),
|
||||
};
|
||||
}
|
||||
|
||||
// US dollars: what the provider reports (OpenRouter), else tokens × your prices
|
||||
// per million tokens, else null when prices aren't known.
|
||||
function costOf(usage, prices) {
|
||||
if (usage && typeof usage.cost === 'number') return usage.cost;
|
||||
if (!prices || prices.input == null || prices.output == null) return null;
|
||||
const { prompt, completion } = usageOf(usage);
|
||||
return (prompt * prices.input + completion * prices.output) / 1e6;
|
||||
}
|
||||
|
||||
// Adds one response's usage to a running total, so retried requests are still counted.
|
||||
function addUsage(total, usage) {
|
||||
const { prompt, completion } = usageOf(usage);
|
||||
total.prompt_tokens += prompt;
|
||||
total.completion_tokens += completion;
|
||||
if (usage && typeof usage.cost === 'number') total.cost = (total.cost || 0) + usage.cost;
|
||||
}
|
||||
|
||||
async function chat(config, body, { timeoutMs = 90000 } = {}) {
|
||||
const headers = { 'Content-Type': 'application/json' };
|
||||
if (config.apiKey) headers.Authorization = `Bearer ${config.apiKey}`;
|
||||
if (config.provider === 'openrouter') headers['X-Title'] = 'Printhive';
|
||||
|
||||
let res;
|
||||
try {
|
||||
res = await fetch(`${config.baseUrl}/chat/completions`, {
|
||||
method: 'POST', headers, body: JSON.stringify(body), signal: AbortSignal.timeout(timeoutMs),
|
||||
});
|
||||
} catch (err) {
|
||||
if (err.name === 'TimeoutError') throw new AiError('The AI service took too long to answer', 0, 'timeout');
|
||||
throw new AiError(`Couldn't reach ${config.baseUrl}: ${(err.cause && err.cause.message) || err.message}`, 0, 'network');
|
||||
}
|
||||
const text = await res.text();
|
||||
let data = null;
|
||||
try { data = JSON.parse(text); } catch { /* not JSON */ }
|
||||
const detail = (data && data.error && (data.error.message || data.error)) || (data && data.message);
|
||||
if (!res.ok) throw new AiError(`${res.status}: ${detail || text.slice(0, 200) || res.statusText}`, res.status);
|
||||
// OpenRouter can report an upstream failure inside a 200.
|
||||
if (data && data.error) throw new AiError(String(detail), Number(data.error.code) || 502);
|
||||
if (!data || !Array.isArray(data.choices) || !data.choices.length) {
|
||||
throw new AiError('The AI service sent back something unexpected', 502);
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
// Asks about one model: tags and a description, and optionally a clearer name and
|
||||
// collection ideas. Adapts to what the model supports: if it refuses JSON mode or
|
||||
// images, the request is retried without them (imageRejected tells the caller to
|
||||
// stop sending images). A reply that isn't the JSON asked for is asked again, up
|
||||
// to twice: with more room if it was cut off, and the last time without JSON mode,
|
||||
// which can send back empty replies. Rate limits and server errors are retried
|
||||
// with backoff. Errors carry the tokens and cost spent on the failed attempts.
|
||||
async function describeModel(config, model, {
|
||||
image = null, knownTags = [], describe = true, suggestName = false, suggestCollections = false, collectionNames = [],
|
||||
} = {}) {
|
||||
let useImage = Boolean(image);
|
||||
let useJson = true;
|
||||
let imageRejected = false;
|
||||
let replyRetries = 0;
|
||||
let maxTokens = 1500;
|
||||
const spent = { prompt_tokens: 0, completion_tokens: 0, cost: null };
|
||||
const withSpend = (err) => Object.assign(err, { usage: usageOf(spent), cost: costOf(spent, config.prices) });
|
||||
const want = { describe, collections: suggestCollections, ...(suggestName ? { currentName: model.name } : {}) };
|
||||
|
||||
for (let attempt = 0; ; attempt++) {
|
||||
const body = {
|
||||
model: config.model,
|
||||
messages: buildMessages(model, {
|
||||
image: useImage ? image : null, knownTags, describe, suggestName, suggestCollections, collectionNames,
|
||||
}),
|
||||
temperature: 0.2,
|
||||
max_tokens: maxTokens,
|
||||
};
|
||||
if (useJson) body.response_format = { type: 'json_object' };
|
||||
if (config.provider === 'openrouter') body.usage = { include: true };
|
||||
try {
|
||||
const data = await chat(config, body);
|
||||
addUsage(spent, data.usage);
|
||||
const choice = data.choices[0] || {};
|
||||
let reply;
|
||||
try {
|
||||
reply = parseReply((choice.message || {}).content, { ...want, finishReason: choice.finish_reason });
|
||||
} catch (err) {
|
||||
if (!(err instanceof ReplyError) || replyRetries >= 2) throw withSpend(err);
|
||||
replyRetries += 1;
|
||||
if (choice.finish_reason === 'length') maxTokens = Math.min(maxTokens * 2, 4000); // cut off: give it room
|
||||
else if (replyRetries === 2) useJson = false;
|
||||
continue;
|
||||
}
|
||||
return {
|
||||
...reply,
|
||||
usage: usageOf(spent),
|
||||
cost: costOf(spent, config.prices),
|
||||
imageUsed: useImage,
|
||||
imageRejected,
|
||||
replyRetried: replyRetries > 0,
|
||||
};
|
||||
} catch (err) {
|
||||
if (!(err instanceof AiError) || attempt >= 5) throw withSpend(err);
|
||||
if (err.status === 400 || err.status === 415 || err.status === 422) {
|
||||
if (useImage && /image|vision|multimodal|modalit|content.?part|content type/i.test(err.message)) {
|
||||
useImage = false; imageRejected = true; continue;
|
||||
}
|
||||
if (useJson) { useJson = false; continue; }
|
||||
if (useImage) { useImage = false; imageRejected = true; continue; }
|
||||
throw withSpend(err);
|
||||
}
|
||||
if (err.status === 429 || err.status >= 500 || err.kind === 'timeout') {
|
||||
await sleep(Math.min(20000, 1500 * 2 ** attempt));
|
||||
continue;
|
||||
}
|
||||
throw withSpend(err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Errors that will fail every request the same way, so a run stops instead of
|
||||
// burning through the queue. null for errors worth moving past.
|
||||
function fatalMessage(err) {
|
||||
if (!(err instanceof AiError)) return null;
|
||||
if (err.status === 401 || err.status === 403) return `Your API key was rejected (${err.message})`;
|
||||
if (err.status === 402) return `Your AI account is out of credit (${err.message})`;
|
||||
if (err.status === 404) return `The model or API address wasn't found (${err.message})`;
|
||||
if (err.kind === 'network') return err.message;
|
||||
return null;
|
||||
}
|
||||
|
||||
// A rough cost for `count` models, from typical token counts.
|
||||
function estimate(count, config) {
|
||||
const prompt = count * (config.sendImages ? 1400 : 500);
|
||||
const completion = count * 120;
|
||||
const cost = costOf({ prompt_tokens: prompt, completion_tokens: completion }, config.prices);
|
||||
return { promptTokens: prompt, completionTokens: completion, cost };
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
PROVIDERS, AiError, ReplyError, configFrom, missingConfig, buildMessages, parseReply, normalizeTags,
|
||||
normalizeCollections, costOf, describeModel, fatalMessage, estimate,
|
||||
};
|
||||
287
lib/aiRuns.js
287
lib/aiRuns.js
|
|
@ -1,287 +0,0 @@
|
|||
// AI runs: a queue of models handled a few at a time. Progress, tokens and cost
|
||||
// live in the database, so a run can be watched, stopped and resumed, and a
|
||||
// server restart pauses it instead of spending on its own.
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const db = require('./db');
|
||||
const store = require('./store');
|
||||
const ai = require('./ai');
|
||||
const fingerprint = require('./fingerprint');
|
||||
|
||||
const CONCURRENCY = 3;
|
||||
const MAX_FAILURES_IN_A_ROW = 5;
|
||||
const MAX_IMAGE_BYTES = 3 * 1024 * 1024;
|
||||
const IMAGE_TYPES = { '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.webp': 'image/webp', '.gif': 'image/gif' };
|
||||
|
||||
let resolveConfig = async () => ai.configFrom({});
|
||||
let working = false;
|
||||
let rerun = false;
|
||||
|
||||
const httpError = (message, status) => Object.assign(new Error(message), { status });
|
||||
|
||||
async function init(resolver) {
|
||||
resolveConfig = resolver;
|
||||
await db.query(
|
||||
`UPDATE ai_runs SET status = 'paused', note = 'Paused when the server restarted. Resume to carry on.'
|
||||
WHERE status = 'running'`);
|
||||
}
|
||||
|
||||
// The model's thumbnail as a data URL, when there is one small enough to send.
|
||||
function thumbnailDataUrl(model) {
|
||||
if (!model.thumbnail_path) return null;
|
||||
const abs = path.resolve(model.thumbnail_path);
|
||||
const type = IMAGE_TYPES[path.extname(abs).toLowerCase()];
|
||||
try {
|
||||
if (!type || fs.statSync(abs).size > MAX_IMAGE_BYTES) return null;
|
||||
return `data:${type};base64,${fs.readFileSync(abs).toString('base64')}`;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Names of the files inside a model's archives, as extra context.
|
||||
async function entryNames(model) {
|
||||
const { rows } = await db.query(
|
||||
`SELECT e.path FROM archive_entries e JOIN files f ON f.id = e.file_id
|
||||
WHERE f.model_id = $1 ORDER BY e.id LIMIT 60`, [model.id]);
|
||||
return rows.map((r) => r.path.split('/').pop()).filter(Boolean);
|
||||
}
|
||||
|
||||
// options: describe (tags + descriptions, default on), overwriteDescriptions,
|
||||
// renameUnclear, suggestCollections, maxCost.
|
||||
async function createRun(modelIds, options = {}) {
|
||||
const config = await resolveConfig();
|
||||
const problem = ai.missingConfig(config);
|
||||
if (problem) throw httpError(problem, 400);
|
||||
const tasks = {
|
||||
describe: options.describe !== false,
|
||||
renameUnclear: Boolean(options.renameUnclear),
|
||||
suggestCollections: Boolean(options.suggestCollections),
|
||||
};
|
||||
if (!tasks.describe && !tasks.renameUnclear && !tasks.suggestCollections) {
|
||||
throw httpError('Choose at least one thing for the AI to do', 400);
|
||||
}
|
||||
const ids = [...new Set((modelIds || []).map(Number).filter(Number.isInteger))];
|
||||
const maxCost = Number(options.maxCost) > 0 ? Number(options.maxCost) : null;
|
||||
|
||||
const run = await db.transaction(async (client) => {
|
||||
const { rows: [created] } = await client.query(
|
||||
`INSERT INTO ai_runs (provider, model, options) VALUES ($1, $2, $3) RETURNING id`,
|
||||
[config.label, config.model, {
|
||||
...tasks,
|
||||
overwriteDescriptions: tasks.describe && Boolean(options.overwriteDescriptions),
|
||||
maxCost,
|
||||
pricesKnown: config.prices.input != null || config.provider === 'openrouter',
|
||||
}]);
|
||||
const { rowCount } = await client.query(
|
||||
`INSERT INTO ai_run_items (run_id, model_id)
|
||||
SELECT $1, id FROM models WHERE id = ANY($2::int[]) AND deleted_at IS NULL`, [created.id, ids]);
|
||||
if (!rowCount) throw httpError('No models to tag', 400);
|
||||
await client.query('UPDATE ai_runs SET total = $2 WHERE id = $1', [created.id, rowCount]);
|
||||
return created;
|
||||
});
|
||||
kick();
|
||||
return getRun(run.id);
|
||||
}
|
||||
|
||||
async function finish(runId, status, error = null) {
|
||||
await db.query(
|
||||
`UPDATE ai_runs SET status = $2, error = $3, finished_at = now() WHERE id = $1 AND status = 'running'`,
|
||||
[runId, status, error]);
|
||||
}
|
||||
|
||||
async function tagOne(runId, modelId, config, context) {
|
||||
const { images, knownTags, collectionNames, tasks } = context;
|
||||
const model = await store.getModel(modelId);
|
||||
if (!model || model.deleted_at) {
|
||||
await db.query(`UPDATE ai_run_items SET status = 'error', error = 'The model was deleted', updated_at = now()
|
||||
WHERE run_id = $1 AND model_id = $2`, [runId, modelId]);
|
||||
await db.query('UPDATE ai_runs SET failed = failed + 1 WHERE id = $1', [runId]);
|
||||
return { ok: true };
|
||||
}
|
||||
try {
|
||||
model.entryNames = await entryNames(model);
|
||||
const result = await ai.describeModel(config, model, {
|
||||
image: images ? thumbnailDataUrl(model) : null,
|
||||
knownTags,
|
||||
describe: tasks.describe,
|
||||
suggestName: tasks.renameUnclear,
|
||||
suggestCollections: tasks.suggestCollections,
|
||||
collectionNames,
|
||||
});
|
||||
const name = tasks.renameUnclear ? result.name : null;
|
||||
await store.applyAiResult(modelId, {
|
||||
tags: result.tags,
|
||||
description: result.description,
|
||||
overwriteDescription: tasks.overwriteDescriptions,
|
||||
name,
|
||||
markTagged: tasks.describe,
|
||||
});
|
||||
const suggested = tasks.suggestCollections ? await store.suggestCollections(modelId, result.collections) : [];
|
||||
await db.query(
|
||||
`UPDATE ai_run_items SET status = 'done', tags = $3, error = NULL, renamed_from = $4, renamed_to = $5,
|
||||
collections = $6, updated_at = now() WHERE run_id = $1 AND model_id = $2`,
|
||||
[runId, modelId, tasks.describe ? result.tags : null, name ? model.name : null, name, suggested]);
|
||||
await db.query(
|
||||
`UPDATE ai_runs SET done = done + 1, prompt_tokens = prompt_tokens + $2,
|
||||
completion_tokens = completion_tokens + $3, cost_usd = cost_usd + $4 WHERE id = $1`,
|
||||
[runId, result.usage.prompt, result.usage.completion, result.cost || 0]);
|
||||
return { ok: true, imageRejected: result.imageRejected, suggested: suggested.length > 0 };
|
||||
} catch (err) {
|
||||
// Failed attempts still cost tokens.
|
||||
if (err.usage && (err.usage.prompt || err.usage.completion)) {
|
||||
await db.query(
|
||||
`UPDATE ai_runs SET prompt_tokens = prompt_tokens + $2, completion_tokens = completion_tokens + $3,
|
||||
cost_usd = cost_usd + $4 WHERE id = $1`,
|
||||
[runId, err.usage.prompt, err.usage.completion, err.cost || 0]);
|
||||
}
|
||||
const fatal = ai.fatalMessage(err);
|
||||
if (fatal) return { ok: false, fatal }; // left pending, so resuming retries it
|
||||
const message = err.message.slice(0, 300);
|
||||
await db.query(`UPDATE ai_run_items SET status = 'error', error = $3, updated_at = now()
|
||||
WHERE run_id = $1 AND model_id = $2`, [runId, modelId, message]);
|
||||
await db.query('UPDATE ai_runs SET failed = failed + 1 WHERE id = $1', [runId]);
|
||||
return { ok: false, error: message };
|
||||
}
|
||||
}
|
||||
|
||||
async function processRun(runId) {
|
||||
const config = await resolveConfig();
|
||||
const problem = ai.missingConfig(config);
|
||||
if (problem) { await finish(runId, 'failed', problem); return; }
|
||||
const knownTags = await store.topTags(150);
|
||||
let images = config.sendImages;
|
||||
let failuresInARow = 0;
|
||||
let lastError = null;
|
||||
let collectionNames = null;
|
||||
|
||||
for (;;) {
|
||||
const { rows: [run] } = await db.query('SELECT status, options, cost_usd FROM ai_runs WHERE id = $1', [runId]);
|
||||
if (!run || run.status !== 'running') return; // stopped, or deleted
|
||||
const options = run.options || {};
|
||||
// Runs from before these options existed only tagged and described.
|
||||
const tasks = {
|
||||
describe: options.describe !== false,
|
||||
overwriteDescriptions: Boolean(options.overwriteDescriptions),
|
||||
renameUnclear: Boolean(options.renameUnclear),
|
||||
suggestCollections: Boolean(options.suggestCollections),
|
||||
};
|
||||
const { rows: items } = await db.query(
|
||||
`SELECT model_id FROM ai_run_items WHERE run_id = $1 AND status = 'pending' ORDER BY model_id LIMIT $2`,
|
||||
[runId, CONCURRENCY]);
|
||||
if (!items.length) { await finish(runId, 'done'); return; }
|
||||
// Checked between batches, so a run can go over by up to one batch.
|
||||
if (options.maxCost && Number(run.cost_usd) >= options.maxCost) {
|
||||
await finish(runId, 'stopped', `Stopped at your $${options.maxCost} spending limit`);
|
||||
return;
|
||||
}
|
||||
// Existing collections and earlier ideas, so suggestions reuse the same names.
|
||||
if (tasks.suggestCollections && collectionNames === null) collectionNames = await store.collectionNameHints();
|
||||
|
||||
const results = await Promise.all(items.map((item) =>
|
||||
tagOne(runId, item.model_id, config, { images, knownTags, collectionNames: collectionNames || [], tasks })));
|
||||
// New tags, descriptions and names change a model's search text, so refresh the index.
|
||||
if (results.some((r) => r.ok)) fingerprint.kick();
|
||||
if (results.some((r) => r.suggested)) collectionNames = null;
|
||||
for (const r of results) {
|
||||
if (r.imageRejected && images) {
|
||||
images = false;
|
||||
await db.query('UPDATE ai_runs SET note = $2 WHERE id = $1',
|
||||
[runId, `${config.model} doesn't accept images, so models are judged from their names and files.`]);
|
||||
}
|
||||
if (r.fatal) { await finish(runId, 'failed', r.fatal); return; }
|
||||
if (r.ok) failuresInARow = 0;
|
||||
else { failuresInARow += 1; lastError = r.error; }
|
||||
}
|
||||
if (failuresInARow >= MAX_FAILURES_IN_A_ROW) {
|
||||
await finish(runId, 'failed', `Stopped after ${failuresInARow} failures in a row. Last: ${lastError}`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Works through running runs, one at a time. Safe to call often.
|
||||
function kick() {
|
||||
if (working) { rerun = true; return; }
|
||||
working = true;
|
||||
(async () => {
|
||||
for (;;) {
|
||||
const { rows: [run] } = await db.query(`SELECT id FROM ai_runs WHERE status = 'running' ORDER BY id LIMIT 1`);
|
||||
if (!run) return;
|
||||
try {
|
||||
await processRun(run.id);
|
||||
} catch (err) {
|
||||
console.error(`AI run ${run.id} failed:`, err.message);
|
||||
await finish(run.id, 'failed', err.message.slice(0, 300));
|
||||
}
|
||||
}
|
||||
})()
|
||||
.catch((err) => console.error('AI runs stopped:', err.message))
|
||||
.finally(() => {
|
||||
working = false;
|
||||
if (rerun) { rerun = false; kick(); }
|
||||
});
|
||||
}
|
||||
|
||||
const shapeRun = (r) => r && ({
|
||||
...r,
|
||||
prompt_tokens: Number(r.prompt_tokens),
|
||||
completion_tokens: Number(r.completion_tokens),
|
||||
cost_usd: Number(r.cost_usd),
|
||||
});
|
||||
|
||||
async function listRuns(limit = 15) {
|
||||
const { rows } = await db.query('SELECT * FROM ai_runs ORDER BY id DESC LIMIT $1', [limit]);
|
||||
return rows.map(shapeRun);
|
||||
}
|
||||
|
||||
// A run with its latest results and errors, for watching it.
|
||||
async function getRun(id) {
|
||||
const { rows: [run] } = await db.query('SELECT * FROM ai_runs WHERE id = $1', [id]);
|
||||
if (!run) return null;
|
||||
const { rows: recent } = await db.query(
|
||||
`SELECT i.model_id, m.name, i.status, i.tags, i.error, i.renamed_from, i.renamed_to, i.collections
|
||||
FROM ai_run_items i LEFT JOIN models m ON m.id = i.model_id
|
||||
WHERE i.run_id = $1 AND i.status <> 'pending' ORDER BY i.updated_at DESC LIMIT 12`, [id]);
|
||||
// Every failure, not only the ones still inside that window: a model that
|
||||
// failed early would otherwise scroll out of sight with no way to find it.
|
||||
const { rows: failures } = await db.query(
|
||||
`SELECT i.model_id, m.name, i.error
|
||||
FROM ai_run_items i LEFT JOIN models m ON m.id = i.model_id
|
||||
WHERE i.run_id = $1 AND i.status = 'error' ORDER BY i.updated_at DESC LIMIT 200`, [id]);
|
||||
return { ...shapeRun(run), recent, failures };
|
||||
}
|
||||
|
||||
async function stop(id) {
|
||||
const { rowCount } = await db.query(
|
||||
`UPDATE ai_runs SET status = 'stopped', finished_at = now(), note = NULL
|
||||
WHERE id = $1 AND status IN ('running', 'paused')`, [id]);
|
||||
return rowCount > 0;
|
||||
}
|
||||
|
||||
// Carries on a paused, stopped or failed run, retrying models that errored.
|
||||
async function resume(id) {
|
||||
const resumed = await db.transaction(async (client) => {
|
||||
const { rows: [run] } = await client.query(
|
||||
`SELECT id FROM ai_runs WHERE id = $1 AND status IN ('paused', 'stopped', 'failed', 'done') FOR UPDATE`, [id]);
|
||||
if (!run) return false;
|
||||
const retried = await client.query(
|
||||
`UPDATE ai_run_items SET status = 'pending', error = NULL WHERE run_id = $1 AND status = 'error'`, [id]);
|
||||
const { rows: [left] } = await client.query(
|
||||
`SELECT COUNT(*)::int AS n FROM ai_run_items WHERE run_id = $1 AND status = 'pending'`, [id]);
|
||||
if (!left.n) return false;
|
||||
await client.query(
|
||||
`UPDATE ai_runs SET status = 'running', error = NULL, note = NULL, finished_at = NULL,
|
||||
failed = GREATEST(failed - $2, 0) WHERE id = $1`, [id, retried.rowCount]);
|
||||
return true;
|
||||
});
|
||||
if (resumed) kick();
|
||||
return resumed;
|
||||
}
|
||||
|
||||
async function hasActiveRun() {
|
||||
const { rows } = await db.query(`SELECT 1 FROM ai_runs WHERE status = 'running' LIMIT 1`);
|
||||
return rows.length > 0;
|
||||
}
|
||||
|
||||
module.exports = { init, createRun, listRuns, getRun, stop, resume, kick, hasActiveRun, thumbnailDataUrl };
|
||||
|
|
@ -73,6 +73,6 @@ function hashResetToken(token) {
|
|||
}
|
||||
|
||||
module.exports = {
|
||||
COOKIE, getSecret, hashPassword, verifyPassword, issueToken, setAuthCookie, clearAuthCookie,
|
||||
COOKIE, hashPassword, verifyPassword, issueToken, setAuthCookie, clearAuthCookie,
|
||||
currentUser, requireAuth, makeResetToken, hashResetToken,
|
||||
};
|
||||
|
|
|
|||
155
lib/autotag.js
155
lib/autotag.js
|
|
@ -1,155 +0,0 @@
|
|||
// Automatic tagging: strict whole-word rules over model names, file names, and the
|
||||
// names (and immediate folders) of files inside archives. Rule tags are recorded
|
||||
// as such (model_tags.source = 'rule'), so a pass only adds or removes its own
|
||||
// tags; ones you typed are never touched.
|
||||
const crypto = require('crypto');
|
||||
const db = require('./db');
|
||||
const store = require('./store');
|
||||
|
||||
// Bump when the built-in rules change, so every model is checked again.
|
||||
const RULES_VERSION = 1;
|
||||
|
||||
const BUILT_IN_RULES = [
|
||||
{ group: 'Part types', tag: 'bracket', phrases: ['bracket'] },
|
||||
{ group: 'Part types', tag: 'holder', phrases: ['holder'] },
|
||||
{ group: 'Part types', tag: 'mount', phrases: ['mount'] },
|
||||
{ group: 'Part types', tag: 'hinge', phrases: ['hinge'] },
|
||||
{ group: 'Part types', tag: 'drawer', phrases: ['drawer'] },
|
||||
{ group: 'Part types', tag: 'baseplate', phrases: ['baseplate', 'base plate'] },
|
||||
{ group: 'Part types', tag: 'hook', phrases: ['hook'] },
|
||||
{ group: 'Part types', tag: 'clip', phrases: ['clip'] },
|
||||
{ group: 'Part types', tag: 'organizer', phrases: ['organizer', 'organiser'] },
|
||||
{ group: 'Part types', tag: 'keychain', phrases: ['keychain', 'key chain', 'keyring', 'key ring'] },
|
||||
{ group: 'Part types', tag: 'gridfinity', phrases: ['gridfinity'] },
|
||||
{ group: 'Mechanisms', tag: 'print-in-place', phrases: ['print in place', 'printinplace', 'pip'] },
|
||||
{ group: 'Mechanisms', tag: 'articulated', phrases: ['articulated', 'flexi'] },
|
||||
{ group: 'Mechanisms', tag: 'threaded', phrases: ['threaded', 'thread'] },
|
||||
{ group: 'Mechanisms', tag: 'gear', phrases: ['gear'] },
|
||||
{ group: 'Materials', tag: 'pla', phrases: ['pla'] },
|
||||
{ group: 'Materials', tag: 'petg', phrases: ['petg'] },
|
||||
{ group: 'Materials', tag: 'abs', phrases: ['abs'] },
|
||||
{ group: 'Materials', tag: 'asa', phrases: ['asa'] },
|
||||
{ group: 'Materials', tag: 'tpu', phrases: ['tpu'] },
|
||||
{ group: 'Materials', tag: 'nylon', phrases: ['nylon'] },
|
||||
{ group: 'Techniques', tag: 'vase mode', phrases: ['vase mode', 'vasemode', 'spiral vase'] },
|
||||
{ group: 'Techniques', tag: 'fuzzy skin', phrases: ['fuzzy skin', 'fuzzyskin'] },
|
||||
{ group: 'Techniques', tag: 'multicolor', phrases: ['multicolor', 'multicolour', 'multi color', 'multi colour'] },
|
||||
];
|
||||
|
||||
// Words in a name: splits on punctuation, camelCase and letter/number boundaries.
|
||||
// "PETGWallHook_v2.stl" -> ["petg", "wall", "hook", "v", "2", "stl"]
|
||||
function tokenize(text) {
|
||||
return String(text || '')
|
||||
.replace(/(?<=[a-z])(?=[A-Z])/g, ' ')
|
||||
.replace(/(?<=[A-Z])(?=[A-Z][a-z])/g, ' ')
|
||||
.replace(/(?<=[A-Za-z])(?=\d)|(?<=\d)(?=[A-Za-z])/g, ' ')
|
||||
.toLowerCase()
|
||||
.split(/[^a-z0-9]+/)
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
const sameWord = (token, word) => token === word || token === `${word}s` || token === `${word}es`;
|
||||
|
||||
function hasPhrase(tokens, words) {
|
||||
if (!words.length) return false;
|
||||
for (let i = 0; i + words.length <= tokens.length; i++) {
|
||||
if (words.every((w, j) => sameWord(tokens[i + j], w))) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
const normalizeTag = (tag) => String(tag).trim().toLowerCase().replace(/\s+/g, ' ').slice(0, 40);
|
||||
|
||||
// rules: [{ tag, phrases }] -> compiled once for matching.
|
||||
function compile(rules) {
|
||||
return rules.map((r) => ({ tag: normalizeTag(r.tag), phrases: r.phrases.map(tokenize).filter((w) => w.length) }));
|
||||
}
|
||||
|
||||
const COMPILED_BUILT_INS = compile(BUILT_IN_RULES);
|
||||
|
||||
// Tags for a set of names (each matched separately, so a phrase never spans two).
|
||||
function matchTags(texts, customRules = []) {
|
||||
const lists = (Array.isArray(texts) ? texts : [texts]).map(tokenize);
|
||||
const rules = [...COMPILED_BUILT_INS, ...compile(customRules.map((r) => ({ tag: r.tag, phrases: [r.phrase] })))];
|
||||
const tags = new Set();
|
||||
for (const rule of rules) {
|
||||
if (rule.tag && rule.phrases.some((words) => lists.some((tokens) => hasPhrase(tokens, words)))) tags.add(rule.tag);
|
||||
}
|
||||
return [...tags];
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- database
|
||||
async function isEnabled() {
|
||||
return (await store.getSetting('autotag_enabled')) !== 'false';
|
||||
}
|
||||
|
||||
async function listRules() {
|
||||
const { rows } = await db.query('SELECT id, phrase, tag FROM tag_rules ORDER BY id');
|
||||
return rows;
|
||||
}
|
||||
|
||||
const saltFor = (rules) => crypto.createHash('md5').update(JSON.stringify([RULES_VERSION, rules])).digest('hex');
|
||||
|
||||
// Models whose names, files or archive contents changed since their rule tags
|
||||
// were worked out (or whose rules changed).
|
||||
const PENDING = `
|
||||
FROM (
|
||||
SELECT m.id, m.name, m.autotag_key,
|
||||
COALESCE((SELECT string_agg(f.original_name, E'\\n' ORDER BY f.id) FROM files f WHERE f.model_id = m.id), '') AS files,
|
||||
COALESCE((SELECT string_agg(e.path, E'\\n' ORDER BY e.id)
|
||||
FROM archive_entries e JOIN files f ON f.id = e.file_id WHERE f.model_id = m.id), '') AS entries
|
||||
FROM models m
|
||||
) d
|
||||
WHERE d.autotag_key IS DISTINCT FROM md5($1 || d.name || E'\\n' || d.files || E'\\n' || d.entries)`;
|
||||
|
||||
async function run() {
|
||||
if (!(await isEnabled())) return false;
|
||||
const rules = await listRules();
|
||||
const salt = saltFor(rules.map((r) => [r.phrase, r.tag]));
|
||||
const { rows } = await db.query(`SELECT d.id, d.name, d.files, d.entries ${PENDING} ORDER BY d.id LIMIT 100`, [salt]);
|
||||
if (!rows.length) return false;
|
||||
for (const row of rows) {
|
||||
const entryNames = row.entries.split('\n').filter(Boolean).map((p) => p.split('/').slice(-2).join('/'));
|
||||
const texts = [row.name, ...row.files.split('\n'), ...entryNames.flatMap((p) => p.split('/'))];
|
||||
await store.replaceRuleTags(row.id, matchTags(texts, rules));
|
||||
await db.query(`UPDATE models SET autotag_key = md5($2 || $3 || E'\\n' || $4 || E'\\n' || $5) WHERE id = $1`,
|
||||
[row.id, salt, row.name, row.files, row.entries]);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
async function pendingCount() {
|
||||
if (!(await isEnabled())) return 0;
|
||||
const rules = await listRules();
|
||||
const { rows } = await db.query(`SELECT COUNT(*)::int AS n ${PENDING}`, [saltFor(rules.map((r) => [r.phrase, r.tag]))]);
|
||||
return rows[0].n;
|
||||
}
|
||||
|
||||
// Checks every model again on the next pass.
|
||||
async function reapply() {
|
||||
await db.query('UPDATE models SET autotag_key = NULL');
|
||||
}
|
||||
|
||||
// Turning it off keeps the tags already applied unless removeTags is set.
|
||||
async function setEnabled(enabled, { removeTags = false } = {}) {
|
||||
await store.setSetting('autotag_enabled', enabled ? 'true' : 'false');
|
||||
await reapply();
|
||||
return !enabled && removeTags ? store.removeTagsFrom('rule') : 0;
|
||||
}
|
||||
|
||||
async function addRule(phrase, tag) {
|
||||
const cleanPhrase = String(phrase || '').trim().slice(0, 80);
|
||||
const cleanTag = normalizeTag(tag || '');
|
||||
if (!tokenize(cleanPhrase).length) throw Object.assign(new Error('Enter a word or phrase to look for'), { status: 400 });
|
||||
if (!cleanTag) throw Object.assign(new Error('Enter the tag to add'), { status: 400 });
|
||||
await db.query('INSERT INTO tag_rules (phrase, tag) VALUES ($1, $2) ON CONFLICT DO NOTHING', [cleanPhrase, cleanTag]);
|
||||
}
|
||||
|
||||
async function removeRule(id) {
|
||||
await db.query('DELETE FROM tag_rules WHERE id = $1', [id]);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
BUILT_IN_RULES, tokenize, matchTags,
|
||||
run, pendingCount, reapply, isEnabled, setEnabled, listRules, addRule, removeRule,
|
||||
};
|
||||
|
|
@ -1,286 +0,0 @@
|
|||
// Watches a MakerWorld collection and imports models added to it. A check reads
|
||||
// the collection page (with your token, so private collections work), imports the
|
||||
// models it hasn't seen before, and remembers each one so it isn't imported twice.
|
||||
// The site is read gently: a few models per check, spaced out.
|
||||
const db = require('./db');
|
||||
const store = require('./store');
|
||||
const downloadBlock = require('./downloadBlock');
|
||||
const makerworld = require('./importers/makerworld');
|
||||
|
||||
const IMPORT_LIMIT = 15; // models per check, so a big collection trickles in
|
||||
const BETWEEN_IMPORTS_MS = 3000; // FlareSolverr runs one browser request at a time
|
||||
// Downloads need much more room: MakerWorld refuses bursts a few seconds apart,
|
||||
// but 67 in a row about 20 seconds apart went through without one refusal.
|
||||
const BETWEEN_DOWNLOADS_MS = 20000;
|
||||
const MORE_SOON_MINUTES = 10; // when a check hit the limit, come back sooner
|
||||
const TICK_MS = 5 * 60 * 1000;
|
||||
const INTERVALS = [1, 3, 6, 12, 24, 48];
|
||||
|
||||
// { fetchCollection(url), importFromUrl(url, opts), startAiRun(modelIds) }
|
||||
let deps = null;
|
||||
let timer = null;
|
||||
let busy = false;
|
||||
const checking = new Set(); // watch ids being checked right now, so checks can't overlap
|
||||
|
||||
const httpError = (message, status, reason) => Object.assign(new Error(message), { status, reason });
|
||||
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
function init(dependencies) {
|
||||
deps = dependencies;
|
||||
}
|
||||
|
||||
// Checks anything due, every few minutes.
|
||||
function start() {
|
||||
// A check that was running when the server stopped isn't running now.
|
||||
db.query(`UPDATE watched_collections SET last_status = NULL WHERE last_status = 'checking'`)
|
||||
.catch((err) => console.warn('Could not clear stale watch checks:', err.message));
|
||||
// Imports recorded before 'partial' existed: a model with an error kept none of its files.
|
||||
db.query(`UPDATE watched_collection_items SET status = 'partial' WHERE status = 'imported' AND error IS NOT NULL`)
|
||||
.catch((err) => console.warn('Could not mark file-less imports:', err.message));
|
||||
clearInterval(timer);
|
||||
timer = setInterval(() => { runDue().catch((err) => console.error('Collection watch failed:', err.message)); }, TICK_MS);
|
||||
if (timer.unref) timer.unref();
|
||||
runDue().catch((err) => console.error('Collection watch failed:', err.message));
|
||||
}
|
||||
|
||||
async function list() {
|
||||
const { rows } = await db.query(`
|
||||
SELECT w.*, p.name AS project_name,
|
||||
(SELECT COUNT(*)::int FROM watched_collection_items i WHERE i.watch_id = w.id AND i.status IN ('imported', 'partial')) AS imported_count,
|
||||
(SELECT COUNT(*)::int FROM watched_collection_items i WHERE i.watch_id = w.id AND i.status = 'partial') AS partial_count,
|
||||
(SELECT COUNT(*)::int FROM watched_collection_items i WHERE i.watch_id = w.id AND i.status = 'error') AS error_count,
|
||||
COALESCE(r.recent, '[]'::json) AS recent,
|
||||
COALESCE(q.pending, '[]'::json) AS pending
|
||||
FROM watched_collections w
|
||||
LEFT JOIN projects p ON p.id = w.project_id
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT json_agg(json_build_object('design_id', i.design_id, 'model_id', i.model_id, 'title', i.title,
|
||||
'status', i.status, 'error', i.error, 'seen_at', i.seen_at)
|
||||
ORDER BY i.seen_at DESC) AS recent
|
||||
FROM (SELECT * FROM watched_collection_items WHERE watch_id = w.id ORDER BY seen_at DESC LIMIT 5) i
|
||||
) r ON true
|
||||
-- Every model still waiting for its files, not just the five most recent.
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT json_agg(json_build_object('model_id', i.model_id, 'title', i.title) ORDER BY i.seen_at DESC) AS pending
|
||||
FROM (SELECT * FROM watched_collection_items
|
||||
WHERE watch_id = w.id AND status = 'partial' AND model_id IS NOT NULL
|
||||
ORDER BY seen_at DESC LIMIT 200) i
|
||||
) q ON true
|
||||
ORDER BY w.id`);
|
||||
return rows;
|
||||
}
|
||||
|
||||
async function get(id) {
|
||||
const { rows: [watch] } = await db.query(`
|
||||
SELECT w.*, p.name AS project_name,
|
||||
(SELECT COUNT(*)::int FROM watched_collection_items i WHERE i.watch_id = w.id AND i.status IN ('imported', 'partial')) AS imported_count,
|
||||
(SELECT COUNT(*)::int FROM watched_collection_items i WHERE i.watch_id = w.id AND i.status = 'partial') AS partial_count,
|
||||
(SELECT COUNT(*)::int FROM watched_collection_items i WHERE i.watch_id = w.id AND i.status = 'error') AS error_count
|
||||
FROM watched_collections w LEFT JOIN projects p ON p.id = w.project_id WHERE w.id = $1`, [id]);
|
||||
if (!watch) return null;
|
||||
const { rows: recent } = await db.query(`
|
||||
SELECT i.design_id, i.model_id, i.title, i.status, i.error, i.seen_at, m.name AS model_name
|
||||
FROM watched_collection_items i LEFT JOIN models m ON m.id = i.model_id
|
||||
WHERE i.watch_id = $1 ORDER BY i.seen_at DESC LIMIT 12`, [id]);
|
||||
return { ...watch, recent };
|
||||
}
|
||||
|
||||
// Adds a watch. Reads the collection first, so a bad link or a refused token is
|
||||
// reported now rather than silently later.
|
||||
async function add({ url, aiTag = false, intervalHours = 6, fetchFiles = false }) {
|
||||
const sourceId = makerworld.collectionIdFromUrl(url);
|
||||
if (!sourceId) throw httpError('That isn’t a MakerWorld collection link (makerworld.com/…/collections/…).', 400);
|
||||
const { rowCount } = await db.query('SELECT 1 FROM watched_collections WHERE site = $1 AND source_id = $2', ['makerworld', sourceId]);
|
||||
if (rowCount) throw httpError('You’re already watching that collection.', 409);
|
||||
|
||||
const collection = await deps.fetchCollection(url);
|
||||
const project = await store.findOrCreateProject(collection.title || `MakerWorld collection ${sourceId}`);
|
||||
const hours = INTERVALS.includes(Number(intervalHours)) ? Number(intervalHours) : 6;
|
||||
const { rows: [watch] } = await db.query(
|
||||
`INSERT INTO watched_collections (site, source_id, url, title, project_id, ai_tag, interval_hours, total, listed, last_status, fetch_files)
|
||||
VALUES ('makerworld', $1, $2, $3, $4, $5, $6, $7, $8, 'checking', $9) RETURNING id`,
|
||||
[sourceId, url, collection.title, project.id, Boolean(aiTag), hours, collection.total, collection.designs.length,
|
||||
Boolean(fetchFiles)]);
|
||||
// The collection was just read, so import straight from it rather than
|
||||
// fetching the whole thing again.
|
||||
const added = await get(watch.id);
|
||||
checking.add(watch.id);
|
||||
importFound(added, collection)
|
||||
.catch((err) => console.warn(`Watch ${watch.id} first import failed:`, err.message))
|
||||
.finally(() => checking.delete(watch.id));
|
||||
return added;
|
||||
}
|
||||
|
||||
async function update(id, { enabled, aiTag, intervalHours, fetchFiles }) {
|
||||
const hours = INTERVALS.includes(Number(intervalHours)) ? Number(intervalHours) : null;
|
||||
const { rowCount } = await db.query(
|
||||
`UPDATE watched_collections SET
|
||||
enabled = COALESCE($2, enabled),
|
||||
ai_tag = COALESCE($3, ai_tag),
|
||||
interval_hours = COALESCE($4, interval_hours),
|
||||
fetch_files = COALESCE($5, fetch_files),
|
||||
next_check_at = CASE WHEN $2 IS TRUE THEN now() ELSE next_check_at END
|
||||
WHERE id = $1`,
|
||||
[id, enabled === undefined ? null : Boolean(enabled), aiTag === undefined ? null : Boolean(aiTag), hours,
|
||||
fetchFiles === undefined ? null : Boolean(fetchFiles)]);
|
||||
if (!rowCount) return null;
|
||||
if (enabled) runDue().catch(() => {});
|
||||
return get(id);
|
||||
}
|
||||
|
||||
// Stops watching. Models already imported stay in the library.
|
||||
async function remove(id) {
|
||||
const { rowCount } = await db.query('DELETE FROM watched_collections WHERE id = $1', [id]);
|
||||
return rowCount > 0;
|
||||
}
|
||||
|
||||
// Checks one collection now: reads it (awaited, so problems come back to the
|
||||
// caller), then imports in the background.
|
||||
async function checkNow(id) {
|
||||
const watch = await get(id);
|
||||
if (!watch) throw httpError('Not watching that collection.', 404);
|
||||
if (checking.has(id)) return watch; // already going; don't import anything twice
|
||||
let collection;
|
||||
try {
|
||||
collection = await deps.fetchCollection(watch.url);
|
||||
} catch (err) {
|
||||
await recordFailure(watch, err);
|
||||
throw err;
|
||||
}
|
||||
await db.query(
|
||||
`UPDATE watched_collections SET title = $2, total = $3, listed = $4, last_status = 'checking',
|
||||
last_error = NULL, last_error_reason = NULL WHERE id = $1`,
|
||||
[id, collection.title || watch.title, collection.total, collection.designs.length]);
|
||||
checking.add(id);
|
||||
importFound(watch, collection)
|
||||
.catch((err) => console.error(`Watch ${id} import failed:`, err.message))
|
||||
.finally(() => checking.delete(id));
|
||||
return get(id);
|
||||
}
|
||||
|
||||
async function recordFailure(watch, err) {
|
||||
await db.query(
|
||||
`UPDATE watched_collections SET last_checked_at = now(), last_status = 'error', last_error = $2,
|
||||
last_error_reason = $3, next_check_at = now() + make_interval(hours => GREATEST(interval_hours, 1))
|
||||
WHERE id = $1`,
|
||||
[watch.id, String(err.message).slice(0, 300), err.reason || null]);
|
||||
}
|
||||
|
||||
// Imports the models this watch hasn't seen yet, a few at a time.
|
||||
async function importFound(watch, collection) {
|
||||
const { rows: seenRows } = await db.query(
|
||||
`SELECT design_id FROM watched_collection_items WHERE watch_id = $1 AND status <> 'error'`, [watch.id]);
|
||||
const seen = new Set(seenRows.map((r) => r.design_id));
|
||||
const pending = collection.designs.filter((d) => !seen.has(d.id));
|
||||
const todo = pending.slice(0, IMPORT_LIMIT);
|
||||
|
||||
let added = 0;
|
||||
let problem = null;
|
||||
let fileMisses = 0; // downloads refused in a row
|
||||
let stoppedEarly = false; // left models unimported, so come back soon
|
||||
const fresh = [];
|
||||
for (const design of todo) {
|
||||
// Paused while this batch was running: stop, rather than import the rest.
|
||||
const { rows: [live] } = await db.query('SELECT enabled FROM watched_collections WHERE id = $1', [watch.id]);
|
||||
if (!live || !live.enabled) break;
|
||||
const url = makerworld.designUrl(design);
|
||||
// While MakerWorld is waiting for you to prove you're not a robot, the
|
||||
// check still brings in the models — their files wait for you. Decided out
|
||||
// here because the wait after each import depends on it.
|
||||
const withFiles = Boolean(watch.fetch_files) && !downloadBlock.isBlocked('makerworld');
|
||||
try {
|
||||
const result = await deps.importFromUrl(url, {
|
||||
projects: watch.project_id ? [watch.project_id] : [],
|
||||
withFiles, // off by default: MakerWorld refuses quick bursts
|
||||
});
|
||||
if (result.existing) {
|
||||
if (watch.project_id) await store.addModelToProject(result.model.id, watch.project_id);
|
||||
await recordItem(watch.id, design, result.model.id, 'existing', null);
|
||||
} else {
|
||||
added += 1;
|
||||
fresh.push(result.model.id);
|
||||
// Without files (or when they couldn't be fetched) the model is only
|
||||
// 'partial' — "Get files" fills it in later.
|
||||
const gotFiles = withFiles && !result.filesError;
|
||||
await recordItem(watch.id, design, result.model.id, gotFiles ? 'imported' : 'partial',
|
||||
result.filesError || null);
|
||||
}
|
||||
// Refused downloads mean we're asking too fast. Stop the batch and come
|
||||
// back shortly, rather than collecting refusals for the whole of it.
|
||||
if (withFiles && result.filesError) {
|
||||
fileMisses += 1;
|
||||
if (fileMisses >= 2 && result.filesErrorReason !== 'makerworld_token') { stoppedEarly = true; break; }
|
||||
} else if (!result.filesError) {
|
||||
fileMisses = 0;
|
||||
}
|
||||
// A refused token, or a check only you can solve, means every later
|
||||
// download fails too: stop and say so.
|
||||
if (['makerworld_token', 'makerworld_challenge'].includes(result.filesErrorReason)) {
|
||||
problem = httpError(result.filesError, 502, result.filesErrorReason);
|
||||
break;
|
||||
}
|
||||
} catch (err) {
|
||||
await recordItem(watch.id, design, null, 'error', err.message);
|
||||
if (['makerworld_token', 'makerworld_challenge'].includes(err.reason)) { problem = err; break; }
|
||||
}
|
||||
await sleep(withFiles ? BETWEEN_DOWNLOADS_MS : BETWEEN_IMPORTS_MS);
|
||||
}
|
||||
|
||||
const more = pending.length > todo.length || stoppedEarly;
|
||||
if (problem) {
|
||||
await recordFailure({ ...watch, id: watch.id }, problem);
|
||||
} else {
|
||||
await db.query(
|
||||
`UPDATE watched_collections SET last_checked_at = now(), last_status = 'ok', last_error = NULL,
|
||||
last_error_reason = NULL, last_added = $2,
|
||||
next_check_at = now() + CASE WHEN $3::boolean THEN make_interval(mins => $4) ELSE make_interval(hours => interval_hours) END
|
||||
WHERE id = $1`,
|
||||
[watch.id, added, more, MORE_SOON_MINUTES]);
|
||||
}
|
||||
|
||||
if (watch.ai_tag && fresh.length && deps.startAiRun) {
|
||||
await deps.startAiRun(fresh).catch((err) => console.warn(`Watch ${watch.id}: AI tagging didn't start:`, err.message));
|
||||
}
|
||||
}
|
||||
|
||||
async function recordItem(watchId, design, modelId, status, error) {
|
||||
await db.query(
|
||||
`INSERT INTO watched_collection_items (watch_id, design_id, model_id, title, status, error, seen_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, now())
|
||||
ON CONFLICT (watch_id, design_id) DO UPDATE SET model_id = EXCLUDED.model_id, title = EXCLUDED.title,
|
||||
status = EXCLUDED.status, error = EXCLUDED.error, seen_at = now()`,
|
||||
[watchId, design.id, modelId, String(design.title || '').slice(0, 200), status, error ? String(error).slice(0, 300) : null]);
|
||||
}
|
||||
|
||||
// Checks every watch that's due, one at a time.
|
||||
async function runDue() {
|
||||
if (busy || !deps) return;
|
||||
busy = true;
|
||||
try {
|
||||
for (;;) {
|
||||
const { rows: [due] } = await db.query(
|
||||
`SELECT id FROM watched_collections WHERE enabled AND next_check_at <= now()
|
||||
AND id <> ALL($1::int[]) ORDER BY next_check_at LIMIT 1`, [[...checking]]);
|
||||
if (!due) return;
|
||||
// checkNow leaves the import running in the background; wait for it here so
|
||||
// watches are checked one after another.
|
||||
const watch = await get(due.id);
|
||||
checking.add(watch.id);
|
||||
try {
|
||||
const collection = await deps.fetchCollection(watch.url);
|
||||
await db.query(
|
||||
`UPDATE watched_collections SET title = $2, total = $3, listed = $4, last_status = 'checking' WHERE id = $1`,
|
||||
[watch.id, collection.title || watch.title, collection.total, collection.designs.length]);
|
||||
await importFound(watch, collection);
|
||||
} catch (err) {
|
||||
await recordFailure(watch, err);
|
||||
} finally {
|
||||
checking.delete(watch.id);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
busy = false;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { INTERVALS, init, start, list, get, add, update, remove, checkNow, runDue };
|
||||
|
|
@ -34,14 +34,4 @@ async function readProjectInfo(inputPath, imagesDir) {
|
|||
'reading this 3MF project');
|
||||
}
|
||||
|
||||
// Just the filaments a project asks for: no --images, so no plate previews are
|
||||
// written, and queued behind anything someone is waiting on — this runs over the
|
||||
// whole library in the background.
|
||||
async function readFilaments(inputPath) {
|
||||
const info = await parseResult(
|
||||
await runScript('threemf_info.py', [inputPath], { timeout: 60000, isBackground: true }),
|
||||
'reading this 3MF project');
|
||||
return Array.isArray(info.filaments) ? info.filaments : [];
|
||||
}
|
||||
|
||||
module.exports = { convert3mfToGlb, readProjectInfo, readFilaments };
|
||||
module.exports = { convert3mfToGlb, readProjectInfo };
|
||||
|
|
|
|||
16
lib/db.js
16
lib/db.js
|
|
@ -21,12 +21,7 @@ function query(text, params) {
|
|||
}
|
||||
|
||||
// Run a function inside a transaction, passing it a dedicated client.
|
||||
// Postgres breaks a deadlock by killing one of the transactions. The victim
|
||||
// rolled back and changed nothing, so running it again is safe — and usually
|
||||
// works, because the transaction it collided with has since finished.
|
||||
const RETRY_CODES = new Set(['40P01', '40001']); // deadlock_detected, serialization_failure
|
||||
|
||||
async function transaction(fn, attempt = 0) {
|
||||
async function transaction(fn) {
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
await client.query('BEGIN');
|
||||
|
|
@ -34,14 +29,11 @@ async function transaction(fn, attempt = 0) {
|
|||
await client.query('COMMIT');
|
||||
return result;
|
||||
} catch (err) {
|
||||
await client.query('ROLLBACK').catch(() => {}); // the connection may be gone
|
||||
if (!RETRY_CODES.has(err.code) || attempt >= 3) throw err;
|
||||
console.warn(`Database ${err.code}, retrying the transaction (attempt ${attempt + 2}).`);
|
||||
await client.query('ROLLBACK');
|
||||
throw err;
|
||||
} finally {
|
||||
client.release(); // before the pause below, so a retry doesn't hold a connection
|
||||
client.release();
|
||||
}
|
||||
await new Promise((r) => setTimeout(r, 50 * (attempt + 1) ** 2));
|
||||
return transaction(fn, attempt + 1);
|
||||
}
|
||||
|
||||
module.exports = { pool, query, transaction, init };
|
||||
|
|
|
|||
|
|
@ -1,29 +0,0 @@
|
|||
// When a site blocks downloads until you prove you're not a robot.
|
||||
//
|
||||
// MakerWorld does this after a few downloads in a row. Only you can clear it —
|
||||
// by solving the check in your own browser — so Printhive stops asking until
|
||||
// you say you have. Attempts while blocked are wasted and may prolong it.
|
||||
//
|
||||
// Held in memory: a restart clears it, and the next attempt finds out again.
|
||||
const blocks = new Map(); // site → { since, message }
|
||||
|
||||
const DEFAULT = 'MakerWorld is asking you to confirm you’re not a robot before it will allow more downloads.';
|
||||
|
||||
function block(site, message = DEFAULT) {
|
||||
if (!blocks.has(site)) blocks.set(site, { since: new Date(), message });
|
||||
return blocks.get(site);
|
||||
}
|
||||
|
||||
// Called when you say you've solved it, and whenever a download succeeds.
|
||||
function clear(site) {
|
||||
return blocks.delete(site);
|
||||
}
|
||||
|
||||
const isBlocked = (site) => blocks.has(site);
|
||||
|
||||
function state(site) {
|
||||
const b = blocks.get(site);
|
||||
return b ? { blocked: true, since: b.since, message: b.message } : { blocked: false };
|
||||
}
|
||||
|
||||
module.exports = { block, clear, isBlocked, state, DEFAULT };
|
||||
|
|
@ -1,46 +0,0 @@
|
|||
// The local text-embedding model behind semantic search: bge-small-en-v1.5
|
||||
// (quantized, ~34 MB) run with transformers.js. Nothing is sent anywhere; the model
|
||||
// is downloaded once into MODELS_DIR (the Docker image ships with it).
|
||||
const path = require('path');
|
||||
|
||||
const MODEL = 'Xenova/bge-small-en-v1.5';
|
||||
const DIMENSIONS = 384;
|
||||
const CACHE_DIR = process.env.MODELS_DIR || path.join(__dirname, '..', 'models');
|
||||
// bge models match short queries against passages best with this instruction.
|
||||
const QUERY_PREFIX = 'Represent this sentence for searching relevant passages: ';
|
||||
const IDLE_MS = 10 * 60 * 1000;
|
||||
|
||||
let pipelinePromise = null;
|
||||
let idleTimer = null;
|
||||
|
||||
// Loaded on first use and released after a while unused (it holds ~150 MB).
|
||||
function load() {
|
||||
if (!pipelinePromise) {
|
||||
pipelinePromise = (async () => {
|
||||
const { pipeline, env } = await import('@huggingface/transformers');
|
||||
env.cacheDir = CACHE_DIR;
|
||||
return pipeline('feature-extraction', MODEL, { dtype: 'q8' });
|
||||
})();
|
||||
pipelinePromise.catch(() => { pipelinePromise = null; });
|
||||
}
|
||||
clearTimeout(idleTimer);
|
||||
idleTimer = setTimeout(release, IDLE_MS);
|
||||
idleTimer.unref();
|
||||
return pipelinePromise;
|
||||
}
|
||||
|
||||
async function release() {
|
||||
const loaded = pipelinePromise;
|
||||
pipelinePromise = null;
|
||||
try { await (await loaded).dispose(); } catch { /* already gone */ }
|
||||
}
|
||||
|
||||
// texts -> unit-length vectors (arrays of numbers).
|
||||
async function embed(texts) {
|
||||
if (!texts.length) return [];
|
||||
const extractor = await load();
|
||||
const output = await extractor(texts, { pooling: 'cls', normalize: true });
|
||||
return output.tolist();
|
||||
}
|
||||
|
||||
module.exports = { MODEL, DIMENSIONS, QUERY_PREFIX, load, embed };
|
||||
|
|
@ -1,88 +0,0 @@
|
|||
// Semantic search index: each model's text (name, description, notes, tags, file
|
||||
// names) embedded locally, refreshed in the background whenever that text changes.
|
||||
const db = require('./db');
|
||||
const embeddingModel = require('./embeddingModel');
|
||||
|
||||
const BATCH = 16;
|
||||
const MAX_CHARS = 2000; // the model reads ~512 tokens; the start of the text matters most
|
||||
const RETRY_AFTER_MS = 10 * 60 * 1000;
|
||||
// Part of every text fingerprint, so switching embedding model re-indexes everything.
|
||||
const SALT = `${embeddingModel.MODEL}:1:`;
|
||||
|
||||
let lastError = null;
|
||||
let retryAt = 0;
|
||||
let vectors = null; // [{ id, vec: Float32Array }], rebuilt after the index changes
|
||||
let vectorsLoadedAt = 0;
|
||||
const VECTORS_MAX_AGE_MS = 60 * 1000; // so trashed and restored models catch up
|
||||
|
||||
const DOCS = `
|
||||
SELECT m.id, m.embedding_key,
|
||||
concat_ws(E'\\n', m.name, NULLIF(btrim(m.description), ''),
|
||||
(SELECT string_agg(tg.name, ', ' ORDER BY tg.name) FROM model_tags mt JOIN tags tg ON tg.id = mt.tag_id WHERE mt.model_id = m.id),
|
||||
NULLIF(btrim(m.notes), ''), m.source_site, d.name,
|
||||
(SELECT string_agg(f.original_name, ', ' ORDER BY f.id) FROM files f WHERE f.model_id = m.id),
|
||||
(SELECT string_agg(e.path, ', ' ORDER BY e.id) FROM archive_entries e JOIN files f ON f.id = e.file_id WHERE f.model_id = m.id)
|
||||
) AS text
|
||||
FROM models m LEFT JOIN designers d ON d.id = m.designer_id
|
||||
WHERE m.deleted_at IS NULL`;
|
||||
const PENDING = `FROM (${DOCS}) doc WHERE doc.embedding_key IS DISTINCT FROM md5($1 || doc.text)`;
|
||||
|
||||
async function run() {
|
||||
if (Date.now() < retryAt) return false;
|
||||
const { rows } = await db.query(`SELECT doc.id, doc.text ${PENDING} ORDER BY doc.id LIMIT $2`, [SALT, BATCH]);
|
||||
if (!rows.length) return false;
|
||||
let vecs;
|
||||
try {
|
||||
vecs = await embeddingModel.embed(rows.map((r) => r.text.slice(0, MAX_CHARS)));
|
||||
lastError = null;
|
||||
} catch (err) {
|
||||
lastError = err.message;
|
||||
retryAt = Date.now() + RETRY_AFTER_MS;
|
||||
console.warn('Semantic search indexing paused:', err.message);
|
||||
return false;
|
||||
}
|
||||
for (let i = 0; i < rows.length; i++) {
|
||||
await db.query('UPDATE models SET embedding = $2, embedding_key = md5($3 || $4) WHERE id = $1',
|
||||
[rows[i].id, vecs[i], SALT, rows[i].text]);
|
||||
}
|
||||
vectors = null;
|
||||
return true;
|
||||
}
|
||||
|
||||
async function loadVectors() {
|
||||
if (!vectors || Date.now() - vectorsLoadedAt > VECTORS_MAX_AGE_MS) {
|
||||
const { rows } = await db.query('SELECT id, embedding FROM models WHERE embedding IS NOT NULL AND deleted_at IS NULL');
|
||||
vectors = rows.map((r) => ({ id: r.id, vec: Float32Array.from(r.embedding) }));
|
||||
vectorsLoadedAt = Date.now();
|
||||
}
|
||||
return vectors;
|
||||
}
|
||||
|
||||
// Models ranked by how close their meaning is to the query: [{ id, score }],
|
||||
// score = cosine similarity (vectors are unit length).
|
||||
async function search(query, limit = 200) {
|
||||
const [q] = await embeddingModel.embed([embeddingModel.QUERY_PREFIX + String(query).slice(0, 300)]);
|
||||
const list = await loadVectors();
|
||||
return list
|
||||
.map(({ id, vec }) => {
|
||||
let score = 0;
|
||||
for (let i = 0; i < vec.length; i++) score += vec[i] * q[i];
|
||||
return { id, score: Math.round(score * 1000) / 1000 };
|
||||
})
|
||||
.sort((a, b) => b.score - a.score)
|
||||
.slice(0, limit);
|
||||
}
|
||||
|
||||
async function status() {
|
||||
const { rows: [counts] } = await db.query(
|
||||
`SELECT (SELECT COUNT(*)::int FROM models WHERE deleted_at IS NULL AND embedding IS NOT NULL) AS indexed,
|
||||
(SELECT COUNT(*)::int ${PENDING}) AS pending`, [SALT]);
|
||||
return { ...counts, error: lastError };
|
||||
}
|
||||
|
||||
// Something outside the worker changed which models are searchable (trash, restore).
|
||||
function invalidate() {
|
||||
vectors = null;
|
||||
}
|
||||
|
||||
module.exports = { run, search, status, invalidate };
|
||||
189
lib/filaments.js
189
lib/filaments.js
|
|
@ -1,189 +0,0 @@
|
|||
// What each model asks for, and what you have on hand.
|
||||
//
|
||||
// A Bambu Studio / OrcaSlicer 3MF names the filaments its plates use. Reading
|
||||
// that means shelling out to Python, so it is done once per model in the
|
||||
// background — like thumbnails and fingerprints — and kept in models.filaments.
|
||||
// With that stored, "what can I print right now" is a plain query rather than
|
||||
// hundreds of subprocesses.
|
||||
const db = require('./db');
|
||||
const converter = require('./converter');
|
||||
|
||||
const BATCH = 25;
|
||||
|
||||
// Models that have never been looked at, paired with their first loose 3MF.
|
||||
// A model whose only 3MF sits inside an archive is left for a later pass rather
|
||||
// than paying an extraction for each one.
|
||||
const PENDING = `
|
||||
FROM models m
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT f.file_path FROM files f
|
||||
WHERE f.model_id = m.id AND f.format = '3mf'
|
||||
ORDER BY f.id LIMIT 1
|
||||
) f3 ON true
|
||||
WHERE m.deleted_at IS NULL AND m.filaments_status IS NULL`;
|
||||
|
||||
// "PLA Basic" and "pla basic" are the same filament; "PLA-CF" is not.
|
||||
const normalise = (s) => String(s || '').trim().toUpperCase().replace(/\s+/g, ' ');
|
||||
|
||||
// Bambu writes #RRGGBB and #RRGGBBAA; the alpha channel is not a colour.
|
||||
function rgb(hex) {
|
||||
const m = /^#?([0-9a-f]{6})(?:[0-9a-f]{2})?$/i.exec(String(hex || '').trim());
|
||||
if (!m) return null;
|
||||
const n = parseInt(m[1], 16);
|
||||
return { r: (n >> 16) & 255, g: (n >> 8) & 255, b: n & 255 };
|
||||
}
|
||||
|
||||
// Redmean distance: closer to how an eye judges two colours than plain RGB.
|
||||
// 0 is identical, black to white is about 765. Null when either is unknown.
|
||||
function colourDistance(a, b) {
|
||||
const x = rgb(a);
|
||||
const y = rgb(b);
|
||||
if (!x || !y) return null;
|
||||
const rm = (x.r + y.r) / 2;
|
||||
const dr = x.r - y.r;
|
||||
const dg = x.g - y.g;
|
||||
const db = x.b - y.b;
|
||||
return Math.sqrt((2 + rm / 256) * dr * dr + 4 * dg * dg + (2 + (255 - rm) / 256) * db * db);
|
||||
}
|
||||
|
||||
// How close counts as the same colour. 50 keeps #000000 and #161616 apart (~66)
|
||||
// while allowing genuinely near shades to stand in for each other.
|
||||
const COLOUR_TOLERANCE = 50;
|
||||
|
||||
// Which spool fills which slot. One spool cannot fill two slots, so the best
|
||||
// colour matches are assigned first. A slot whose project gives no colour is
|
||||
// satisfied by any spool of the right material.
|
||||
function assignSpools(filaments, spools) {
|
||||
const slots = (filaments || []).map((f, i) => ({
|
||||
index: i, material: normalise(f.type), colour: f.colour || null,
|
||||
}));
|
||||
const shelf = (spools || [])
|
||||
.filter((s) => s.remaining_g === null || s.remaining_g === undefined || Number(s.remaining_g) > 0)
|
||||
.map((s, i) => ({
|
||||
key: s.id == null ? `i${i}` : `s${s.id}`, material: normalise(s.material),
|
||||
colour: s.colour_hex || null, name: s.colour || s.material,
|
||||
}));
|
||||
|
||||
const pairs = [];
|
||||
for (const slot of slots) {
|
||||
for (const spool of shelf) {
|
||||
if (spool.material !== slot.material) continue;
|
||||
// A slot that takes any colour is filled last, from whatever is left:
|
||||
// scoring it 0 would let it claim a spool an exact-match slot needs.
|
||||
if (slot.colour === null) { pairs.push({ slot: slot.index, spool: spool.key, d: Number.MAX_SAFE_INTEGER }); continue; }
|
||||
const d = colourDistance(slot.colour, spool.colour);
|
||||
if (d !== null && d <= COLOUR_TOLERANCE) pairs.push({ slot: slot.index, spool: spool.key, d });
|
||||
}
|
||||
}
|
||||
pairs.sort((a, b) => a.d - b.d);
|
||||
|
||||
const filledBy = new Map();
|
||||
const usedSpools = new Set();
|
||||
for (const p of pairs) {
|
||||
if (filledBy.has(p.slot) || usedSpools.has(p.spool)) continue;
|
||||
filledBy.set(p.slot, p.spool);
|
||||
usedSpools.add(p.spool);
|
||||
}
|
||||
|
||||
return slots.map((slot) => {
|
||||
const key = filledBy.get(slot.index);
|
||||
const by = key ? shelf.find((s) => s.key === key) : null;
|
||||
let nearest = null;
|
||||
if (!by && slot.colour) {
|
||||
for (const spool of shelf) {
|
||||
if (spool.material !== slot.material) continue;
|
||||
const d = colourDistance(slot.colour, spool.colour);
|
||||
if (d !== null && (!nearest || d < nearest.distance)) nearest = { name: spool.name, distance: Math.round(d) };
|
||||
}
|
||||
}
|
||||
return { ...slot, filled: Boolean(by), by: by ? by.name : null, nearest };
|
||||
});
|
||||
}
|
||||
|
||||
// The slots you can't fill from the shelf.
|
||||
function shortfall(filaments, spools) {
|
||||
return assignSpools(filaments, spools).filter((s) => !s.filled);
|
||||
}
|
||||
|
||||
// True when every slot has a spool. A model we know nothing about is not
|
||||
// claimed to be printable.
|
||||
function isPrintable(filaments, spools) {
|
||||
if (!filaments || !filaments.length) return false;
|
||||
return shortfall(filaments, spools).length === 0;
|
||||
}
|
||||
|
||||
// One pass. Returns whether it did any work, so it fits the background worker
|
||||
// loop in fingerprint.js.
|
||||
async function run() {
|
||||
const { rows } = await db.query(
|
||||
`SELECT m.id, f3.file_path ${PENDING} ORDER BY m.id LIMIT ${BATCH}`);
|
||||
if (!rows.length) return false;
|
||||
|
||||
for (const row of rows) {
|
||||
if (!row.file_path) {
|
||||
await mark(row.id, 'none', null);
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
const filaments = await converter.readFilaments(row.file_path);
|
||||
// A plain 3MF mesh names no filaments — that is an answer, not a failure.
|
||||
await mark(row.id, filaments.length ? 'done' : 'none', filaments.length ? filaments : null);
|
||||
} catch (err) {
|
||||
console.warn(`Could not read filaments for model ${row.id}:`, err.message);
|
||||
await mark(row.id, 'error', null);
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
async function mark(modelId, status, filaments) {
|
||||
await db.query('UPDATE models SET filaments = $2, filaments_status = $3 WHERE id = $1',
|
||||
[modelId, filaments ? JSON.stringify(filaments) : null, status]);
|
||||
}
|
||||
|
||||
async function pendingCount() {
|
||||
const { rows } = await db.query(`SELECT COUNT(*)::int AS n ${PENDING}`);
|
||||
return rows[0].n;
|
||||
}
|
||||
|
||||
// Look at every model again — after adding files, say. Slots you set by hand
|
||||
// are left alone: they aren't the reader's to overwrite.
|
||||
async function reread() {
|
||||
await db.query("UPDATE models SET filaments_status = NULL WHERE filaments_status IS DISTINCT FROM 'edited'");
|
||||
}
|
||||
|
||||
// Slots as sent from the page. Keeps the material, accepts only a proper hex as
|
||||
// a colour, and drops anything with no material — a slot that asks for nothing
|
||||
// would quietly count as satisfied.
|
||||
function cleanSlots(list) {
|
||||
if (!Array.isArray(list)) return null;
|
||||
return list
|
||||
.map((f) => ({
|
||||
type: String((f && f.type) || '').trim(),
|
||||
colour: /^#[0-9a-f]{6}$/i.test(String((f && f.colour) || '').trim())
|
||||
? String(f.colour).trim().toUpperCase()
|
||||
: null,
|
||||
}))
|
||||
.filter((f) => f.type);
|
||||
}
|
||||
|
||||
// Bambu's own name and code for a spool, from the bundled filament list: the
|
||||
// same material and product line, and the closest colour within a tight
|
||||
// threshold — this identifies a colour rather than substituting for one.
|
||||
function paletteMatch(palette, material, type, hex) {
|
||||
let best = null;
|
||||
for (const f of palette || []) {
|
||||
if (normalise(f.material) !== normalise(material)) continue;
|
||||
if (type && normalise(f.type) !== normalise(type)) continue;
|
||||
for (const c of f.colours || []) {
|
||||
const d = colourDistance(hex, c);
|
||||
if (d !== null && d <= 12 && (!best || d < best.d)) best = { d, name: f.name, code: f.code || null, type: f.type };
|
||||
}
|
||||
}
|
||||
return best && { name: best.name, code: best.code, type: best.type };
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
run, pendingCount, reread, cleanSlots, paletteMatch,
|
||||
assignSpools, shortfall, isPrintable, normalise, colourDistance, rgb, COLOUR_TOLERANCE,
|
||||
};
|
||||
|
|
@ -11,8 +11,6 @@ const db = require('./db');
|
|||
const store = require('./store');
|
||||
const archives = require('./archives');
|
||||
const thumbnails = require('./thumbnails');
|
||||
const autotag = require('./autotag');
|
||||
const embeddings = require('./embeddings');
|
||||
const { MODEL_FORMATS, ARCHIVE_FORMATS, formatOf } = require('./fileProcessor');
|
||||
const { PYTHON, runScript } = require('./python');
|
||||
|
||||
|
|
@ -211,13 +209,8 @@ async function fingerprintEntries() {
|
|||
|
||||
async function run() {
|
||||
for (;;) {
|
||||
// Thumbnails, tags and search come after indexing so they can use what's inside
|
||||
// newly indexed archives.
|
||||
// require here rather than at the top: filaments.js pulls in the converter,
|
||||
// and this keeps the module graph one-directional.
|
||||
const filaments = require('./filaments');
|
||||
const worked = [await fingerprintFiles(), await indexArchives(), await fingerprintEntries(),
|
||||
await thumbnails.generate(), await autotag.run(), await embeddings.run(), await filaments.run()];
|
||||
// Thumbnails go last so they can use what's inside newly indexed archives.
|
||||
const worked = [await fingerprintFiles(), await indexArchives(), await fingerprintEntries(), await thumbnails.generate()];
|
||||
if (!worked.some(Boolean)) return;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -21,9 +21,6 @@ async function solve(flaresolverrUrl, targetUrl, opts = {}) {
|
|||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
// FlareSolverr is told to give up at maxTimeout; if it doesn't answer
|
||||
// even then, stop waiting rather than hanging the request for ever.
|
||||
signal: AbortSignal.timeout((opts.maxTimeout || 60000) + 15000),
|
||||
});
|
||||
if (res.status >= 500) { lastErr = new Error(`FlareSolverr HTTP ${res.status}`); continue; }
|
||||
if (!res.ok) throw new Error(`FlareSolverr HTTP ${res.status}`);
|
||||
|
|
@ -32,7 +29,7 @@ async function solve(flaresolverrUrl, targetUrl, opts = {}) {
|
|||
return data.solution.response;
|
||||
} catch (err) {
|
||||
lastErr = err;
|
||||
if (!/HTTP 5\d\d|fetch failed|ECONNRESET|network|timeout|aborted/i.test(err.message)) throw err;
|
||||
if (!/HTTP 5\d\d|fetch failed|ECONNRESET|network/i.test(err.message)) throw err;
|
||||
}
|
||||
}
|
||||
throw lastErr;
|
||||
|
|
@ -51,9 +48,7 @@ async function solveJson(flaresolverrUrl, targetUrl, opts = {}) {
|
|||
try {
|
||||
return JSON.parse(text);
|
||||
} catch {
|
||||
// Keep a little of what came back: a site answering with a page instead of
|
||||
// JSON is usually asking the visitor to prove they're human.
|
||||
throw Object.assign(new Error('FlareSolverr did not return parseable JSON.'), { raw: text.slice(0, 300) });
|
||||
throw new Error('FlareSolverr did not return parseable JSON.');
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,46 +0,0 @@
|
|||
// Links for "Open in slicer". The slicer app downloads the file itself, outside
|
||||
// the browser and without the session cookie, so it gets a short-lived link that
|
||||
// grants just that one file.
|
||||
const crypto = require('crypto');
|
||||
const path = require('path');
|
||||
const jwt = require('jsonwebtoken');
|
||||
|
||||
const TTL_SECONDS = 10 * 60;
|
||||
const AUDIENCE = 'printhive-handoff';
|
||||
const KINDS = ['file', 'entry'];
|
||||
// What slicers open: meshes, projects and STEP (Bambu Studio / OrcaSlicer import STEP).
|
||||
const SLICEABLE = ['3mf', 'stl', 'obj', 'step', 'stp'];
|
||||
|
||||
// Keyed off the session secret but never equal to it, so a hand-off link can't
|
||||
// pass as a login and a login can't pass as a hand-off link.
|
||||
const keyFor = (secret) => crypto.createHmac('sha256', secret).update('handoff').digest('hex');
|
||||
|
||||
function createToken(secret, { kind, id }, ttl = TTL_SECONDS) {
|
||||
if (!KINDS.includes(kind)) throw new Error(`Unknown hand-off kind: ${kind}`);
|
||||
return jwt.sign({ k: kind, id: Number(id) }, keyFor(secret), { audience: AUDIENCE, expiresIn: ttl, algorithm: 'HS256' });
|
||||
}
|
||||
|
||||
// { kind, id }, or null for a link that's expired, altered or not a hand-off link.
|
||||
function readToken(secret, token) {
|
||||
try {
|
||||
const p = jwt.verify(String(token), keyFor(secret), { audience: AUDIENCE, algorithms: ['HS256'] });
|
||||
return KINDS.includes(p.k) && Number.isInteger(p.id) ? { kind: p.k, id: p.id } : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Slicers name the download after the link's last path segment and decode the
|
||||
// link in different ways, so that segment is kept to plain ASCII.
|
||||
function urlName(name) {
|
||||
const ext = path.extname(String(name)).toLowerCase().replace(/[^\w.]/g, '');
|
||||
const base = path.basename(String(name), path.extname(String(name)))
|
||||
.normalize('NFKD')
|
||||
.replace(/[̀-ͯ]/g, '') // accents split off by NFKD: "é" -> "e"
|
||||
.replace(/[^\w.-]+/g, '_')
|
||||
.replace(/^[_.-]+|[_.-]+$/g, '')
|
||||
.slice(0, 80);
|
||||
return `${base || 'model'}${ext}`;
|
||||
}
|
||||
|
||||
module.exports = { TTL_SECONDS, SLICEABLE, createToken, readToken, urlName };
|
||||
|
|
@ -52,7 +52,7 @@ async function fetchHtml(url) {
|
|||
}
|
||||
|
||||
// Extract Open Graph / title metadata from raw HTML.
|
||||
function parseMetadata(html, _url) { // _url: kept for the call shape, not read
|
||||
function parseMetadata(html, url) {
|
||||
const titleTag = (html.match(/<title[^>]*>([^<]*)<\/title>/i) || [])[1];
|
||||
return {
|
||||
title: metaContent(html, 'property', 'og:title') || (titleTag ? decodeEntities(titleTag).trim() : null),
|
||||
|
|
|
|||
|
|
@ -27,19 +27,6 @@ function parseInstances(html) {
|
|||
return result;
|
||||
}
|
||||
|
||||
// MakerWorld answers with an anti-bot message once an account has asked for a
|
||||
// couple of files in a row. It isn't Cloudflare — FlareSolverr reports no
|
||||
// challenge, and an unauthenticated call still gets a normal reply — so retrying
|
||||
// doesn't clear it; only asking for fewer files does.
|
||||
const CHALLENGE_RE = /not a robot|just a moment|captcha|attention required|cf[- ]challenge|verify (?:you|your|that)|verification (?:required|needed)|security check|select in this order/i;
|
||||
|
||||
const isChallengeText = (text) => CHALLENGE_RE.test(String(text || ''));
|
||||
|
||||
function isChallenge(answer) {
|
||||
if (!answer || answer.url) return false;
|
||||
return isChallengeText(answer.error || answer.message || answer.msg);
|
||||
}
|
||||
|
||||
// Resolve an instance to { name, url } (a signed, short-lived CDN download URL).
|
||||
async function resolveDownload(flaresolverrUrl, instanceId, token) {
|
||||
const api = `https://makerworld.com/api/v1/design-service/instance/${instanceId}/f3mf?type=download&fileType=&devModelName=N1`;
|
||||
|
|
@ -48,82 +35,10 @@ async function resolveDownload(flaresolverrUrl, instanceId, token) {
|
|||
});
|
||||
}
|
||||
|
||||
// Why a download-link answer came back without a link: { message, tokenRejected },
|
||||
// or null when it has one. Not logged in: {"code":1,"error":"Please log in to download models."}
|
||||
function downloadProblem(answer) {
|
||||
if (answer && answer.url) return null;
|
||||
const detail = String((answer && (answer.error || answer.message || answer.msg)) || '').trim();
|
||||
if (/log ?in|sign ?in|unauthori[sz]ed|token|expired/i.test(detail)) {
|
||||
return { message: 'MakerWorld didn’t accept your auth token (it has probably expired).', tokenRejected: true };
|
||||
}
|
||||
if (isChallenge(answer)) {
|
||||
return {
|
||||
message: 'MakerWorld’s anti-bot limit blocked the download — it only allows a couple in a row.',
|
||||
tokenRejected: false,
|
||||
challenged: true,
|
||||
};
|
||||
}
|
||||
return { message: `MakerWorld didn’t give a download link${detail ? `: ${detail}` : ''}`, tokenRejected: false };
|
||||
}
|
||||
|
||||
// The collection id in a MakerWorld collection link, or null.
|
||||
function collectionIdFromUrl(url) {
|
||||
const m = String(url || '').match(/makerworld\.com\/(?:[a-z-]{2,5}\/)?collections\/(\d+)/i);
|
||||
return m ? m[1] : null;
|
||||
}
|
||||
|
||||
// Where a collection page loads the rest of its models from (its own "load more"
|
||||
// call). A stable `seed` keeps the pages from overlapping.
|
||||
const collectionDesignsUrl = (collectionId, { offset = 0, limit = 64, seed } = {}) =>
|
||||
`https://makerworld.com/api/v1/design-service/favorites/${collectionId}/designs?offset=${offset}&limit=${limit}`
|
||||
+ (seed === undefined || seed === null ? '' : `&seed=${seed}`);
|
||||
|
||||
// That call's answer -> { designs, total, seed }.
|
||||
function parseCollectionDesigns(json) {
|
||||
const hits = (json && json.hits) || [];
|
||||
return {
|
||||
designs: hits
|
||||
.filter((d) => d && (typeof d.id === 'number' || typeof d.id === 'string'))
|
||||
.map((d) => ({ id: String(d.id), slug: d.slug || '', title: d.title || '' })),
|
||||
total: Number(json && json.total) || 0,
|
||||
seed: json ? json.seed : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
const designUrl = (design) => `https://makerworld.com/en/models/${design.id}${design.slug ? `-${design.slug}` : ''}`;
|
||||
|
||||
// A collection page -> { id, title, total, designs: [{ id, slug, title }] }, or null
|
||||
// when the page holds no collection (a private one read without a signed-in token).
|
||||
// `total` is how many models the collection holds; the page may list fewer.
|
||||
function parseCollection(html) {
|
||||
const m = String(html || '').match(/<script id="__NEXT_DATA__"[^>]*>([\s\S]*?)<\/script>/);
|
||||
if (!m) return null;
|
||||
let props;
|
||||
try { props = JSON.parse(m[1]).props.pageProps; } catch { return null; }
|
||||
// A collection you can't see (private, read without a signed-in token) comes back
|
||||
// as a placeholder: id -1, no title, no models.
|
||||
const id = Number(props && props.favorite && props.favorite.id);
|
||||
if (!Number.isFinite(id) || id <= 0) return null;
|
||||
const hits = (props.favoriteDesigns && props.favoriteDesigns.hits) || [];
|
||||
const designs = hits
|
||||
.filter((d) => d && (typeof d.id === 'number' || typeof d.id === 'string'))
|
||||
.map((d) => ({ id: String(d.id), slug: d.slug || '', title: d.title || '' }));
|
||||
const total = Number((props.favoriteDesigns && props.favoriteDesigns.total) ?? props.favorite.designCnt);
|
||||
return {
|
||||
id: String(id),
|
||||
title: props.favorite.title || '',
|
||||
total: Number.isFinite(total) ? total : designs.length,
|
||||
designs,
|
||||
};
|
||||
}
|
||||
|
||||
// Pull the license string out of the page data (e.g. "MakerWorld Exclusive License").
|
||||
function parseLicense(html) {
|
||||
const m = html.match(/"license"\s*:\s*"([^"]+)"/);
|
||||
return m ? m[1] : null;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
parseInstances, resolveDownload, downloadProblem, isChallenge, isChallengeText, parseLicense,
|
||||
parseCollection, parseCollectionDesigns, collectionIdFromUrl, collectionDesignsUrl, designUrl,
|
||||
};
|
||||
module.exports = { parseInstances, resolveDownload, parseLicense };
|
||||
|
|
|
|||
170
lib/printer.js
170
lib/printer.js
|
|
@ -1,170 +0,0 @@
|
|||
// A read-only connection to a Bambu Lab printer on your network.
|
||||
//
|
||||
// The printer runs an MQTT broker on port 8883 — TLS with a self-signed
|
||||
// certificate, username "bblp", and the access code from its screen as the
|
||||
// password. Printhive only subscribes to the printer's reports and asks once for
|
||||
// a full one ("pushall"); it never sends anything that changes what the printer
|
||||
// is doing.
|
||||
const mqtt = require('mqtt');
|
||||
|
||||
const PORT = 8883;
|
||||
|
||||
// An id the printer sends as all zeros means no RFID tag was read.
|
||||
const realId = (v) => (v && !/^0+$/.test(String(v)) ? String(v) : null);
|
||||
|
||||
// The product line within a material: "PLA Basic" with material "PLA" → "Basic".
|
||||
function productLine(brand, material) {
|
||||
const b = String(brand || '').trim();
|
||||
const m = String(material || '').trim();
|
||||
if (!b) return null;
|
||||
const line = m && b.toUpperCase().startsWith(`${m.toUpperCase()} `) ? b.slice(m.length) : b;
|
||||
return line.trim() || null;
|
||||
}
|
||||
|
||||
// One tray, as the printer describes it. `remain` is a percentage; the printer
|
||||
// sends -1 when it can't tell. An AMS lite only knows full or empty, so its
|
||||
// figure isn't a measurement — callers decide whether to trust it.
|
||||
function describeTray(tray, unit = null) {
|
||||
const loaded = Boolean(tray && tray.tray_type);
|
||||
return {
|
||||
unit,
|
||||
slot: tray && tray.id != null ? Number(tray.id) : null,
|
||||
empty: !loaded,
|
||||
material: loaded ? tray.tray_type : null,
|
||||
colour: loaded && tray.tray_color ? `#${String(tray.tray_color).slice(0, 6).toUpperCase()}` : null,
|
||||
profile: loaded ? tray.tray_info_idx || null : null, // Bambu's filament profile, e.g. GFA00
|
||||
brand: loaded ? tray.tray_sub_brands || null : null,
|
||||
remain: loaded && Number(tray.remain) >= 0 ? Number(tray.remain) : null,
|
||||
// A Bambu spool's RFID tag: tray_uuid names the spool, tag_uid the tag.
|
||||
uuid: loaded ? realId(tray.tray_uuid) : null,
|
||||
tagUid: loaded ? realId(tray.tag_uid) : null,
|
||||
idName: loaded ? tray.tray_id_name || null : null, // e.g. "A00-W1", on firmware that sends it
|
||||
weight: loaded && Number(tray.tray_weight) > 0 ? Number(tray.tray_weight) : null, // grams, when new
|
||||
type: loaded ? productLine(tray.tray_sub_brands, tray.tray_type) : null,
|
||||
};
|
||||
}
|
||||
|
||||
// Every tray in every AMS unit, in order.
|
||||
function describeAms(units) {
|
||||
const trays = [];
|
||||
for (const unit of Array.isArray(units) ? units : []) {
|
||||
for (const tray of unit.tray || []) trays.push(describeTray(tray, Number(unit.id)));
|
||||
}
|
||||
return trays;
|
||||
}
|
||||
|
||||
// Speed settings as the printer numbers them.
|
||||
const SPEEDS = { 1: 'Silent', 2: 'Standard', 3: 'Sport', 4: 'Ludicrous' };
|
||||
|
||||
const num = (v) => (v === undefined || v === null || v === '' || Number.isNaN(Number(v)) ? undefined : Number(v));
|
||||
|
||||
// The status fields in one report. Printers often send only what changed, so
|
||||
// anything a report leaves out is left out here too, for the caller to merge.
|
||||
function readStatus(p) {
|
||||
const out = {};
|
||||
const set = (key, value) => { if (value !== undefined) out[key] = value; };
|
||||
set('state', p.gcode_state || undefined);
|
||||
set('progress', num(p.mc_percent));
|
||||
set('remainingMin', num(p.mc_remaining_time));
|
||||
set('file', p.subtask_name || p.gcode_file || undefined);
|
||||
set('layer', num(p.layer_num));
|
||||
set('totalLayers', num(p.total_layer_num));
|
||||
set('nozzle', num(p.nozzle_temper));
|
||||
set('nozzleTarget', num(p.nozzle_target_temper));
|
||||
set('bed', num(p.bed_temper));
|
||||
set('bedTarget', num(p.bed_target_temper));
|
||||
set('chamber', num(p.chamber_temper));
|
||||
if (p.spd_lvl !== undefined) set('speed', SPEEDS[Number(p.spd_lvl)] || null);
|
||||
if (p.print_error !== undefined) {
|
||||
const code = Number(p.print_error) || 0;
|
||||
set('printError', code ? code.toString(16).toUpperCase().padStart(8, '0') : null);
|
||||
}
|
||||
if (Array.isArray(p.hms)) {
|
||||
// Health messages: a module/severity word and a code, shown as Bambu writes them.
|
||||
set('hms', p.hms.map((h) => {
|
||||
const hex = (n) => (Number(n) >>> 0).toString(16).toUpperCase().padStart(8, '0');
|
||||
const a = hex(h.attr);
|
||||
const c = hex(h.code);
|
||||
return `${a.slice(0, 4)}_${a.slice(4)}_${c.slice(0, 4)}_${c.slice(4)}`;
|
||||
}));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// The messages the MQTT library gives are terse; say what they mean.
|
||||
function friendlyError(err) {
|
||||
const m = String((err && err.message) || err || '');
|
||||
if (/not authori[sz]ed|bad user ?name or password/i.test(m)) return 'The printer refused the access code.';
|
||||
if (/ECONNREFUSED/.test(m)) return 'Nothing answered on port 8883 at that address.';
|
||||
if (/ETIMEDOUT|EHOSTUNREACH|ENETUNREACH|timed? ?out/i.test(m)) return 'Couldn’t reach the printer from the server.';
|
||||
return m.slice(0, 200) || 'The connection failed.';
|
||||
}
|
||||
|
||||
// Connects, asks for a full report, and gathers what arrives for a few seconds.
|
||||
// Resolves with what was seen — never with the access code.
|
||||
// With `debug`, also returns one loaded tray exactly as the printer sent it, to
|
||||
// see which fields a given printer and firmware actually provide.
|
||||
function probe({ host, serial, accessCode, waitMs = 8000, debug = false }) {
|
||||
return new Promise((resolve) => {
|
||||
const started = Date.now();
|
||||
const seen = { connected: false, reports: 0, printer: null, ams: null, external: null, error: null, raw: null };
|
||||
let done = false;
|
||||
let timer = null;
|
||||
const client = mqtt.connect(`mqtts://${host}:${PORT}`, {
|
||||
username: 'bblp',
|
||||
password: accessCode,
|
||||
rejectUnauthorized: false, // the printer's certificate is self-signed
|
||||
connectTimeout: 6000,
|
||||
reconnectPeriod: 0, // one attempt: this is a test, not a session
|
||||
clientId: `printhive-${Math.random().toString(16).slice(2, 10)}`,
|
||||
});
|
||||
const finish = () => {
|
||||
if (done) return;
|
||||
done = true;
|
||||
clearTimeout(timer);
|
||||
client.end(true);
|
||||
resolve({ ...seen, ms: Date.now() - started });
|
||||
};
|
||||
timer = setTimeout(finish, waitMs);
|
||||
|
||||
client.on('connect', () => {
|
||||
seen.connected = true;
|
||||
client.subscribe(`device/${serial}/report`, (err) => {
|
||||
if (err) { seen.error = `The printer wouldn’t let Printhive listen: ${err.message}`; return; }
|
||||
// The printer only sends everything when asked.
|
||||
client.publish(`device/${serial}/request`,
|
||||
JSON.stringify({ pushing: { sequence_id: '0', command: 'pushall' } }));
|
||||
});
|
||||
});
|
||||
client.on('message', (_topic, payload) => {
|
||||
seen.reports += 1;
|
||||
let msg;
|
||||
try { msg = JSON.parse(payload.toString()); } catch { return; }
|
||||
const p = msg && msg.print;
|
||||
if (!p) return;
|
||||
if (p.gcode_state) seen.printer = { ...(seen.printer || {}), state: p.gcode_state };
|
||||
if (p.mc_percent != null) seen.printer = { ...(seen.printer || {}), progress: Number(p.mc_percent) };
|
||||
if (p.ams && Array.isArray(p.ams.ams)) {
|
||||
seen.ams = describeAms(p.ams.ams);
|
||||
if (debug) {
|
||||
const unit = p.ams.ams[0] || {};
|
||||
seen.raw = {
|
||||
amsKeys: Object.keys(p.ams),
|
||||
unitKeys: Object.keys(unit),
|
||||
unitInfo: { info: unit.info, humidity: unit.humidity, temp: unit.temp },
|
||||
trayNow: p.ams.tray_now, trayTar: p.ams.tray_tar,
|
||||
tray: (unit.tray || []).find((t) => t && t.tray_type) || null,
|
||||
};
|
||||
}
|
||||
}
|
||||
if (p.vt_tray) seen.external = describeTray(p.vt_tray, null);
|
||||
});
|
||||
client.on('error', (err) => {
|
||||
seen.error = friendlyError(err);
|
||||
finish();
|
||||
});
|
||||
client.on('close', () => { if (!seen.connected) finish(); });
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = { probe, describeAms, describeTray, friendlyError, productLine, readStatus, PORT };
|
||||
|
|
@ -1,159 +0,0 @@
|
|||
// The chamber camera on a P1 or A1 series printer.
|
||||
//
|
||||
// The printer serves still frames on port 6000 over TLS: an 80-byte hello with
|
||||
// the same "bblp" username and access code as the status connection, then a
|
||||
// stream of JPEGs, each behind a 16-byte header giving its size. Roughly one
|
||||
// frame a second — stills, not video. Read-only, like everything else here.
|
||||
//
|
||||
// X1 printers don't do this; they serve RTSP on port 322, which a browser
|
||||
// can't show without transcoding.
|
||||
const tls = require('tls');
|
||||
|
||||
const PORT = 6000;
|
||||
const HEADER = 16;
|
||||
const MAX_FRAME = 12 * 1024 * 1024; // a frame is well under this; a bigger one means we've lost our place
|
||||
|
||||
function hello(accessCode) {
|
||||
const buf = Buffer.alloc(80);
|
||||
buf.writeUInt32LE(0x40, 0); // payload length
|
||||
buf.writeUInt32LE(0x3000, 4); // protocol marker
|
||||
buf.write('bblp', 16, 32, 'ascii');
|
||||
buf.write(String(accessCode).slice(0, 31), 48, 32, 'ascii');
|
||||
return buf;
|
||||
}
|
||||
|
||||
// The picture's size, from the JPEG's own frame header — a cheap sanity check
|
||||
// that what arrived really is an image.
|
||||
function jpegSize(buf) {
|
||||
let i = 2;
|
||||
while (i + 9 <= buf.length) {
|
||||
if (buf[i] !== 0xff) { i += 1; continue; }
|
||||
const marker = buf[i + 1];
|
||||
// SOF0…SOF3, SOF5…SOF7, SOF9…SOF11, SOF13…SOF15 carry the dimensions.
|
||||
if (marker >= 0xc0 && marker <= 0xcf && ![0xc4, 0xc8, 0xcc].includes(marker)) {
|
||||
return { height: buf.readUInt16BE(i + 5), width: buf.readUInt16BE(i + 7) };
|
||||
}
|
||||
i += 2 + buf.readUInt16BE(i + 2);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Connects and calls onFrame for each JPEG until stop() is called.
|
||||
// onError is called once, with a plain-language reason.
|
||||
function watch({ host, accessCode, onFrame, onError = () => {}, connectTimeoutMs = 8000 }) {
|
||||
let closed = false;
|
||||
let pending = Buffer.alloc(0);
|
||||
let expecting = null; // size of the frame being gathered
|
||||
|
||||
const socket = tls.connect({
|
||||
host,
|
||||
port: PORT,
|
||||
rejectUnauthorized: false, // the printer's certificate is self-signed
|
||||
timeout: connectTimeoutMs,
|
||||
});
|
||||
|
||||
const fail = (message) => {
|
||||
if (closed) return;
|
||||
closed = true;
|
||||
socket.destroy();
|
||||
onError(message);
|
||||
};
|
||||
|
||||
socket.on('secureConnect', () => {
|
||||
socket.setTimeout(0);
|
||||
socket.write(hello(accessCode));
|
||||
});
|
||||
socket.on('timeout', () => fail('The printer didn’t answer on its camera port.'));
|
||||
socket.on('error', (err) => fail(
|
||||
/ECONNREFUSED/.test(err.message)
|
||||
? 'The printer refused the camera connection — “LAN Mode Liveview” may be off.'
|
||||
: err.message));
|
||||
socket.on('close', () => fail('The printer closed the camera connection — the access code may be wrong, or “LAN Mode Liveview” is off.'));
|
||||
|
||||
socket.on('data', (chunk) => {
|
||||
pending = pending.length ? Buffer.concat([pending, chunk]) : chunk;
|
||||
for (;;) {
|
||||
if (expecting === null) {
|
||||
if (pending.length < HEADER) return;
|
||||
expecting = pending.readUInt32LE(0);
|
||||
pending = pending.subarray(HEADER);
|
||||
if (!expecting || expecting > MAX_FRAME) { fail('The printer sent something Printhive couldn’t read as a picture.'); return; }
|
||||
}
|
||||
if (pending.length < expecting) return;
|
||||
const frame = pending.subarray(0, expecting);
|
||||
pending = pending.subarray(expecting);
|
||||
expecting = null;
|
||||
// A frame always starts with the JPEG marker; anything else means trouble.
|
||||
if (frame[0] !== 0xff || frame[1] !== 0xd8) { fail('The printer sent something Printhive couldn’t read as a picture.'); return; }
|
||||
onFrame(frame);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
if (closed) return;
|
||||
closed = true;
|
||||
socket.destroy();
|
||||
};
|
||||
}
|
||||
|
||||
// One connection per printer, however many people are watching, closed as soon
|
||||
// as the last of them looks away. Frames aren't stored beyond the latest one,
|
||||
// which new viewers get straight away instead of waiting for the next.
|
||||
const feeds = new Map();
|
||||
|
||||
function subscribe(id, { host, accessCode }, onFrame, onError = () => {}) {
|
||||
let feed = feeds.get(id);
|
||||
if (!feed) {
|
||||
feed = { viewers: new Set(), latest: null, stop: () => {} };
|
||||
feeds.set(id, feed);
|
||||
feed.stop = watch({
|
||||
host,
|
||||
accessCode,
|
||||
onFrame: (frame) => {
|
||||
feed.latest = frame;
|
||||
for (const v of feed.viewers) v.onFrame(frame);
|
||||
},
|
||||
onError: (error) => {
|
||||
feeds.delete(id);
|
||||
for (const v of feed.viewers) v.onError(error);
|
||||
feed.viewers.clear();
|
||||
},
|
||||
});
|
||||
}
|
||||
const viewer = { onFrame, onError };
|
||||
feed.viewers.add(viewer);
|
||||
if (feed.latest) onFrame(feed.latest);
|
||||
return () => {
|
||||
feed.viewers.delete(viewer);
|
||||
if (!feed.viewers.size) {
|
||||
feed.stop();
|
||||
feeds.delete(id);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// Connects, waits for one frame, and says what came back. Used to check a
|
||||
// printer's camera without opening a viewer.
|
||||
function testCamera({ host, accessCode, waitMs = 10000 }) {
|
||||
return new Promise((resolve) => {
|
||||
const started = Date.now();
|
||||
let stop = () => {};
|
||||
let done = false;
|
||||
const finish = (result) => {
|
||||
if (done) return;
|
||||
done = true;
|
||||
clearTimeout(timer);
|
||||
stop();
|
||||
resolve({ ...result, ms: Date.now() - started });
|
||||
};
|
||||
const timer = setTimeout(() => finish({ ok: false, error: 'No picture arrived within ten seconds.' }), waitMs);
|
||||
stop = watch({
|
||||
host,
|
||||
accessCode,
|
||||
onFrame: (frame) => finish({ ok: true, bytes: frame.length, ...(jpegSize(frame) || {}) }),
|
||||
onError: (error) => finish({ ok: false, error }),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = { watch, subscribe, testCamera, jpegSize, hello, PORT };
|
||||
|
|
@ -1,235 +0,0 @@
|
|||
// Live, read-only connections to your printers.
|
||||
//
|
||||
// One connection per printer stays open, so status arrives as the printer sends
|
||||
// it (every second or two while printing) instead of on a timer. The printer is
|
||||
// only ever asked for a full report — never sent anything that changes what it's
|
||||
// doing. Access codes are held in memory for the connection and never leave
|
||||
// this module.
|
||||
const mqtt = require('mqtt');
|
||||
const { describeAms, describeTray, friendlyError, readStatus, PORT } = require('./printer');
|
||||
|
||||
const RECONNECT_MS = 15 * 1000;
|
||||
const FULL_REPORT_MS = 10 * 60 * 1000; // a fresh full report now and then, in case a change was missed
|
||||
const TRAYS_SETTLE_MS = 5 * 1000; // let a spool swap finish reporting before syncing it
|
||||
|
||||
const connections = new Map(); // printer id → connection
|
||||
let onTrays = async () => null;
|
||||
let connect = (url, options) => mqtt.connect(url, options); // replaceable in tests
|
||||
|
||||
// What's loaded, reduced to what decides whether spools need syncing.
|
||||
const traysKey = (trays) => JSON.stringify((trays || [])
|
||||
.map((t) => [t.unit, t.slot, t.empty, t.material, t.colour, t.uuid]));
|
||||
|
||||
function allTrays(c) {
|
||||
return [...(c.ams || []), ...(c.external ? [c.external] : [])];
|
||||
}
|
||||
|
||||
// Which tray is feeding the toolhead. The printer sends `tray_now` as a number:
|
||||
// 255 for none, 254 for the external spool, otherwise unit × 4 + slot — where
|
||||
// "unit" is the AMS's id on most printers, or its position among the units.
|
||||
function activeTray(c) {
|
||||
const n = c.trayNow;
|
||||
if (n == null || Number.isNaN(n) || n === 255) return null;
|
||||
if (n === 254) return { external: true };
|
||||
const ids = c.unitIds || [];
|
||||
const byId = Math.floor(n / 4);
|
||||
const unit = ids.includes(byId) ? byId : ids[byId];
|
||||
return unit === undefined ? null : { unit, slot: n % 4 };
|
||||
}
|
||||
|
||||
function requestFullReport(c) {
|
||||
if (!c.client.connected) return;
|
||||
c.client.publish(`device/${c.serial}/request`,
|
||||
JSON.stringify({ pushing: { sequence_id: String(Date.now()), command: 'pushall' } }));
|
||||
}
|
||||
|
||||
// Syncs spools with what's loaded now, and hands back what that did.
|
||||
async function flushTrays(c) {
|
||||
clearTimeout(c.traysTimer);
|
||||
c.traysTimer = null;
|
||||
c.pendingKey = null;
|
||||
const trays = allTrays(c);
|
||||
if (!c.ams && !c.external) return null;
|
||||
c.syncedKey = traysKey(trays);
|
||||
try {
|
||||
c.lastSync = await onTrays(c.id, trays);
|
||||
return c.lastSync;
|
||||
} catch (err) {
|
||||
console.warn(`Printer ${c.id} spool sync failed:`, err.message);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function handleMessage(c, payload) {
|
||||
let msg;
|
||||
try { msg = JSON.parse(payload.toString()); } catch { return; }
|
||||
const p = msg && msg.print;
|
||||
if (!p) return;
|
||||
c.reports += 1;
|
||||
c.updatedAt = new Date();
|
||||
Object.assign(c.status, readStatus(p));
|
||||
|
||||
// AMS details come whole when they change; a report without them changes nothing.
|
||||
const units = p.ams && p.ams.ams;
|
||||
if (Array.isArray(units) && units.every((u) => Array.isArray(u.tray))) {
|
||||
c.ams = describeAms(units);
|
||||
c.unitIds = units.map((u) => Number(u.id));
|
||||
}
|
||||
if (p.ams && p.ams.tray_now !== undefined) c.trayNow = Number(p.ams.tray_now);
|
||||
if (p.vt_tray) c.external = describeTray(p.vt_tray, null);
|
||||
|
||||
// A printer mid-print reports every second or so, so only a change in what's
|
||||
// loaded restarts the wait — otherwise the sync would never come round.
|
||||
const key = (c.ams || c.external) ? traysKey(allTrays(c)) : null;
|
||||
if (key && key !== c.syncedKey && key !== c.pendingKey) {
|
||||
c.pendingKey = key;
|
||||
clearTimeout(c.traysTimer);
|
||||
c.traysTimer = setTimeout(() => flushTrays(c), TRAYS_SETTLE_MS);
|
||||
}
|
||||
// Someone waiting for a full report is answered by one with the trays in it.
|
||||
if (Array.isArray(units) || p.vt_tray) for (const resolve of c.waiters.splice(0)) resolve();
|
||||
}
|
||||
|
||||
function open(p) {
|
||||
const c = {
|
||||
id: p.id,
|
||||
serial: p.serial,
|
||||
key: [p.host, p.serial, p.access_code].join('\n'),
|
||||
connected: false,
|
||||
error: null,
|
||||
reports: 0,
|
||||
updatedAt: null,
|
||||
connectedAt: null,
|
||||
status: {},
|
||||
ams: null,
|
||||
external: null,
|
||||
syncedKey: null,
|
||||
pendingKey: null,
|
||||
trayNow: null,
|
||||
unitIds: [],
|
||||
lastSync: null,
|
||||
traysTimer: null,
|
||||
waiters: [],
|
||||
};
|
||||
c.client = connect(`mqtts://${p.host}:${PORT}`, {
|
||||
username: 'bblp',
|
||||
password: p.access_code,
|
||||
rejectUnauthorized: false, // the printer's certificate is self-signed
|
||||
connectTimeout: 10 * 1000,
|
||||
reconnectPeriod: RECONNECT_MS,
|
||||
keepalive: 30,
|
||||
clientId: `printhive-${p.id}-${Math.random().toString(16).slice(2, 8)}`,
|
||||
});
|
||||
c.client.on('connect', () => {
|
||||
c.connected = true;
|
||||
c.error = null;
|
||||
c.connectedAt = new Date();
|
||||
c.client.subscribe(`device/${c.serial}/report`, (err) => {
|
||||
if (err) { c.error = `The printer wouldn’t let Printhive listen: ${err.message}`; return; }
|
||||
requestFullReport(c);
|
||||
});
|
||||
});
|
||||
c.client.on('message', (_topic, payload) => handleMessage(c, payload));
|
||||
c.client.on('error', (err) => { c.error = friendlyError(err); });
|
||||
c.client.on('close', () => { c.connected = false; });
|
||||
c.client.on('offline', () => { c.connected = false; });
|
||||
c.fullReportTimer = setInterval(() => requestFullReport(c), FULL_REPORT_MS);
|
||||
c.fullReportTimer.unref();
|
||||
return c;
|
||||
}
|
||||
|
||||
function close(c) {
|
||||
clearInterval(c.fullReportTimer);
|
||||
clearTimeout(c.traysTimer);
|
||||
for (const resolve of c.waiters.splice(0)) resolve();
|
||||
c.client.end(true);
|
||||
}
|
||||
|
||||
// Brings the connections in line with the printers you have: new ones opened,
|
||||
// removed ones closed, and any whose address, serial or code changed reopened.
|
||||
function reconcile(printerRows) {
|
||||
const wanted = new Map();
|
||||
for (const p of printerRows) {
|
||||
if (p.host && p.serial && p.access_code) wanted.set(p.id, p);
|
||||
}
|
||||
for (const [id, c] of connections) {
|
||||
const p = wanted.get(id);
|
||||
if (!p || [p.host, p.serial, p.access_code].join('\n') !== c.key) {
|
||||
close(c);
|
||||
connections.delete(id);
|
||||
}
|
||||
}
|
||||
for (const [id, p] of wanted) {
|
||||
if (!connections.has(id)) connections.set(id, open(p));
|
||||
}
|
||||
}
|
||||
|
||||
// What one printer is doing, as last reported. Nothing here identifies a spool
|
||||
// or carries a credential.
|
||||
function snapshot(id) {
|
||||
const c = connections.get(id);
|
||||
if (!c) return { live: false };
|
||||
const loaded = allTrays(c).map((t) => ({
|
||||
unit: t.unit, slot: t.slot, empty: t.empty, material: t.material, colour: t.colour, tagged: Boolean(t.uuid),
|
||||
}));
|
||||
return {
|
||||
live: true,
|
||||
connected: c.connected,
|
||||
error: c.connected ? null : c.error,
|
||||
updatedAt: c.updatedAt,
|
||||
status: { ...c.status, active: activeTray(c) },
|
||||
loaded: c.ams || c.external ? loaded : null,
|
||||
};
|
||||
}
|
||||
|
||||
function snapshots() {
|
||||
const out = {};
|
||||
for (const id of connections.keys()) out[id] = snapshot(id);
|
||||
return out;
|
||||
}
|
||||
|
||||
// Asks for a full report now, waits for it, and syncs spools from it — what
|
||||
// "Test & sync" does. Uses the open connection rather than making another.
|
||||
async function refresh(id, waitMs = 8000) {
|
||||
const c = connections.get(id);
|
||||
if (!c) return null;
|
||||
const started = Date.now();
|
||||
const before = c.reports;
|
||||
if (!c.connected && !c.client.reconnecting) c.client.reconnect();
|
||||
await new Promise((resolve) => {
|
||||
const timer = setTimeout(resolve, waitMs);
|
||||
const ask = () => {
|
||||
if (c.connected) { requestFullReport(c); return; }
|
||||
if (Date.now() - started < waitMs) setTimeout(ask, 250);
|
||||
};
|
||||
c.waiters.push(() => { clearTimeout(timer); setTimeout(resolve, 1000); }); // let the rest of the report arrive
|
||||
ask();
|
||||
});
|
||||
const sync = c.reports > before ? await flushTrays(c) : null;
|
||||
return {
|
||||
connected: c.connected,
|
||||
reports: c.reports - before,
|
||||
printer: { state: c.status.state, progress: c.status.progress },
|
||||
ams: c.ams,
|
||||
external: c.external,
|
||||
error: c.connected ? null : (c.error || 'Couldn’t reach the printer.'),
|
||||
sync,
|
||||
ms: Date.now() - started,
|
||||
};
|
||||
}
|
||||
|
||||
function setTraysHandler(fn) {
|
||||
onTrays = fn;
|
||||
}
|
||||
|
||||
function stopAll() {
|
||||
for (const c of connections.values()) close(c);
|
||||
connections.clear();
|
||||
}
|
||||
|
||||
// For tests: connect with a stand-in instead of the network.
|
||||
function setConnector(fn) {
|
||||
connect = fn;
|
||||
}
|
||||
|
||||
module.exports = { reconcile, snapshot, snapshots, refresh, setTraysHandler, stopAll, setConnector };
|
||||
105
lib/printers.js
105
lib/printers.js
|
|
@ -1,105 +0,0 @@
|
|||
// The printers you've added.
|
||||
//
|
||||
// Access codes are stored here but never handed back to the browser: every
|
||||
// read returns `codeSet` instead, and saving with a blank code keeps the one
|
||||
// already stored.
|
||||
const db = require('./db');
|
||||
|
||||
// Which AMS types can say how much filament is left. An AMS lite only knows
|
||||
// full or empty, so its reading is never used as a weight.
|
||||
const AMS_TYPES = { none: false, ams_lite: false, ams: true, ams_2_pro: true, ams_ht: true };
|
||||
|
||||
const clean = (value, max) => {
|
||||
const s = String(value ?? '').trim();
|
||||
return s ? s.slice(0, max) : null;
|
||||
};
|
||||
|
||||
function publicPrinter(row) {
|
||||
if (!row) return null;
|
||||
const { access_code: accessCode, ...rest } = row;
|
||||
return { ...rest, codeSet: Boolean(accessCode) };
|
||||
}
|
||||
|
||||
function fields(data) {
|
||||
return {
|
||||
nickname: clean(data.nickname, 60),
|
||||
model: clean(data.model, 40),
|
||||
host: clean(data.host, 255),
|
||||
serial: clean(data.serial, 40),
|
||||
ams_type: Object.prototype.hasOwnProperty.call(AMS_TYPES, data.ams_type) ? data.ams_type : null,
|
||||
};
|
||||
}
|
||||
|
||||
async function list() {
|
||||
const { rows } = await db.query('SELECT * FROM printers ORDER BY id');
|
||||
return rows.map(publicPrinter);
|
||||
}
|
||||
|
||||
// Server-side only: includes the access code, for connecting.
|
||||
async function listWithCodes() {
|
||||
const { rows } = await db.query('SELECT * FROM printers ORDER BY id');
|
||||
return rows;
|
||||
}
|
||||
|
||||
async function get(id, { withCode = false } = {}) {
|
||||
const { rows: [row] } = await db.query('SELECT * FROM printers WHERE id = $1', [id]);
|
||||
if (!row) return null;
|
||||
return withCode ? row : publicPrinter(row);
|
||||
}
|
||||
|
||||
async function create(data) {
|
||||
const f = fields(data);
|
||||
const { rows: [row] } = await db.query(
|
||||
`INSERT INTO printers (nickname, model, host, serial, access_code, ams_type)
|
||||
VALUES ($1, $2, $3, $4, $5, $6) RETURNING *`,
|
||||
[f.nickname, f.model, f.host, f.serial, clean(data.access_code, 64), f.ams_type]);
|
||||
return publicPrinter(row);
|
||||
}
|
||||
|
||||
// Only the fields sent are changed; a blank access code keeps the stored one.
|
||||
async function update(id, data) {
|
||||
const f = fields(data);
|
||||
const has = (key) => Object.prototype.hasOwnProperty.call(data, key);
|
||||
const { rows: [row] } = await db.query(
|
||||
`UPDATE printers SET
|
||||
nickname = CASE WHEN $2::boolean THEN $3 ELSE nickname END,
|
||||
model = CASE WHEN $4::boolean THEN $5 ELSE model END,
|
||||
host = CASE WHEN $6::boolean THEN $7 ELSE host END,
|
||||
serial = CASE WHEN $8::boolean THEN $9 ELSE serial END,
|
||||
ams_type = CASE WHEN $10::boolean THEN $11 ELSE ams_type END,
|
||||
access_code = COALESCE($12, access_code),
|
||||
date_modified = now()
|
||||
WHERE id = $1 RETURNING *`,
|
||||
[id, has('nickname'), f.nickname, has('model'), f.model, has('host'), f.host,
|
||||
has('serial'), f.serial, has('ams_type'), f.ams_type, clean(data.access_code, 64)]);
|
||||
return publicPrinter(row);
|
||||
}
|
||||
|
||||
// Removing a printer also marks its spools as no longer loaded anywhere.
|
||||
async function remove(id) {
|
||||
const { rows: [row] } = await db.query('DELETE FROM printers WHERE id = $1 RETURNING serial', [id]);
|
||||
if (!row) return false;
|
||||
if (row.serial) {
|
||||
await db.query('UPDATE filament_spools SET ams_slot = NULL WHERE printer_serial = $1', [row.serial]);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// Keeps what a printer reported loaded — enough to draw its card, nothing that
|
||||
// identifies a spool.
|
||||
async function recordLoaded(id, trays) {
|
||||
const loaded = (trays || []).map((t) => ({
|
||||
unit: t.unit ?? null,
|
||||
slot: t.slot ?? null,
|
||||
empty: Boolean(t.empty),
|
||||
material: t.material || null,
|
||||
colour: t.colour || null,
|
||||
tagged: Boolean(t.uuid),
|
||||
}));
|
||||
await db.query('UPDATE printers SET loaded = $2::jsonb, loaded_at = now() WHERE id = $1',
|
||||
[id, JSON.stringify(loaded)]);
|
||||
}
|
||||
|
||||
const measures = (amsType) => Boolean(AMS_TYPES[amsType]);
|
||||
|
||||
module.exports = { AMS_TYPES, list, listWithCodes, get, create, update, remove, recordLoaded, measures, publicPrinter };
|
||||
138
lib/spoolSync.js
138
lib/spoolSync.js
|
|
@ -1,138 +0,0 @@
|
|||
// Keeps the Filament page in step with what a printer has loaded.
|
||||
//
|
||||
// planSync() works out, without touching the database, what a printer's report
|
||||
// means for your spools; applySync() carries that out. Only spools with an RFID
|
||||
// tag are handled automatically: an untagged spool looks identical to any other
|
||||
// of its colour, so there's no way to tell it from one you already have.
|
||||
const db = require('./db');
|
||||
const { colourDistance, normalise, paletteMatch } = require('./filaments');
|
||||
|
||||
// How close a hand-added spool's colour must be for it to adopt a printer's tag.
|
||||
const ADOPT_TOLERANCE = 12;
|
||||
|
||||
// Where a tray is, as stored on a spool: "<printer serial>/<unit>/<slot>", with
|
||||
// "ext" as the unit for the external spool holder.
|
||||
const slotKey = (printerSerial, tray) =>
|
||||
`${printerSerial}/${tray.unit == null ? 'ext' : tray.unit}/${tray.slot == null ? 0 : tray.slot}`;
|
||||
|
||||
function planSync(existing, trays, printerSerial, palette, { measures = false } = {}) {
|
||||
const actions = [];
|
||||
// Grams left, only when this printer's AMS actually measures it. An AMS lite
|
||||
// reports full or empty, which is not a weight.
|
||||
const gramsLeft = (tray) => (measures && tray.remain != null && tray.weight
|
||||
? Math.round((tray.weight * tray.remain) / 100)
|
||||
: null);
|
||||
const untracked = [];
|
||||
const byUuid = new Map(existing.filter((s) => s.rfid_uuid).map((s) => [s.rfid_uuid, s]));
|
||||
const adopted = new Set(); // hand-added spools already matched in this report
|
||||
const seen = new Set(); // existing spools this report accounts for
|
||||
|
||||
for (const tray of trays || []) {
|
||||
if (!tray || tray.empty) continue;
|
||||
const slot = slotKey(printerSerial, tray);
|
||||
if (!tray.uuid) {
|
||||
untracked.push({ slot, material: tray.material, colour: tray.colour });
|
||||
continue;
|
||||
}
|
||||
|
||||
const known = byUuid.get(tray.uuid);
|
||||
if (known) {
|
||||
seen.add(known.id);
|
||||
actions.push({ op: 'update', id: known.id, slot, printerSerial, remaining: gramsLeft(tray) });
|
||||
continue;
|
||||
}
|
||||
|
||||
const named = paletteMatch(palette, tray.material, tray.type, tray.colour);
|
||||
|
||||
// A spool you added by hand before the printer reported it: take it over
|
||||
// rather than adding a second copy.
|
||||
const adopt = existing.find((s) => {
|
||||
if (s.rfid_uuid || adopted.has(s.id)) return false;
|
||||
if (normalise(s.material) !== normalise(tray.material)) return false;
|
||||
const d = colourDistance(s.colour_hex, tray.colour);
|
||||
return d !== null && d <= ADOPT_TOLERANCE;
|
||||
});
|
||||
if (adopt) {
|
||||
adopted.add(adopt.id);
|
||||
seen.add(adopt.id);
|
||||
actions.push({
|
||||
op: 'adopt', id: adopt.id, slot, printerSerial,
|
||||
uuid: tray.uuid, tagUid: tray.tagUid, productCode: tray.idName,
|
||||
code: named ? named.code : null, totalG: tray.weight, remaining: gramsLeft(tray),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
actions.push({
|
||||
op: 'create', slot, printerSerial,
|
||||
spool: {
|
||||
material: tray.material,
|
||||
colour: named ? named.name : (tray.idName || tray.colour || tray.material),
|
||||
colour_hex: tray.colour,
|
||||
brand: 'Bambu Lab',
|
||||
code: named ? named.code : null,
|
||||
total_g: tray.weight,
|
||||
remaining_g: gramsLeft(tray), // blank unless this printer's AMS measures it
|
||||
rfid_uuid: tray.uuid,
|
||||
tag_uid: tray.tagUid,
|
||||
product_code: tray.idName,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Anything this printer used to hold that it no longer reports has come out.
|
||||
for (const s of existing) {
|
||||
if (s.printer_serial === printerSerial && s.ams_slot && !seen.has(s.id)) {
|
||||
actions.push({ op: 'unload', id: s.id });
|
||||
}
|
||||
}
|
||||
return { actions, untracked };
|
||||
}
|
||||
|
||||
async function applySync({ actions }) {
|
||||
const summary = { created: 0, adopted: 0, updated: 0, unloaded: 0 };
|
||||
for (const a of actions) {
|
||||
if (a.op === 'create') {
|
||||
const s = a.spool;
|
||||
const { rowCount } = await db.query(
|
||||
`INSERT INTO filament_spools (material, colour, colour_hex, brand, code, total_g, remaining_g,
|
||||
rfid_uuid, tag_uid, product_code, ams_slot, printer_serial, last_seen_at)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12, now())
|
||||
ON CONFLICT (rfid_uuid) WHERE rfid_uuid IS NOT NULL DO NOTHING`,
|
||||
[s.material, s.colour, s.colour_hex, s.brand, s.code, s.total_g, s.remaining_g,
|
||||
s.rfid_uuid, s.tag_uid, s.product_code, a.slot, a.printerSerial]);
|
||||
summary.created += rowCount;
|
||||
} else if (a.op === 'adopt') {
|
||||
const { rowCount } = await db.query(
|
||||
`UPDATE filament_spools SET rfid_uuid = $2, tag_uid = $3, product_code = $4,
|
||||
code = COALESCE(code, $5), total_g = COALESCE(total_g, $6),
|
||||
ams_slot = $7, printer_serial = $8, remaining_g = COALESCE($9, remaining_g),
|
||||
last_seen_at = now(), date_modified = now()
|
||||
WHERE id = $1 AND rfid_uuid IS NULL`,
|
||||
[a.id, a.uuid, a.tagUid, a.productCode, a.code, a.totalG, a.slot, a.printerSerial, a.remaining ?? null]);
|
||||
summary.adopted += rowCount;
|
||||
} else if (a.op === 'update') {
|
||||
// A measured weight replaces the stored one; no reading leaves it alone.
|
||||
await db.query(
|
||||
`UPDATE filament_spools SET ams_slot = $2, printer_serial = $3, last_seen_at = now(),
|
||||
remaining_g = COALESCE($4, remaining_g)
|
||||
WHERE id = $1`,
|
||||
[a.id, a.slot, a.printerSerial, a.remaining ?? null]);
|
||||
summary.updated += 1;
|
||||
} else if (a.op === 'unload') {
|
||||
await db.query('UPDATE filament_spools SET ams_slot = NULL WHERE id = $1', [a.id]);
|
||||
summary.unloaded += 1;
|
||||
}
|
||||
}
|
||||
return summary;
|
||||
}
|
||||
|
||||
// Reads the shelf, plans against one printer's trays, and applies it.
|
||||
async function syncPrinter(printerSerial, trays, palette, options = {}) {
|
||||
const { rows: existing } = await db.query('SELECT * FROM filament_spools');
|
||||
const plan = planSync(existing, trays, printerSerial, palette, options);
|
||||
const summary = await applySync(plan);
|
||||
return { ...summary, untracked: plan.untracked };
|
||||
}
|
||||
|
||||
module.exports = { planSync, syncPrinter, slotKey };
|
||||
262
lib/store.js
262
lib/store.js
|
|
@ -5,16 +5,15 @@ const { MODEL_FORMATS, ARCHIVE_FORMATS } = require('./fileProcessor');
|
|||
const MODEL_SELECT = `
|
||||
SELECT m.id, m.name, m.description, m.source_site, m.source_url, m.source_id,
|
||||
m.license, m.commercial_use, m.thumbnail_path, m.print_settings, m.date_added, m.date_modified,
|
||||
m.notes, m.deleted_at, m.ai_tagged_at, m.original_name, m.filaments, m.filaments_status,
|
||||
m.notes, m.deleted_at,
|
||||
d.id AS designer_id, d.name AS designer_name, d.profile_url AS designer_url,
|
||||
COALESCE(t.tags, '[]'::json) AS tags,
|
||||
COALESCE(t.tag_sources, '{}'::json) AS tag_sources,
|
||||
COALESCE(p.projects, '[]'::json) AS projects,
|
||||
COALESCE(f.files, '[]'::json) AS files
|
||||
FROM models m
|
||||
LEFT JOIN designers d ON d.id = m.designer_id
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT json_agg(tg.name ORDER BY tg.name) AS tags, json_object_agg(tg.name, mt.source) AS tag_sources
|
||||
SELECT json_agg(tg.name ORDER BY tg.name) AS tags
|
||||
FROM model_tags mt JOIN tags tg ON tg.id = mt.tag_id
|
||||
WHERE mt.model_id = m.id
|
||||
) t ON true
|
||||
|
|
@ -103,11 +102,6 @@ async function updateModel(id, updates) {
|
|||
for (const col of ['name', 'description', 'notes', 'license', 'commercial_use', 'thumbnail_path']) {
|
||||
if (updates[col] !== undefined) { fields.push(`${col} = $${i++}`); values.push(updates[col]); }
|
||||
}
|
||||
// A name you give it yourself replaces an AI rename, so there's nothing left to undo.
|
||||
if (updates.name !== undefined) {
|
||||
fields.push(`original_name = CASE WHEN name IS DISTINCT FROM $${i++} THEN NULL ELSE original_name END`);
|
||||
values.push(updates.name);
|
||||
}
|
||||
if (updates.print_settings !== undefined) {
|
||||
fields.push(`print_settings = $${i++}`); values.push(updates.print_settings);
|
||||
}
|
||||
|
|
@ -301,184 +295,20 @@ async function insertFile(client, modelId, file) {
|
|||
}
|
||||
|
||||
// ---------------------------------------------------------------- tags
|
||||
// Always tag in the same order. Two transactions touching the same tags in
|
||||
// opposite orders each hold a row the other wants, which Postgres resolves by
|
||||
// killing one of them ("deadlock detected").
|
||||
const tagOrder = (names) => [...new Set((names || []).map((n) => String(n).trim()).filter(Boolean))].sort();
|
||||
|
||||
// The tag's id, creating it if it's new. Deliberately not ON CONFLICT DO UPDATE:
|
||||
// that rewrites the row and so write-locks it for the rest of the transaction,
|
||||
// even though the name never changes — which is what made tagging deadlock.
|
||||
async function tagId(client, name) {
|
||||
for (let attempt = 0; attempt < 3; attempt++) {
|
||||
const { rows: found } = await client.query('SELECT id FROM tags WHERE name = $1', [name]);
|
||||
if (found.length) return found[0].id;
|
||||
const { rows } = await client.query(
|
||||
'INSERT INTO tags (name) VALUES ($1) ON CONFLICT (name) DO NOTHING RETURNING id', [name]);
|
||||
if (rows.length) return rows[0].id;
|
||||
// Someone else is inserting this very tag and hasn't committed yet.
|
||||
await new Promise((r) => setTimeout(r, 20 * (attempt + 1)));
|
||||
}
|
||||
throw new Error(`Could not get an id for the tag “${name}”`);
|
||||
}
|
||||
|
||||
// Sets a model's tags from an edit. A tag that stays keeps where it came from
|
||||
// (rule / ai); new ones are yours.
|
||||
async function setTags(client, modelId, tagNames) {
|
||||
const { rows: before } = await client.query('SELECT tag_id, source FROM model_tags WHERE model_id = $1', [modelId]);
|
||||
const sourceOf = new Map(before.map((r) => [r.tag_id, r.source]));
|
||||
await client.query('DELETE FROM model_tags WHERE model_id = $1', [modelId]);
|
||||
for (const name of tagOrder(tagNames)) {
|
||||
const id = await tagId(client, name);
|
||||
for (const raw of tagNames) {
|
||||
const name = String(raw).trim();
|
||||
if (!name) continue;
|
||||
const { rows } = await client.query(
|
||||
`INSERT INTO tags (name) VALUES ($1)
|
||||
ON CONFLICT (name) DO UPDATE SET name = EXCLUDED.name RETURNING id`, [name]);
|
||||
await client.query(
|
||||
'INSERT INTO model_tags (model_id, tag_id, source) VALUES ($1,$2,$3) ON CONFLICT DO NOTHING',
|
||||
[modelId, id, sourceOf.get(id) || 'user']);
|
||||
'INSERT INTO model_tags (model_id, tag_id) VALUES ($1,$2) ON CONFLICT DO NOTHING',
|
||||
[modelId, rows[0].id]);
|
||||
}
|
||||
}
|
||||
|
||||
// Automatic tagging: makes the model's rule tags exactly `tagNames`. A tag the
|
||||
// model already has from you or the AI is left as it is.
|
||||
async function replaceRuleTags(modelId, tagNames) {
|
||||
await db.transaction(async (client) => {
|
||||
const ids = [];
|
||||
for (const name of tagOrder(tagNames)) ids.push(await tagId(client, name));
|
||||
await client.query(
|
||||
`DELETE FROM model_tags WHERE model_id = $1 AND source = 'rule' AND NOT (tag_id = ANY($2::int[]))`, [modelId, ids]);
|
||||
for (const id of ids) {
|
||||
await client.query(`INSERT INTO model_tags (model_id, tag_id, source) VALUES ($1,$2,'rule') ON CONFLICT DO NOTHING`,
|
||||
[modelId, id]);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function removeTagsFrom(source) {
|
||||
const { rowCount } = await db.query('DELETE FROM model_tags WHERE source = $1', [source]);
|
||||
return rowCount;
|
||||
}
|
||||
|
||||
// AI tagging: adds the suggested tags (never removes any), fills in the
|
||||
// description if it's empty (or replaces it when asked to), and renames the model
|
||||
// when given a name, keeping the name it had before AI first renamed it.
|
||||
// markTagged records that the model has had AI tags and a description.
|
||||
async function applyAiResult(modelId, { tags, description, overwriteDescription, name = null, markTagged = true }) {
|
||||
await db.transaction(async (client) => {
|
||||
for (const tagName of tagOrder(tags)) { // not `name`: that's this function's rename parameter
|
||||
const id = await tagId(client, tagName);
|
||||
await client.query(`INSERT INTO model_tags (model_id, tag_id, source) VALUES ($1,$2,'ai') ON CONFLICT DO NOTHING`,
|
||||
[modelId, id]);
|
||||
}
|
||||
await client.query(
|
||||
`UPDATE models SET ai_tagged_at = CASE WHEN $5::boolean THEN now() ELSE ai_tagged_at END,
|
||||
description = CASE WHEN $2::text <> '' AND ($3::boolean OR btrim(description) = '') THEN $2 ELSE description END,
|
||||
original_name = CASE WHEN $4::text IS NOT NULL THEN COALESCE(original_name, name) ELSE original_name END,
|
||||
name = COALESCE($4::text, name)
|
||||
WHERE id = $1`, [modelId, description || '', Boolean(overwriteDescription), name, Boolean(markTagged)]);
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- collection suggestions
|
||||
// Records the AI's collection ideas for a model. A name matching an existing
|
||||
// collection or earlier idea takes its spelling, so ideas group together; ideas
|
||||
// the model already belongs to, or that you dismissed for it, are skipped.
|
||||
// Returns the names recorded.
|
||||
async function suggestCollections(modelId, names) {
|
||||
const recorded = [];
|
||||
for (const raw of names) {
|
||||
const { rows: [known] } = await db.query(
|
||||
`SELECT name FROM (
|
||||
SELECT name, 0 AS rank FROM projects WHERE lower(name) = lower($1)
|
||||
UNION ALL
|
||||
SELECT name, 1 AS rank FROM collection_suggestions WHERE lower(name) = lower($1)
|
||||
) n ORDER BY rank, name = lower(name), name LIMIT 1`, [raw]);
|
||||
// An all-lowercase idea gives way to a capitalised spelling of the same name.
|
||||
const name = known && !(known.name === known.name.toLowerCase() && raw !== raw.toLowerCase()) ? known.name : raw;
|
||||
const { rowCount: member } = await db.query(
|
||||
`SELECT 1 FROM model_projects mp JOIN projects p ON p.id = mp.project_id
|
||||
WHERE mp.model_id = $1 AND lower(p.name) = lower($2)`, [modelId, name]);
|
||||
if (member) continue;
|
||||
const { rowCount } = await db.query(
|
||||
'INSERT INTO collection_suggestions (model_id, name) VALUES ($1, $2) ON CONFLICT DO NOTHING', [modelId, name]);
|
||||
if (rowCount) recorded.push(name);
|
||||
}
|
||||
return recorded;
|
||||
}
|
||||
|
||||
// Collection names the AI should reuse: your collections, then ideas waiting for review.
|
||||
async function collectionNameHints(limit = 100) {
|
||||
const { rows } = await db.query(
|
||||
`SELECT name FROM projects
|
||||
UNION
|
||||
SELECT name FROM collection_suggestions WHERE status = 'pending'
|
||||
ORDER BY name LIMIT $1`, [limit]);
|
||||
return rows.map((r) => r.name);
|
||||
}
|
||||
|
||||
// Waiting ideas grouped by name: [{ name, project_id (if it exists), models: [{ id, name }] }].
|
||||
async function listCollectionSuggestions() {
|
||||
const { rows } = await db.query(`
|
||||
SELECT (array_agg(COALESCE(p.name, s.name) ORDER BY p.id IS NULL, s.name = lower(s.name), s.name))[1] AS name,
|
||||
MIN(p.id) AS project_id,
|
||||
json_agg(DISTINCT jsonb_build_object('id', m.id, 'name', m.name)) AS models
|
||||
FROM collection_suggestions s
|
||||
JOIN models m ON m.id = s.model_id AND m.deleted_at IS NULL
|
||||
LEFT JOIN projects p ON lower(p.name) = lower(s.name)
|
||||
WHERE s.status = 'pending'
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM model_projects mp JOIN projects pp ON pp.id = mp.project_id
|
||||
WHERE mp.model_id = m.id AND lower(pp.name) = lower(s.name))
|
||||
GROUP BY lower(s.name)
|
||||
ORDER BY COUNT(DISTINCT m.id) DESC, 1`);
|
||||
return rows.map((r) => ({ ...r, models: r.models.sort((a, b) => a.name.localeCompare(b.name)) }));
|
||||
}
|
||||
|
||||
// Adds the models an idea was suggested for (or just modelIds) to the collection of
|
||||
// that name, creating it if needed. Returns { project, added }, or null if none waited.
|
||||
async function acceptCollectionSuggestion(name, modelIds = null) {
|
||||
return db.transaction(async (client) => {
|
||||
const { rows: waiting } = await client.query(
|
||||
`SELECT s.model_id FROM collection_suggestions s JOIN models m ON m.id = s.model_id AND m.deleted_at IS NULL
|
||||
WHERE s.status = 'pending' AND lower(s.name) = lower($1) AND ($2::int[] IS NULL OR s.model_id = ANY($2::int[]))`,
|
||||
[name, modelIds]);
|
||||
if (!waiting.length) return null;
|
||||
let { rows: [project] } = await client.query(
|
||||
'SELECT * FROM projects WHERE lower(name) = lower($1) ORDER BY id LIMIT 1', [name]);
|
||||
if (!project) {
|
||||
// Named with the idea's best spelling, however it was asked for.
|
||||
({ rows: [project] } = await client.query(
|
||||
`INSERT INTO projects (name)
|
||||
SELECT COALESCE((SELECT name FROM collection_suggestions WHERE status = 'pending' AND lower(name) = lower($1)
|
||||
ORDER BY name = lower(name), name LIMIT 1), $1)
|
||||
RETURNING *`, [name]));
|
||||
}
|
||||
// Sorted, so two of these at once take the model_projects rows in the same
|
||||
// order rather than deadlocking on each other.
|
||||
const ids = waiting.map((w) => w.model_id).sort((a, b) => a - b);
|
||||
for (const id of ids) {
|
||||
await client.query('INSERT INTO model_projects (model_id, project_id) VALUES ($1, $2) ON CONFLICT DO NOTHING', [id, project.id]);
|
||||
}
|
||||
await client.query(
|
||||
`UPDATE collection_suggestions SET status = 'accepted' WHERE lower(name) = lower($1) AND model_id = ANY($2::int[])`,
|
||||
[name, ids]);
|
||||
return { project, added: ids.length };
|
||||
});
|
||||
}
|
||||
|
||||
async function dismissCollectionSuggestion(name, modelIds = null) {
|
||||
const { rowCount } = await db.query(
|
||||
`UPDATE collection_suggestions SET status = 'dismissed'
|
||||
WHERE status = 'pending' AND lower(name) = lower($1) AND ($2::int[] IS NULL OR model_id = ANY($2::int[]))`,
|
||||
[name, modelIds]);
|
||||
return rowCount;
|
||||
}
|
||||
|
||||
// The library's most used tags, so AI suggestions reuse the same words.
|
||||
async function topTags(limit = 150) {
|
||||
const { rows } = await db.query(
|
||||
`SELECT t.name FROM tags t JOIN model_tags mt ON mt.tag_id = t.id
|
||||
GROUP BY t.name ORDER BY COUNT(*) DESC, t.name LIMIT $1`, [limit]);
|
||||
return rows.map((r) => r.name);
|
||||
}
|
||||
|
||||
async function getAllTags() {
|
||||
const { rows } = await db.query('SELECT name FROM tags ORDER BY name');
|
||||
return rows.map((r) => r.name);
|
||||
|
|
@ -487,7 +317,7 @@ async function getAllTags() {
|
|||
// ---------------------------------------------------------------- projects
|
||||
async function setProjects(client, modelId, projectIds) {
|
||||
await client.query('DELETE FROM model_projects WHERE model_id = $1', [modelId]);
|
||||
for (const pid of [...projectIds].sort((a, b) => a - b)) { // one lock order, always
|
||||
for (const pid of projectIds) {
|
||||
await client.query(
|
||||
'INSERT INTO model_projects (model_id, project_id) VALUES ($1,$2) ON CONFLICT DO NOTHING',
|
||||
[modelId, pid]);
|
||||
|
|
@ -517,20 +347,6 @@ async function createProject({ name, description }) {
|
|||
return rows[0];
|
||||
}
|
||||
|
||||
// The collection with this name, created if there isn't one yet.
|
||||
async function findOrCreateProject(name) {
|
||||
const { rows: [existing] } = await db.query(
|
||||
'SELECT * FROM projects WHERE lower(name) = lower($1) ORDER BY id LIMIT 1', [name]);
|
||||
if (existing) return existing;
|
||||
const { rows: [created] } = await db.query('INSERT INTO projects (name) VALUES ($1) RETURNING *', [name]);
|
||||
return created;
|
||||
}
|
||||
|
||||
async function addModelToProject(modelId, projectId) {
|
||||
await db.query('INSERT INTO model_projects (model_id, project_id) VALUES ($1, $2) ON CONFLICT DO NOTHING',
|
||||
[modelId, projectId]);
|
||||
}
|
||||
|
||||
async function updateProject(id, { name, description }) {
|
||||
const { rows } = await db.query(
|
||||
`UPDATE projects SET name = COALESCE($2, name),
|
||||
|
|
@ -656,67 +472,15 @@ async function upsertDesigner(client, { name, source_site, profile_url }) {
|
|||
return rows[0].id;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- filament
|
||||
async function listSpools() {
|
||||
const { rows } = await db.query(
|
||||
`SELECT * FROM filament_spools ORDER BY material, colour, id`);
|
||||
return rows;
|
||||
}
|
||||
|
||||
async function createSpool(data) {
|
||||
const { rows } = await db.query(
|
||||
`INSERT INTO filament_spools (material, colour, colour_hex, brand, total_g, remaining_g, notes, code)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8) RETURNING *`,
|
||||
[String(data.material || '').trim(), data.colour || '', data.colour_hex || null, data.brand || '',
|
||||
data.total_g ?? null, data.remaining_g ?? null, data.notes || '', data.code || null]);
|
||||
return rows[0];
|
||||
}
|
||||
|
||||
// Only the fields given are changed, so a weight edit can't wipe the notes.
|
||||
async function updateSpool(id, data) {
|
||||
const { rows } = await db.query(
|
||||
`UPDATE filament_spools SET
|
||||
material = COALESCE($2, material),
|
||||
colour = COALESCE($3, colour),
|
||||
colour_hex = COALESCE($4, colour_hex),
|
||||
brand = COALESCE($5, brand),
|
||||
total_g = COALESCE($6, total_g),
|
||||
remaining_g = COALESCE($7, remaining_g),
|
||||
notes = COALESCE($8, notes),
|
||||
code = COALESCE($9, code),
|
||||
date_modified = now()
|
||||
WHERE id = $1 RETURNING *`,
|
||||
[id, data.material ?? null, data.colour ?? null, data.colour_hex ?? null, data.brand ?? null,
|
||||
data.total_g ?? null, data.remaining_g ?? null, data.notes ?? null, data.code ?? null]);
|
||||
return rows[0] || null;
|
||||
}
|
||||
|
||||
async function deleteSpool(id) {
|
||||
const { rowCount } = await db.query('DELETE FROM filament_spools WHERE id = $1', [id]);
|
||||
return rowCount > 0;
|
||||
}
|
||||
|
||||
// The filament slots a model needs, set by hand. Marked 'edited' so the
|
||||
// background reader treats them as yours and leaves them alone.
|
||||
async function setModelFilaments(modelId, filaments) {
|
||||
const { rowCount } = await db.query(
|
||||
`UPDATE models SET filaments = $2, filaments_status = 'edited', date_modified = now()
|
||||
WHERE id = $1 AND deleted_at IS NULL`,
|
||||
[modelId, JSON.stringify(filaments)]);
|
||||
return rowCount > 0;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
getAllModels, getModel, getModelsByIds, findModelBySource, createModel, updateModel, deleteModel, addFiles,
|
||||
updateFileConversion,
|
||||
trashModels, restoreModel, trashedModelIds, countTrashed,
|
||||
duplicateCandidateFiles, matchableCounts, mergeAndTrash, dismissDuplicateGroup, dismissedDuplicateSignatures,
|
||||
getFile, getArchiveEntries, getArchiveEntry, replaceArchiveEntries,
|
||||
getAllTags, replaceRuleTags, removeTagsFrom, applyAiResult, topTags,
|
||||
suggestCollections, collectionNameHints, listCollectionSuggestions, acceptCollectionSuggestion, dismissCollectionSuggestion,
|
||||
getAllProjects, getProject, createProject, updateProject, deleteProject, findOrCreateProject, addModelToProject,
|
||||
getAllTags,
|
||||
getAllProjects, getProject, createProject, updateProject, deleteProject,
|
||||
getSetting, setSetting,
|
||||
listSpools, createSpool, updateSpool, deleteSpool, setModelFilaments,
|
||||
countUsers, getUserByEmail, getUserById, createUser, updateUserPassword,
|
||||
createPasswordReset, getValidReset, markResetUsed,
|
||||
getLicenseRule, upsertLicenseRule, listLicenseRules, distinctModelLicenses, applyLicenseRuleToModels,
|
||||
|
|
|
|||
2314
package-lock.json
generated
2314
package-lock.json
generated
File diff suppressed because it is too large
Load diff
11
package.json
11
package.json
|
|
@ -1,14 +1,12 @@
|
|||
{
|
||||
"name": "printhive",
|
||||
"version": "1.0.0",
|
||||
"description": "Printhive \u2014 a web app for organizing 3D-printing files (STL, 3MF, OBJ, ...) with URL import",
|
||||
"description": "Printhive — a web app for organizing 3D-printing files (STL, 3MF, OBJ, ...) with URL import",
|
||||
"main": "server.js",
|
||||
"scripts": {
|
||||
"start": "node server.js",
|
||||
"dev": "nodemon server.js",
|
||||
"test": "node --test test/*.test.js",
|
||||
"lint": "eslint .",
|
||||
"check": "npm run lint && npm test"
|
||||
"test": "node --test test/*.test.js"
|
||||
},
|
||||
"keywords": [
|
||||
"stl",
|
||||
|
|
@ -19,7 +17,6 @@
|
|||
"author": "",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@huggingface/transformers": "^3.8.1",
|
||||
"7z-wasm": "^1.2.0",
|
||||
"adm-zip": "^0.5.17",
|
||||
"bcryptjs": "^3.0.3",
|
||||
|
|
@ -27,7 +24,6 @@
|
|||
"dotenv": "^17.4.2",
|
||||
"express": "^4.18.2",
|
||||
"jsonwebtoken": "^9.0.3",
|
||||
"mqtt": "^5.16.0",
|
||||
"multer": "^1.4.5-lts.1",
|
||||
"node-stl": "^0.7.0",
|
||||
"nodemailer": "^9.0.1",
|
||||
|
|
@ -37,9 +33,6 @@
|
|||
"tar": "^7.5.22"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^10.0.1",
|
||||
"eslint": "^10.11.0",
|
||||
"globals": "^17.12.0",
|
||||
"nodemon": "^3.0.2"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
2262
public/app.js
2262
public/app.js
File diff suppressed because it is too large
Load diff
|
|
@ -94,22 +94,6 @@
|
|||
<span>Duplicates</span>
|
||||
<span class="count" id="duplicatesCount">0</span>
|
||||
</div>
|
||||
<div class="project-item" data-scope="ai">
|
||||
<span>AI & auto-tags</span>
|
||||
<span class="count ai-busy" id="aiBusy" hidden title="AI tagging is running">●</span>
|
||||
</div>
|
||||
<div class="project-item" data-scope="watch">
|
||||
<span>Watched</span>
|
||||
<span class="count" id="watchCount">0</span>
|
||||
</div>
|
||||
<div class="project-item" data-scope="filament">
|
||||
<span>Filament</span>
|
||||
<span class="count" id="spoolCount">0</span>
|
||||
</div>
|
||||
<div class="project-item" data-scope="printers">
|
||||
<span>Printers</span>
|
||||
<span class="count" id="printerCount">0</span>
|
||||
</div>
|
||||
<div class="project-item" data-scope="trash">
|
||||
<span>Trash</span>
|
||||
<span class="count" id="trashCount">0</span>
|
||||
|
|
@ -154,10 +138,6 @@
|
|||
<div id="filterBar" class="filter-bar">
|
||||
<input type="search" id="searchInput" class="search-input"
|
||||
placeholder="Search names, tags, notes, files, designers… ( / )" aria-label="Search models">
|
||||
<div class="seg search-mode" id="searchMode" title="Match the words you type, or find models by what they are">
|
||||
<button type="button" data-mode="words">Words</button>
|
||||
<button type="button" data-mode="meaning">Meaning</button>
|
||||
</div>
|
||||
<select id="filterFormat" aria-label="Filter by format"><option value="">Any format</option></select>
|
||||
<select id="filterSource" aria-label="Filter by source"><option value="">Any source</option></select>
|
||||
<select id="filterLicense" aria-label="Filter by license">
|
||||
|
|
@ -166,11 +146,6 @@
|
|||
<option value="noncommercial">Non-commercial</option>
|
||||
<option value="unknown">License unclear</option>
|
||||
</select>
|
||||
<select id="filterPrintable" aria-label="Filter by what you can print">
|
||||
<option value="">Any filament</option>
|
||||
<option value="yes">Printable now</option>
|
||||
<option value="no">Missing filament</option>
|
||||
</select>
|
||||
<select id="sortBy" aria-label="Sort">
|
||||
<option value="newest">Newest first</option>
|
||||
<option value="oldest">Oldest first</option>
|
||||
|
|
@ -244,66 +219,6 @@
|
|||
</div>
|
||||
<div class="modal-body">
|
||||
<form id="settingsForm">
|
||||
<h3 class="settings-group-title">Slicer</h3>
|
||||
<div class="form-group">
|
||||
<p class="form-hint">
|
||||
“Open in slicer” uses <strong id="settingsSlicerName">no slicer yet</strong>. This choice is
|
||||
remembered in this browser only, since each computer has its own slicers installed.
|
||||
</p>
|
||||
<button type="button" class="btn btn-secondary btn-sm" id="chooseSlicerBtn">Choose slicer…</button>
|
||||
</div>
|
||||
|
||||
<h3 class="settings-group-title" id="aiSettings">AI tagging</h3>
|
||||
<div class="ai-grid">
|
||||
<div class="form-group">
|
||||
<label for="aiProvider">Provider</label>
|
||||
<select id="aiProvider" data-setting="ai_provider">
|
||||
<option value="deepseek">DeepSeek</option>
|
||||
<option value="openrouter">OpenRouter</option>
|
||||
<option value="openai">OpenAI</option>
|
||||
<option value="custom">Other OpenAI-compatible (Ollama, LM Studio…)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="aiApiKey">API key</label>
|
||||
<input type="password" id="aiApiKey" data-setting="ai_api_key" placeholder="Paste your API key" autocomplete="off">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="aiModel">Model</label>
|
||||
<input type="text" id="aiModel" data-setting="ai_model" data-clear-blank autocomplete="off" spellcheck="false">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="aiBaseUrl">API address</label>
|
||||
<input type="text" id="aiBaseUrl" data-setting="ai_base_url" data-clear-blank autocomplete="off" spellcheck="false">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="aiPriceInput">Input price ($ per million tokens)</label>
|
||||
<input type="number" id="aiPriceInput" data-setting="ai_price_input" data-clear-blank min="0" step="0.001">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="aiPriceOutput">Output price ($ per million tokens)</label>
|
||||
<input type="number" id="aiPriceOutput" data-setting="ai_price_output" data-clear-blank min="0" step="0.001">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="aiSendImages">Send thumbnails</label>
|
||||
<select id="aiSendImages" data-setting="ai_send_images">
|
||||
<option value="true">Yes: better tags, slightly higher cost</option>
|
||||
<option value="false">No: names and files only</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<p class="form-hint">
|
||||
Key status: <span class="setting-status" data-status="ai_api_key">—</span>.
|
||||
<a id="aiKeyLink" target="_blank" rel="noopener" hidden></a>
|
||||
The key is stored on your server and only sent to the provider. Leave model, address and prices
|
||||
blank to use the provider's defaults (shown greyed out). Prices are only used to show what runs cost.
|
||||
</p>
|
||||
<div class="ai-test-row">
|
||||
<button type="button" class="btn btn-secondary btn-sm" id="aiTestBtn">Save & test connection</button>
|
||||
<button type="button" class="btn btn-link clear-setting" data-setting="ai_api_key">Clear key</button>
|
||||
</div>
|
||||
<div id="aiTestResult" class="form-hint ai-test-result"></div>
|
||||
|
||||
<h3 class="settings-group-title">Import sources</h3>
|
||||
|
||||
<div class="form-group">
|
||||
|
|
@ -337,10 +252,8 @@
|
|||
placeholder="Bearer token from your logged-in session" autocomplete="off">
|
||||
<p class="form-hint">
|
||||
Status: <span class="setting-status" data-status="makerworld_token">—</span>.
|
||||
Lets Printhive download files from MakerWorld using your account. In a browser signed in to
|
||||
makerworld.com, open the developer tools (Application / Storage → Cookies → makerworld.com)
|
||||
and copy the value of the <code>token</code> cookie. It expires after a while: paste a new one if
|
||||
MakerWorld downloads stop working.
|
||||
Lets Printhive download files from MakerWorld using your account. Paste the bearer
|
||||
token from your logged-in browser session (see instructions in chat).
|
||||
</p>
|
||||
<button type="button" class="btn btn-link clear-setting" data-setting="makerworld_token">Clear token</button>
|
||||
</div>
|
||||
|
|
@ -498,26 +411,13 @@
|
|||
<div id="detailViewer" class="model-viewer"></div>
|
||||
<div id="detailPlates" class="plate-chips" hidden></div>
|
||||
<div id="detailFileTabs" class="file-tabs"></div>
|
||||
<div class="preview-actions">
|
||||
<span id="slicerBar" class="slicer-bar" hidden>
|
||||
<button type="button" class="btn btn-primary btn-sm" id="openInSlicerBtn">Open in slicer</button>
|
||||
<button type="button" class="link-btn slicer-change" id="changeSlicerBtn">change slicer</button>
|
||||
</span>
|
||||
<button type="button" class="btn btn-secondary btn-sm drag-out-btn" id="dragOutBtn" draggable="true" hidden>⠿ Drag out</button>
|
||||
<button type="button" class="btn btn-secondary btn-sm" id="regenThumbBtn">Regenerate thumbnail</button>
|
||||
</div>
|
||||
<div id="slicerNotice" class="slicer-notice" role="status" hidden></div>
|
||||
<button type="button" class="btn btn-secondary btn-sm" id="regenThumbBtn">Regenerate thumbnail</button>
|
||||
</div>
|
||||
<div class="file-detail-info">
|
||||
<div id="detailRenamed" class="renamed-note" hidden></div>
|
||||
<div class="info-section">
|
||||
<h3>Source</h3>
|
||||
<div id="detailSource"></div>
|
||||
</div>
|
||||
<div class="info-section" id="detailPrintableSection">
|
||||
<h3>Printable now</h3>
|
||||
<div id="detailPrintable"></div>
|
||||
</div>
|
||||
<div class="info-section">
|
||||
<h3>License</h3>
|
||||
<div id="detailLicense"></div>
|
||||
|
|
@ -557,7 +457,6 @@
|
|||
<div class="form-actions" id="detailActions">
|
||||
<button class="btn btn-danger" id="deleteFileBtn">Move to trash</button>
|
||||
<button class="btn btn-secondary" id="addFilesBtn">Add files</button>
|
||||
<button class="btn btn-secondary" id="aiTagBtn" title="Suggest tags and a description with AI">✦ Tag with AI</button>
|
||||
<button class="btn btn-primary" id="editModelBtn">Edit</button>
|
||||
</div>
|
||||
<div class="form-actions" id="trashActions" hidden>
|
||||
|
|
@ -570,65 +469,6 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<!-- MakerWorld token prompt (shown when MakerWorld refuses the saved token) -->
|
||||
<div id="tokenModal" class="modal">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h2>Update your MakerWorld token</h2>
|
||||
<button class="close-btn">×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<form id="tokenForm">
|
||||
<p id="tokenReason" class="token-reason"></p>
|
||||
<ol class="token-steps">
|
||||
<li>In this browser, sign in to <a href="https://makerworld.com" target="_blank" rel="noopener">makerworld.com</a>.</li>
|
||||
<li>Open the developer tools (<kbd>⌥⌘I</kbd> on a Mac, <kbd>F12</kbd> elsewhere) and go to
|
||||
Application (Storage in Firefox) → Cookies → https://makerworld.com.</li>
|
||||
<li>Copy the value of the <code>token</code> cookie and paste it below.</li>
|
||||
</ol>
|
||||
<div class="form-group">
|
||||
<label for="tokenInput">MakerWorld auth token</label>
|
||||
<input type="password" id="tokenInput" autocomplete="off" spellcheck="false" required>
|
||||
</div>
|
||||
<div id="tokenStatus" class="form-hint token-status" role="status"></div>
|
||||
<div class="form-actions">
|
||||
<button type="button" class="btn btn-secondary" id="cancelToken">Not now</button>
|
||||
<button type="submit" class="btn btn-primary" id="tokenSubmit">Save & download files</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Slicer choice (per browser) -->
|
||||
<div id="slicerModal" class="modal">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h2>Choose your slicer</h2>
|
||||
<button class="close-btn">×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<form id="slicerForm">
|
||||
<p class="form-hint slicer-intro">Your browser can't tell which apps are installed, so pick the slicer this computer uses.</p>
|
||||
<div id="slicerOptions" class="slicer-options"></div>
|
||||
<div class="form-group" id="slicerTemplateGroup" hidden>
|
||||
<label for="slicerTemplate">Open-link template</label>
|
||||
<input type="text" id="slicerTemplate" placeholder="myslicer://open?file={url}" autocomplete="off" spellcheck="false">
|
||||
<p class="form-hint">
|
||||
<code>{url}</code> becomes the file's download link, URL-encoded (<code>{rawurl}</code> leaves it as is).
|
||||
The link works for 10 minutes.
|
||||
</p>
|
||||
</div>
|
||||
<div id="slicerFormError" class="slicer-error"></div>
|
||||
<div class="form-actions">
|
||||
<button type="button" class="btn btn-secondary" id="cancelSlicer">Cancel</button>
|
||||
<button type="submit" class="btn btn-primary">Save</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Edit Model Modal -->
|
||||
<div id="editModal" class="modal">
|
||||
<div class="modal-content">
|
||||
|
|
@ -678,7 +518,6 @@
|
|||
</div>
|
||||
|
||||
<script type="module" src="viewer.js"></script>
|
||||
<script src="printer-art.js"></script>
|
||||
<script src="app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
|
|
@ -1,169 +0,0 @@
|
|||
// Printer models, and simple line drawings of their shapes, drawn for
|
||||
// Printhive. The drawings suggest a model's form — open frame, enclosed, large
|
||||
// format — rather than depict any product exactly.
|
||||
(function () {
|
||||
const svg = (inner, width = 150) =>
|
||||
`<svg viewBox="0 0 ${width} 120" xmlns="http://www.w3.org/2000/svg" fill="none" stroke="currentColor"
|
||||
stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" role="img">${inner}</svg>`;
|
||||
|
||||
// One spool in a drawing, filled with what's loaded there: a colour for a
|
||||
// spool, dashed for an empty slot, plain when nothing is known.
|
||||
const HEX = /^#[0-9a-f]{6}$/i;
|
||||
const colourOf = (slot) => (slot && HEX.test(slot.colour || '') ? slot.colour : null);
|
||||
const spool = (cx, cy, r, slot, active) => {
|
||||
if (slot && slot.empty) return `<circle cx="${cx}" cy="${cy}" r="${r}" stroke-dasharray="2 3" opacity="0.6"/>`;
|
||||
const fill = colourOf(slot);
|
||||
const ring = active ? ' stroke-width="3.4"' : '';
|
||||
return fill
|
||||
? `<circle cx="${cx}" cy="${cy}" r="${r}" fill="${fill}"${ring}/><circle cx="${cx}" cy="${cy}" r="${r / 3}"/>`
|
||||
: `<circle cx="${cx}" cy="${cy}" r="${r}"${ring}/>`;
|
||||
};
|
||||
|
||||
const toolhead = (x, y) => `<rect x="${x}" y="${y}" width="14" height="15" rx="2"/>
|
||||
<line x1="${x + 7}" y1="${y + 15}" x2="${x + 7}" y2="${y + 20}"/>`;
|
||||
|
||||
// Where each shape's spools sit, where its toolhead takes filament in, and
|
||||
// where an external spool is drawn when it's the one in use.
|
||||
const amsLiteAt = (x) => [[x - 9, 40, 7], [x + 9, 40, 7], [x - 9, 58, 7], [x + 9, 58, 7]];
|
||||
const amsTopAt = (x, width) => [0, 1, 2, 3].map((i) => [x + width / 8 + (i * width) / 4, 23, 5]);
|
||||
|
||||
const layouts = {
|
||||
// A1: a bed on a base, an upright gantry, an AMS lite alongside.
|
||||
open: {
|
||||
spools: amsLiteAt(126), head: [50, 49], external: [104, 12, 6],
|
||||
body: () => `
|
||||
<rect x="12" y="94" width="92" height="10" rx="3"/>
|
||||
<rect x="24" y="84" width="68" height="6" rx="2"/>
|
||||
<line x1="20" y1="94" x2="20" y2="26"/><line x1="96" y1="94" x2="96" y2="26"/>
|
||||
<line x1="16" y1="26" x2="100" y2="26"/><line x1="20" y1="56" x2="96" y2="56"/>`,
|
||||
stand: (x = 126) => `<line x1="${x}" y1="104" x2="${x}" y2="52"/><line x1="${x - 10}" y1="104" x2="${x + 10}" y2="104"/>`,
|
||||
},
|
||||
// A1 mini: a single column holding the gantry out over the bed.
|
||||
openMini: {
|
||||
spools: amsLiteAt(122), head: [58, 47], external: [29, 18, 6],
|
||||
body: () => `
|
||||
<rect x="22" y="94" width="72" height="10" rx="3"/>
|
||||
<rect x="36" y="84" width="50" height="6" rx="2"/>
|
||||
<rect x="24" y="30" width="10" height="64" rx="2"/>
|
||||
<line x1="34" y1="54" x2="90" y2="54"/>`,
|
||||
stand: () => `<line x1="122" y1="104" x2="122" y2="52"/><line x1="112" y1="104" x2="132" y2="104"/>`,
|
||||
},
|
||||
// A2L: the A1's form, stretched to a larger bed.
|
||||
openLarge: {
|
||||
spools: amsLiteAt(132), head: [52, 47], external: [112, 10, 6],
|
||||
body: () => `
|
||||
<rect x="6" y="94" width="104" height="10" rx="3"/>
|
||||
<rect x="14" y="84" width="88" height="6" rx="2"/>
|
||||
<line x1="10" y1="94" x2="10" y2="22"/><line x1="106" y1="94" x2="106" y2="22"/>
|
||||
<line x1="6" y1="22" x2="110" y2="22"/><line x1="10" y1="54" x2="106" y2="54"/>`,
|
||||
stand: () => `<line x1="132" y1="104" x2="132" y2="52"/><line x1="122" y1="104" x2="142" y2="104"/>`,
|
||||
},
|
||||
// P1P: a bare cube frame.
|
||||
frame: {
|
||||
width: 140, spools: [], head: [56, 53], external: [124, 18, 6],
|
||||
body: () => `
|
||||
<rect x="30" y="38" width="66" height="66" rx="3"/>
|
||||
<path d="M30 38 L44 28 L110 28 L96 38 M110 28 L110 94 L96 104"/>
|
||||
<line x1="30" y1="60" x2="96" y2="60"/>
|
||||
<rect x="40" y="90" width="46" height="5" rx="2"/>`,
|
||||
},
|
||||
// P1S, P2S, X1C, X1E, X2D: an enclosed box with an AMS on top.
|
||||
box: {
|
||||
width: 126, spools: amsTopAt(30, 66), head: [56, 52], external: [112, 60, 6],
|
||||
body: () => `
|
||||
<rect x="30" y="14" width="66" height="18" rx="3"/>
|
||||
<rect x="28" y="36" width="70" height="70" rx="5"/>
|
||||
<rect x="36" y="44" width="54" height="46" rx="3"/>
|
||||
<line x1="36" y1="98" x2="90" y2="98"/>`,
|
||||
},
|
||||
// H2S, H2D, H2C: a wide enclosed box with an AMS on top.
|
||||
large: {
|
||||
spools: amsTopAt(28, 94), head: [58, 52], secondHead: [78, 52], external: [142, 60, 6],
|
||||
body: () => `
|
||||
<rect x="28" y="14" width="94" height="18" rx="3"/>
|
||||
<rect x="18" y="36" width="114" height="70" rx="5"/>
|
||||
<rect x="26" y="44" width="98" height="46" rx="3"/>
|
||||
<line x1="26" y1="98" x2="124" y2="98"/>`,
|
||||
},
|
||||
// Anything else: a plain box with a nozzle.
|
||||
generic: {
|
||||
width: 140, spools: [], head: [63, 50], external: [122, 22, 6],
|
||||
body: () => `
|
||||
<rect x="34" y="30" width="72" height="72" rx="8"/>
|
||||
<line x1="44" y1="92" x2="96" y2="92"/>`,
|
||||
},
|
||||
};
|
||||
|
||||
// The filament's path from a spool into the toolhead: a tube curving over
|
||||
// the frame, outlined so a dark filament still shows on a dark page.
|
||||
const feed = (from, head, colour, moving) => {
|
||||
const [sx, sy] = from;
|
||||
const tx = head[0] + 7;
|
||||
const ty = head[1];
|
||||
const lift = Math.max(2, Math.min(sy, ty) - 30);
|
||||
const d = `M${sx} ${sy} C${sx} ${lift}, ${tx} ${lift}, ${tx} ${ty}`;
|
||||
return `<g class="feed${moving ? ' is-moving' : ''}">
|
||||
<path d="${d}" stroke-width="4.6" opacity="0.55"/>
|
||||
<path d="${d}" stroke="${colour}" stroke-width="2.6"/>
|
||||
${moving ? `<path class="feed-flow" d="${d}" stroke="#ffffff" stroke-width="1.2" stroke-dasharray="1 7" opacity="0.8"/>` : ''}
|
||||
</g>`;
|
||||
};
|
||||
|
||||
// `slots`: the first AMS unit's four slots, as { empty, colour }.
|
||||
// `use`: which is feeding — { slot } for one of those, { external, colour }
|
||||
// for the external spool — and `moving` while it's printing.
|
||||
function draw(shapeName, slots = [], use = null, moving = false) {
|
||||
const L = layouts[shapeName] || layouts.generic;
|
||||
let parts = L.body();
|
||||
let feedPart = '';
|
||||
if (use && use.external && L.external) {
|
||||
const [x, y, r] = L.external;
|
||||
parts += spool(x, y, r, { colour: use.colour }, true);
|
||||
feedPart = feed([x, y], L.head, colourOf(use) || 'currentColor', moving);
|
||||
} else if (use && use.slot != null && L.spools[use.slot]) {
|
||||
const [x, y] = L.spools[use.slot];
|
||||
const colour = colourOf(slots[use.slot]);
|
||||
if (colour) feedPart = feed([x, y], L.head, colour, moving);
|
||||
}
|
||||
if (L.stand) parts += L.stand();
|
||||
parts += L.spools.map(([x, y, r], i) => spool(x, y, r, slots[i], use && use.slot === i)).join('');
|
||||
// The tube is drawn behind the toolhead, so it disappears into it.
|
||||
parts += feedPart + toolhead(...L.head);
|
||||
if (L.secondHead) parts += toolhead(...L.secondHead);
|
||||
return svg(parts, L.width);
|
||||
}
|
||||
|
||||
// ams is only a suggestion — a P1S might have an AMS, an AMS 2 Pro or an AMS HT.
|
||||
const models = [
|
||||
{ id: 'A1 mini', shape: 'openMini', ams: 'ams_lite' },
|
||||
{ id: 'A1', shape: 'open', ams: 'ams_lite' },
|
||||
{ id: 'A2L', shape: 'openLarge', ams: 'ams_lite' },
|
||||
{ id: 'P1P', shape: 'frame', ams: 'ams' },
|
||||
{ id: 'P1S', shape: 'box', ams: 'ams' },
|
||||
{ id: 'P2S', shape: 'box', ams: 'ams_2_pro' },
|
||||
{ id: 'X1C', shape: 'box', ams: 'ams' },
|
||||
{ id: 'X1E', shape: 'box', ams: 'ams' },
|
||||
{ id: 'X2D', shape: 'box', ams: 'ams_2_pro' },
|
||||
{ id: 'H2S', shape: 'large', ams: 'ams_2_pro' },
|
||||
{ id: 'H2D', shape: 'large', ams: 'ams_2_pro' },
|
||||
{ id: 'H2C', shape: 'large', ams: 'ams_2_pro' },
|
||||
{ id: 'Other', shape: 'generic', ams: 'none' },
|
||||
];
|
||||
|
||||
const amsTypes = [
|
||||
{ id: 'none', label: 'No AMS (external spool)' },
|
||||
{ id: 'ams_lite', label: 'AMS lite (full or empty only)' },
|
||||
{ id: 'ams', label: 'AMS' },
|
||||
{ id: 'ams_2_pro', label: 'AMS 2 Pro' },
|
||||
{ id: 'ams_ht', label: 'AMS HT' },
|
||||
];
|
||||
|
||||
const find = (id) => models.find((m) => m.id === id) || null;
|
||||
|
||||
window.PRINTER_MODELS = {
|
||||
models,
|
||||
amsTypes,
|
||||
find,
|
||||
art: (id, slots = [], use = null, moving = false) => draw((find(id) || {}).shape, slots, use, moving),
|
||||
};
|
||||
}());
|
||||
|
|
@ -105,7 +105,7 @@ header {
|
|||
|
||||
.tags-list { display: flex; flex-wrap: wrap; gap: 6px; }
|
||||
.tag {
|
||||
font-size: 12px; padding: 4px 10px; border-radius: 12px; cursor: pointer; white-space: nowrap;
|
||||
font-size: 12px; padding: 4px 10px; border-radius: 12px; cursor: pointer;
|
||||
background: var(--surface-2); color: var(--text-muted); border: 1px solid var(--border);
|
||||
transition: all 0.12s;
|
||||
}
|
||||
|
|
@ -114,7 +114,7 @@ header {
|
|||
.tag.exclude { background: var(--danger-soft); color: var(--danger); border-color: var(--danger); text-decoration: line-through; }
|
||||
|
||||
/* ---- form fields ---- */
|
||||
.search-input, input[type="text"], input[type="search"], input[type="url"], input[type="password"], input[type="number"], input[type="file"], textarea, select {
|
||||
.search-input, input[type="text"], input[type="search"], input[type="url"], input[type="password"], input[type="file"], textarea, select {
|
||||
width: 100%;
|
||||
font-family: inherit;
|
||||
font-size: 14px;
|
||||
|
|
@ -202,8 +202,7 @@ label { display: block; font-size: 13px; font-weight: 500; color: var(--text-mut
|
|||
.modal-body { padding: 22px; }
|
||||
.form-group { margin-bottom: 18px; }
|
||||
.print-settings { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }
|
||||
.form-actions { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 10px; margin-top: 22px; }
|
||||
.form-actions .btn { white-space: nowrap; }
|
||||
.form-actions { display: flex; justify-content: flex-end; gap: 10px; margin-top: 22px; }
|
||||
|
||||
/* ---- model detail ---- */
|
||||
/* minmax(0, …) keeps a long file name from widening its column and crushing the viewer */
|
||||
|
|
@ -251,34 +250,12 @@ label { display: block; font-size: 13px; font-weight: 500; color: var(--text-mut
|
|||
.info-section:last-of-type { margin-bottom: 0; }
|
||||
.info-section h3 { font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.08em; color: var(--text-dim); margin-bottom: 9px; }
|
||||
.info-section p, .info-section div { color: var(--text); font-size: 14px; line-height: 1.6; }
|
||||
.info-section .tags { display: flex; flex-wrap: wrap; gap: 6px; }
|
||||
.info-section .tag { cursor: default; }
|
||||
|
||||
.file-row { display: flex; justify-content: space-between; align-items: center; gap: 10px; padding: 7px 0; border-bottom: 1px solid var(--border); font-size: 14px; color: var(--text-muted); }
|
||||
.file-row:last-child { border-bottom: none; }
|
||||
.file-row em { color: var(--text-dim); font-style: normal; font-family: var(--mono); font-size: 12px; }
|
||||
.file-row > span:first-child { min-width: 0; overflow-wrap: anywhere; }
|
||||
.file-row[draggable="true"], .entry-row[draggable="true"], .drag-out-btn { cursor: grab; }
|
||||
.drag-out-btn:active { cursor: grabbing; }
|
||||
|
||||
/* ---- slicer hand-off ---- */
|
||||
.preview-actions { display: flex; flex-wrap: wrap; align-items: center; gap: 8px 14px; }
|
||||
.preview-actions .btn-sm { align-self: auto; }
|
||||
.slicer-bar { display: inline-flex; align-items: center; gap: 10px; }
|
||||
.slicer-change { font-size: 12px; color: var(--text-dim); }
|
||||
.slicer-notice { font-size: 13px; line-height: 1.5; color: var(--text-muted); background: var(--surface-2); border: 1px solid var(--border); border-radius: var(--radius-sm); padding: 10px 12px; }
|
||||
.slicer-notice.is-warning { border-color: var(--accent); color: var(--text); }
|
||||
.slicer-notice-actions { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 8px; }
|
||||
.slicer-notice-actions .btn-sm { align-self: auto; }
|
||||
.slicer-intro { margin: 0 0 14px; }
|
||||
.slicer-options { display: flex; flex-direction: column; gap: 8px; margin-bottom: 16px; }
|
||||
.slicer-option { display: flex; gap: 10px; align-items: flex-start; padding: 10px 12px; border: 1px solid var(--border); border-radius: var(--radius-sm); cursor: pointer; transition: border-color 0.12s, background 0.12s; }
|
||||
.slicer-option:hover { border-color: var(--border-strong); }
|
||||
.slicer-option:has(input:checked) { border-color: var(--accent); background: var(--accent-soft); }
|
||||
.slicer-option input { width: auto; margin-top: 3px; accent-color: var(--accent); flex-shrink: 0; }
|
||||
.slicer-option strong { display: block; font-size: 14px; font-weight: 500; color: var(--text); }
|
||||
.slicer-option em { display: block; font-style: normal; font-size: 12px; color: var(--text-dim); }
|
||||
.slicer-error { color: var(--danger); font-size: 13px; min-height: 1em; }
|
||||
|
||||
/* ---- import / settings ---- */
|
||||
.import-status { margin-top: 14px; color: var(--text-muted); font-size: 14px; min-height: 20px; }
|
||||
|
|
@ -377,162 +354,3 @@ label { display: block; font-size: 13px; font-weight: 500; color: var(--text-mut
|
|||
.dup-card .btn { margin-top: 6px; }
|
||||
.rec-badge { align-self: flex-start; font-size: 10px; text-transform: uppercase; letter-spacing: 0.05em; padding: 2px 8px; border-radius: 10px; background: rgba(76, 195, 138, 0.16); color: var(--success); }
|
||||
.warn { color: var(--accent); }
|
||||
|
||||
/* ---- AI & automatic tags ---- */
|
||||
.ai-panel { display: flex; flex-direction: column; gap: 18px; max-width: 920px; }
|
||||
.ai-card { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); padding: 18px 20px; display: flex; flex-direction: column; gap: 12px; }
|
||||
.ai-card-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
|
||||
.ai-card h3 { font-size: 15px; font-weight: 600; }
|
||||
.ai-card h4 { font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.08em; color: var(--text-dim); margin-bottom: 6px; }
|
||||
.ai-card .form-hint { margin: 0; }
|
||||
.ai-callout { font-size: 13px; color: var(--text-muted); background: var(--surface-2); border: 1px solid var(--border); border-radius: var(--radius-sm); padding: 10px 12px; display: flex; flex-wrap: wrap; align-items: center; gap: 8px 12px; }
|
||||
.ai-callout.is-warning { border-color: var(--accent); color: var(--text); }
|
||||
.ai-callout code { font-family: var(--mono); font-size: 12px; color: var(--text); }
|
||||
.ai-callout .btn-sm, .ai-run-actions .btn-sm, .ai-start-row .btn-sm, .rule-form .btn-sm, .ai-test-row .btn-sm { align-self: auto; }
|
||||
.ai-start { display: flex; flex-direction: column; gap: 10px; }
|
||||
.ai-start label { display: inline-flex; align-items: center; gap: 8px; font-size: 14px; color: var(--text-muted); cursor: pointer; }
|
||||
.ai-scope { display: flex; flex-wrap: wrap; gap: 8px 22px; }
|
||||
.ai-start input[type="radio"], .ai-start input[type="checkbox"], .switch input { width: auto; margin: 0; accent-color: var(--accent); }
|
||||
.count-inline { font-family: var(--mono); font-size: 12px; color: var(--text-dim); }
|
||||
.money-input { display: inline-flex; align-items: center; gap: 4px; }
|
||||
.money-input input { width: 100px; padding: 6px 8px; }
|
||||
.ai-start-row { display: flex; flex-wrap: wrap; align-items: center; gap: 10px 14px; }
|
||||
.ai-runs { display: flex; flex-direction: column; gap: 10px; }
|
||||
.ai-run { border: 1px solid var(--border); border-radius: var(--radius-sm); padding: 12px 14px; display: flex; flex-direction: column; gap: 8px; background: var(--surface-2); }
|
||||
.ai-run-head { display: flex; flex-wrap: wrap; align-items: center; gap: 6px 12px; font-size: 13px; color: var(--text); }
|
||||
.ai-run-meta { color: var(--text-dim); font-size: 12px; }
|
||||
.ai-run-actions { margin-left: auto; display: flex; gap: 8px; }
|
||||
.ai-run-stats { font-family: var(--mono); font-size: 12px; color: var(--text-muted); }
|
||||
.ai-run-error { color: var(--danger); font-size: 13px; }
|
||||
.pill { font-size: 10px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.06em; padding: 3px 8px; border-radius: 10px; background: var(--surface-3); color: var(--text-muted); }
|
||||
.pill-running { background: var(--accent-soft); color: var(--accent); }
|
||||
.pill-done { background: rgba(76, 195, 138, 0.16); color: var(--success); }
|
||||
.pill-failed { background: var(--danger-soft); color: var(--danger); }
|
||||
.progress { height: 6px; background: var(--surface-3); border-radius: 3px; overflow: hidden; }
|
||||
.progress span { display: block; height: 100%; background: var(--accent); transition: width 0.4s; }
|
||||
.ai-feed { list-style: none; display: flex; flex-direction: column; gap: 6px; max-height: 260px; overflow-y: auto; font-size: 13px; }
|
||||
.ai-feed li { display: flex; flex-wrap: wrap; align-items: center; gap: 4px 6px; }
|
||||
.ai-card .tag { cursor: default; padding: 2px 8px; font-size: 11px; }
|
||||
.rule-groups { display: grid; grid-template-columns: repeat(auto-fill, minmax(190px, 1fr)); gap: 14px; }
|
||||
.rule-tags { display: flex; flex-wrap: wrap; gap: 5px; }
|
||||
.rule-subhead { margin-top: 4px; }
|
||||
.rule-list { display: flex; flex-direction: column; }
|
||||
.rule-row { display: flex; justify-content: space-between; align-items: center; gap: 10px; padding: 6px 0; border-bottom: 1px solid var(--border); font-size: 14px; color: var(--text-muted); }
|
||||
.rule-form { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; }
|
||||
.rule-form input { flex: 1 1 160px; width: auto; padding: 7px 10px; font-size: 13px; }
|
||||
.switch { display: inline-flex; align-items: center; gap: 8px; font-size: 13px; color: var(--text-muted); cursor: pointer; }
|
||||
.tag.tag-ai::before { content: "✦ "; color: var(--accent); }
|
||||
.tag.tag-rule { border-style: dashed; }
|
||||
.ai-busy { background: transparent !important; color: var(--accent) !important; font-size: 10px; animation: pulse 1.4s ease-in-out infinite; }
|
||||
@keyframes pulse { 50% { opacity: 0.35; } }
|
||||
.filter-bar .search-mode { align-self: stretch; align-items: center; }
|
||||
.filter-bar .search-mode button { padding: 5px 10px; font-size: 12px; }
|
||||
.ai-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 0 14px; }
|
||||
.ai-grid .form-group { margin-bottom: 12px; }
|
||||
.ai-test-row { display: flex; align-items: center; gap: 12px; margin-top: 10px; }
|
||||
.ai-test-result { min-height: 1em; margin-top: 8px; }
|
||||
.ai-test-result.is-ok { color: var(--success); }
|
||||
.ai-test-result.is-error { color: var(--danger); }
|
||||
@media (max-width: 640px) { .ai-grid { grid-template-columns: 1fr; } .ai-run-actions { margin-left: 0; } }
|
||||
.renamed-note { font-size: 13px; color: var(--text); background: var(--accent-soft); border: 1px solid rgba(240, 151, 42, 0.35); border-radius: var(--radius-sm); padding: 8px 12px; margin-bottom: 16px; }
|
||||
.renamed-from { font-size: 12px; color: var(--text-dim); }
|
||||
.ai-tasks { border: none; padding: 0; margin: 0; display: flex; flex-direction: column; gap: 8px; }
|
||||
.ai-tasks legend { font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.08em; color: var(--text-dim); margin-bottom: 8px; }
|
||||
.ai-start .ai-subtask { margin-left: 26px; }
|
||||
.ai-start label:has(input:disabled) { opacity: 0.5; cursor: default; }
|
||||
.suggestion-list { display: flex; flex-direction: column; gap: 10px; }
|
||||
.suggestion { border: 1px solid var(--border); border-radius: var(--radius-sm); padding: 10px 14px; background: var(--surface-2); display: flex; flex-direction: column; gap: 6px; }
|
||||
.suggestion-head { display: flex; flex-wrap: wrap; align-items: center; gap: 6px 12px; font-size: 14px; }
|
||||
.suggestion-actions { margin-left: auto; display: flex; gap: 8px; }
|
||||
.suggestion-actions .btn-sm { align-self: auto; }
|
||||
.suggestion-models { font-size: 13px; color: var(--text-muted); line-height: 1.7; }
|
||||
.suggested-collections { font-size: 12px; color: var(--accent); }
|
||||
.token-reason { font-size: 14px; color: var(--text); line-height: 1.5; margin-bottom: 12px; }
|
||||
.token-steps { margin: 0 0 16px 18px; font-size: 13px; color: var(--text-muted); line-height: 1.7; }
|
||||
.token-steps code, .token-steps kbd { font-family: var(--mono); font-size: 12px; }
|
||||
.token-status { min-height: 1em; }
|
||||
.token-status.is-error { color: var(--danger); }
|
||||
.watch-form { display: flex; flex-wrap: wrap; align-items: center; gap: 8px 12px; }
|
||||
.watch-form input[type="url"] { flex: 1 1 320px; width: auto; }
|
||||
.watch-form label { display: inline-flex; align-items: center; gap: 6px; font-size: 13px; color: var(--text-muted); }
|
||||
.watch-form select { width: auto; padding: 7px 10px; font-size: 13px; }
|
||||
.watch-form input[type="checkbox"] { width: auto; accent-color: var(--accent); }
|
||||
.watch-form .btn { align-self: auto; }
|
||||
.watch-list { display: flex; flex-direction: column; gap: 10px; }
|
||||
.watch { border: 1px solid var(--border); border-radius: var(--radius-sm); background: var(--surface-2); padding: 12px 14px; display: flex; flex-direction: column; gap: 8px; }
|
||||
.watch.is-paused { opacity: 0.65; }
|
||||
.watch-head { display: flex; flex-wrap: wrap; align-items: center; gap: 6px 12px; font-size: 14px; }
|
||||
.watch-actions { margin-left: auto; display: flex; flex-wrap: wrap; gap: 8px; }
|
||||
.watch-actions .btn-sm { align-self: auto; }
|
||||
.slot-list { display: flex; flex-direction: column; gap: 6px; margin: 8px 0; }
|
||||
.slot-actions { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 10px; }
|
||||
.slot-edit .slot-material { width: 110px; padding: 5px 8px; font-size: 13px; }
|
||||
.slot-edit .slot-colour { width: 42px; padding: 2px; }
|
||||
.slot-edit .slot-colour:disabled { opacity: 0.35; }
|
||||
.slot-any { display: inline-flex; align-items: center; gap: 5px; font-size: 12px; color: var(--text-muted); }
|
||||
.slot-any input { width: auto; accent-color: var(--accent); }
|
||||
.print-badge.yours { background: color-mix(in srgb, #6366f1 20%, transparent); color: #a5b4fc; }
|
||||
.slot-row { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; font-size: 13px; }
|
||||
.print-badge { display: inline-block; padding: 2px 8px; border-radius: 999px; font-size: 11px; white-space: nowrap; }
|
||||
.print-badge.ok { background: color-mix(in srgb, #22c55e 18%, transparent); color: #4ade80; }
|
||||
.print-badge.no { background: color-mix(in srgb, #f59e0b 16%, transparent); color: #fbbf24; }
|
||||
.spool-dot { width: 14px; height: 14px; border-radius: 50%; border: 1px solid var(--border); display: inline-block; }
|
||||
.spool .watch-actions label { display: inline-flex; align-items: center; gap: 6px; }
|
||||
.spool .spool-remaining { width: 90px; padding: 5px 8px; font-size: 13px; }
|
||||
.ai-failures summary { cursor: pointer; font-size: 13px; color: var(--text-muted); }
|
||||
.ai-failures summary:hover { color: var(--text); }
|
||||
.watch-pending summary { cursor: pointer; font-size: 13px; color: var(--text-muted); }
|
||||
.watch-pending summary:hover { color: var(--text); }
|
||||
.watch-pending .watch-recent { margin-top: 6px; }
|
||||
.watch-meta { font-family: var(--mono); font-size: 12px; color: var(--text-dim); }
|
||||
.watch-recent { display: flex; flex-direction: column; gap: 4px; font-size: 13px; color: var(--text-muted); max-height: 180px; overflow-y: auto; }
|
||||
#watchStatus.is-error { color: var(--danger); }
|
||||
|
||||
/* ---- Printers ---- */
|
||||
.printer-cards { display: flex; flex-direction: column; gap: 18px; }
|
||||
.printer-card { flex-direction: row; align-items: flex-start; gap: 20px; }
|
||||
.printer-side { flex: 0 0 180px; min-width: 0; display: flex; flex-direction: column; gap: 8px; }
|
||||
.printer-art { color: var(--text-muted); padding: 20px 10px 0; }
|
||||
.loaded-list { min-width: 0; list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 3px; font-size: 12px; color: var(--text-muted); }
|
||||
.loaded-list li { display: flex; flex-wrap: wrap; align-items: center; gap: 2px 6px; }
|
||||
.loaded-slot { font-family: var(--mono); color: var(--text-dim); min-width: 62px; }
|
||||
.spool-dot.is-empty { background: transparent; border: 1px dashed var(--text-dim); }
|
||||
.printer-art svg { width: 100%; height: auto; display: block; }
|
||||
.printer-form { flex: 1; min-width: 0; display: grid; grid-template-columns: repeat(auto-fit, minmax(190px, 1fr)); gap: 10px 14px; }
|
||||
.printer-form label { display: flex; flex-direction: column; gap: 4px; font-size: 12px; color: var(--text-muted); }
|
||||
.printer-form input, .printer-form select { width: 100%; }
|
||||
.printer-title, .printer-actions, .printer-result { grid-column: 1 / -1; }
|
||||
.printer-actions { display: flex; flex-wrap: wrap; gap: 8px; }
|
||||
.printer-actions .btn-sm { align-self: auto; }
|
||||
.printer-result:empty { display: none; }
|
||||
.spool-tag code { color: var(--text-muted); }
|
||||
@media (max-width: 600px) {
|
||||
.printer-card { flex-direction: column; }
|
||||
.printer-side { flex-basis: auto; width: 100%; flex-direction: row; flex-wrap: wrap; align-items: flex-start; }
|
||||
.printer-art { width: 140px; padding: 0; }
|
||||
}
|
||||
.printer-ask { grid-column: 1 / -1; color: var(--accent); }
|
||||
.loaded-list .spool-dot { flex-shrink: 0; width: 12px; height: 12px; }
|
||||
.printer-status { grid-column: 1 / -1; display: flex; flex-direction: column; gap: 6px; font-size: 13px; color: var(--text-muted); }
|
||||
.printer-status:empty { display: none; }
|
||||
.status-head { display: flex; align-items: center; gap: 8px; min-width: 0; }
|
||||
.status-pill { flex-shrink: 0; font-size: 10px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.06em; padding: 3px 8px; border-radius: 10px; background: var(--surface-3); color: var(--text-muted); }
|
||||
.status-pill.is-busy { background: var(--accent-soft); color: var(--accent); }
|
||||
.status-pill.is-warn { background: var(--accent-soft); color: var(--text); }
|
||||
.status-pill.is-ok { background: rgba(76, 195, 138, 0.16); color: var(--success); }
|
||||
.status-pill.is-bad, .status-pill.is-off { background: var(--danger-soft); color: var(--danger); }
|
||||
.status-file { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--text); }
|
||||
.status-bar { height: 6px; border-radius: 3px; background: var(--surface-3); overflow: hidden; }
|
||||
.status-bar span { display: block; height: 100%; background: var(--accent); transition: width 0.6s ease; }
|
||||
.status-facts { font-family: var(--mono); font-size: 12px; }
|
||||
.status-problems { color: var(--danger); font-size: 12px; }
|
||||
.loaded-list li.is-active { color: var(--text); }
|
||||
.loaded-active { color: var(--accent); font-size: 11px; }
|
||||
.status-using { display: flex; align-items: center; gap: 6px; color: var(--text); }
|
||||
.status-using .spool-dot { width: 12px; height: 12px; flex-shrink: 0; }
|
||||
.feed-flow { animation: feed-flow 0.8s linear infinite; }
|
||||
@keyframes feed-flow { to { stroke-dashoffset: -8; } }
|
||||
@media (prefers-reduced-motion: reduce) { .feed-flow { animation: none; } }
|
||||
.printer-camera { grid-column: 1 / -1; display: flex; flex-direction: column; gap: 6px; }
|
||||
.printer-camera img { width: 100%; max-width: 520px; border-radius: var(--radius-sm); border: 1px solid var(--border); background: var(--surface-2); display: block; }
|
||||
.printer-camera img:not([src]) { display: none; }
|
||||
|
|
|
|||
803
server.js
803
server.js
|
|
@ -23,37 +23,6 @@ const fingerprint = require('./lib/fingerprint');
|
|||
const duplicates = require('./lib/duplicates');
|
||||
const archives = require('./lib/archives');
|
||||
const thumbnails = require('./lib/thumbnails');
|
||||
const handoff = require('./lib/handoff');
|
||||
const ai = require('./lib/ai');
|
||||
const aiRuns = require('./lib/aiRuns');
|
||||
const collectionWatch = require('./lib/collectionWatch');
|
||||
const autotag = require('./lib/autotag');
|
||||
const filaments = require('./lib/filaments');
|
||||
const filamentPalette = require('./db/bambu-filaments.json');
|
||||
const printer = require('./lib/printer');
|
||||
const printers = require('./lib/printers');
|
||||
const spoolSync = require('./lib/spoolSync');
|
||||
|
||||
const printerLive = require('./lib/printerLive');
|
||||
const printerCamera = require('./lib/printerCamera');
|
||||
const downloadBlock = require('./lib/downloadBlock');
|
||||
|
||||
// What a printer has loaded → its card and the Filament page.
|
||||
async function syncTrays(printerId, trays) {
|
||||
const p = await printers.get(printerId);
|
||||
if (!p || !p.serial) return null;
|
||||
await printers.recordLoaded(p.id, trays);
|
||||
return spoolSync.syncPrinter(p.serial, trays, filamentPalette.filaments,
|
||||
{ measures: printers.measures(p.ams_type) });
|
||||
}
|
||||
printerLive.setTraysHandler(syncTrays);
|
||||
|
||||
// Opens, closes or reopens live connections to match the printers saved.
|
||||
async function reconnectPrinters() {
|
||||
printerLive.reconcile(await printers.listWithCodes());
|
||||
}
|
||||
|
||||
const embeddings = require('./lib/embeddings');
|
||||
|
||||
const app = express();
|
||||
const PORT = process.env.PORT || 3000;
|
||||
|
|
@ -138,22 +107,8 @@ const SETTINGS = {
|
|||
smtp_user: { env: 'SMTP_USER', label: 'SMTP username', secret: false },
|
||||
smtp_pass: { env: 'SMTP_PASS', label: 'SMTP password', secret: true },
|
||||
smtp_from: { env: 'SMTP_FROM', label: 'From address', secret: false },
|
||||
ai_provider: { env: 'AI_PROVIDER', label: 'AI provider', secret: false },
|
||||
ai_base_url: { env: 'AI_BASE_URL', label: 'AI API address', secret: false },
|
||||
ai_api_key: { env: 'AI_API_KEY', label: 'AI API key', secret: true },
|
||||
ai_model: { env: 'AI_MODEL', label: 'AI model', secret: false },
|
||||
ai_send_images: { env: 'AI_SEND_IMAGES', label: 'Send thumbnails to the AI', secret: false },
|
||||
ai_price_input: { env: 'AI_PRICE_INPUT', label: 'Input price ($ per million tokens)', secret: false },
|
||||
ai_price_output: { env: 'AI_PRICE_OUTPUT', label: 'Output price ($ per million tokens)', secret: false },
|
||||
};
|
||||
|
||||
const AI_SETTING_KEYS = Object.keys(SETTINGS).filter((k) => k.startsWith('ai_'));
|
||||
async function resolveAiConfig() {
|
||||
const values = {};
|
||||
for (const key of AI_SETTING_KEYS) values[key] = (await resolveSetting(key)).value;
|
||||
return ai.configFrom(values);
|
||||
}
|
||||
|
||||
async function resolveSetting(key) {
|
||||
const fromDb = await store.getSetting(key);
|
||||
if (fromDb) return { value: fromDb, source: 'database' };
|
||||
|
|
@ -344,143 +299,6 @@ app.put('/api/settings', async (req, res, next) => {
|
|||
} catch (e) { next(e); }
|
||||
});
|
||||
|
||||
// ----------------------------------------------------------------- filament
|
||||
// The spools you have. What a model needs is read from its 3MF in the
|
||||
// background and kept on the model itself, so matching is a plain comparison.
|
||||
// ----------------------------------------------------------------- printers
|
||||
// Access codes go in but never come back out: responses carry `codeSet`.
|
||||
app.get('/api/printers', async (req, res, next) => {
|
||||
try { res.json(await printers.list()); } catch (e) { next(e); }
|
||||
});
|
||||
|
||||
app.post('/api/printers', async (req, res, next) => {
|
||||
try {
|
||||
const saved = await printers.create(req.body || {});
|
||||
await reconnectPrinters();
|
||||
res.status(201).json(saved);
|
||||
} catch (e) { next(e); }
|
||||
});
|
||||
|
||||
app.put('/api/printers/:id', async (req, res, next) => {
|
||||
try {
|
||||
const saved = await printers.update(Number(req.params.id), req.body || {});
|
||||
if (!saved) return res.status(404).json({ error: 'No such printer' });
|
||||
await reconnectPrinters();
|
||||
res.json(saved);
|
||||
} catch (e) { next(e); }
|
||||
});
|
||||
|
||||
app.delete('/api/printers/:id', async (req, res, next) => {
|
||||
try {
|
||||
if (!(await printers.remove(Number(req.params.id)))) return res.status(404).json({ error: 'No such printer' });
|
||||
await reconnectPrinters();
|
||||
res.json({ message: 'Printer removed' });
|
||||
} catch (e) { next(e); }
|
||||
});
|
||||
|
||||
// The chamber camera as a stream of pictures, which an <img> can show directly.
|
||||
// The connection to the printer is made here, so the access code stays put, and
|
||||
// it's dropped as soon as the last viewer looks away. Nothing is recorded.
|
||||
app.get('/api/printers/:id/camera', async (req, res, next) => {
|
||||
try {
|
||||
const p = await printers.get(Number(req.params.id), { withCode: true });
|
||||
if (!p) return res.status(404).json({ error: 'No such printer' });
|
||||
if (!p.host || !p.access_code) return res.status(400).json({ error: 'Fill in the printer’s address and access code first.' });
|
||||
|
||||
const boundary = 'printhiveframe';
|
||||
res.writeHead(200, {
|
||||
'Content-Type': `multipart/x-mixed-replace; boundary=${boundary}`,
|
||||
'Cache-Control': 'no-store, no-cache, must-revalidate',
|
||||
Connection: 'close',
|
||||
Pragma: 'no-cache',
|
||||
});
|
||||
|
||||
const unsubscribe = printerCamera.subscribe(p.id, { host: p.host, accessCode: p.access_code },
|
||||
(frame) => {
|
||||
if (res.writableEnded) return;
|
||||
res.write(`--${boundary}\r\nContent-Type: image/jpeg\r\nContent-Length: ${frame.length}\r\n\r\n`);
|
||||
res.write(frame);
|
||||
res.write('\r\n');
|
||||
},
|
||||
() => { if (!res.writableEnded) res.end(); });
|
||||
req.on('close', () => { unsubscribe(); if (!res.writableEnded) res.end(); });
|
||||
} catch (e) { next(e); }
|
||||
});
|
||||
|
||||
// Tries the printer's chamber camera and says whether a picture arrived.
|
||||
// P1 and A1 series only, and the printer needs "LAN Mode Liveview" switched on.
|
||||
app.post('/api/printers/:id/camera/test', async (req, res, next) => {
|
||||
try {
|
||||
const p = await printers.get(Number(req.params.id), { withCode: true });
|
||||
if (!p) return res.status(404).json({ error: 'No such printer' });
|
||||
if (!p.host || !p.access_code) return res.json({ ok: false, error: 'Fill in the printer’s address and access code first.' });
|
||||
res.json(await printerCamera.testCamera({ host: p.host, accessCode: p.access_code }));
|
||||
} catch (e) { next(e); }
|
||||
});
|
||||
|
||||
// What every printer is doing right now, from the live connections.
|
||||
app.get('/api/printers/live', (req, res) => {
|
||||
res.set('Cache-Control', 'no-store');
|
||||
res.json(printerLive.snapshots());
|
||||
});
|
||||
|
||||
// Asks one printer for a full report, says what it reported, and updates the
|
||||
// spools it has loaded. ?debug=1 instead makes a separate short connection and
|
||||
// adds one tray's raw fields — filament data, no credentials.
|
||||
app.post('/api/printers/:id/test', async (req, res, next) => {
|
||||
try {
|
||||
const p = await printers.get(Number(req.params.id), { withCode: true });
|
||||
if (!p) return res.status(404).json({ error: 'No such printer' });
|
||||
const missing = [!p.host && 'address', !p.serial && 'serial number', !p.access_code && 'access code']
|
||||
.filter(Boolean);
|
||||
if (missing.length) return res.json({ connected: false, error: `Fill in the printer’s ${missing.join(', ')} first.` });
|
||||
if (req.query.debug === '1') {
|
||||
const result = await printer.probe({ host: p.host, serial: p.serial, accessCode: p.access_code, debug: true });
|
||||
const trays = [...(result.ams || []), ...(result.external ? [result.external] : [])];
|
||||
if (result.connected && (result.ams || result.external)) result.sync = await syncTrays(p.id, trays);
|
||||
return res.json(result);
|
||||
}
|
||||
await reconnectPrinters();
|
||||
res.json(await printerLive.refresh(p.id));
|
||||
} catch (e) { next(e); }
|
||||
});
|
||||
|
||||
// The maker's filament list, for picking a spool without typing hex codes.
|
||||
// Static, so it's cached hard rather than re-read.
|
||||
app.get('/api/filament/palette', (req, res) => {
|
||||
res.set('Cache-Control', 'public, max-age=86400');
|
||||
res.json(filamentPalette);
|
||||
});
|
||||
|
||||
app.get('/api/filament', async (req, res, next) => {
|
||||
try {
|
||||
res.json({ spools: await store.listSpools(), pending: await filaments.pendingCount() });
|
||||
} catch (e) { next(e); }
|
||||
});
|
||||
|
||||
app.post('/api/filament', async (req, res, next) => {
|
||||
try {
|
||||
const material = String((req.body || {}).material || '').trim();
|
||||
if (!material) return res.status(400).json({ error: 'A spool needs a material (PLA, PETG, …).' });
|
||||
res.status(201).json(await store.createSpool({ ...req.body, material }));
|
||||
} catch (e) { next(e); }
|
||||
});
|
||||
|
||||
app.put('/api/filament/:id', async (req, res, next) => {
|
||||
try {
|
||||
const spool = await store.updateSpool(Number(req.params.id), req.body || {});
|
||||
if (!spool) return res.status(404).json({ error: 'No such spool' });
|
||||
res.json(spool);
|
||||
} catch (e) { next(e); }
|
||||
});
|
||||
|
||||
app.delete('/api/filament/:id', async (req, res, next) => {
|
||||
try {
|
||||
if (!(await store.deleteSpool(Number(req.params.id)))) return res.status(404).json({ error: 'No such spool' });
|
||||
res.json({ message: 'Spool removed' });
|
||||
} catch (e) { next(e); }
|
||||
});
|
||||
|
||||
// ----------------------------------------------------------------- models
|
||||
app.get('/api/models', async (req, res, next) => {
|
||||
try {
|
||||
|
|
@ -538,7 +356,6 @@ app.put('/api/models/:id', async (req, res, next) => {
|
|||
if (updates.license !== undefined) updates.commercial_use = await resolveCommercial(updates.license);
|
||||
const model = await store.updateModel(req.params.id, updates);
|
||||
if (!model) return res.status(404).json({ error: 'Model not found' });
|
||||
fingerprint.kick(); // names and tags feed automatic tags and search
|
||||
res.json(publicModel(model));
|
||||
} catch (e) { next(e); }
|
||||
});
|
||||
|
|
@ -585,34 +402,6 @@ async function refreshMetadata(model) {
|
|||
// Re-download files from the source into an existing model (recovers imports
|
||||
// that came back empty/partial, e.g. when FlareSolverr was flaky). Only adds
|
||||
// files not already present.
|
||||
// The filament slots a model needs — for when the project's own list is wrong,
|
||||
// or you plan to print it in something else. Saved as yours, so the background
|
||||
// reader won't overwrite it.
|
||||
app.put('/api/models/:id/filaments', async (req, res, next) => {
|
||||
try {
|
||||
const sent = (req.body || {}).filaments;
|
||||
const cleaned = filaments.cleanSlots(sent);
|
||||
if (!cleaned) return res.status(400).json({ error: 'Send { filaments: [{ type, colour }] }' });
|
||||
if (sent.length > 32) return res.status(400).json({ error: 'That is more slots than any printer has.' });
|
||||
if (!(await store.setModelFilaments(Number(req.params.id), cleaned))) {
|
||||
return res.status(404).json({ error: 'Model not found' });
|
||||
}
|
||||
res.json(publicModel(await store.getModel(req.params.id)));
|
||||
} catch (e) { next(e); }
|
||||
});
|
||||
|
||||
// Throw away your edits and read the project's own list again.
|
||||
app.delete('/api/models/:id/filaments', async (req, res, next) => {
|
||||
try {
|
||||
const { rowCount } = await db.query(
|
||||
`UPDATE models SET filaments = NULL, filaments_status = NULL WHERE id = $1 AND deleted_at IS NULL`,
|
||||
[Number(req.params.id)]);
|
||||
if (!rowCount) return res.status(404).json({ error: 'Model not found' });
|
||||
fingerprint.kick(); // the reader picks it up again
|
||||
res.json({ message: 'Back to the project’s own filament list' });
|
||||
} catch (e) { next(e); }
|
||||
});
|
||||
|
||||
app.post('/api/models/:id/download-files', async (req, res, next) => {
|
||||
try {
|
||||
const model = await store.getModel(req.params.id);
|
||||
|
|
@ -626,9 +415,6 @@ app.post('/api/models/:id/download-files', async (req, res, next) => {
|
|||
return res.status(200).json({ ...publicModel(model), added: 0 });
|
||||
}
|
||||
const updated = await store.addFiles(model.id, newFiles);
|
||||
// If a watched collection imported this one without its files, it's fine now.
|
||||
await db.query(`UPDATE watched_collection_items SET status = 'imported', error = NULL
|
||||
WHERE model_id = $1 AND status = 'partial'`, [model.id]);
|
||||
fingerprint.kick();
|
||||
res.json({ ...publicModel(updated), added: newFiles.length });
|
||||
} catch (e) { next(e); }
|
||||
|
|
@ -638,7 +424,6 @@ app.post('/api/models/:id/download-files', async (req, res, next) => {
|
|||
// original name is already present.
|
||||
async function fetchSourceFiles(model, skipNames) {
|
||||
if (model.source_site === 'makerworld') {
|
||||
await requireMakerworldSettings(); // before the page fetch, which fails without FlareSolverr
|
||||
const html = await fetchPageHtml(model.source_url);
|
||||
return downloadMakerworldFiles(html, skipNames);
|
||||
}
|
||||
|
|
@ -805,54 +590,6 @@ app.get('/api/entries/:id/raw', async (req, res, next) => {
|
|||
} catch (e) { next(e); }
|
||||
});
|
||||
|
||||
// ----------------------------------------------------------------- slicer hand-off
|
||||
// "Open in slicer" launches the slicer with a link it downloads itself. The slicer
|
||||
// has no session cookie, so the link carries a short-lived grant for one file.
|
||||
async function handoffTarget(kind, id) {
|
||||
if (!Number.isInteger(Number(id))) return null;
|
||||
if (kind === 'file') {
|
||||
const file = await store.getFile(Number(id));
|
||||
return file && { name: file.original_name, format: file.format, resolve: async () => path.resolve(file.file_path) };
|
||||
}
|
||||
if (kind === 'entry') {
|
||||
const entry = await store.getArchiveEntry(Number(id));
|
||||
return entry && {
|
||||
name: path.posix.basename(entry.path), format: entry.format,
|
||||
resolve: () => archives.extractEntry(entry.archive, entry),
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// -> { path: "/dl/<token>/<name>", name, expiresIn }. The browser adds its own
|
||||
// origin, which is the address this machine already reaches the server on.
|
||||
app.post('/api/handoff', async (req, res, next) => {
|
||||
try {
|
||||
const { kind, id } = req.body || {};
|
||||
const target = await handoffTarget(kind, id);
|
||||
if (!target) return res.status(404).json({ error: 'File not found' });
|
||||
if (!handoff.SLICEABLE.includes(target.format)) {
|
||||
return res.status(415).json({ error: `.${target.format} files can't be opened in a slicer` });
|
||||
}
|
||||
const token = handoff.createToken(await auth.getSecret(), { kind, id: Number(id) });
|
||||
res.json({ path: `/dl/${token}/${handoff.urlName(target.name)}`, name: target.name, expiresIn: handoff.TTL_SECONDS });
|
||||
} catch (e) { next(e); }
|
||||
});
|
||||
|
||||
// Outside /api on purpose: the token is the only credential.
|
||||
app.get('/dl/:token/:name', async (req, res, next) => {
|
||||
try {
|
||||
const grant = handoff.readToken(await auth.getSecret(), req.params.token);
|
||||
if (!grant) return res.status(403).type('text/plain').send('This download link has expired. Open the model in Printhive again.');
|
||||
const target = await handoffTarget(grant.kind, grant.id);
|
||||
if (!target) return res.status(404).type('text/plain').send('File not found');
|
||||
const abs = await target.resolve();
|
||||
if (!fs.existsSync(abs)) return res.status(404).type('text/plain').send('File missing on disk');
|
||||
res.set({ 'Cache-Control': 'no-store', 'X-Content-Type-Options': 'nosniff', 'Referrer-Policy': 'no-referrer' });
|
||||
res.download(abs, target.name);
|
||||
} catch (e) { next(e); }
|
||||
});
|
||||
|
||||
// Serve a 3MF as GLB, converting on first request (cached thereafter). This lets
|
||||
// the browser render Bambu/MakerWorld 3MFs and backfills volume/bbox.
|
||||
// ?plate=N converts just that plate of a slicer project.
|
||||
|
|
@ -983,112 +720,88 @@ function removeDerivedFiles(fileIds, entryIds) {
|
|||
}
|
||||
|
||||
// ----------------------------------------------------------------- import
|
||||
// Unified import. Thingiverse downloads files automatically (needs a token);
|
||||
// Printables / MakerWorld / any other URL import page metadata + preview image,
|
||||
// and the user attaches the files afterwards.
|
||||
// Imports one model URL: its details, its files where they can be downloaded, and
|
||||
// its license. Returns { model, existing, metadataFetched, filesError, filesErrorReason }.
|
||||
// Used by the import endpoint and by watched collections.
|
||||
// withFiles: false imports the page only (name, description, thumbnail, licence).
|
||||
// Watched collections use it — MakerWorld refuses more than a couple of downloads
|
||||
// in a row, so files are fetched later, per model, with "Get files".
|
||||
async function importFromUrl(url, { projects = [], withFiles = true } = {}) {
|
||||
const detected = generic.detectSite(url);
|
||||
if (!detected) throw Object.assign(new Error('That does not look like a valid URL.'), { status: 400 });
|
||||
|
||||
if (detected.site === 'thingiverse') {
|
||||
return { model: await importThingiverse(url, projects), metadataFetched: true };
|
||||
}
|
||||
|
||||
// Metadata-only import for everything else.
|
||||
if (detected.id) {
|
||||
const existing = await store.findModelBySource(detected.site, detected.id);
|
||||
if (existing) return { model: publicModel(existing), existing: true };
|
||||
}
|
||||
// Best-effort: read the page (via FlareSolverr if it's Cloudflare-blocked).
|
||||
// If it still can't be read, fall back to a stub so it's not a dead end.
|
||||
let html = null;
|
||||
let meta = { title: null, description: '', image: null, author: null };
|
||||
let metadataFetched = false;
|
||||
try {
|
||||
html = await fetchPageHtml(url);
|
||||
meta = generic.parseMetadata(html, url);
|
||||
// Printables og:title carries a " | Download free STL model | Printables.com" suffix.
|
||||
if (detected.site === 'printables' && meta.title) meta.title = meta.title.split(' | ')[0].trim();
|
||||
// cubee3d (Wix) serves a generic site-wide og:title, so use the URL slug instead.
|
||||
if (detected.site === 'cubee3d') meta.title = generic.nameFromUrl(url);
|
||||
metadataFetched = Boolean(meta.title);
|
||||
} catch (err) {
|
||||
console.warn('Metadata fetch failed, creating stub model:', err.message);
|
||||
}
|
||||
|
||||
// Download the actual files where we can (MakerWorld via FlareSolverr+token,
|
||||
// Printables via its public GraphQL API), and capture the license.
|
||||
let files = [];
|
||||
let filesError = null; // why no files could be downloaded, shown after the import
|
||||
let filesErrorReason = null; // makerworld_token: the app offers to update the token
|
||||
let license = meta.license || null;
|
||||
let commercial;
|
||||
if (detected.site === 'makerworld' && html) {
|
||||
if (withFiles) {
|
||||
files = await downloadMakerworldFiles(html).catch((err) => {
|
||||
console.warn('MakerWorld file download failed:', err.message);
|
||||
filesError = err.message;
|
||||
filesErrorReason = err.reason || null;
|
||||
return [];
|
||||
});
|
||||
}
|
||||
license = makerworld.parseLicense(html) || license;
|
||||
commercial = await resolveCommercial(license);
|
||||
} else if (detected.site === 'printables' && detected.id) {
|
||||
const result = await downloadPrintablesFiles(detected.id).catch((err) => {
|
||||
console.warn('Printables file download failed:', err.message);
|
||||
filesError = err.message;
|
||||
return { files: [] };
|
||||
});
|
||||
files = result.files;
|
||||
if (result.license) license = result.license;
|
||||
commercial = result.commercial ?? await resolveCommercial(license);
|
||||
} else if (detected.site === 'cubee3d') {
|
||||
// Auth-gated Wix site — files added manually. You hold a commercial license.
|
||||
license = 'Cubee3D Commercial License';
|
||||
commercial = await resolveCommercial(license, true);
|
||||
} else {
|
||||
commercial = await resolveCommercial(license);
|
||||
}
|
||||
|
||||
const thumbnail = (meta.image ? await downloadImageThumbnail(meta.image) : null) || pickThumbnail(files);
|
||||
const model = await store.createModel({
|
||||
name: meta.title || generic.nameFromUrl(url),
|
||||
description: meta.description || '',
|
||||
source_site: detected.site,
|
||||
source_url: url,
|
||||
source_id: detected.id,
|
||||
license,
|
||||
commercial_use: commercial,
|
||||
designer: meta.author ? { name: meta.author, source_site: detected.site } : null,
|
||||
thumbnail_path: thumbnail,
|
||||
tags: [],
|
||||
projects,
|
||||
files,
|
||||
});
|
||||
fingerprint.kick();
|
||||
return { model: publicModel(model), metadataFetched, filesError, filesErrorReason };}
|
||||
|
||||
// Unified import. Thingiverse downloads files automatically (needs a token);
|
||||
// Printables / MakerWorld / any other URL import page metadata + preview image,
|
||||
// and the user attaches the files afterwards.
|
||||
app.post('/api/import', async (req, res, next) => {
|
||||
try {
|
||||
const result = await importFromUrl((req.body.url || '').trim(), { projects: req.body.projects || [] });
|
||||
if (result.existing) return res.status(409).json({ error: 'Already imported', model: result.model });
|
||||
res.status(201).json({
|
||||
...result.model,
|
||||
metadataOnly: (result.model.files || []).length === 0,
|
||||
metadataFetched: Boolean(result.metadataFetched),
|
||||
filesError: result.filesError || null,
|
||||
filesErrorReason: result.filesErrorReason || null,
|
||||
const url = (req.body.url || '').trim();
|
||||
const projects = req.body.projects || [];
|
||||
const detected = generic.detectSite(url);
|
||||
if (!detected) return res.status(400).json({ error: 'That does not look like a valid URL.' });
|
||||
|
||||
if (detected.site === 'thingiverse') {
|
||||
const model = await importThingiverse(url, projects);
|
||||
return res.status(201).json(model);
|
||||
}
|
||||
|
||||
// Metadata-only import for everything else.
|
||||
if (detected.id) {
|
||||
const existing = await store.findModelBySource(detected.site, detected.id);
|
||||
if (existing) return res.status(409).json({ error: 'Already imported', model: publicModel(existing) });
|
||||
}
|
||||
// Best-effort: read the page (via FlareSolverr if it's Cloudflare-blocked).
|
||||
// If it still can't be read, fall back to a stub so it's not a dead end.
|
||||
let html = null;
|
||||
let meta = { title: null, description: '', image: null, author: null };
|
||||
let metadataFetched = false;
|
||||
try {
|
||||
html = await fetchPageHtml(url);
|
||||
meta = generic.parseMetadata(html, url);
|
||||
// Printables og:title carries a " | Download free STL model | Printables.com" suffix.
|
||||
if (detected.site === 'printables' && meta.title) meta.title = meta.title.split(' | ')[0].trim();
|
||||
// cubee3d (Wix) serves a generic site-wide og:title, so use the URL slug instead.
|
||||
if (detected.site === 'cubee3d') meta.title = generic.nameFromUrl(url);
|
||||
metadataFetched = Boolean(meta.title);
|
||||
} catch (err) {
|
||||
console.warn('Metadata fetch failed, creating stub model:', err.message);
|
||||
}
|
||||
|
||||
// Download the actual files where we can (MakerWorld via FlareSolverr+token,
|
||||
// Printables via its public GraphQL API), and capture the license.
|
||||
let files = [];
|
||||
let license = meta.license || null;
|
||||
let commercial = null;
|
||||
if (detected.site === 'makerworld' && html) {
|
||||
files = await downloadMakerworldFiles(html).catch((err) => {
|
||||
console.warn('MakerWorld file download failed:', err.message);
|
||||
return [];
|
||||
});
|
||||
license = makerworld.parseLicense(html) || license;
|
||||
commercial = await resolveCommercial(license);
|
||||
} else if (detected.site === 'printables' && detected.id) {
|
||||
const result = await downloadPrintablesFiles(detected.id).catch((err) => {
|
||||
console.warn('Printables file download failed:', err.message);
|
||||
return { files: [] };
|
||||
});
|
||||
files = result.files;
|
||||
if (result.license) license = result.license;
|
||||
commercial = result.commercial ?? await resolveCommercial(license);
|
||||
} else if (detected.site === 'cubee3d') {
|
||||
// Auth-gated Wix site — files added manually. You hold a commercial license.
|
||||
license = 'Cubee3D Commercial License';
|
||||
commercial = await resolveCommercial(license, true);
|
||||
} else {
|
||||
commercial = await resolveCommercial(license);
|
||||
}
|
||||
|
||||
const thumbnail = (meta.image ? await downloadImageThumbnail(meta.image) : null) || pickThumbnail(files);
|
||||
const model = await store.createModel({
|
||||
name: meta.title || generic.nameFromUrl(url),
|
||||
description: meta.description || '',
|
||||
source_site: detected.site,
|
||||
source_url: url,
|
||||
source_id: detected.id,
|
||||
license,
|
||||
commercial_use: commercial,
|
||||
designer: meta.author ? { name: meta.author, source_site: detected.site } : null,
|
||||
thumbnail_path: thumbnail,
|
||||
tags: [],
|
||||
projects,
|
||||
files,
|
||||
});
|
||||
fingerprint.kick();
|
||||
res.status(201).json({ ...publicModel(model), metadataOnly: files.length === 0, metadataFetched });
|
||||
} catch (e) { next(e); }
|
||||
});
|
||||
|
||||
|
|
@ -1137,33 +850,14 @@ async function resolveCommercial(licenseName, fallback) {
|
|||
|
||||
// Download every printable instance of a MakerWorld model (needs token + FlareSolverr).
|
||||
// skipNames: original filenames already present (so a re-download only adds new ones).
|
||||
// MakerWorld downloads need both a FlareSolverr address and your auth token.
|
||||
// Errors carry a reason the app can act on: makerworld_token opens the token prompt.
|
||||
async function requireMakerworldSettings() {
|
||||
async function downloadMakerworldFiles(html, skipNames = new Set()) {
|
||||
const { value: token } = await resolveSetting('makerworld_token');
|
||||
const { value: flareUrl } = await resolveSetting('flaresolverr_url');
|
||||
if (!flareUrl) {
|
||||
throw Object.assign(new Error('Add your FlareSolverr URL in Settings to download MakerWorld files (MakerWorld is behind Cloudflare).'),
|
||||
{ status: 400, reason: 'makerworld_settings' });
|
||||
}
|
||||
if (!token) {
|
||||
throw Object.assign(new Error('Add your MakerWorld auth token to download MakerWorld files.'), { status: 400, reason: 'makerworld_token' });
|
||||
}
|
||||
return { token, flareUrl };
|
||||
}
|
||||
|
||||
// Downloads a MakerWorld model's files. When nothing could be downloaded it throws
|
||||
// with the reason (missing settings, an expired token, …) so the import can say why.
|
||||
async function downloadMakerworldFiles(html, skipNames = new Set()) {
|
||||
const { token, flareUrl } = await requireMakerworldSettings();
|
||||
if (!token || !flareUrl) return [];
|
||||
|
||||
const instances = makerworld.parseInstances(html);
|
||||
if (!instances.length) {
|
||||
throw Object.assign(new Error('Couldn’t find any printable files on the MakerWorld page.'), { status: 502 });
|
||||
}
|
||||
ensureUploadDir();
|
||||
const files = [];
|
||||
const problems = [];
|
||||
for (let i = 0; i < instances.length; i++) {
|
||||
if (i > 0) await sleep(1200); // give FlareSolverr's browser room between calls
|
||||
const inst = instances[i];
|
||||
|
|
@ -1172,44 +866,17 @@ async function downloadMakerworldFiles(html, skipNames = new Set()) {
|
|||
dl = await makerworld.resolveDownload(flareUrl, inst.id, token);
|
||||
} catch (err) {
|
||||
console.warn(`MakerWorld instance ${inst.id} resolve failed:`, err.message);
|
||||
// A page where JSON was expected is usually the "prove you're human" check.
|
||||
if (makerworld.isChallengeText(err.raw)) {
|
||||
problems.push({ message: downloadBlock.DEFAULT, challenged: true });
|
||||
downloadBlock.block('makerworld');
|
||||
break;
|
||||
}
|
||||
problems.push({ message: `Couldn’t ask MakerWorld for a download link: ${err.message}` });
|
||||
continue;
|
||||
}
|
||||
const problem = makerworld.downloadProblem(dl);
|
||||
if (problem) {
|
||||
console.warn(`MakerWorld instance ${inst.id}: ${problem.message}`);
|
||||
problems.push(problem);
|
||||
// Only you can clear this one, so stop rather than collect refusals.
|
||||
if (problem.challenged) { downloadBlock.block('makerworld', problem.message); break; }
|
||||
continue;
|
||||
}
|
||||
if (!dl || !dl.url) continue;
|
||||
const original = dl.name || `instance-${inst.id}.3mf`;
|
||||
if (skipNames.has(original)) continue;
|
||||
// A stalled CDN connection would otherwise hang the download for ever.
|
||||
const resp = await fetch(dl.url, { signal: AbortSignal.timeout(180000) });
|
||||
if (!resp.ok) {
|
||||
console.warn(`MakerWorld CDN download ${resp.status} for ${original}`);
|
||||
problems.push({ message: `Downloading ${original} from MakerWorld failed (HTTP ${resp.status})` });
|
||||
continue;
|
||||
}
|
||||
const resp = await fetch(dl.url);
|
||||
if (!resp.ok) { console.warn(`MakerWorld CDN download ${resp.status} for ${original}`); continue; }
|
||||
const fileName = uniqueName(original);
|
||||
const filePath = path.join(UPLOAD_DIR, fileName);
|
||||
fs.writeFileSync(filePath, Buffer.from(await resp.arrayBuffer()));
|
||||
files.push(describeFile(filePath, fileName, original));
|
||||
downloadBlock.clear('makerworld'); // downloads are going through again
|
||||
}
|
||||
if (!files.length && problems.length) {
|
||||
const refused = problems.find((p) => p.tokenRejected);
|
||||
const challenged = problems.find((p) => p.challenged);
|
||||
const worst = refused || challenged || problems[0];
|
||||
throw Object.assign(new Error(worst.message),
|
||||
{ status: 502, reason: refused ? 'makerworld_token' : challenged ? 'makerworld_challenge' : undefined });
|
||||
}
|
||||
return files;
|
||||
}
|
||||
|
|
@ -1266,8 +933,7 @@ async function downloadThingiverseFiles(meta, skipNames = new Set()) {
|
|||
for (const dl of meta.downloads) {
|
||||
if (!fileProcessor.isAllowed(dl.name)) continue; // skip readmes/images
|
||||
if (skipNames.has(dl.name)) continue;
|
||||
// A stalled CDN connection would otherwise hang the download for ever.
|
||||
const resp = await fetch(dl.url, { signal: AbortSignal.timeout(180000) });
|
||||
const resp = await fetch(dl.url);
|
||||
if (!resp.ok) { console.error(`Download failed (${resp.status}): ${dl.name}`); continue; }
|
||||
const buffer = Buffer.from(await resp.arrayBuffer());
|
||||
const fileName = uniqueName(dl.name);
|
||||
|
|
@ -1343,12 +1009,9 @@ async function findDuplicateGroups() {
|
|||
|
||||
app.get('/api/library/stats', async (req, res, next) => {
|
||||
try {
|
||||
const [trash, groups, pending, thumbnailsPending, tagsPending, aiRunning] = await Promise.all([
|
||||
store.countTrashed(), findDuplicateGroups(), fingerprint.pendingCount(), thumbnails.pendingCount(),
|
||||
autotag.pendingCount(), aiRuns.hasActiveRun()]);
|
||||
const { rows: [watches] } = await db.query('SELECT COUNT(*)::int AS n FROM watched_collections WHERE enabled');
|
||||
res.json({ trash, duplicateGroups: groups.length, fingerprintsPending: pending, thumbnailsPending, tagsPending,
|
||||
aiRunning, watches: watches.n });
|
||||
const [trash, groups, pending, thumbnailsPending] = await Promise.all([
|
||||
store.countTrashed(), findDuplicateGroups(), fingerprint.pendingCount(), thumbnails.pendingCount()]);
|
||||
res.json({ trash, duplicateGroups: groups.length, fingerprintsPending: pending, thumbnailsPending });
|
||||
} catch (e) { next(e); }
|
||||
});
|
||||
|
||||
|
|
@ -1408,307 +1071,15 @@ function parseJson(value, fallback) {
|
|||
try { return JSON.parse(value); } catch { return fallback; }
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------- AI tagging
|
||||
// Which models a run covers: not yet tagged by AI, everything, or a chosen list.
|
||||
async function aiScopeIds(scope, ids) {
|
||||
if (scope === 'ids') return (Array.isArray(ids) ? ids : []).map(Number).filter(Number.isInteger);
|
||||
const { rows } = await db.query(
|
||||
`SELECT id FROM models WHERE deleted_at IS NULL ${scope === 'all' ? '' : 'AND ai_tagged_at IS NULL'} ORDER BY id`);
|
||||
return rows.map((r) => r.id);
|
||||
}
|
||||
|
||||
app.get('/api/ai/status', async (req, res, next) => {
|
||||
try {
|
||||
const config = await resolveAiConfig();
|
||||
const { rows: [counts] } = await db.query(
|
||||
`SELECT COUNT(*)::int AS all, COUNT(*) FILTER (WHERE ai_tagged_at IS NULL)::int AS untagged
|
||||
FROM models WHERE deleted_at IS NULL`);
|
||||
const { apiKey, ...safe } = config;
|
||||
res.json({
|
||||
config: { ...safe, keySet: Boolean(apiKey) },
|
||||
problem: ai.missingConfig(config),
|
||||
providers: ai.PROVIDERS,
|
||||
counts,
|
||||
search: await embeddings.status(),
|
||||
autotag: { enabled: await autotag.isEnabled(), pending: await autotag.pendingCount() },
|
||||
});
|
||||
} catch (e) { next(e); }
|
||||
});
|
||||
|
||||
// Tags a sample model with the saved settings, sending a library thumbnail too
|
||||
// when images are on, to show whether the key, model and image input all work.
|
||||
app.post('/api/ai/test', async (req, res, next) => {
|
||||
try {
|
||||
const config = await resolveAiConfig();
|
||||
const problem = ai.missingConfig(config);
|
||||
if (problem) return res.status(400).json({ error: problem });
|
||||
let image = null;
|
||||
if (config.sendImages) {
|
||||
const { rows } = await db.query(
|
||||
`SELECT id, thumbnail_path FROM models WHERE thumbnail_path IS NOT NULL AND deleted_at IS NULL ORDER BY id DESC LIMIT 10`);
|
||||
image = rows.map((m) => aiRuns.thumbnailDataUrl(m)).find(Boolean) || null;
|
||||
}
|
||||
const started = Date.now();
|
||||
const sample = { name: 'Flexi articulated dragon', files: [{ originalName: 'flexi_dragon_print_in_place.3mf' }], tags: [] };
|
||||
try {
|
||||
const result = await ai.describeModel(config, sample, { image });
|
||||
res.json({
|
||||
ok: true, model: config.model, ms: Date.now() - started, tags: result.tags, description: result.description,
|
||||
images: !image ? 'not tried' : result.imageUsed ? 'accepted' : 'not supported by this model',
|
||||
usage: result.usage, cost: result.cost,
|
||||
});
|
||||
} catch (err) {
|
||||
res.status(502).json({ error: ai.fatalMessage(err) || err.message });
|
||||
}
|
||||
} catch (e) { next(e); }
|
||||
});
|
||||
|
||||
app.post('/api/ai/estimate', async (req, res, next) => {
|
||||
try {
|
||||
const ids = await aiScopeIds(req.body && req.body.scope, req.body && req.body.ids);
|
||||
res.json({ count: ids.length, ...ai.estimate(ids.length, await resolveAiConfig()) });
|
||||
} catch (e) { next(e); }
|
||||
});
|
||||
|
||||
app.get('/api/ai/runs', async (req, res, next) => {
|
||||
try { res.json(await aiRuns.listRuns()); } catch (e) { next(e); }
|
||||
});
|
||||
|
||||
app.post('/api/ai/runs', async (req, res, next) => {
|
||||
try {
|
||||
const { scope, ids, describe, overwriteDescriptions, renameUnclear, suggestCollections, maxCost } = req.body || {};
|
||||
const run = await aiRuns.createRun(await aiScopeIds(scope, ids),
|
||||
{ describe, overwriteDescriptions, renameUnclear, suggestCollections, maxCost });
|
||||
res.status(201).json(run);
|
||||
} catch (e) { next(e); }
|
||||
});
|
||||
|
||||
app.get('/api/ai/runs/:id', async (req, res, next) => {
|
||||
try {
|
||||
const run = await aiRuns.getRun(Number(req.params.id));
|
||||
if (!run) return res.status(404).json({ error: 'Run not found' });
|
||||
res.json(run);
|
||||
} catch (e) { next(e); }
|
||||
});
|
||||
|
||||
app.post('/api/ai/runs/:id/stop', async (req, res, next) => {
|
||||
try {
|
||||
if (!(await aiRuns.stop(Number(req.params.id)))) return res.status(409).json({ error: 'This run isn’t going' });
|
||||
res.json(await aiRuns.getRun(Number(req.params.id)));
|
||||
} catch (e) { next(e); }
|
||||
});
|
||||
|
||||
app.post('/api/ai/runs/:id/resume', async (req, res, next) => {
|
||||
try {
|
||||
if (!(await aiRuns.resume(Number(req.params.id)))) return res.status(409).json({ error: 'Nothing left to do in this run' });
|
||||
res.json(await aiRuns.getRun(Number(req.params.id)));
|
||||
} catch (e) { next(e); }
|
||||
});
|
||||
|
||||
// ----------------------------------------------------------------- watched collections
|
||||
// Reads a MakerWorld collection page as you: a private collection is only listed
|
||||
// when the request carries your token.
|
||||
const COLLECTION_PAGE_SIZE = 64; // the most that call returns at once
|
||||
const COLLECTION_MAX_PAGES = 10; // so one check reads at most ~640 models
|
||||
|
||||
async function fetchMakerworldCollection(url) {
|
||||
const { token, flareUrl } = await requireMakerworldSettings();
|
||||
const cookies = [{ name: 'token', value: token, domain: '.makerworld.com' }];
|
||||
const html = await flaresolverr.solve(flareUrl, url, { cookies });
|
||||
const collection = makerworld.parseCollection(html);
|
||||
if (!collection) {
|
||||
throw Object.assign(
|
||||
new Error('MakerWorld didn’t show that collection. If it’s private, your MakerWorld token may have expired; otherwise check the link.'),
|
||||
{ status: 502, reason: 'makerworld_token' });
|
||||
}
|
||||
|
||||
// The page itself lists only the first models; the rest come from the same call
|
||||
// its "load more" uses. A shared seed keeps the pages from overlapping.
|
||||
const byId = new Map(collection.designs.map((d) => [d.id, d]));
|
||||
let seed;
|
||||
for (let page = 0; page < COLLECTION_MAX_PAGES && byId.size < collection.total; page++) {
|
||||
const before = byId.size;
|
||||
let more;
|
||||
try {
|
||||
const json = await flaresolverr.solveJson(flareUrl,
|
||||
makerworld.collectionDesignsUrl(collection.id, { offset: page * COLLECTION_PAGE_SIZE, limit: COLLECTION_PAGE_SIZE, seed }),
|
||||
{ cookies, retries: 1 });
|
||||
more = makerworld.parseCollectionDesigns(json);
|
||||
} catch (err) {
|
||||
console.warn(`MakerWorld collection ${collection.id}: couldn't read more models:`, err.message);
|
||||
break;
|
||||
}
|
||||
if (seed === undefined) seed = more.seed;
|
||||
for (const design of more.designs) if (!byId.has(design.id)) byId.set(design.id, design);
|
||||
if (!more.designs.length || byId.size === before) break; // nothing new: stop asking
|
||||
}
|
||||
return { ...collection, designs: [...byId.values()] };
|
||||
}
|
||||
|
||||
// Whether a site is waiting for you to prove you're not a robot, and the
|
||||
// button that says you have. Clearing it only lets Printhive try again.
|
||||
app.get('/api/downloads/block', (req, res) => {
|
||||
res.set('Cache-Control', 'no-store');
|
||||
res.json(downloadBlock.state('makerworld'));
|
||||
});
|
||||
|
||||
app.post('/api/downloads/block/clear', (req, res) => {
|
||||
downloadBlock.clear('makerworld');
|
||||
res.json({ message: 'Printhive will try MakerWorld downloads again.' });
|
||||
});
|
||||
|
||||
app.get('/api/watches', async (req, res, next) => {
|
||||
try { res.json(await collectionWatch.list()); } catch (e) { next(e); }
|
||||
});
|
||||
|
||||
app.get('/api/watches/:id', async (req, res, next) => {
|
||||
try {
|
||||
const watch = await collectionWatch.get(Number(req.params.id));
|
||||
if (!watch) return res.status(404).json({ error: 'Not watching that collection' });
|
||||
res.json(watch);
|
||||
} catch (e) { next(e); }
|
||||
});
|
||||
|
||||
app.post('/api/watches', async (req, res, next) => {
|
||||
try {
|
||||
const { url, aiTag, intervalHours, fetchFiles } = req.body || {};
|
||||
res.status(201).json(await collectionWatch.add({ url: String(url || '').trim(), aiTag, intervalHours, fetchFiles }));
|
||||
} catch (e) { next(e); }
|
||||
});
|
||||
|
||||
app.put('/api/watches/:id', async (req, res, next) => {
|
||||
try {
|
||||
const watch = await collectionWatch.update(Number(req.params.id), req.body || {});
|
||||
if (!watch) return res.status(404).json({ error: 'Not watching that collection' });
|
||||
res.json(watch);
|
||||
} catch (e) { next(e); }
|
||||
});
|
||||
|
||||
app.delete('/api/watches/:id', async (req, res, next) => {
|
||||
try {
|
||||
if (!(await collectionWatch.remove(Number(req.params.id)))) return res.status(404).json({ error: 'Not watching that collection' });
|
||||
res.json({ message: 'Stopped watching. The models it imported are kept.' });
|
||||
} catch (e) { next(e); }
|
||||
});
|
||||
|
||||
// Reads the collection now (so problems come back here) and imports in the background.
|
||||
app.post('/api/watches/:id/check', async (req, res, next) => {
|
||||
try { res.json(await collectionWatch.checkNow(Number(req.params.id))); } catch (e) { next(e); }
|
||||
});
|
||||
|
||||
// ----------------------------------------------------------------- collection suggestions
|
||||
function suggestionTarget(body) {
|
||||
const name = typeof (body && body.name) === 'string' ? body.name.trim() : '';
|
||||
const modelIds = Array.isArray(body && body.modelIds) ? body.modelIds.map(Number).filter(Number.isInteger) : null;
|
||||
return { name, modelIds: modelIds && modelIds.length ? modelIds : null };
|
||||
}
|
||||
|
||||
app.get('/api/collection-suggestions', async (req, res, next) => {
|
||||
try { res.json(await store.listCollectionSuggestions()); } catch (e) { next(e); }
|
||||
});
|
||||
|
||||
app.post('/api/collection-suggestions/accept', async (req, res, next) => {
|
||||
try {
|
||||
const { name, modelIds } = suggestionTarget(req.body);
|
||||
if (!name) return res.status(400).json({ error: 'Which suggestion?' });
|
||||
const result = await store.acceptCollectionSuggestion(name, modelIds);
|
||||
if (!result) return res.status(404).json({ error: 'That suggestion is no longer waiting' });
|
||||
res.json(result);
|
||||
} catch (e) { next(e); }
|
||||
});
|
||||
|
||||
app.post('/api/collection-suggestions/dismiss', async (req, res, next) => {
|
||||
try {
|
||||
const { name, modelIds } = suggestionTarget(req.body);
|
||||
if (!name) return res.status(400).json({ error: 'Which suggestion?' });
|
||||
res.json({ dismissed: await store.dismissCollectionSuggestion(name, modelIds) });
|
||||
} catch (e) { next(e); }
|
||||
});
|
||||
|
||||
// ----------------------------------------------------------------- semantic search
|
||||
app.get('/api/search', async (req, res, next) => {
|
||||
try {
|
||||
const q = String(req.query.q || '').trim();
|
||||
if (!q) return res.json({ results: [] });
|
||||
try {
|
||||
res.json({ results: await embeddings.search(q) });
|
||||
} catch (err) {
|
||||
res.status(503).json({ error: `Search by meaning isn’t available: ${err.message}` });
|
||||
}
|
||||
} catch (e) { next(e); }
|
||||
});
|
||||
|
||||
// ----------------------------------------------------------------- automatic tags
|
||||
async function autotagState() {
|
||||
return {
|
||||
enabled: await autotag.isEnabled(),
|
||||
pending: await autotag.pendingCount(),
|
||||
builtIn: autotag.BUILT_IN_RULES,
|
||||
rules: await autotag.listRules(),
|
||||
};
|
||||
}
|
||||
|
||||
app.get('/api/autotag', async (req, res, next) => {
|
||||
try { res.json(await autotagState()); } catch (e) { next(e); }
|
||||
});
|
||||
|
||||
app.put('/api/autotag', async (req, res, next) => {
|
||||
try {
|
||||
const { enabled, removeTags } = req.body || {};
|
||||
const removed = await autotag.setEnabled(Boolean(enabled), { removeTags: Boolean(removeTags) });
|
||||
fingerprint.kick();
|
||||
res.json({ ...(await autotagState()), removed });
|
||||
} catch (e) { next(e); }
|
||||
});
|
||||
|
||||
app.post('/api/autotag/rules', async (req, res, next) => {
|
||||
try {
|
||||
await autotag.addRule(req.body && req.body.phrase, req.body && req.body.tag);
|
||||
fingerprint.kick();
|
||||
res.status(201).json(await autotagState());
|
||||
} catch (e) { next(e); }
|
||||
});
|
||||
|
||||
app.delete('/api/autotag/rules/:id', async (req, res, next) => {
|
||||
try {
|
||||
await autotag.removeRule(Number(req.params.id));
|
||||
fingerprint.kick();
|
||||
res.json(await autotagState());
|
||||
} catch (e) { next(e); }
|
||||
});
|
||||
|
||||
app.post('/api/autotag/reapply', async (req, res, next) => {
|
||||
try {
|
||||
await autotag.reapply();
|
||||
fingerprint.kick();
|
||||
res.json(await autotagState());
|
||||
} catch (e) { next(e); }
|
||||
});
|
||||
|
||||
// Central error handler.
|
||||
app.use((err, req, res, next) => {
|
||||
if (!err.status) console.error('Request error:', err.message);
|
||||
res.status(err.status || 500).json({ error: err.message, ...(err.reason ? { reason: err.reason } : {}) });
|
||||
res.status(err.status || 500).json({ error: err.message });
|
||||
});
|
||||
|
||||
db.init()
|
||||
.then(() => {
|
||||
app.listen(PORT, () => console.log(`Printhive running on http://localhost:${PORT}`));
|
||||
fingerprint.kick(); // backfill fingerprints, tags and the search index
|
||||
collectionWatch.init({
|
||||
fetchCollection: fetchMakerworldCollection,
|
||||
importFromUrl,
|
||||
// Same treatment a manual run gives: tags and a description, a clearer name
|
||||
// where the site's one is boilerplate, and collections to review.
|
||||
startAiRun: (modelIds) => aiRuns.createRun(modelIds,
|
||||
{ describe: true, renameUnclear: true, suggestCollections: true }),
|
||||
});
|
||||
collectionWatch.start();
|
||||
|
||||
// Live connections to the printers keep status and spools current. One
|
||||
// that's off or unreachable keeps retrying quietly.
|
||||
reconnectPrinters().catch((err) => console.warn('Printer connections failed:', err.message));
|
||||
|
||||
return aiRuns.init(resolveAiConfig);
|
||||
fingerprint.kick(); // backfill fingerprints for files stored before this ran
|
||||
})
|
||||
.catch((err) => { console.error('Failed to start (DB init):', err); process.exit(1); });
|
||||
|
|
|
|||
218
test/ai.test.js
218
test/ai.test.js
|
|
@ -1,218 +0,0 @@
|
|||
// AI tagging client (lib/ai.js), against a fake OpenAI-compatible server.
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const http = require('http');
|
||||
const ai = require('../lib/ai');
|
||||
|
||||
// Starts a server that answers each request with the next handler's result.
|
||||
async function fakeProvider(handlers) {
|
||||
const requests = [];
|
||||
const server = http.createServer((req, res) => {
|
||||
let body = '';
|
||||
req.on('data', (c) => { body += c; });
|
||||
req.on('end', () => {
|
||||
const parsed = JSON.parse(body);
|
||||
requests.push({ path: req.url, auth: req.headers.authorization, body: parsed });
|
||||
const [status, payload] = handlers[Math.min(requests.length, handlers.length) - 1](parsed);
|
||||
res.writeHead(status, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify(payload));
|
||||
});
|
||||
});
|
||||
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
|
||||
const config = ai.configFrom({
|
||||
ai_provider: 'custom', ai_base_url: `http://127.0.0.1:${server.address().port}/v1/`, ai_api_key: 'sk-test',
|
||||
ai_model: 'test-model', ai_price_input: '0.3', ai_price_output: '1.2',
|
||||
});
|
||||
return { config, requests, close: () => new Promise((resolve) => server.close(resolve)) };
|
||||
}
|
||||
|
||||
const reply = (content, usage = { prompt_tokens: 1000, completion_tokens: 100 }) =>
|
||||
[200, { choices: [{ message: { content } }], usage }];
|
||||
|
||||
const MODEL = { name: 'Flexi Dragon', files: [{ originalName: 'dragon_PLA.3mf', bbox: { x: 120, y: 40, z: 30 } }], tags: ['toy'] };
|
||||
|
||||
test('presets fill in DeepSeek by default, and your own values win', () => {
|
||||
const deepseek = ai.configFrom({ ai_api_key: 'k' });
|
||||
assert.equal(deepseek.provider, 'deepseek');
|
||||
assert.equal(deepseek.baseUrl, 'https://api.deepseek.com');
|
||||
assert.equal(deepseek.model, 'deepseek-flash');
|
||||
assert.equal(ai.missingConfig(deepseek), null);
|
||||
assert.match(ai.missingConfig(ai.configFrom({})), /API key/);
|
||||
|
||||
const openrouter = ai.configFrom({ ai_provider: 'openrouter', ai_api_key: 'k' });
|
||||
assert.match(ai.missingConfig(openrouter), /model/);
|
||||
assert.equal(openrouter.prices.input, null);
|
||||
// A local server needs no key.
|
||||
assert.equal(ai.missingConfig(ai.configFrom({ ai_provider: 'custom', ai_base_url: 'http://localhost:11434/v1', ai_model: 'llava' })), null);
|
||||
});
|
||||
|
||||
test('the prompt carries the model details and, when given, its image', () => {
|
||||
const [system, user] = ai.buildMessages(MODEL, { image: 'data:image/png;base64,AAAA', knownTags: ['dragon', 'toy'] });
|
||||
assert.equal(system.role, 'system');
|
||||
const text = user.content[0].text;
|
||||
assert.match(text, /Name: Flexi Dragon/);
|
||||
assert.match(text, /Files: dragon_PLA\.3mf/);
|
||||
assert.match(text, /Size: 120 × 40 × 30 mm/);
|
||||
assert.match(text, /Tags already used in this library: dragon, toy/);
|
||||
assert.deepEqual(user.content[1], { type: 'image_url', image_url: { url: 'data:image/png;base64,AAAA' } });
|
||||
assert.equal(typeof ai.buildMessages(MODEL)[1].content, 'string');
|
||||
});
|
||||
|
||||
test('replies are read leniently and tags tidied', () => {
|
||||
const r = ai.parseReply('Sure!\n```json\n{"tags": ["#Dragon", "3D print", "Print_In Place", "dragon", "a", "b", "c", "d", "e", "f"], "description": " A flexible dragon toy. "}\n```');
|
||||
assert.deepEqual(r.tags, ['dragon', 'print in place', 'a', 'b', 'c', 'd', 'e', 'f']);
|
||||
assert.equal(r.description, 'A flexible dragon toy.');
|
||||
assert.deepEqual(ai.parseReply('{"tags": "hook, wall mount", "description": ""}').tags, ['hook', 'wall mount']);
|
||||
assert.throws(() => ai.parseReply('I cannot help with that'), /JSON/);
|
||||
assert.throws(() => ai.parseReply('{"tags": [], "description": ""}'), /no tags/);
|
||||
});
|
||||
|
||||
test('a clearer name is suggested only when asked, and only when it changes', () => {
|
||||
assert.match(ai.buildMessages(MODEL, { suggestName: true })[0].content, /"name"/);
|
||||
assert.doesNotMatch(ai.buildMessages(MODEL)[0].content, /"name"/);
|
||||
const reply = '{"name": "“Articulated Dragon Pair.3mf”", "tags": ["dragon"], "description": "Two dragons."}';
|
||||
assert.equal(ai.parseReply(reply, { currentName: 'Dra g _v4' }).name, 'Articulated Dragon Pair');
|
||||
assert.equal(ai.parseReply('{"name": "flexi dragon!", "tags": ["dragon"]}', { currentName: 'Flexi Dragon' }).name, null);
|
||||
assert.equal(ai.parseReply(reply).name, null); // not asked for
|
||||
});
|
||||
|
||||
test('cost comes from the provider when reported, else from your prices', () => {
|
||||
assert.equal(ai.costOf({ prompt_tokens: 1e6, completion_tokens: 1e6, cost: 0.0123 }, { input: 1, output: 1 }), 0.0123);
|
||||
assert.equal(ai.costOf({ prompt_tokens: 2e6, completion_tokens: 1e6 }, { input: 0.3, output: 1.2 }), 1.8);
|
||||
assert.equal(ai.costOf({ prompt_tokens: 10, completion_tokens: 10 }, { input: null, output: null }), null);
|
||||
});
|
||||
|
||||
test('a model that refuses JSON mode and images is retried without them', async () => {
|
||||
const provider = await fakeProvider([
|
||||
() => [400, { error: { message: 'response_format is not supported by this model' } }],
|
||||
() => [400, { error: { message: 'This model does not support image input' } }],
|
||||
() => reply('{"tags": ["dragon", "articulated"], "description": "A flexi dragon."}'),
|
||||
]);
|
||||
try {
|
||||
const result = await ai.describeModel(provider.config, MODEL, { image: 'data:image/png;base64,AAAA' });
|
||||
assert.deepEqual(result.tags, ['dragon', 'articulated']);
|
||||
assert.equal(result.imageRejected, true);
|
||||
assert.equal(result.imageUsed, false);
|
||||
assert.deepEqual(result.usage, { prompt: 1000, completion: 100 });
|
||||
assert.ok(Math.abs(result.cost - 0.00042) < 1e-9);
|
||||
|
||||
assert.equal(provider.requests.length, 3);
|
||||
assert.equal(provider.requests[0].path, '/v1/chat/completions');
|
||||
assert.equal(provider.requests[0].auth, 'Bearer sk-test');
|
||||
assert.ok(provider.requests[0].body.response_format);
|
||||
assert.equal(provider.requests[1].body.response_format, undefined);
|
||||
assert.equal(typeof provider.requests[2].body.messages[1].content, 'string'); // no image part
|
||||
} finally {
|
||||
await provider.close();
|
||||
}
|
||||
});
|
||||
|
||||
test('collection ideas are asked for with the library’s collections, and tidied', () => {
|
||||
const [system, user] = ai.buildMessages(MODEL, { describe: false, suggestCollections: true, collectionNames: ['Bookends', 'Halloween'] });
|
||||
assert.match(system.content, /"collections"/);
|
||||
assert.doesNotMatch(system.content, /"tags"/);
|
||||
assert.match(user.content, /Existing collections in this library: Bookends, Halloween/);
|
||||
assert.match(ai.buildMessages(MODEL, { suggestCollections: true })[1].content, /no collections yet/);
|
||||
|
||||
const reply = '{"collections": ["bookends", " Halloween Decor ", "Book-ends", "Dragons", "Toys"]}';
|
||||
const r = ai.parseReply(reply, { describe: false, collections: true });
|
||||
assert.deepEqual(r.collections, ['bookends', 'Halloween Decor', 'Dragons']);
|
||||
assert.deepEqual(r.tags, []); // not asked for, so an answer without tags is fine
|
||||
assert.deepEqual(ai.parseReply('{"tags": ["dragon"], "collections": ["Dragons"]}').collections, []);
|
||||
});
|
||||
|
||||
test('a reply that isn’t JSON is asked again, and every answer counts towards cost', async () => {
|
||||
const provider = await fakeProvider([
|
||||
() => reply('Sure! Tags: dragon, toy'),
|
||||
() => reply('{"tags": ["dragon"], "description": "A dragon."}'),
|
||||
]);
|
||||
try {
|
||||
const result = await ai.describeModel(provider.config, MODEL);
|
||||
assert.deepEqual(result.tags, ['dragon']);
|
||||
assert.equal(result.replyRetried, true);
|
||||
assert.deepEqual(result.usage, { prompt: 2000, completion: 200 });
|
||||
assert.ok(Math.abs(result.cost - 0.00084) < 1e-9);
|
||||
assert.equal(provider.requests.length, 2);
|
||||
} finally {
|
||||
await provider.close();
|
||||
}
|
||||
|
||||
// Empty replies in JSON mode: the last try goes without JSON mode.
|
||||
const empty = await fakeProvider([() => reply(''), () => reply(''), () => reply('{"tags": ["dragon"], "description": "A dragon."}')]);
|
||||
try {
|
||||
const result = await ai.describeModel(empty.config, MODEL);
|
||||
assert.deepEqual(result.tags, ['dragon']);
|
||||
assert.ok(empty.requests[1].body.response_format);
|
||||
assert.equal(empty.requests[2].body.response_format, undefined);
|
||||
} finally {
|
||||
await empty.close();
|
||||
}
|
||||
|
||||
// Still nothing usable: the error says what came back and carries the spend.
|
||||
const stubborn = await fakeProvider([() => reply('')]);
|
||||
try {
|
||||
const err = await ai.describeModel(stubborn.config, MODEL).catch((e) => e);
|
||||
assert.match(err.message, /reply was empty, not the JSON asked for/);
|
||||
assert.equal(stubborn.requests.length, 3);
|
||||
assert.deepEqual(err.usage, { prompt: 3000, completion: 300 });
|
||||
} finally {
|
||||
await stubborn.close();
|
||||
}
|
||||
});
|
||||
|
||||
test('a reply that was cut off is asked again with more room', async () => {
|
||||
const cutOff = [200, { choices: [{ message: { content: '{"tags": ["red panda", "fl' }, finish_reason: 'length' }], usage: { prompt_tokens: 1000, completion_tokens: 100 } }];
|
||||
const provider = await fakeProvider([() => cutOff, () => reply('{"tags": ["red panda"], "description": "A flexi red panda."}')]);
|
||||
try {
|
||||
const result = await ai.describeModel(provider.config, MODEL);
|
||||
assert.deepEqual(result.tags, ['red panda']);
|
||||
assert.equal(provider.requests[0].body.max_tokens, 1500);
|
||||
assert.equal(provider.requests[1].body.max_tokens, 3000);
|
||||
} finally {
|
||||
await provider.close();
|
||||
}
|
||||
assert.throws(() => ai.parseReply('{"tags": ["red', { finishReason: 'length' }),
|
||||
/cut off\)\. It began: “\{"tags": \["red”/);
|
||||
});
|
||||
|
||||
test('JSON is found inside fences, notes and braces after it', () => {
|
||||
assert.deepEqual(ai.parseReply('```json\n{"tags": ["owl"], "description": "An owl {bookend}."}\n```').tags, ['owl']);
|
||||
assert.equal(ai.parseReply('Here you go: {"tags": ["owl"], "description": "An owl."} Note: fields were {tags, description}.').description, 'An owl.');
|
||||
assert.throws(() => ai.parseReply('I cannot help with that'), /It began: “I cannot help with that”/);
|
||||
});
|
||||
|
||||
test('the prompt shows the reply format for just the fields asked for', () => {
|
||||
const plain = ai.buildMessages(MODEL)[0].content;
|
||||
assert.match(plain, /The format, shown for a different model: \{"tags":\["wall hook","headphone holder"\],"description":"A wall hook for hanging headphones\."\}/);
|
||||
const collectionsOnly = ai.buildMessages(MODEL, { describe: false, suggestCollections: true })[0].content;
|
||||
assert.match(collectionsOnly, /\{"collections":\["Desk Organizers"\]\}/);
|
||||
});
|
||||
|
||||
test('text sent to the AI never holds half an emoji', () => {
|
||||
// The panda starts at unit 799, so a plain cut at 800 would split it.
|
||||
const description = `${'a'.repeat(799)}🐼 red panda`;
|
||||
const [, user] = ai.buildMessages({ name: 'Red panda', files: [], tags: [], description });
|
||||
assert.ok(user.content.isWellFormed());
|
||||
assert.ok(!user.content.includes('\uFFFD'), 'clipped before the emoji, not replaced');
|
||||
assert.match(user.content, /Description so far: a{799}$/m);
|
||||
|
||||
// Text that's already broken in the library is made safe before it's sent.
|
||||
const [, broken] = ai.buildMessages({ name: 'Bad \uD83D name', files: [], tags: ['ok'], description: '' }, { knownTags: ['tag\uDC3C'] });
|
||||
assert.ok(broken.content.isWellFormed());
|
||||
|
||||
const reply = JSON.stringify({ name: `x${'🐼'.repeat(50)}`, tags: ['panda'], description: `x${'🐼'.repeat(400)}`, collections: [`x${'🐼'.repeat(40)}`] });
|
||||
const parsed = ai.parseReply(reply, { currentName: 'Red panda', collections: true });
|
||||
assert.ok(parsed.name.isWellFormed() && parsed.description.isWellFormed() && parsed.collections[0].isWellFormed());
|
||||
});
|
||||
|
||||
test('a rejected key is fatal for a run; a bad reply is not', async () => {
|
||||
const provider = await fakeProvider([() => [401, { error: { message: 'Authentication Fails' } }]]);
|
||||
try {
|
||||
const err = await ai.describeModel(provider.config, MODEL).catch((e) => e);
|
||||
assert.match(ai.fatalMessage(err), /API key was rejected/);
|
||||
} finally {
|
||||
await provider.close();
|
||||
}
|
||||
assert.equal(ai.fatalMessage(new Error('The AI reply wasn’t the JSON asked for')), null);
|
||||
assert.match(ai.fatalMessage(new ai.AiError('nope', 402)), /out of credit/);
|
||||
});
|
||||
|
|
@ -1,33 +0,0 @@
|
|||
// Automatic tagging rules (lib/autotag.js).
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const { tokenize, matchTags } = require('../lib/autotag');
|
||||
|
||||
test('names split into words at punctuation, camelCase and numbers', () => {
|
||||
assert.deepEqual(tokenize('PETGWallHook_v2.stl'), ['petg', 'wall', 'hook', 'v', '2', 'stl']);
|
||||
assert.deepEqual(tokenize('Gridfinity-6x4 baseplate'), ['gridfinity', '6', 'x', '4', 'baseplate']);
|
||||
});
|
||||
|
||||
test('rules match whole words only, plurals included', () => {
|
||||
assert.deepEqual(matchTags(['Wall Hooks.stl']), ['hook']);
|
||||
assert.deepEqual(matchTags(['facebook_logo.stl', 'blocked-path.3mf', 'planter.stl', 'absolute.obj']), []);
|
||||
assert.deepEqual(matchTags(['gears_and_threads.3mf']).sort(), ['gear', 'threaded']);
|
||||
});
|
||||
|
||||
test('phrases match across separators but not across different names', () => {
|
||||
assert.deepEqual(matchTags(['Print-In-Place_Dragon.3mf']), ['print-in-place']);
|
||||
assert.deepEqual(matchTags(['Dragon Print', 'in place']), []);
|
||||
assert.deepEqual(matchTags(['Spiral Vase Mode lamp']), ['vase mode']);
|
||||
});
|
||||
|
||||
test('materials and techniques are picked up', () => {
|
||||
assert.deepEqual(matchTags(['benchy_PLA.3mf', 'TPU tyre']).sort(), ['pla', 'tpu']);
|
||||
assert.deepEqual(matchTags(['MultiColour Keyring']).sort(), ['keychain', 'multicolor']);
|
||||
});
|
||||
|
||||
test('your own rules add tags the same strict way', () => {
|
||||
const rules = [{ phrase: 'benchy', tag: 'Calibration' }, { phrase: 'cable tie', tag: 'cable management' }];
|
||||
assert.deepEqual(matchTags(['3DBenchy.stl'], rules), ['calibration']);
|
||||
assert.deepEqual(matchTags(['cable_ties_holder.stl'], rules).sort(), ['cable management', 'holder']);
|
||||
assert.deepEqual(matchTags(['benchymark.stl'], rules), []);
|
||||
});
|
||||
|
|
@ -1,34 +0,0 @@
|
|||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const block = require('../lib/downloadBlock');
|
||||
const makerworld = require('../lib/importers/makerworld');
|
||||
|
||||
test('a blocked site stays blocked until it’s cleared', () => {
|
||||
assert.equal(block.isBlocked('makerworld'), false);
|
||||
block.block('makerworld', 'Prove you’re not a robot.');
|
||||
assert.equal(block.isBlocked('makerworld'), true);
|
||||
const state = block.state('makerworld');
|
||||
assert.equal(state.blocked, true);
|
||||
assert.equal(state.message, 'Prove you’re not a robot.');
|
||||
assert.ok(state.since instanceof Date);
|
||||
// Blocking again keeps the original message and time.
|
||||
const again = block.block('makerworld', 'something else');
|
||||
assert.equal(again.message, 'Prove you’re not a robot.');
|
||||
block.clear('makerworld');
|
||||
assert.deepEqual(block.state('makerworld'), { blocked: false });
|
||||
});
|
||||
|
||||
test('MakerWorld’s anti-bot answers are recognised, ordinary refusals aren’t', () => {
|
||||
for (const text of ['Please verify you are not a robot', 'Just a moment…', 'Select in this order',
|
||||
'Security check required', 'Verification required']) {
|
||||
assert.equal(makerworld.isChallengeText(text), true, text);
|
||||
}
|
||||
for (const text of ['Please log in to download models.', 'Model not found', '']) {
|
||||
assert.equal(makerworld.isChallengeText(text), false, text);
|
||||
}
|
||||
// An answer carrying a link is never a challenge.
|
||||
assert.equal(makerworld.isChallenge({ url: 'https://cdn/x.3mf', message: 'captcha' }), false);
|
||||
const problem = makerworld.downloadProblem({ error: 'Please verify you are not a robot' });
|
||||
assert.equal(problem.challenged, true);
|
||||
assert.equal(makerworld.downloadProblem({ error: 'Please log in to download models.' }).tokenRejected, true);
|
||||
});
|
||||
|
|
@ -1,148 +0,0 @@
|
|||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const filaments = require('../lib/filaments');
|
||||
|
||||
const spool = (material, hex, name) => ({ id: hex, material, colour_hex: hex, colour: name || hex, remaining_g: 500 });
|
||||
|
||||
// The model that started this: four PLA slots, one spool of Jade White.
|
||||
const pooh = [
|
||||
{ type: 'PLA', colour: '#000000' },
|
||||
{ type: 'PLA', colour: '#586579' },
|
||||
{ type: 'PLA', colour: '#8E9089' },
|
||||
{ type: 'PLA', colour: '#FFFFFF' },
|
||||
];
|
||||
|
||||
test('a four-colour print needs those colours, not four spools of anything', () => {
|
||||
const shelf = [spool('PLA', '#ffffff', 'Jade White')];
|
||||
const slots = filaments.assignSpools(pooh, shelf);
|
||||
assert.deepEqual(slots.map((s) => s.filled), [false, false, false, true]);
|
||||
assert.equal(slots[3].by, 'Jade White');
|
||||
assert.equal(filaments.isPrintable(pooh, shelf), false);
|
||||
assert.equal(filaments.shortfall(pooh, shelf).length, 3);
|
||||
|
||||
// Four white spools still can't print it: the other three colours are missing.
|
||||
const fourWhite = ['a', 'b', 'c', 'd'].map((k) => ({ ...spool('PLA', '#ffffff', 'Jade White'), id: k }));
|
||||
assert.equal(filaments.isPrintable(pooh, fourWhite), false);
|
||||
});
|
||||
|
||||
test('with every colour on the shelf it prints', () => {
|
||||
const shelf = [spool('PLA', '#000000', 'Black'), spool('PLA', '#586579', 'Blue Grey'),
|
||||
spool('PLA', '#8E9089', 'Grey'), spool('PLA', '#ffffff', 'Jade White')];
|
||||
assert.equal(filaments.isPrintable(pooh, shelf), true);
|
||||
assert.deepEqual(filaments.assignSpools(pooh, shelf).map((s) => s.by),
|
||||
['Black', 'Blue Grey', 'Grey', 'Jade White']);
|
||||
});
|
||||
|
||||
test('one spool cannot fill two slots of the same colour', () => {
|
||||
const twoBlack = [{ type: 'PLA', colour: '#000000' }, { type: 'PLA', colour: '#000000' }];
|
||||
assert.equal(filaments.isPrintable(twoBlack, [spool('PLA', '#000000', 'Black')]), false);
|
||||
assert.equal(filaments.isPrintable(twoBlack,
|
||||
[spool('PLA', '#000000', 'Black'), { ...spool('PLA', '#000000', 'Black 2'), id: 'b2' }]), true);
|
||||
});
|
||||
|
||||
test('8-digit hex with alpha parses as its colour', () => {
|
||||
assert.deepEqual(filaments.rgb('#FFFFFFFF'), { r: 255, g: 255, b: 255 });
|
||||
assert.deepEqual(filaments.rgb('#586579'), { r: 88, g: 101, b: 121 });
|
||||
assert.equal(filaments.rgb('not a colour'), null);
|
||||
// A slot written with alpha still matches a plain spool of that colour.
|
||||
assert.equal(filaments.isPrintable([{ type: 'PLA', colour: '#FFFFFFFF' }],
|
||||
[spool('PLA', '#ffffff', 'Jade White')]), true);
|
||||
});
|
||||
|
||||
test('near shades count, but near-black is not black', () => {
|
||||
const nearWhite = [{ type: 'PLA', colour: '#FDFDFD' }];
|
||||
assert.equal(filaments.isPrintable(nearWhite, [spool('PLA', '#ffffff', 'Jade White')]), true);
|
||||
// #161616 sits about 66 away from black, beyond the tolerance of 50.
|
||||
assert.ok(filaments.colourDistance('#000000', '#161616') > filaments.COLOUR_TOLERANCE);
|
||||
assert.equal(filaments.isPrintable([{ type: 'PLA', colour: '#161616' }],
|
||||
[spool('PLA', '#000000', 'Black')]), false);
|
||||
});
|
||||
|
||||
test('the nearest spool is named when a slot can’t be filled', () => {
|
||||
const [slot] = filaments.assignSpools([{ type: 'PLA', colour: '#000000' }],
|
||||
[spool('PLA', '#ffffff', 'Jade White')]);
|
||||
assert.equal(slot.filled, false);
|
||||
assert.equal(slot.nearest.name, 'Jade White');
|
||||
assert.ok(slot.nearest.distance > 0);
|
||||
});
|
||||
|
||||
test('material still matters, and a slot with no colour takes any spool of it', () => {
|
||||
assert.equal(filaments.isPrintable([{ type: 'PETG', colour: '#ffffff' }],
|
||||
[spool('PLA', '#ffffff', 'Jade White')]), false);
|
||||
assert.equal(filaments.isPrintable([{ type: 'PLA', colour: null }],
|
||||
[spool('PLA', '#123456', 'Anything')]), true);
|
||||
});
|
||||
|
||||
test('an empty spool doesn’t count, but an unweighed one does', () => {
|
||||
const one = [{ type: 'PLA', colour: '#ffffff' }];
|
||||
assert.equal(filaments.isPrintable(one, [{ ...spool('PLA', '#ffffff', 'Jade White'), remaining_g: 0 }]), false);
|
||||
assert.equal(filaments.isPrintable(one, [{ ...spool('PLA', '#ffffff', 'Jade White'), remaining_g: null }]), true);
|
||||
});
|
||||
|
||||
test('a model we know nothing about is not claimed to be printable', () => {
|
||||
assert.equal(filaments.isPrintable(null, [spool('PLA', '#ffffff')]), false);
|
||||
assert.equal(filaments.isPrintable([], [spool('PLA', '#ffffff')]), false);
|
||||
assert.deepEqual(filaments.assignSpools(null, []), []);
|
||||
});
|
||||
|
||||
test('slots sent from the page are cleaned before they reach the database', () => {
|
||||
const cleaned = filaments.cleanSlots([
|
||||
{ type: ' pla ', colour: '#ffffff' }, // trimmed, hex upper-cased
|
||||
{ type: 'PETG', colour: 'not a colour' }, // bad colour becomes "any colour"
|
||||
{ type: '', colour: '#000000' }, // no material: dropped entirely
|
||||
{ type: 'PLA', colour: '#8e9089' },
|
||||
]);
|
||||
assert.deepEqual(cleaned, [
|
||||
{ type: 'pla', colour: '#FFFFFF' },
|
||||
{ type: 'PETG', colour: null },
|
||||
{ type: 'PLA', colour: '#8E9089' },
|
||||
]);
|
||||
});
|
||||
|
||||
test('anything that isn’t a list of slots is refused outright', () => {
|
||||
assert.equal(filaments.cleanSlots(null), null);
|
||||
assert.equal(filaments.cleanSlots('PLA'), null);
|
||||
assert.equal(filaments.cleanSlots({ type: 'PLA' }), null);
|
||||
assert.deepEqual(filaments.cleanSlots([]), []);
|
||||
});
|
||||
|
||||
test('"any colour" survives the trip from the page to the database', () => {
|
||||
// What the editor sends when the box is ticked, plus the two shapes that can
|
||||
// arrive from older clients or a hand-made request.
|
||||
assert.deepEqual(filaments.cleanSlots([
|
||||
{ type: 'PLA', colour: null },
|
||||
{ type: 'PETG', colour: '' },
|
||||
{ type: 'ABS' },
|
||||
]), [
|
||||
{ type: 'PLA', colour: null },
|
||||
{ type: 'PETG', colour: null },
|
||||
{ type: 'ABS', colour: null },
|
||||
]);
|
||||
});
|
||||
|
||||
test('a slot with no colour is filled by any spool of that material', () => {
|
||||
const anyWhite = [{ type: 'PLA', colour: null }];
|
||||
assert.equal(filaments.isPrintable(anyWhite,
|
||||
[{ id: 1, material: 'PLA', colour_hex: '#123456', colour: 'Anything', remaining_g: 200 }]), true);
|
||||
// …but still not by the wrong material, and still one spool per slot.
|
||||
assert.equal(filaments.isPrintable(anyWhite,
|
||||
[{ id: 1, material: 'PETG', colour_hex: '#123456', colour: 'Anything', remaining_g: 200 }]), false);
|
||||
assert.equal(filaments.isPrintable([{ type: 'PLA', colour: null }, { type: 'PLA', colour: null }],
|
||||
[{ id: 1, material: 'PLA', colour_hex: '#123456', colour: 'Anything', remaining_g: 200 }]), false);
|
||||
});
|
||||
|
||||
test('an "any colour" slot takes what’s left, not what another slot needs', () => {
|
||||
// Reported from the live library: editing slot 2 of a bookmark to "any
|
||||
// colour" stole the one white spool, leaving the slot that actually wanted
|
||||
// #FFFFFF empty and the verdict no better than before.
|
||||
const shelf = [{ id: 1, material: 'PLA', colour_hex: '#FFFFFF', colour: 'Jade White', remaining_g: 1000 }];
|
||||
const slots = filaments.assignSpools([{ type: 'PLA', colour: null }, { type: 'PLA', colour: '#FFFFFF' }], shelf);
|
||||
assert.equal(slots[1].filled, true, 'the slot that asks for white gets the white spool');
|
||||
assert.equal(slots[1].by, 'Jade White');
|
||||
assert.equal(slots[0].filled, false, 'the any-colour slot goes without');
|
||||
|
||||
// With two spools, both are filled and the exact match still gets white.
|
||||
const two = shelf.concat({ id: 2, material: 'PLA', colour_hex: '#123456', colour: 'Anything', remaining_g: 500 });
|
||||
const both = filaments.assignSpools([{ type: 'PLA', colour: null }, { type: 'PLA', colour: '#FFFFFF' }], two);
|
||||
assert.deepEqual(both.map((s) => s.by), ['Anything', 'Jade White']);
|
||||
});
|
||||
|
|
@ -1,42 +0,0 @@
|
|||
// Short-lived "Open in slicer" download links (lib/handoff.js).
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const jwt = require('jsonwebtoken');
|
||||
const handoff = require('../lib/handoff');
|
||||
|
||||
const SECRET = 'a'.repeat(96);
|
||||
|
||||
test('a link grants the file it was made for', () => {
|
||||
const token = handoff.createToken(SECRET, { kind: 'entry', id: '42' });
|
||||
assert.deepEqual(handoff.readToken(SECRET, token), { kind: 'entry', id: 42 });
|
||||
});
|
||||
|
||||
test('expired, altered or foreign links grant nothing', () => {
|
||||
const expired = handoff.createToken(SECRET, { kind: 'file', id: 1 }, -5);
|
||||
assert.equal(handoff.readToken(SECRET, expired), null);
|
||||
|
||||
const token = handoff.createToken(SECRET, { kind: 'file', id: 1 });
|
||||
const [head, , sig] = token.split('.');
|
||||
const otherBody = Buffer.from(JSON.stringify({ k: 'file', id: 2, aud: 'printhive-handoff', exp: 9999999999 })).toString('base64url');
|
||||
assert.equal(handoff.readToken(SECRET, `${head}.${otherBody}.${sig}`), null);
|
||||
assert.equal(handoff.readToken('b'.repeat(96), token), null);
|
||||
assert.equal(handoff.readToken(SECRET, 'not-a-token'), null);
|
||||
});
|
||||
|
||||
test('a login token is not a hand-off link', () => {
|
||||
const session = jwt.sign({ sub: 1, email: 'x@example.com' }, SECRET, { expiresIn: '30d' });
|
||||
assert.equal(handoff.readToken(SECRET, session), null);
|
||||
// …and a hand-off link doesn't verify as a login.
|
||||
assert.throws(() => jwt.verify(handoff.createToken(SECRET, { kind: 'file', id: 1 }), SECRET));
|
||||
});
|
||||
|
||||
test('only files and archive entries can be handed off', () => {
|
||||
assert.throws(() => handoff.createToken(SECRET, { kind: 'model', id: 1 }), /Unknown hand-off kind/);
|
||||
});
|
||||
|
||||
test('link file names stay plain ASCII and keep their extension', () => {
|
||||
assert.equal(handoff.urlName('Toy Keychain (v2).3mf'), 'Toy_Keychain_v2.3mf');
|
||||
assert.equal(handoff.urlName('Café déco.STL'), 'Cafe_deco.stl');
|
||||
assert.equal(handoff.urlName('日本.stl'), 'model.stl');
|
||||
assert.equal(handoff.urlName('../../etc/passwd.obj'), 'passwd.obj');
|
||||
});
|
||||
|
|
@ -1,97 +0,0 @@
|
|||
// MakerWorld importer helpers (lib/importers/makerworld.js).
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const makerworld = require('../lib/importers/makerworld');
|
||||
|
||||
const page = (design) =>
|
||||
`<html><script id="__NEXT_DATA__" type="application/json">${JSON.stringify({ props: { pageProps: { design } } })}</script></html>`;
|
||||
|
||||
test('printable instances and the license are read from the page data', () => {
|
||||
const html = page({
|
||||
license: 'Standard Digital File License',
|
||||
instances: [{ id: 650088, title: 'AMS - 0.4 Nozzle - 0.08 Layer' }, { id: 650089 }],
|
||||
});
|
||||
assert.deepEqual(makerworld.parseInstances(html), [
|
||||
{ id: 650088, title: 'AMS - 0.4 Nozzle - 0.08 Layer' },
|
||||
{ id: 650089, title: '' },
|
||||
]);
|
||||
assert.equal(makerworld.parseLicense(html), 'Standard Digital File License');
|
||||
assert.deepEqual(makerworld.parseInstances('<html><title>Just a moment...</title></html>'), []);
|
||||
});
|
||||
|
||||
test('a download answer without a link says why, and flags a refused token', () => {
|
||||
assert.equal(makerworld.downloadProblem({ name: 'bookmarks.3mf', url: 'https://cdn.example/bookmarks.3mf' }), null);
|
||||
const refused = makerworld.downloadProblem({ code: 1, error: 'Please log in to download models.' });
|
||||
assert.equal(refused.tokenRejected, true);
|
||||
assert.match(refused.message, /token \(it has probably expired\)/);
|
||||
assert.deepEqual(makerworld.downloadProblem({ code: 7, error: 'Model not found' }),
|
||||
{ message: 'MakerWorld didn’t give a download link: Model not found', tokenRejected: false });
|
||||
assert.deepEqual(makerworld.downloadProblem(null), { message: 'MakerWorld didn’t give a download link', tokenRejected: false });
|
||||
});
|
||||
|
||||
const collectionPage = (favorite, favoriteDesigns) =>
|
||||
`<html><script id="__NEXT_DATA__" type="application/json">${JSON.stringify({ props: { pageProps: { favorite, favoriteDesigns } } })}</script></html>`;
|
||||
|
||||
test('a collection page gives its models, name and total', () => {
|
||||
const html = collectionPage(
|
||||
{ id: 31103374, title: 'markets', designCnt: 3 },
|
||||
{ total: 3, hits: [
|
||||
{ id: 719274, slug: 'skeleton-bookmarks', title: 'Skeleton Bookmarks' },
|
||||
{ id: 926432, slug: 'doomhammer', title: 'Doomhammer' },
|
||||
{ id: 12345, title: 'No slug' },
|
||||
] });
|
||||
const collection = makerworld.parseCollection(html);
|
||||
assert.equal(collection.id, '31103374');
|
||||
assert.equal(collection.title, 'markets');
|
||||
assert.equal(collection.total, 3);
|
||||
assert.deepEqual(collection.designs.map((d) => d.id), ['719274', '926432', '12345']);
|
||||
assert.equal(makerworld.designUrl(collection.designs[0]), 'https://makerworld.com/en/models/719274-skeleton-bookmarks');
|
||||
assert.equal(makerworld.designUrl(collection.designs[2]), 'https://makerworld.com/en/models/12345');
|
||||
|
||||
// The page can hold fewer models than the collection does.
|
||||
const partial = makerworld.parseCollection(collectionPage({ id: 1, title: 'big', designCnt: 120 }, { total: 120, hits: [{ id: 5, slug: 'a', title: 'A' }] }));
|
||||
assert.equal(partial.total, 120);
|
||||
assert.equal(partial.designs.length, 1);
|
||||
});
|
||||
|
||||
test('a page without a collection (private, or Cloudflare) parses as nothing', () => {
|
||||
assert.equal(makerworld.parseCollection('<html><title>Just a moment...</title></html>'), null);
|
||||
assert.equal(makerworld.parseCollection(collectionPage(null, null)), null);
|
||||
// What MakerWorld serves for a private collection when the request isn't signed in.
|
||||
assert.equal(makerworld.parseCollection(collectionPage({ id: -1, title: '', status: 0, designCnt: 0 }, { total: 0, hits: [] })), null);
|
||||
});
|
||||
|
||||
test('more models are read from the collection’s own load-more call', () => {
|
||||
assert.equal(makerworld.collectionDesignsUrl('1465524', { offset: 64, limit: 64, seed: 0 }),
|
||||
'https://makerworld.com/api/v1/design-service/favorites/1465524/designs?offset=64&limit=64&seed=0');
|
||||
assert.equal(makerworld.collectionDesignsUrl('7'),
|
||||
'https://makerworld.com/api/v1/design-service/favorites/7/designs?offset=0&limit=64');
|
||||
|
||||
const page = makerworld.parseCollectionDesigns({ total: 535, seed: 0, hits: [{ id: 516328, slug: 'a-thing', title: 'A thing' }, { id: 887552 }] });
|
||||
assert.equal(page.total, 535);
|
||||
assert.equal(page.seed, 0);
|
||||
assert.deepEqual(page.designs, [{ id: '516328', slug: 'a-thing', title: 'A thing' }, { id: '887552', slug: '', title: '' }]);
|
||||
assert.deepEqual(makerworld.parseCollectionDesigns(null), { designs: [], total: 0, seed: undefined });
|
||||
});
|
||||
|
||||
test('collection links are recognised', () => {
|
||||
assert.equal(makerworld.collectionIdFromUrl('https://makerworld.com/en/collections/31103374-markets'), '31103374');
|
||||
assert.equal(makerworld.collectionIdFromUrl('https://makerworld.com/collections/31103374'), '31103374');
|
||||
assert.equal(makerworld.collectionIdFromUrl('https://makerworld.com/en/models/719274-skeleton-bookmarks'), null);
|
||||
});
|
||||
|
||||
test('MakerWorld’s anti-bot check is told apart from a refused token', () => {
|
||||
const challenge = { code: 1, error: 'We need to confirm that you are not a robot.' };
|
||||
assert.equal(makerworld.isChallenge(challenge), true);
|
||||
assert.equal(makerworld.isChallenge({ url: 'https://cdn.example/x.3mf' }), false);
|
||||
assert.equal(makerworld.isChallenge({ code: 1, error: 'Please log in to download models.' }), false);
|
||||
|
||||
const blocked = makerworld.downloadProblem(challenge);
|
||||
assert.equal(blocked.challenged, true);
|
||||
assert.equal(blocked.tokenRejected, false);
|
||||
assert.match(blocked.message, /anti-bot/);
|
||||
|
||||
const refused = makerworld.downloadProblem({ code: 1, error: 'Please log in to download models.' });
|
||||
assert.equal(refused.tokenRejected, true);
|
||||
assert.equal(refused.challenged, undefined);
|
||||
});
|
||||
|
|
@ -1,94 +0,0 @@
|
|||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const printer = require('../lib/printer');
|
||||
|
||||
test('AMS trays are read into material, colour and how much is left', () => {
|
||||
const trays = printer.describeAms([{ id: '0', tray: [
|
||||
{ id: '0', tray_type: 'PLA', tray_color: 'FFFFFFFF', tray_info_idx: 'GFA00', tray_sub_brands: 'PLA Basic', remain: 82 },
|
||||
{ id: '1', tray_type: '', tray_color: '00000000' }, // empty slot
|
||||
{ id: '2', tray_type: 'PETG', tray_color: '000000FF', remain: -1 }, // not a Bambu spool
|
||||
] }]);
|
||||
assert.deepEqual(trays[0], {
|
||||
unit: 0, slot: 0, empty: false, material: 'PLA', colour: '#FFFFFF',
|
||||
profile: 'GFA00', brand: 'PLA Basic', remain: 82, uuid: null, tagUid: null, idName: null,
|
||||
weight: null, type: 'Basic',
|
||||
});
|
||||
assert.equal(trays[1].empty, true);
|
||||
assert.equal(trays[1].material, null);
|
||||
assert.equal(trays[1].colour, null, 'an empty slot has no colour, whatever the printer says');
|
||||
assert.equal(trays[2].remain, null, '-1 means the printer can’t tell');
|
||||
assert.equal(trays[2].colour, '#000000', 'the alpha byte is dropped');
|
||||
});
|
||||
|
||||
test('a Bambu spool’s RFID identity is kept, and an unread tag is not invented', () => {
|
||||
const [tagged] = printer.describeAms([{ id: '0', tray: [{
|
||||
id: '0', tray_type: 'PLA', tray_color: 'C12E1FFF',
|
||||
tray_uuid: 'A1B2C3D4E5F60718293A4B5C6D7E8F90', tag_uid: '04AB12CD34EF5680', tray_id_name: 'A00-R0',
|
||||
}] }]);
|
||||
assert.equal(tagged.uuid, 'A1B2C3D4E5F60718293A4B5C6D7E8F90');
|
||||
assert.equal(tagged.tagUid, '04AB12CD34EF5680');
|
||||
assert.equal(tagged.idName, 'A00-R0');
|
||||
|
||||
const [plain] = printer.describeAms([{ id: '0', tray: [{
|
||||
id: '0', tray_type: 'PLA', tray_color: 'FFFFFFFF',
|
||||
tray_uuid: '00000000000000000000000000000000', tag_uid: '0000000000000000',
|
||||
}] }]);
|
||||
assert.equal(plain.uuid, null, 'all zeros means no tag was read');
|
||||
assert.equal(plain.tagUid, null);
|
||||
assert.equal(plain.idName, null);
|
||||
});
|
||||
|
||||
test('a printer with no AMS reports nothing rather than failing', () => {
|
||||
assert.deepEqual(printer.describeAms(undefined), []);
|
||||
assert.deepEqual(printer.describeAms([{ id: '0' }]), []);
|
||||
});
|
||||
|
||||
test('the external spool holder is read the same way as a tray', () => {
|
||||
const ext = printer.describeTray({ id: '254', tray_type: 'PLA', tray_color: 'C12E1FFF', remain: 40 });
|
||||
assert.equal(ext.unit, null);
|
||||
assert.equal(ext.material, 'PLA');
|
||||
assert.equal(ext.colour, '#C12E1F');
|
||||
assert.equal(ext.remain, 40);
|
||||
});
|
||||
|
||||
test('connection failures are explained in plain words', () => {
|
||||
assert.equal(printer.friendlyError(new Error('Connection refused: Not authorized')),
|
||||
'The printer refused the access code.');
|
||||
assert.equal(printer.friendlyError(new Error('connect ECONNREFUSED 192.0.2.10:8883')),
|
||||
'Nothing answered on port 8883 at that address.');
|
||||
assert.equal(printer.friendlyError(new Error('connect EHOSTUNREACH 192.0.2.10:8883')),
|
||||
'Couldn’t reach the printer from the server.');
|
||||
assert.equal(printer.friendlyError(null), 'The connection failed.');
|
||||
});
|
||||
|
||||
test('the product line is what follows the material in the brand', () => {
|
||||
assert.equal(printer.productLine('PLA Basic', 'PLA'), 'Basic');
|
||||
assert.equal(printer.productLine('PLA Silk+', 'PLA'), 'Silk+');
|
||||
assert.equal(printer.productLine('PETG HF', 'PETG'), 'HF');
|
||||
assert.equal(printer.productLine('Generic', 'PLA'), 'Generic', 'a brand without the material is kept whole');
|
||||
assert.equal(printer.productLine('', 'PLA'), null);
|
||||
});
|
||||
|
||||
test('a tray reports its weight when new', () => {
|
||||
const t = printer.describeTray({ id: '0', tray_type: 'PLA', tray_color: 'FFFFFFFF', tray_weight: '1000' });
|
||||
assert.equal(t.weight, 1000);
|
||||
assert.equal(printer.describeTray({ id: '0', tray_type: 'PLA', tray_weight: '0' }).weight, null);
|
||||
});
|
||||
|
||||
test('status is read from a report, and fields a report leaves out stay out', () => {
|
||||
const { readStatus } = require('../lib/printer');
|
||||
const full = readStatus({
|
||||
gcode_state: 'RUNNING', mc_percent: 42, mc_remaining_time: 73, subtask_name: 'Benchy',
|
||||
layer_num: 80, total_layer_num: 210, nozzle_temper: 219.6, nozzle_target_temper: 220,
|
||||
bed_temper: 59.8, bed_target_temper: 60, spd_lvl: 2, print_error: 0,
|
||||
hms: [{ attr: 0x07002000, code: 0x00020001 }],
|
||||
});
|
||||
assert.deepEqual(full, {
|
||||
state: 'RUNNING', progress: 42, remainingMin: 73, file: 'Benchy', layer: 80, totalLayers: 210,
|
||||
nozzle: 219.6, nozzleTarget: 220, bed: 59.8, bedTarget: 60, speed: 'Standard', printError: null,
|
||||
hms: ['0700_2000_0002_0001'],
|
||||
});
|
||||
// A delta with only the nozzle temperature says nothing about the rest.
|
||||
assert.deepEqual(readStatus({ nozzle_temper: 200 }), { nozzle: 200 });
|
||||
assert.equal(readStatus({ print_error: 117473298 }).printError, '07008012');
|
||||
});
|
||||
|
|
@ -1,24 +0,0 @@
|
|||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const { hello, jpegSize } = require('../lib/printerCamera');
|
||||
|
||||
test('the camera hello carries the username and code in the right places', () => {
|
||||
const buf = hello('12345678');
|
||||
assert.equal(buf.length, 80);
|
||||
assert.equal(buf.readUInt32LE(0), 0x40);
|
||||
assert.equal(buf.readUInt32LE(4), 0x3000);
|
||||
assert.equal(buf.subarray(16, 20).toString('ascii'), 'bblp');
|
||||
assert.equal(buf.subarray(16, 48).toString('ascii').replace(/\0+$/, ''), 'bblp');
|
||||
assert.equal(buf.subarray(48, 80).toString('ascii').replace(/\0+$/, ''), '12345678');
|
||||
});
|
||||
|
||||
test('a frame’s size is read from the JPEG itself', () => {
|
||||
// A JPEG header: start marker, a comment segment, then the frame header.
|
||||
const jpeg = Buffer.concat([
|
||||
Buffer.from([0xff, 0xd8]),
|
||||
Buffer.from([0xff, 0xfe, 0x00, 0x04, 0x61, 0x62]), // comment, skipped
|
||||
Buffer.from([0xff, 0xc0, 0x00, 0x11, 0x08, 0x04, 0x38, 0x07, 0x80]), // 1080 × 1920
|
||||
]);
|
||||
assert.deepEqual(jpegSize(jpeg), { height: 1080, width: 1920 });
|
||||
assert.equal(jpegSize(Buffer.from([0xff, 0xd8, 0x00])), null);
|
||||
});
|
||||
|
|
@ -1,117 +0,0 @@
|
|||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const { EventEmitter } = require('node:events');
|
||||
|
||||
// A stand-in for the MQTT client, so no network is involved.
|
||||
const clients = [];
|
||||
const live = require('../lib/printerLive');
|
||||
|
||||
live.setConnector((url, options) => {
|
||||
const c = new EventEmitter();
|
||||
Object.assign(c, {
|
||||
url, options, connected: false, reconnecting: false, published: [], ended: false,
|
||||
subscribe: (_topic, cb) => cb(null),
|
||||
publish: (topic, body) => c.published.push({ topic, body: JSON.parse(body) }),
|
||||
end: () => { c.ended = true; },
|
||||
reconnect: () => {},
|
||||
});
|
||||
clients.push(c);
|
||||
return c;
|
||||
});
|
||||
|
||||
const report = (print) => Buffer.from(JSON.stringify({ print }));
|
||||
const printerRow = { id: 1, host: '10.0.0.5', serial: 'SER1', access_code: 'code1' };
|
||||
const tray = (id, type, colour) => ({ id: String(id), tray_type: type, tray_color: colour, tray_uuid: type ? `U${id}` : '0' });
|
||||
|
||||
test('live status merges partial reports, and a tray change syncs spools once', async (t) => {
|
||||
t.after(() => live.stopAll());
|
||||
const synced = [];
|
||||
live.setTraysHandler(async (id, trays) => { synced.push({ id, trays }); return { updated: trays.length }; });
|
||||
live.reconcile([printerRow, { id: 2, host: '', serial: 'X', access_code: 'y' }]);
|
||||
assert.equal(clients.length, 1, 'a printer missing its address isn’t connected');
|
||||
const c = clients[0];
|
||||
assert.equal(c.options.password, 'code1');
|
||||
|
||||
c.connected = true;
|
||||
c.emit('connect');
|
||||
assert.equal(c.published[0].topic, 'device/SER1/request');
|
||||
assert.equal(c.published[0].body.pushing.command, 'pushall');
|
||||
|
||||
c.emit('message', 'device/SER1/report', report({
|
||||
gcode_state: 'RUNNING', mc_percent: 10, nozzle_temper: 220,
|
||||
ams: { ams: [{ id: '0', tray: [tray(0, 'PLA', 'FF0000FF'), tray(1, '', '')] }] },
|
||||
}));
|
||||
c.emit('message', 'device/SER1/report', report({ mc_percent: 11 }));
|
||||
const snap = live.snapshot(1);
|
||||
assert.equal(snap.connected, true);
|
||||
assert.equal(snap.status.state, 'RUNNING');
|
||||
assert.equal(snap.status.progress, 11);
|
||||
assert.equal(snap.status.nozzle, 220, 'kept from the earlier report');
|
||||
assert.deepEqual(snap.loaded.map((l) => [l.slot, l.empty, l.colour, l.tagged]),
|
||||
[[0, false, '#FF0000', true], [1, true, null, false]]);
|
||||
assert.equal(JSON.stringify(snap).includes('code1'), false, 'no access code in a snapshot');
|
||||
|
||||
// "Test & sync" asks again and syncs straight away.
|
||||
const pending = live.refresh(1, 3000);
|
||||
c.emit('message', 'device/SER1/report', report({ ams: { ams: [{ id: '0', tray: [tray(0, 'PLA', 'FF0000FF'), tray(1, '', '')] }] } }));
|
||||
const result = await pending;
|
||||
assert.equal(result.connected, true);
|
||||
assert.equal(result.sync.updated, 2);
|
||||
assert.equal(synced.length, 1);
|
||||
assert.equal(c.published.at(-1).body.pushing.command, 'pushall');
|
||||
});
|
||||
|
||||
test('a busy printer’s steady reports don’t hold back a spool sync', async (t) => {
|
||||
t.mock.timers.enable({ apis: ['setTimeout'] });
|
||||
t.after(() => live.stopAll());
|
||||
clients.length = 0;
|
||||
const synced = [];
|
||||
live.setTraysHandler(async (id) => { synced.push(id); return {}; });
|
||||
live.reconcile([{ ...printerRow, id: 5, serial: 'SER5' }]);
|
||||
const c = clients[0];
|
||||
c.connected = true;
|
||||
c.emit('connect');
|
||||
c.emit('message', 't', report({ ams: { ams: [{ id: '0', tray: [tray(0, 'PLA', 'FF0000FF')] }] } }));
|
||||
for (let i = 0; i < 10; i += 1) {
|
||||
t.mock.timers.tick(1000);
|
||||
c.emit('message', 't', report({ mc_percent: i }));
|
||||
}
|
||||
assert.equal(synced.length, 1, 'synced once, five seconds after the change');
|
||||
// The same trays again change nothing.
|
||||
c.emit('message', 't', report({ ams: { ams: [{ id: '0', tray: [tray(0, 'PLA', 'FF0000FF')] }] } }));
|
||||
t.mock.timers.tick(6000);
|
||||
assert.equal(synced.length, 1);
|
||||
});
|
||||
|
||||
test('the tray feeding the toolhead is worked out from tray_now', (t) => {
|
||||
t.after(() => live.stopAll());
|
||||
clients.length = 0;
|
||||
live.setTraysHandler(async () => ({}));
|
||||
live.reconcile([{ ...printerRow, id: 7, serial: 'SER7' }, { ...printerRow, id: 8, serial: 'SER8' }]);
|
||||
const [a, b] = clients;
|
||||
const units = (ids) => ({ ams: ids.map((id) => ({ id: String(id), tray: [tray(0, 'PLA', 'FF0000FF')] })) });
|
||||
// Two AMS units numbered 0 and 1: tray 6 is the second unit's third slot.
|
||||
a.emit('message', 't', report({ ams: { ...units([0, 1]), tray_now: '6' } }));
|
||||
assert.deepEqual(live.snapshot(7).status.active, { unit: 1, slot: 2 });
|
||||
// A later report changing only tray_now still counts.
|
||||
a.emit('message', 't', report({ ams: { tray_now: '254' } }));
|
||||
assert.deepEqual(live.snapshot(7).status.active, { external: true });
|
||||
a.emit('message', 't', report({ ams: { tray_now: '255' } }));
|
||||
assert.equal(live.snapshot(7).status.active, null);
|
||||
// An AMS lite numbered 16 (as on the A2L): tray 1 is its second slot.
|
||||
b.emit('message', 't', report({ ams: { ...units([16]), tray_now: '1' } }));
|
||||
assert.deepEqual(live.snapshot(8).status.active, { unit: 16, slot: 1 });
|
||||
});
|
||||
|
||||
test('changing a printer’s code reopens its connection; removing it closes it', () => {
|
||||
clients.length = 0;
|
||||
live.reconcile([printerRow]);
|
||||
live.reconcile([printerRow]);
|
||||
assert.equal(clients.length, 1, 'no change, no new connection');
|
||||
live.reconcile([{ ...printerRow, access_code: 'code2' }]);
|
||||
assert.equal(clients.length, 2);
|
||||
assert.equal(clients[0].ended, true);
|
||||
live.reconcile([]);
|
||||
assert.equal(clients[1].ended, true);
|
||||
assert.deepEqual(live.snapshot(1), { live: false });
|
||||
});
|
||||
|
|
@ -1,19 +0,0 @@
|
|||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const { publicPrinter, measures } = require('../lib/printers');
|
||||
|
||||
test('a printer sent to the browser never carries its access code', () => {
|
||||
const p = publicPrinter({ id: 1, nickname: null, model: 'A1', serial: 'S1', access_code: 'secret' });
|
||||
assert.equal('access_code' in p, false);
|
||||
assert.equal(p.codeSet, true);
|
||||
assert.equal(publicPrinter({ id: 2, access_code: null }).codeSet, false);
|
||||
});
|
||||
|
||||
test('only AMS types that weigh filament count as measuring', () => {
|
||||
assert.equal(measures('ams_lite'), false);
|
||||
assert.equal(measures('none'), false);
|
||||
assert.equal(measures(null), false);
|
||||
assert.equal(measures('ams'), true);
|
||||
assert.equal(measures('ams_2_pro'), true);
|
||||
assert.equal(measures('ams_ht'), true);
|
||||
});
|
||||
|
|
@ -11,7 +11,6 @@ const fs = require('fs');
|
|||
const os = require('os');
|
||||
const path = require('path');
|
||||
const { execFileSync, spawnSync } = require('child_process');
|
||||
const AdmZip = require('adm-zip');
|
||||
const { PYTHON } = require('../lib/python');
|
||||
|
||||
const TOOLS = path.join(__dirname, '..', 'tools');
|
||||
|
|
@ -50,21 +49,6 @@ test('project info lists plates, their previews and the filaments', { skip }, ()
|
|||
assert.ok(!fs.existsSync(path.join(images, '2.png')));
|
||||
});
|
||||
|
||||
test('project info names the app that saved the 3MF', { skip }, () => {
|
||||
const info = (file) => lastJson(execFileSync(PYTHON, [path.join(TOOLS, 'threemf_info.py'), file], { encoding: 'utf8' }));
|
||||
const zip = new AdmZip(FIXTURE);
|
||||
const model = zip.readAsText('3D/3dmodel.model').replace(/\s*<metadata name="Application">[^<]*<\/metadata>/, '');
|
||||
const plain = path.join(tmp, 'plain.3mf');
|
||||
zip.updateFile('3D/3dmodel.model', Buffer.from(model));
|
||||
zip.writeZip(plain);
|
||||
assert.equal(info(plain).application, null);
|
||||
|
||||
const saved = path.join(tmp, 'saved.3mf');
|
||||
zip.updateFile('3D/3dmodel.model', Buffer.from(model.replace(/<model\b[^>]*>/, (m) => `${m}\n <metadata name="Application">BambuStudio-02.01.01.52</metadata>`)));
|
||||
zip.writeZip(saved);
|
||||
assert.equal(info(saved).application, 'BambuStudio-02.01.01.52');
|
||||
});
|
||||
|
||||
test('a plate converts alone, coloured by its part’s filament, without negative parts', { skip }, () => {
|
||||
const { summary, colours } = convert(1);
|
||||
assert.equal(summary.faces, 12);
|
||||
|
|
|
|||
|
|
@ -1,98 +0,0 @@
|
|||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const { planSync, slotKey } = require('../lib/spoolSync');
|
||||
const { paletteMatch } = require('../lib/filaments');
|
||||
const palette = require('../db/bambu-filaments.json').filaments;
|
||||
|
||||
const SERIAL = 'PRINTER1';
|
||||
const tray = (slot, hex, uuid, extra = {}) => ({
|
||||
unit: 0, slot, empty: false, material: 'PLA', type: 'Basic', colour: hex, brand: 'PLA Basic',
|
||||
uuid, tagUid: uuid ? `TAG${slot}` : null, idName: null, weight: 1000, ...extra,
|
||||
});
|
||||
|
||||
test('a tagged spool the shelf has never seen is created and named from the filament list', () => {
|
||||
const { actions, untracked } = planSync([], [tray(0, '#C12E1F', 'U-RED', { idName: 'A00-R0' })], SERIAL, palette);
|
||||
assert.equal(untracked.length, 0);
|
||||
assert.equal(actions.length, 1);
|
||||
assert.equal(actions[0].op, 'create');
|
||||
assert.equal(actions[0].slot, 'PRINTER1/0/0');
|
||||
assert.equal(actions[0].spool.colour, 'Red');
|
||||
assert.equal(actions[0].spool.code, '10200');
|
||||
assert.equal(actions[0].spool.colour_hex, '#C12E1F');
|
||||
assert.equal(actions[0].spool.product_code, 'A00-R0');
|
||||
assert.equal(actions[0].spool.total_g, 1000);
|
||||
assert.equal(actions[0].spool.remaining_g, null, 'an AMS lite can’t say how much is left');
|
||||
});
|
||||
|
||||
test('a spool the shelf already knows is moved, not added again', () => {
|
||||
const existing = [{ id: 7, material: 'PLA', colour_hex: '#000000', rfid_uuid: 'U-BLACK',
|
||||
printer_serial: SERIAL, ams_slot: 'PRINTER1/0/3' }];
|
||||
const { actions } = planSync(existing, [tray(1, '#000000', 'U-BLACK')], SERIAL, palette);
|
||||
assert.deepEqual(actions, [{ op: 'update', id: 7, slot: 'PRINTER1/0/1', printerSerial: SERIAL, remaining: null }]);
|
||||
});
|
||||
|
||||
test('a spool added by hand is adopted by the printer rather than duplicated', () => {
|
||||
// The Jade White spool added before the printer was connected.
|
||||
const existing = [{ id: 1, material: 'PLA', colour_hex: '#ffffff', rfid_uuid: null, printer_serial: null, ams_slot: null }];
|
||||
const { actions } = planSync(existing, [tray(3, '#FFFFFF', 'U-WHITE', { idName: 'A00-W1' })], SERIAL, palette);
|
||||
assert.equal(actions.length, 1);
|
||||
assert.equal(actions[0].op, 'adopt');
|
||||
assert.equal(actions[0].id, 1);
|
||||
assert.equal(actions[0].uuid, 'U-WHITE');
|
||||
assert.equal(actions[0].code, '10100');
|
||||
});
|
||||
|
||||
test('one hand-added spool is adopted once, even with two matching trays', () => {
|
||||
const existing = [{ id: 1, material: 'PLA', colour_hex: '#FFFFFF', rfid_uuid: null }];
|
||||
const { actions } = planSync(existing, [tray(0, '#FFFFFF', 'U-W1'), tray(1, '#FFFFFF', 'U-W2')], SERIAL, palette);
|
||||
assert.deepEqual(actions.map((a) => a.op), ['adopt', 'create']);
|
||||
});
|
||||
|
||||
test('a hand-added spool of another colour or material is left alone', () => {
|
||||
const existing = [
|
||||
{ id: 1, material: 'PLA', colour_hex: '#FF0000', rfid_uuid: null }, // wrong colour
|
||||
{ id: 2, material: 'PETG', colour_hex: '#FFFFFF', rfid_uuid: null }, // wrong material
|
||||
];
|
||||
const { actions } = planSync(existing, [tray(0, '#FFFFFF', 'U-W')], SERIAL, palette);
|
||||
assert.deepEqual(actions.map((a) => a.op), ['create']);
|
||||
});
|
||||
|
||||
test('an untagged spool is listed, not guessed at', () => {
|
||||
const { actions, untracked } = planSync([], [tray(2, '#123456', null)], SERIAL, palette);
|
||||
assert.deepEqual(actions, []);
|
||||
assert.deepEqual(untracked, [{ slot: 'PRINTER1/0/2', material: 'PLA', colour: '#123456' }]);
|
||||
});
|
||||
|
||||
test('a spool this printer no longer reports is unloaded; other printers’ spools are not', () => {
|
||||
const existing = [
|
||||
{ id: 5, material: 'PLA', colour_hex: '#000000', rfid_uuid: 'U-GONE', printer_serial: SERIAL, ams_slot: 'PRINTER1/0/0' },
|
||||
{ id: 6, material: 'PLA', colour_hex: '#000000', rfid_uuid: 'U-ELSEWHERE', printer_serial: 'OTHER', ams_slot: 'OTHER/0/0' },
|
||||
];
|
||||
const { actions } = planSync(existing, [], SERIAL, palette);
|
||||
assert.deepEqual(actions, [{ op: 'unload', id: 5 }]);
|
||||
});
|
||||
|
||||
test('empty trays are ignored, and the external holder has its own slot key', () => {
|
||||
assert.deepEqual(planSync([], [{ empty: true, unit: 0, slot: 0 }], SERIAL, palette).actions, []);
|
||||
assert.equal(slotKey(SERIAL, { unit: null, slot: 254 }), 'PRINTER1/ext/254');
|
||||
});
|
||||
|
||||
test('palette matching names a colour within its product line, and doesn’t guess', () => {
|
||||
assert.deepEqual(paletteMatch(palette, 'PLA', 'Basic', '#FFFFFF'), { name: 'Jade White', code: '10100', type: 'Basic' });
|
||||
assert.equal(paletteMatch(palette, 'PLA', 'Matte', '#FFFFFF').name, 'Ivory White');
|
||||
assert.equal(paletteMatch(palette, 'PLA', 'Basic', '#123456'), null);
|
||||
});
|
||||
|
||||
test('an AMS that measures fills in grams left; an AMS lite never does', () => {
|
||||
const trays = [tray(0, '#C12E1F', 'U-RED', { remain: 42 })];
|
||||
const lite = planSync([], trays, SERIAL, palette, { measures: false });
|
||||
assert.equal(lite.actions[0].spool.remaining_g, null);
|
||||
const pro = planSync([], trays, SERIAL, palette, { measures: true });
|
||||
assert.equal(pro.actions[0].spool.remaining_g, 420);
|
||||
|
||||
const existing = [{ id: 7, material: 'PLA', colour_hex: '#C12E1F', rfid_uuid: 'U-RED', printer_serial: SERIAL, ams_slot: 'PRINTER1/0/0' }];
|
||||
assert.equal(planSync(existing, trays, SERIAL, palette, { measures: true }).actions[0].remaining, 420);
|
||||
// No reading at all leaves the stored amount alone.
|
||||
const unknown = [tray(0, '#C12E1F', 'U-RED', { remain: null })];
|
||||
assert.equal(planSync(existing, unknown, SERIAL, palette, { measures: true }).actions[0].remaining, null);
|
||||
});
|
||||
103
tools/backup.sh
103
tools/backup.sh
|
|
@ -1,103 +0,0 @@
|
|||
#!/usr/bin/env bash
|
||||
# Nightly backup: a dated dump of the database, and a mirror of the model files.
|
||||
#
|
||||
# Run it from anywhere — it finds the folder holding docker-compose.yml itself.
|
||||
# Printhive keeps running throughout; pg_dump takes a consistent snapshot.
|
||||
#
|
||||
# ./tools/backup.sh
|
||||
#
|
||||
# Settings (in .env beside docker-compose.yml, or the environment):
|
||||
# BACKUP_DIR where backups go (default: ./backups)
|
||||
# UPLOADS_DIR the model files (default: asked of the running container)
|
||||
# KEEP_DAYS dumps to keep, in days (default: 14)
|
||||
set -euo pipefail
|
||||
|
||||
cd "$(dirname "$0")/.."
|
||||
|
||||
# A setting from the environment, else from .env, else the fallback. Tolerates
|
||||
# "export NAME=", quotes, spaces and Windows line endings.
|
||||
setting() { # name, fallback
|
||||
local from_env="${!1:-}"
|
||||
if [ -n "$from_env" ]; then echo "$from_env"; return; fi
|
||||
if [ -f .env ]; then
|
||||
local line
|
||||
line="$(tr -d '\r' < .env \
|
||||
| grep -E "^[[:space:]]*(export[[:space:]]+)?$1[[:space:]]*=" \
|
||||
| tail -1 | cut -d= -f2- \
|
||||
| sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//' -e 's/^"\(.*\)"$/\1/' -e "s/^'\(.*\)'$/\1/")"
|
||||
if [ -n "$line" ]; then echo "$line"; return; fi
|
||||
fi
|
||||
echo "$2"
|
||||
}
|
||||
|
||||
BACKUP_DIR="$(setting BACKUP_DIR ./backups)"
|
||||
KEEP_DAYS="$(setting KEEP_DAYS 14)"
|
||||
stamp="$(date +%Y-%m-%d)"
|
||||
|
||||
# Where the model files really are: ask the running container, rather than
|
||||
# trusting a setting that may have drifted from how the stack was deployed.
|
||||
UPLOADS_DIR="${UPLOADS_DIR:-}"
|
||||
if [ -z "$UPLOADS_DIR" ]; then
|
||||
UPLOADS_DIR="$(docker inspect printhive-app \
|
||||
--format '{{range .Mounts}}{{if eq .Destination "/app/uploads"}}{{.Source}}{{end}}{{end}}' 2>/dev/null || true)"
|
||||
fi
|
||||
if [ -z "$UPLOADS_DIR" ]; then
|
||||
echo "$(date '+%F %T') FAILED: couldn’t find where /app/uploads comes from — is printhive-app running?" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "$(date '+%F %T') files: $UPLOADS_DIR → backups: $BACKUP_DIR"
|
||||
|
||||
# A path the container can see but this machine can't (a stack manager deploying
|
||||
# from inside a container does that) would back up nothing at all.
|
||||
if [ ! -d "$UPLOADS_DIR" ]; then
|
||||
echo "$(date '+%F %T') FAILED: $UPLOADS_DIR isn’t reachable from here — set UPLOADS_DIR to its path on this machine" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [ -z "$(ls -A "$UPLOADS_DIR" 2>/dev/null)" ]; then
|
||||
echo "$(date '+%F %T') FAILED: $UPLOADS_DIR is empty — refusing to mirror nothing over a good backup" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
mkdir -p "$BACKUP_DIR/db"
|
||||
|
||||
# --- the database -----------------------------------------------------------
|
||||
# Written to a .part file first, so an interrupted run never leaves behind
|
||||
# something that looks like a good backup.
|
||||
# An empty library where backups already exist means something is wrong with
|
||||
# the stack, not that you deleted everything: don't write that over good ones.
|
||||
models="$(docker compose exec -T db psql -U printhive -d printhive -tAc 'select count(*) from models' 2>/dev/null | tr -d '[:space:]' || true)"
|
||||
if [ "$models" = "0" ] && ls "$BACKUP_DIR"/db/printhive-*.dump >/dev/null 2>&1; then
|
||||
echo "$(date '+%F %T') FAILED: the database has no models, but older backups do — leaving them alone" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
dump="$BACKUP_DIR/db/printhive-$stamp.dump"
|
||||
|
||||
# Dumped inside the container and read back there — a dump that can't be listed
|
||||
# is no backup at all, and pg_restore can only list a real file, not a pipe.
|
||||
if ! docker compose exec -T db sh -c \
|
||||
'pg_dump -U printhive -Fc printhive > /tmp/printhive.dump && pg_restore -l /tmp/printhive.dump > /dev/null'; then
|
||||
echo "$(date '+%F %T') FAILED: couldn’t dump the database — keeping the old backups" >&2
|
||||
docker compose exec -T db rm -f /tmp/printhive.dump || true
|
||||
exit 1
|
||||
fi
|
||||
docker compose exec -T db cat /tmp/printhive.dump > "$dump.part"
|
||||
docker compose exec -T db rm -f /tmp/printhive.dump || true
|
||||
|
||||
if [ ! -s "$dump.part" ]; then
|
||||
echo "$(date '+%F %T') FAILED: the dump came out empty — keeping the old backups" >&2
|
||||
rm -f "$dump.part"
|
||||
exit 1
|
||||
fi
|
||||
mv "$dump.part" "$dump"
|
||||
echo "$(date '+%F %T') database → $dump ($(du -h "$dump" | cut -f1))"
|
||||
|
||||
# Older dumps go, so the folder doesn't grow for ever.
|
||||
find "$BACKUP_DIR/db" -name 'printhive-*.dump' -type f -mtime "+$KEEP_DAYS" -delete
|
||||
|
||||
# --- the model files --------------------------------------------------------
|
||||
# A mirror, not a history: it matches what's there now, deletions included.
|
||||
# Thumbnails, previews and extracted archives are left out — they rebuild.
|
||||
rsync -a --delete "$UPLOADS_DIR/" "$BACKUP_DIR/uploads/"
|
||||
echo "$(date '+%F %T') files → $BACKUP_DIR/uploads ($(du -sh "$BACKUP_DIR/uploads" | cut -f1))"
|
||||
|
|
@ -33,7 +33,6 @@ def main():
|
|||
return 1
|
||||
|
||||
print(json.dumps({
|
||||
"application": project["application"],
|
||||
"plates": [{"index": p["index"], "name": p["name"], "objectCount": len(p["objects"]),
|
||||
"thumbnail": bool(p["thumbnail"] or p["thumbnail_small"])} for p in project["plates"]],
|
||||
"filaments": project["filaments"],
|
||||
|
|
|
|||
|
|
@ -159,38 +159,16 @@ def load(path):
|
|||
return meshes, placements
|
||||
|
||||
|
||||
def _application(z, names):
|
||||
"""The app that wrote the file, from the root model's <metadata name="Application">.
|
||||
Metadata comes before the geometry, so reading stops at <resources>."""
|
||||
root = _root_part(z)
|
||||
if root not in names:
|
||||
return None
|
||||
try:
|
||||
with z.open(root) as fh:
|
||||
for event, el in etree.iterparse(fh, events=("start", "end"), **PARSER):
|
||||
name = _local(el.tag)
|
||||
if event == "start":
|
||||
if name in ("resources", "build"):
|
||||
break
|
||||
elif name == "metadata" and el.get("name") == "Application":
|
||||
return (el.text or "").strip() or None
|
||||
except etree.XMLSyntaxError:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
def read_project(path):
|
||||
"""Slicer project metadata (Bambu Studio / OrcaSlicer), empty when absent:
|
||||
|
||||
{application: "BambuStudio-02.01.01.52" | None,
|
||||
plates: [{index, name, objects: [(object_id, instance)], thumbnail, thumbnail_small}],
|
||||
{plates: [{index, name, objects: [(object_id, instance)], thumbnail, thumbnail_small}],
|
||||
filaments: [{colour, type}],
|
||||
objects: {object_id: {name, extruder, parts: {part_id: {extruder, subtype}}}}}
|
||||
"""
|
||||
project = {"application": None, "plates": [], "filaments": [], "objects": {}}
|
||||
project = {"plates": [], "filaments": [], "objects": {}}
|
||||
with zipfile.ZipFile(path) as z:
|
||||
names = set(z.namelist())
|
||||
project["application"] = _application(z, names)
|
||||
|
||||
if "Metadata/project_settings.config" in names:
|
||||
try:
|
||||
|
|
|
|||
|
|
@ -1,68 +0,0 @@
|
|||
#!/usr/bin/env bash
|
||||
# Copies Printhive's data out of Docker's named volumes into ./data, for the
|
||||
# switch to host folders. Run it on the server, from the folder holding
|
||||
# docker-compose.yml, with the stack stopped.
|
||||
#
|
||||
# It only copies: the named volumes are left exactly as they were, so if
|
||||
# anything looks wrong you can put the old docker-compose.yml back and start
|
||||
# again with nothing lost.
|
||||
set -euo pipefail
|
||||
|
||||
# Same settings Compose uses, so the copy lands where the stack will look.
|
||||
if [ -f .env ]; then
|
||||
DATA_DIR="${DATA_DIR:-$(grep -E '^DATA_DIR=' .env | tail -1 | cut -d= -f2-)}"
|
||||
PGDATA_DIR="${PGDATA_DIR:-$(grep -E '^PGDATA_DIR=' .env | tail -1 | cut -d= -f2-)}"
|
||||
fi
|
||||
DATA_DIR="${DATA_DIR:-./data}"
|
||||
PGDATA_DIR="${PGDATA_DIR:-./data/pgdata}"
|
||||
VOLUMES=(pgdata uploads thumbnails glb extracted)
|
||||
|
||||
# Where each volume's contents should end up.
|
||||
target_for() {
|
||||
case "$1" in
|
||||
pgdata) echo "$PGDATA_DIR" ;;
|
||||
*) echo "$DATA_DIR/$1" ;;
|
||||
esac
|
||||
}
|
||||
|
||||
# Compose names volumes "<project>_<name>", the project being this folder's name
|
||||
# unless it's set elsewhere.
|
||||
PROJECT="${COMPOSE_PROJECT_NAME:-$(basename "$(pwd)")}"
|
||||
|
||||
if docker compose ps --status running --quiet 2>/dev/null | grep -q .; then
|
||||
echo "Printhive is still running. Stop it first: docker compose down" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Project: $PROJECT"
|
||||
echo "Files: $DATA_DIR"
|
||||
echo "Database: $PGDATA_DIR"
|
||||
echo
|
||||
|
||||
for name in "${VOLUMES[@]}"; do
|
||||
volume="${PROJECT}_${name}"
|
||||
target="$(target_for "$name")"
|
||||
|
||||
if ! docker volume inspect "$volume" >/dev/null 2>&1; then
|
||||
echo "· $volume — no such volume, skipping (nothing to copy)"
|
||||
mkdir -p "$target"
|
||||
continue
|
||||
fi
|
||||
if [ -d "$target" ] && [ -n "$(ls -A "$target" 2>/dev/null)" ]; then
|
||||
echo "· $target already has files in it — skipping, so nothing is overwritten" >&2
|
||||
continue
|
||||
fi
|
||||
|
||||
mkdir -p "$target"
|
||||
# -a keeps ownership and timestamps, which Postgres's data folder needs.
|
||||
docker run --rm \
|
||||
-v "$volume":/from:ro \
|
||||
-v "$(cd "$(dirname "$target")" && pwd)/$(basename "$target")":/to \
|
||||
alpine sh -c 'cp -a /from/. /to/'
|
||||
echo "· $volume → $target ($(du -sh "$target" | cut -f1))"
|
||||
done
|
||||
|
||||
echo
|
||||
echo "Done. Start it up again with: docker compose up -d"
|
||||
echo "Once you're happy, the old volumes can be removed with:"
|
||||
for name in "${VOLUMES[@]}"; do echo " docker volume rm ${PROJECT}_${name}"; done
|
||||
Loading…
Reference in a new issue