Skip to content
45 changes: 45 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,51 @@ GitHub release.

## [Unreleased]

## [0.2.1] — 2026-06-17

### Added

- **Bidirectional archive/retire lifecycle sync (FR-21)** — a mapped spool's lifecycle
state now mirrors between Spoolman (`archived`) and Filament DB (`retired`) in both
directions: archiving/retiring one side flips the other, and un-archiving/un-retiring
mirrors back too (re-enabling weight sync). A new `archive_sync` policy category
(`archive_sync_direction`, default `two_way`; `archive_conflict_policy`, default `manual`)
governs it from Settings → Archive / retire sync; `newest_wins` is rejected (422) since
the state is a boolean with no timestamp. The wizard import gate is preserved — *unmapped*
archived spools are still never auto-imported; only *mapped-pair* diffing includes archived
spools. The lifecycle pass runs **after** the weight pass, so a depleted-and-archived spool
settles its final decrement and FDB usage-log entry (and refreshes both snapshots) before
the archive bit mirrors. A one-sided flip is a clean push; only a both-sides-diverge-to-
opposite-states case queues a `cross_system` lifecycle conflict, whose human resolution
writes the chosen state to both systems. The "Never import empties" setting was relabeled
"Skip empty & archived spools on import" to clarify it is import-only (config key unchanged).

### Fixed

- **Synced Records now shows the Filament DB color for solid filaments (#2)** — the FDB color
cell rendered "—" for purely-solid filaments (e.g. "Beige") even when the color was set and
in sync. The display value (`_mc_color`) was written only by the multicolor sync pass, which
skips solid filaments, so most filaments never captured a color for display. The engine now
captures a representative display hex for **every** mapped filament (solid and multicolor)
each cycle. Multicolor filaments (which store `color=null` with the real hexes in
`secondaryColors`) also now resolve a representative hex instead of "—", and the FDB color is
normalized to the Spoolman convention so a truly in-sync color reads as matched. Existing
records self-heal on the next sync cycle.
- **Dashboard count clarity for master/container filaments (#3)** — when `generic_container`
mode is in use, the "Connected systems → Filament DB" line now breaks out real filaments and
synthetic master/container parents separately (e.g. `filaments: 37 masters: 13` instead of a
lone `filaments: 50`), so it reconciles with the rest of the bridge (which excludes masters).
The Spools and Filaments dashboard sections also gained help text clarifying they are counted
independently — a filament can hold several spools, so the two totals legitimately differ and
green-but-unequal totals are not a mismatch. Master detection is now a single shared helper
(`core/masters.is_master_fdb`) reused by the wizard, reconcile, and health surfaces.
- **Help tooltips no longer clipped by the sidebar or page header** — the `?` HelpTip bubble
was absolutely positioned within the page flow and got cut off near the left edge and top of
the screen. It now renders in a portal with fixed positioning, flips above/below to stay in
view, and clamps horizontally so it's always fully visible. Dashboard section headers
(Spools / Filaments / Connected systems) were also made larger and higher-contrast so the
sections read as distinct blocks.

## [0.2.0] — 2026-06-15

### Added
Expand Down
17 changes: 16 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -271,7 +271,9 @@ Several settings can be changed at runtime via the Settings UI (stored in SQLite
| `new_filament_policy` | `manual_review` | What the engine does when it detects an unmapped filament: `manual_review` queues an actionable `new_filament` conflict; `auto_import` creates it immediately using the wizard code path. Falls back to `manual_review` if `variant_parent_mode` is `unset` and the filament looks like a variant-cluster member. |
| `new_spool_policy` | `manual_review` | What the engine does when an unmapped spool appears on an already-mapped filament: `manual_review` queues a `new_spool` conflict; `auto_import` creates it immediately. A spool is always held when its filament is unmapped, regardless of this setting. |
| `debug_mode` | `false` | Enables `POST /api/debug/clear-spoolman-fdb-refs`, `POST /api/debug/reset-bridge-state`, and `POST /api/debug/full-reset` (403 when off) |
| `never_import_empties` | `false` | Wizard skips spools with zero remaining weight at preview/execute |
| `never_import_empties` | `false` | **Import-only** (UI label: "Skip empty & archived spools on import"). Wizard + ongoing new-spool import skip spools with zero remaining weight or that are archived, at preview/execute. Does NOT affect ongoing archive/retire lifecycle mirroring for already-mapped pairs (that runs regardless). Config key unchanged. |
| `archive_sync_direction` | `two_way` | Direction for the archive/retire lifecycle category (`two_way` / `spoolman_to_filamentdb` / `filamentdb_to_spoolman`). Mirrors SM `archived` ↔ FDB `retired` for already-mapped spool pairs. |
| `archive_conflict_policy` | `manual` | Conflict policy for the archive/retire category (consulted only under `two_way` when both sides diverge to opposite states): `manual` / `spoolman_wins` / `filamentdb_wins`. `newest_wins` is rejected at the API (422) — the state is a boolean with no timestamp. |
| `sync_log_retention_days` | `30` | Sync log entries older than this are pruned automatically |
| `variant_parent_mode` | `unset` | Wizard variant hierarchy mode: `unset` (must choose), `promote_color` (original behavior), or `generic_container` (colorless container parent for every cluster). See `docs/variant-parent-mode.md`. |
| `api_token_enabled` | `false` | When `true`, requests may authenticate via `Authorization: Bearer <token>` or `X-API-Key`. Toggle in Settings → Security. |
Expand All @@ -297,6 +299,19 @@ Spoolman `remaining_weight` is net filament. Filament DB `totalWeight` is gross
agreed values** (SM `remaining_weight` and FDB `totalWeight`), or the propagated change is
re-detected as a fresh change on the other side next cycle → ping-pong.

### Archive/retire lifecycle sync
Archive/retire lifecycle state mirrors **bidirectionally for already-mapped spool pairs**:
SM `archived` ↔ FDB `retired`. A dedicated lifecycle pass runs **after** the weight pass on
purpose — a spool is usually archived right as it hits ~0 g, so the final weight decrement
(and its FDB usage-log entry) must settle and both snapshots refresh *before* the archive
bit mirrors, or the far side lands retired/archived with a stale weight and a missing usage
entry. One-sided flips (either direction, archive or un-archive) are clean pushes; only a
both-sides-flip-to-opposite-states divergence queues a `cross_system` conflict
(`field_name="lifecycle"`). Governed by the `archive_sync` category (`archive_sync_direction`
/ `archive_conflict_policy`). The wizard import gate (`never_import_empties`) still keeps
*unmapped* archived spools out of auto-import — only mapped pairs are mirrored. After any
lifecycle push, refresh BOTH snapshots (same anti-ping-pong rule as weight).

### Filament DB API endpoints the bridge uses
- `GET /api/filaments` — list all filaments with embedded spools
- `GET /api/filaments/:id` — single filament with full detail
Expand Down
49 changes: 43 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# filament-bridge

![version](https://img.shields.io/badge/version-0.2.0-blue)
![version](https://img.shields.io/badge/version-0.2.1-blue)

Bidirectional sync between [Filament DB](https://github.com/hyiger/filament-db) and [Spoolman](https://github.com/Donkie/Spoolman) for 3D printing filament management.

Expand All @@ -11,14 +11,18 @@ Bidirectional sync between [Filament DB](https://github.com/hyiger/filament-db)
> auto-sync, **back up all three databases** (Spoolman, Filament DB, and the bridge). See
> [Backups](#backups). Test against non-critical data first.

<p align="center">
<img src="docs/images/dashboard.png" alt="filament-bridge Dashboard — in-sync spool and filament counts, connected-system health with versions, and sync controls" width="820">
</p>

---

## Why?

Filament DB and Spoolman are both excellent tools that solve different parts of the filament management problem:

- **Filament DB** excels at material profile management — deep slicer integration (PrusaSlicer, OrcaSlicer, Bambu Studio), per-printer/nozzle calibration storage, material science properties, NFC tag support, and AI-powered data sheet import.
- **Spoolman** is a long-standing inventory solution with native connections to OctoPrint, Moonraker/Klipper, and Home Assistant (among others), plus a broad surrounding ecosystem. If you want to start using Filament DB but still rely on features Spoolman offers, this tool lets you combine both experiences instead of choosing one.
- **[Filament DB](https://github.com/hyiger/filament-db)** excels at material profile management — deep slicer integration (PrusaSlicer, OrcaSlicer, Bambu Studio), per-printer/nozzle calibration storage, material science properties, NFC tag support, and AI-powered data sheet import.
- **[Spoolman](https://github.com/Donkie/Spoolman)** is a long-standing inventory solution with native connections to OctoPrint, Moonraker/Klipper, and Home Assistant (among others), plus a broad surrounding ecosystem. If you want to start using Filament DB but still rely on features Spoolman offers, this tool lets you combine both experiences instead of choosing one.

filament-bridge keeps the two in sync so you can use both without manual data entry. It runs as a single Docker container next to your existing instances, links records via Spoolman extra fields and Filament DB spool labels, and keeps its own state in SQLite — neither upstream system is ever modified beyond its documented REST API.

Expand Down Expand Up @@ -47,10 +51,19 @@ There are **two ways to onboard**: just bridge the two systems and create your F
- **Version badge + update check** — the sidebar shows the running version and surfaces new GitHub releases (checked server-side, cached 6 h)
- **Debug reset tools** — a gated Danger Zone (off by default) with three reset tools for clean re-testing: clear Spoolman cross-refs, reset the bridge DB, or both at once

<img src="docs/images/wizard-matches.png" alt="Bulk Import Wizard, Matches step — fuzzy vendor/name/color pairing of Spoolman and Filament DB records with per-row status and bulk actions" width="760">

---

## What's New

### v0.2.1 (2026-06-17)

- **Bidirectional archive/retire sync** — a synced spool's lifecycle state now mirrors both ways (archive in Spoolman ↔ retire in Filament DB), with the final weight settling before the archive bit propagates so nothing lands with a stale weight (FR-21)
- **Fix** — the Filament DB color now shows in Synced Records for solid filaments instead of "—" (#2)
- **Fix** — the Dashboard's Filament DB line breaks out real filaments vs synthetic master/container parents, and the Spools/Filaments sections read as distinct counts rather than a mismatch (#3)
- **Fix** — help tooltips are no longer clipped by the sidebar or page header

### v0.2.0 (2026-06-15)

First public release. The bridge is feature-complete for two-way sync between Filament DB and Spoolman:
Expand Down Expand Up @@ -140,7 +153,7 @@ per-system warning explaining why sync is off — so you can see and fix it. An
version does *not* block sync (that is treated as a connectivity issue, surfaced as `degraded`
health, not as "too old").

Latest tested upstreams: **Filament DB 1.42.0** and **Spoolman 0.23.1**.
Latest tested upstreams: **Filament DB 1.49.0** and **Spoolman 0.23.1**.

- **Filament DB** — the bridge gates version-specific features automatically.
- **Spoolman** — the bridge creates its required extra fields (`filamentdb_id`, `filamentdb_spool_id`, etc.) automatically on startup if they are missing.
Expand Down Expand Up @@ -217,14 +230,20 @@ Each data category is configured independently on two axes in Settings:
`spoolman_wins`, `filamentdb_wins`, or `newest_wins` (weight only — Spoolman exposes no
per-filament modification timestamp)

Defaults: weight syncs Spoolman→FDB; material properties sync FDB→Spoolman; new spools sync two-way.
Defaults: weight syncs Spoolman→FDB; material properties sync FDB→Spoolman; new spools and archive/retire both sync two-way.

<img src="docs/images/settings-direction-policy.png" alt="Settings — per-category sync direction and conflict policy controls for weight, material properties, and new records" width="760">

### What syncs

Beyond spool weight, the engine syncs the shared filament surface per cycle: material/type,
density, diameter, spool (tare) weight, net filament weight, bed/nozzle temperatures, cost,
structured multicolor/gradient colors, OpenPrintTag finish tags, and any extra fields you map
via `FIELD_MAPPINGS`. The full pass-by-pass model lives in [docs/sync-model.md](docs/sync-model.md).
via `FIELD_MAPPINGS`. It also mirrors each synced spool's **archive/retire lifecycle state**
between the two systems (see [below](#archive--retire-lifecycle)). The full pass-by-pass model
lives in [docs/sync-model.md](docs/sync-model.md).

<img src="docs/images/synced-records.png" alt="Synced Records — paired spools with an expanded row showing field-by-field Spoolman vs Filament DB values and deep links to each system" width="820">

### Weight model translation

Expand All @@ -233,6 +252,20 @@ Spoolman tracks **net filament weight** (`remaining_weight` excludes the reel).
- Spoolman → Filament DB: weight decrements are logged as usage entries — never raw overwrites
- Filament DB → Spoolman: `remaining_weight = totalWeight − spoolWeight`

### Archive / retire (lifecycle)

Once a spool is synced, its lifecycle state stays in step across both systems: archiving a
spool in Spoolman retires it in Filament DB, and retiring it in Filament DB archives it in
Spoolman — un-archiving/un-retiring mirrors back the same way. It runs as its own category
(`archive_sync`, default `two_way` / `manual`), and the final weight always settles **before**
the archive bit propagates, so a depleted-then-archived spool never lands on the other side
with a stale weight. A one-sided change is a clean push; only a genuine both-sides divergence
queues a conflict.

This is separate from the wizard's **Skip empty & archived spools on import** setting, which
only controls whether already-dead spools are pulled in during a bulk import — it does not
affect ongoing lifecycle mirroring.

### Variant tracking

Filament DB uses parent/variant inheritance (one parent with shared settings, color variants underneath). Spoolman is flat — one filament per color. The bridge tracks the relationship via Spoolman extra fields (`filamentdb_id`, `filamentdb_parent_id`) and builds the hierarchy at import time according to your [variant parent mode](docs/variant-parent-mode.md). When a Spoolman change would override a variant's *inherited* setting, the bridge queues a master-divergence conflict instead of silently detaching the variant from its parent — you decide whether the change applies to the whole line, just that variant, or not at all.
Expand All @@ -241,6 +274,8 @@ Filament DB uses parent/variant inheritance (one parent with shared settings, co

All conflicts are queued — never silently resolved — and shown on the Conflicts page with both values and deep links. Resolving a standard conflict records your choice; resolving a master-divergence conflict applies your chosen action upstream. Details in [docs/conflicts.md](docs/conflicts.md).

<img src="docs/images/conflicts.png" alt="Conflicts page — queued new-filament and conflict entries with per-entry Add/Dismiss and resolution actions; nothing is auto-resolved" width="760">

---

## Concepts
Expand Down Expand Up @@ -300,6 +335,8 @@ The OpenTag tool matches your Spoolman filaments against the [OpenPrintTag](http

Vendor-name and color-word mappings for the matcher are editable in Settings. Full guide: [docs/opentag-cleanup.md](docs/opentag-cleanup.md).

<img src="docs/images/opentag-review.png" alt="OpenTag Cleanup — match list across the catalog with an expanded filament showing field-by-field Filament DB / Spoolman / use-value comparison and per-field keep-mine controls" width="820">

---

## Security
Expand Down
2 changes: 1 addition & 1 deletion backend/app/__init__.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import os

__version__ = "0.2.0"
__version__ = "0.2.1"
__channel__ = os.environ.get("BRIDGE_CHANNEL", "release").strip() or "release"
__commit__ = os.environ.get("BRIDGE_COMMIT", "").strip() or None
28 changes: 28 additions & 0 deletions backend/app/api/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,18 @@ def get_config_value(db: Session, key: str, default: Any = None) -> Any:
return json.loads(row.value) if row else default


def resolve_container_parent_marker(db: Session) -> str:
"""Return the effective container_parent_marker from BridgeConfig (or env default).

An explicitly-stored empty string is honored (means "no marker suffix"); only a
missing key falls back to the env/start-up default.
"""
raw = get_config_value(db, "container_parent_marker", None)
if raw is None:
return _settings.container_parent_marker
return str(raw)


def set_config_value(db: Session, key: str, value: Any) -> None:
"""Upsert one BridgeConfig key (value is JSON-encoded)."""
value_json = json.dumps(value)
Expand Down Expand Up @@ -132,6 +144,8 @@ def _config_response(db: Session) -> ConfigResponse:
weight_conflict_policy=cfg.get("weight_conflict_policy", "manual"),
material_properties_sync_direction=cfg.get("material_properties_sync_direction", "filamentdb_to_spoolman"),
material_properties_conflict_policy=cfg.get("material_properties_conflict_policy", "manual"),
archive_sync_direction=cfg.get("archive_sync_direction", "two_way"),
archive_conflict_policy=cfg.get("archive_conflict_policy", "manual"),
new_spool_sync_direction=cfg.get("new_spool_sync_direction", "two_way"),
new_filament_policy=cfg.get("new_filament_policy", "manual_review") or "manual_review",
new_spool_policy=cfg.get("new_spool_policy", "manual_review") or "manual_review",
Expand Down Expand Up @@ -175,6 +189,20 @@ def update_config(
},
)

# newest_wins is also meaningless for archive/retire — the state is a boolean,
# not a timestamped value, so there is no "newer" side to pick.
if payload.archive_conflict_policy == "newest_wins":
raise HTTPException(
status_code=422,
detail={
"code": "invalid_conflict_policy",
"message": (
"newest_wins is not supported for archive_sync — archive/retire "
"is a boolean state with no comparable timestamp."
),
},
)

updates = payload.model_dump(exclude_unset=True)

# Extract sync_interval_seconds before persisting so we can reschedule.
Expand Down
Loading
Loading