Every Bambu spool reports an RFID identity — a spool id and a tag id, both unique — so a printer's report can now keep the Filament page in step: a new tagged spool is created, named and coded from the bundled filament list; one already known is moved to its current slot; one you added by hand before the printer was connected is adopted rather than duplicated; and one the printer no longer reports is marked as taken out. It runs on "Save & test connection" and every ten minutes in the background, still only reading from the printer. Untagged spools are listed, not stored: without a tag, one spool of a colour is indistinguishable from the next, so guessing would mean duplicates. The planning is a pure function, tested against the cases that would cause duplicates. The database side was exercised separately against a real Postgres: three successive reports created, moved and unloaded spools as intended, and a deliberate duplicate was refused by the partial unique index. The amount left isn't taken from the printer yet: an AMS lite only reports full or empty, so it's left blank until printers can say which kind of AMS they have. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
299 lines
14 KiB
SQL
299 lines
14 KiB
SQL
-- STL Library Manager schema
|
|
-- A "model" is one logical download (a Thingiverse thing, a Printables model, a
|
|
-- single uploaded STL). A model owns one or more files (the actual .stl/.3mf/...).
|
|
|
|
-- Per-license commercial-use policy. Auto-registered as licenses are seen;
|
|
-- editable in Settings. commercial: true=sellable, false=non-commercial, null=unknown.
|
|
CREATE TABLE IF NOT EXISTS license_rules (
|
|
name TEXT PRIMARY KEY,
|
|
commercial BOOLEAN
|
|
);
|
|
|
|
-- App users (email/password auth).
|
|
CREATE TABLE IF NOT EXISTS users (
|
|
id SERIAL PRIMARY KEY,
|
|
email TEXT NOT NULL UNIQUE,
|
|
password_hash TEXT NOT NULL,
|
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
);
|
|
|
|
-- One-time password-reset tokens (token stored hashed).
|
|
CREATE TABLE IF NOT EXISTS password_resets (
|
|
id SERIAL PRIMARY KEY,
|
|
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
|
token_hash TEXT NOT NULL,
|
|
expires_at TIMESTAMPTZ NOT NULL,
|
|
used BOOLEAN NOT NULL DEFAULT false
|
|
);
|
|
CREATE INDEX IF NOT EXISTS idx_password_resets_token ON password_resets(token_hash);
|
|
|
|
-- Simple key/value app settings (API tokens, site auth). Editable from the UI.
|
|
CREATE TABLE IF NOT EXISTS settings (
|
|
key TEXT PRIMARY KEY,
|
|
value TEXT,
|
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS designers (
|
|
id SERIAL PRIMARY KEY,
|
|
name TEXT NOT NULL,
|
|
source_site TEXT, -- thingiverse | printables | makerworld | ...
|
|
profile_url TEXT,
|
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
UNIQUE (name, source_site)
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS projects (
|
|
id SERIAL PRIMARY KEY,
|
|
name TEXT NOT NULL,
|
|
description TEXT NOT NULL DEFAULT '',
|
|
date_created TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
date_modified TIMESTAMPTZ
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS tags (
|
|
id SERIAL PRIMARY KEY,
|
|
name TEXT NOT NULL UNIQUE
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS models (
|
|
id SERIAL PRIMARY KEY,
|
|
name TEXT NOT NULL,
|
|
description TEXT NOT NULL DEFAULT '',
|
|
source_site TEXT, -- where it came from, null for manual upload
|
|
source_url TEXT, -- original page URL (for attribution)
|
|
source_id TEXT, -- the site's id for the thing/model
|
|
license TEXT,
|
|
designer_id INTEGER REFERENCES designers(id) ON DELETE SET NULL,
|
|
thumbnail_path TEXT,
|
|
print_settings JSONB NOT NULL DEFAULT '{}'::jsonb,
|
|
date_added TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
date_modified TIMESTAMPTZ
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS files (
|
|
id SERIAL PRIMARY KEY,
|
|
model_id INTEGER NOT NULL REFERENCES models(id) ON DELETE CASCADE,
|
|
file_name TEXT NOT NULL, -- on-disk name (unique suffix)
|
|
original_name TEXT NOT NULL, -- name as downloaded/uploaded
|
|
file_path TEXT NOT NULL, -- path on disk
|
|
format TEXT NOT NULL, -- stl | 3mf | obj | step | gcode | zip | ...
|
|
size BIGINT NOT NULL DEFAULT 0,
|
|
volume DOUBLE PRECISION, -- cm^3 (from node-stl), when computable
|
|
bbox JSONB, -- {x,y,z} bounding box
|
|
facets INTEGER,
|
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS model_projects (
|
|
model_id INTEGER NOT NULL REFERENCES models(id) ON DELETE CASCADE,
|
|
project_id INTEGER NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
|
|
PRIMARY KEY (model_id, project_id)
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS model_tags (
|
|
model_id INTEGER NOT NULL REFERENCES models(id) ON DELETE CASCADE,
|
|
tag_id INTEGER NOT NULL REFERENCES tags(id) ON DELETE CASCADE,
|
|
PRIMARY KEY (model_id, tag_id)
|
|
);
|
|
|
|
-- Cached GLB conversion of a 3MF, for in-browser 3D preview.
|
|
ALTER TABLE files ADD COLUMN IF NOT EXISTS glb_path TEXT;
|
|
|
|
-- Whether the model's license permits commercial use (selling). true/false/null.
|
|
ALTER TABLE models ADD COLUMN IF NOT EXISTS commercial_use BOOLEAN;
|
|
|
|
CREATE INDEX IF NOT EXISTS idx_files_model ON files(model_id);
|
|
CREATE INDEX IF NOT EXISTS idx_model_projects_p ON model_projects(project_id);
|
|
CREATE INDEX IF NOT EXISTS idx_model_tags_t ON model_tags(tag_id);
|
|
CREATE INDEX IF NOT EXISTS idx_models_source ON models(source_site, source_id);
|
|
|
|
-- Content + geometry fingerprints for the duplicate finder (filled in the
|
|
-- background by lib/fingerprint.js).
|
|
ALTER TABLE files ADD COLUMN IF NOT EXISTS sha256 TEXT;
|
|
ALTER TABLE files ADD COLUMN IF NOT EXISTS geometry_hash TEXT;
|
|
-- null = not checked yet; ok | error | missing | na (format has no geometry fingerprint)
|
|
ALTER TABLE files ADD COLUMN IF NOT EXISTS geometry_status TEXT;
|
|
ALTER TABLE files ADD COLUMN IF NOT EXISTS fingerprinted_at TIMESTAMPTZ;
|
|
CREATE INDEX IF NOT EXISTS idx_files_sha256 ON files(sha256);
|
|
CREATE INDEX IF NOT EXISTS idx_files_geometry ON files(geometry_hash);
|
|
|
|
-- Free-form notes per model.
|
|
ALTER TABLE models ADD COLUMN IF NOT EXISTS notes TEXT NOT NULL DEFAULT '';
|
|
-- Soft delete: set when a model is moved to the trash.
|
|
ALTER TABLE models ADD COLUMN IF NOT EXISTS deleted_at TIMESTAMPTZ;
|
|
CREATE INDEX IF NOT EXISTS idx_models_deleted ON models(deleted_at);
|
|
|
|
-- Archive files (zip/7z/rar/tar) are indexed in the background:
|
|
-- null = not yet, ok | error (with archive_error).
|
|
ALTER TABLE files ADD COLUMN IF NOT EXISTS archive_status TEXT;
|
|
ALTER TABLE files ADD COLUMN IF NOT EXISTS archive_error TEXT;
|
|
|
|
-- Files inside archives, so they can be browsed, previewed and matched as duplicates.
|
|
CREATE TABLE IF NOT EXISTS archive_entries (
|
|
id SERIAL PRIMARY KEY,
|
|
file_id INTEGER NOT NULL REFERENCES files(id) ON DELETE CASCADE,
|
|
path TEXT NOT NULL, -- path inside the archive, as the archive stores it
|
|
format TEXT NOT NULL,
|
|
size BIGINT NOT NULL DEFAULT 0,
|
|
sha256 TEXT,
|
|
geometry_hash TEXT,
|
|
geometry_status TEXT, -- same states as files.geometry_status
|
|
fingerprinted_at TIMESTAMPTZ,
|
|
UNIQUE (file_id, path)
|
|
);
|
|
CREATE INDEX IF NOT EXISTS idx_entries_sha256 ON archive_entries(sha256);
|
|
CREATE INDEX IF NOT EXISTS idx_entries_geometry ON archive_entries(geometry_hash);
|
|
|
|
-- Background thumbnails: null = not tried yet, none = nothing to make one from
|
|
-- (left to the browser), error. Cleared when files are added.
|
|
ALTER TABLE models ADD COLUMN IF NOT EXISTS thumbnail_status TEXT;
|
|
|
|
-- Duplicate groups marked "not duplicates" (signature = sorted model ids).
|
|
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;
|