Browse Source

fix(inventory): deleting a spool keeps its usage history; spool ids are never reused

On PostgreSQL, deleting a spool cascaded to its usage history, so
recalculating the cost of its prints fell back to an estimate. On SQLite the
history stayed with the old spool id, which the next spool could get and
then show the deleted spool's prints as its own.

spool_usage_history.spool_id is now nullable with ON DELETE SET NULL: the
model, a PostgreSQL migration, an edit of the stored table definition on
SQLite (both historical shapes), and the SQLite rule list, whose start-up
sweep also clears ids left by earlier deletes. The spool table joins
AUTOINCREMENT on SQLite. Readers handle usage without a spool: the usage
list (was a 500), the forecast panel and the stock forecast skip it, Git
restore keeps it.
maziggy 1 week ago
parent
commit
e1714b5115
66 changed files with 232 additions and 11 deletions
  1. 1 0
      CHANGELOG.md
  2. 48 1
      backend/app/core/database.py
  3. 2 0
      backend/app/core/sqlite_fk.py
  4. 2 0
      backend/app/models/spool.py
  5. 3 1
      backend/app/models/spool_usage_history.py
  6. 1 1
      backend/app/schemas/spool_usage.py
  7. 3 1
      backend/app/services/github_restore.py
  8. 3 1
      backend/app/services/print_scheduler.py
  9. 76 0
      backend/tests/integration/test_spool_delete_keeps_usage.py
  10. 15 0
      backend/tests/unit/test_github_restore.py
  11. 71 0
      backend/tests/unit/test_spool_usage_spool_migration.py
  12. 2 1
      frontend/src/api/client.ts
  13. 2 0
      frontend/src/components/ForecastPanel.tsx
  14. 0 0
      static/assets/AdditionalSection-Bp4Eihhj.js
  15. 0 0
      static/assets/AmsUnitCard-CE34wK3W.js
  16. 0 0
      static/assets/ArchivesPage-BxREyRVk.js
  17. 0 0
      static/assets/AssignToAmsModal-CTQkyBPV.js
  18. 0 0
      static/assets/CamWallPage-wM3PkOT0.js
  19. 0 0
      static/assets/CameraDiagnoseModal-BUPP5f3P.js
  20. 0 0
      static/assets/CameraPage-6shN-dJ-.js
  21. 0 0
      static/assets/CameraWall-CSAwKLLj.js
  22. 0 0
      static/assets/ColumnConfigModal-CU306aok.js
  23. 0 0
      static/assets/ConfigureAmsSlotModal-hEfWEQjS.js
  24. 0 0
      static/assets/ConnectAuthorizePage-DeVFS7Ct.js
  25. 0 0
      static/assets/ContextMenu-nQPETN6B.js
  26. 0 0
      static/assets/ExternalLinkPage-CvGoV2BR.js
  27. 0 1
      static/assets/FileManagerPage-C94svCZU.js
  28. 0 0
      static/assets/FileUploadModal-PApFWhZn.js
  29. 0 0
      static/assets/FinancePage-D4SYEupN.js
  30. 0 0
      static/assets/GCodeViewerPage-2aegkOcq.js
  31. 0 0
      static/assets/GroupEditPage-CxbwCRAq.js
  32. 0 0
      static/assets/ImagePreviewModal-Blg_NPf6.js
  33. 0 0
      static/assets/InventoryPage-B3GyYVHr.js
  34. 0 0
      static/assets/LibraryTrashPage-Bbs01jkZ.js
  35. 0 0
      static/assets/LoginPage-D_cJ0xE1.js
  36. 0 0
      static/assets/MaintenancePage-BzPU6OXI.js
  37. 0 0
      static/assets/ModelSourcesPage-mxxUE-ID.js
  38. 0 0
      static/assets/NotificationsPage-7qZENtHO.js
  39. 0 1
      static/assets/PdfPreviewModal-DLA-u40Z.js
  40. 0 0
      static/assets/PhotoGalleryModal-BoHcSJtg.js
  41. 0 0
      static/assets/PrinterLocationsPage-FGn7xuxm.js
  42. 0 0
      static/assets/PrintersPage-CCnHdO33.js
  43. 0 0
      static/assets/ProfilesPage-Ckb9rEhU.js
  44. 0 0
      static/assets/ProjectDetailPage-BYRgsPpU.js
  45. 0 0
      static/assets/ProjectsPage-MMjTokAh.js
  46. 0 0
      static/assets/QueuePage-DwNr6xz0.js
  47. 0 0
      static/assets/RichTextEditor-BVIwKcsc.js
  48. 0 0
      static/assets/SettingsPage-DGzi3cjL.js
  49. 0 0
      static/assets/SetupPage-D-f4LlTf.js
  50. 0 0
      static/assets/SliceModal-D5MJJ2pF.js
  51. 0 0
      static/assets/SpoolBuddyAmsPage-CbmgPIux.js
  52. 0 0
      static/assets/SpoolBuddyCalibrationPage-BV09t1br.js
  53. 0 0
      static/assets/SpoolBuddyDashboard-DjqalX2f.js
  54. 0 0
      static/assets/SpoolBuddyInventoryPage-CpjrpSIP.js
  55. 1 1
      static/assets/SpoolBuddySettingsPage-Da2K6nFy.js
  56. 0 0
      static/assets/SpoolBuddyWriteTagPage-BwjhnVJS.js
  57. 0 0
      static/assets/SpreadsheetPreviewModal-DzwnwXiY.js
  58. 0 0
      static/assets/StatsPage-CmNJYOiJ.js
  59. 0 0
      static/assets/StreamOverlayPage-DZad98pv.js
  60. 0 0
      static/assets/SystemInfoPage-CNADlEGd.js
  61. 0 0
      static/assets/index-5pmk7Nlq.js
  62. 1 1
      static/assets/inventorySearch-Btiv8N-6.js
  63. 0 0
      static/assets/locationSensorDefaults-BVdYIwqk.js
  64. 0 0
      static/assets/pdf-DeTA-F9L.js
  65. 0 0
      static/assets/utils-oU0m4kow.js
  66. 1 1
      static/index.html

+ 1 - 0
CHANGELOG.md

@@ -162,6 +162,7 @@ All notable changes to Bambuddy will be documented in this file.
 - **The frontend build no longer warns about `path` and `crypto` being externalized for the STEP previewer (#2976)** — `occt-import-js`, the Emscripten build behind STEP previews, requires both modules, but only inside its `ENVIRONMENT_IS_NODE` branches; in the browser it loads its `.wasm` from the URL the preview worker passes and draws randomness from `crypto.getRandomValues`. Vite still externalized both and printed two warnings on every build. `vite.config.ts` now drops exactly those two warnings for that one package through `build.rolldownOptions.onLog`, so an externalization anywhere else, or of any other module, still shows.
 
 ### Fixed
+- **Deleting a spool keeps its usage history, and a new spool no longer takes over a deleted one's** — On PostgreSQL, deleting a spool deleted its usage history, so **Recalculate costs** replaced the real spool cost of its prints with an estimate. On SQLite the history stayed, but the next spool created could get the deleted spool's number and then showed its prints and consumption as its own. The usage history now stays on both databases without a spool, the prints keep their spool cost, and spool numbers are never given out again on SQLite. A Git backup restores such history as well. Supplier and material statistics leave out usage of deleted spools, as there is no spool left to count it for.
 - **Archives a spool was used for can be deleted on PostgreSQL** — With PostgreSQL, deleting an archive for good (deleting it with **purge from statistics**, the automatic archive purge with that option, or deleting a printer together with its archives) failed with a server error when a spool from the inventory had been used for that print. The spool's usage record now stays and only loses its link to the deleted archive, on both databases; on SQLite, usage records still pointing at an archive deleted earlier are cleared of that link at the next start. **Recalculate costs** no longer charges usage records without an archive link to archives by their print name: that added the spool cost of a deleted print, of a print made with archiving off, or of every earlier run to each archive with the same name. Archives without linked usage get the estimate from the filament price instead, as they already did when no such record existed (usage recorded before 0.2.1, or with archiving off).
 - **An id of 0 no longer slips past the checks when queueing, batching or creating a project** — Adding to the queue with an archive id of `0` next to a library file, creating a batch with an archive or library file id of `0`, and creating a project with parent `0` skipped the check that the archive, file or project exists and saved `0` as its id: a server error on PostgreSQL, a link to nothing on SQLite. `0` now means "none" there, as it already did when editing a project. Creating a batch also checks that its archive or library file exists, and refuses an unknown one.
 - **Archiving a virtual-printer upload into a project that does not exist is refused up front** — The project chosen when archiving a pending upload was saved without checking it. On PostgreSQL an unknown project failed with a server error after the file had already been archived; on SQLite the archive was saved with a broken project link. The project is now checked first and an unknown one refused with "Project not found", before anything is archived.

+ 48 - 1
backend/app/core/database.py

@@ -343,7 +343,7 @@ def load_all_models():
 # Tables SQLite must never give a freed id to a new row: whatever still points at
 # a deleted row (a token, a socket, in-memory state, files, a row inserted while
 # the delete committed) would point at the new one. PostgreSQL uses sequences.
-AUTOINCREMENT_TABLES = ("users", "api_keys", "printers", "print_archives", "long_lived_tokens")
+AUTOINCREMENT_TABLES = ("users", "api_keys", "printers", "print_archives", "long_lived_tokens", "spool")
 
 # Columns that keep one of their ids without a foreign key, so the seed below
 # cannot find them in the metadata: a deleted printer's print log is kept as
@@ -5804,6 +5804,7 @@ async def _run_migrations(conn):
     await _migrate_wallet_transactions_keep_deleted_users(conn)
     await _migrate_notification_providers_go_with_their_printer(conn)
     await _migrate_spool_usage_outlives_its_archive(conn)
+    await _migrate_spool_usage_outlives_its_spool(conn)
     await _migrate_pg_column_types(conn)
     await _migrate_pg_columns_to_model(conn)
 
@@ -6068,6 +6069,52 @@ async def _migrate_spool_usage_outlives_its_archive(conn) -> None:
         await _pg_set_fk_ondelete(conn, "spool_usage_history", "archive_id", "print_archives", "SET NULL")
 
 
+async def _migrate_spool_usage_outlives_its_spool(conn) -> None:
+    """spool_usage_history.spool_id: nullable, ON DELETE SET NULL, as the model says.
+
+    Deleting a spool deleted its usage on PostgreSQL (the costs its prints were
+    recalculated from) and left it on SQLite pointing at an id the next spool
+    could get. SQLite enforces neither rule; core/sqlite_fk applies SET NULL,
+    which needs the column nullable.
+    """
+    from sqlalchemy import text
+
+    if is_sqlite():
+        select_sql = text("SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'spool_usage_history'")
+        sql = (await conn.execute(select_sql)).scalar()
+        if not isinstance(sql, str):
+            return
+        new_sql = re.sub(r"(\bspool_id\s+INTEGER)\s+NOT\s+NULL", r"\1", sql)
+        new_sql = re.sub(
+            r"(\bspool_id\s*\)?\s+(?:INTEGER\s+)?REFERENCES\s+spool\s*\(\s*id\s*\)\s+ON\s+DELETE)\s+CASCADE",
+            r"\1 SET NULL",
+            new_sql,
+        )
+        if new_sql == sql:
+            return
+        try:
+            schema_version = (await conn.execute(text("PRAGMA schema_version"))).scalar() or 0
+            await conn.execute(text("PRAGMA writable_schema = ON"))
+            await conn.execute(
+                text("UPDATE sqlite_master SET sql = :sql WHERE type = 'table' AND name = 'spool_usage_history'"),
+                {"sql": new_sql},
+            )
+            # Makes this connection reload the table definition.
+            await conn.execute(text(f"PRAGMA schema_version = {schema_version + 1}"))
+            await conn.execute(text("PRAGMA writable_schema = OFF"))
+        except (OperationalError, ProgrammingError) as exc:
+            logger.error(
+                "Could not make spool_usage_history.spool_id nullable; usage of deleted spools keeps "
+                "pointing at their old id on this install: %s",
+                exc,
+                exc_info=True,
+            )
+        return
+
+    await _safe_execute(conn, "ALTER TABLE spool_usage_history ALTER COLUMN spool_id DROP NOT NULL")
+    await _pg_set_fk_ondelete(conn, "spool_usage_history", "spool_id", "spool", "SET NULL")
+
+
 _PG_ON_DELETE_CODES = {"CASCADE": "c", "SET NULL": "n"}
 
 

+ 2 - 0
backend/app/core/sqlite_fk.py

@@ -51,6 +51,8 @@ APPLIED: frozenset[tuple[str, str]] = frozenset(
         ("print_queue", "archive_id"),
         ("project_bom_items", "archive_id"),
         ("spool_usage_history", "archive_id"),
+        # spool
+        ("spool_usage_history", "spool_id"),
         ("telegram_pending_verdicts", "archive_id"),
         ("wallet_transactions", "print_archive_id"),
         # library_files. print_queue.library_file_id is CASCADE in the model, but

+ 2 - 0
backend/app/models/spool.py

@@ -10,6 +10,8 @@ class Spool(Base):
     """Spool inventory item for tracking filament spools and their properties."""
 
     __tablename__ = "spool"
+    # A freed id must not pass to a new spool: its usage history would follow.
+    __table_args__ = {"sqlite_autoincrement": True}
 
     id: Mapped[int] = mapped_column(primary_key=True)
     material: Mapped[str] = mapped_column(String(50))  # PLA, PETG, ABS, etc.

+ 3 - 1
backend/app/models/spool_usage_history.py

@@ -12,7 +12,9 @@ class SpoolUsageHistory(Base):
     __tablename__ = "spool_usage_history"
 
     id: Mapped[int] = mapped_column(primary_key=True)
-    spool_id: Mapped[int] = mapped_column(ForeignKey("spool.id", ondelete="CASCADE"))
+    # Kept when the spool is deleted, like the archive link below: the usage is
+    # what the prints cost, and what recalculating their cost reads.
+    spool_id: Mapped[int | None] = mapped_column(ForeignKey("spool.id", ondelete="SET NULL"), nullable=True)
     printer_id: Mapped[int | None] = mapped_column(ForeignKey("printers.id", ondelete="SET NULL"))
     print_name: Mapped[str | None] = mapped_column(String(500))
     # The spool's consumption outlives the archive: deleting the archive only

+ 1 - 1
backend/app/schemas/spool_usage.py

@@ -5,7 +5,7 @@ from pydantic import BaseModel
 
 class SpoolUsageHistoryResponse(BaseModel):
     id: int
-    spool_id: int
+    spool_id: int | None = None  # None: the spool was deleted, its usage kept
     printer_id: int | None = None
     print_name: str | None = None
     weight_used: float

+ 3 - 1
backend/app/services/github_restore.py

@@ -1749,7 +1749,9 @@ class GitHubRestoreService:
 
             old_spool_id = entry.get("spool_id")
             spool_id = spool_id_map.get(old_spool_id) if isinstance(old_spool_id, int) else None
-            if spool_id is None:
+            # Usage of a spool deleted before the backup has no spool, and is
+            # restored without one; only a spool id that resolves to nothing is skipped.
+            if spool_id is None and old_spool_id is not None:
                 # The parent spool never made it into the map: the backup's spool
                 # list didn't include it, or its entry carried no integer id. A
                 # spool that was merely *skipped* (matched locally, overwrite off)

+ 3 - 1
backend/app/services/print_scheduler.py

@@ -5492,7 +5492,9 @@ class PrintScheduler:
         ).all()
         history: dict[int, list[stock_forecast.UsageRecord]] = {}
         for spool_id, created_at, weight_used in rows:
-            if created_at is None:
+            # A deleted spool's usage forecasts no stock. Skipped here rather than
+            # filtered before the limit, so the window matches the forecast panel's.
+            if created_at is None or spool_id is None:
                 continue
             history.setdefault(spool_id, []).append(stock_forecast.UsageRecord(created_at, float(weight_used or 0)))
         return history

+ 76 - 0
backend/tests/integration/test_spool_delete_keeps_usage.py

@@ -0,0 +1,76 @@
+"""Deleting a spool keeps its usage history, and its id is not given to a new spool.
+
+PostgreSQL deleted the usage with the spool, so recalculating the cost of its
+prints fell back to an estimate. SQLite kept the usage with the old spool id,
+and could give that id to the next spool, which then showed the deleted
+spool's prints as its own.
+"""
+
+from __future__ import annotations
+
+import pytest
+from httpx import AsyncClient
+from sqlalchemy import select
+
+from backend.app.models.archive import PrintArchive
+from backend.app.models.spool import Spool
+from backend.app.models.spool_usage_history import SpoolUsageHistory
+
+pytestmark = [pytest.mark.asyncio, pytest.mark.integration]
+
+
+async def test_deleting_a_spool_keeps_the_usage_and_the_print_cost(
+    async_client: AsyncClient, db_session, printer_factory, archive_factory
+):
+    printer = await printer_factory()
+    archive = await archive_factory(printer.id, filament_used_grams=20.0, cost=None)
+    archive_id = archive.id
+    spool = Spool(material="PLA")
+    db_session.add(spool)
+    await db_session.flush()
+    spool_id = spool.id
+    db_session.add(
+        SpoolUsageHistory(spool_id=spool_id, printer_id=printer.id, archive_id=archive_id, weight_used=20, cost=0.5)
+    )
+    await db_session.commit()
+
+    deleted = await async_client.delete(f"/api/v1/inventory/spools/{spool_id}")
+    recalculated = await async_client.post("/api/v1/archives/recalculate-costs")
+
+    assert deleted.status_code in (200, 204), deleted.text
+    assert recalculated.status_code == 200, recalculated.text
+    db_session.expire_all()
+    usage = (await db_session.execute(select(SpoolUsageHistory.spool_id, SpoolUsageHistory.cost))).one()
+    assert tuple(usage) == (None, 0.5)
+    cost = (await db_session.execute(select(PrintArchive.cost).where(PrintArchive.id == archive_id))).scalar()
+    assert cost == 0.5
+
+
+async def test_a_new_spool_does_not_get_the_id_of_a_deleted_one(async_client: AsyncClient, db_session):
+    spool = Spool(material="PLA")
+    db_session.add(spool)
+    await db_session.commit()
+    old_id = spool.id
+
+    deleted = await async_client.delete(f"/api/v1/inventory/spools/{old_id}")
+    assert deleted.status_code in (200, 204), deleted.text
+    replacement = Spool(material="PETG")
+    db_session.add(replacement)
+    await db_session.commit()
+
+    assert replacement.id != old_id
+
+
+async def test_the_usage_list_shows_usage_of_a_deleted_spool(async_client: AsyncClient, db_session):
+    spool = Spool(material="PLA")
+    db_session.add(spool)
+    await db_session.flush()
+    db_session.add(SpoolUsageHistory(spool_id=spool.id, weight_used=12, cost=0.3))
+    await db_session.commit()
+    deleted = await async_client.delete(f"/api/v1/inventory/spools/{spool.id}")
+    assert deleted.status_code in (200, 204), deleted.text
+
+    response = await async_client.get("/api/v1/inventory/usage")
+
+    assert response.status_code == 200, response.text
+    assert [(row["spool_id"], row["weight_used"]) for row in response.json()] == [(None, 12)]

+ 15 - 0
backend/tests/unit/test_github_restore.py

@@ -1073,6 +1073,21 @@ class TestRestoreSpools:
         row = (await db_session.execute(select(SpoolUsageHistory))).scalar_one()
         assert row.archive_id == archive.id
 
+    @pytest.mark.asyncio
+    async def test_usage_of_a_spool_deleted_before_the_backup_is_restored_without_a_spool(self, db_session):
+        """A deleted spool's usage is kept (spool_id null); it is still the cost of its prints."""
+        tally = _CategoryTally()
+        usage = {
+            "usage_history": [{"spool_id": None, "weight_used": 4.0, "cost": 0.2, "created_at": "2026-02-01 09:00:00"}]
+        }
+
+        await _service()._restore_spools(db_session, {"spools": []}, usage, False, tally, {})
+        await db_session.commit()
+
+        row = (await db_session.execute(select(SpoolUsageHistory))).scalar_one()
+        assert (row.spool_id, row.weight_used, row.cost) == (None, 4.0, 0.2)
+        assert tally.skipped == 0
+
     @pytest.mark.asyncio
     async def test_usage_row_with_unresolvable_spool_is_skipped_and_explained(self, db_session):
         tally = _CategoryTally()

+ 71 - 0
backend/tests/unit/test_spool_usage_spool_migration.py

@@ -0,0 +1,71 @@
+"""spool_usage_history.spool_id becomes nullable, ON DELETE SET NULL, on existing SQLite databases.
+
+Deleting a spool must keep its usage (the costs its prints are recalculated
+from), so the column has to take NULL on tables made before. (PostgreSQL is
+checked by scripts/check_postgres_upgrade.py.)
+"""
+
+import pytest
+from sqlalchemy import text
+from sqlalchemy.ext.asyncio import create_async_engine
+
+from backend.app.core import database
+
+# As create_all made the table from the old model ...
+CREATED_BY_THE_MODEL = """
+CREATE TABLE spool_usage_history (
+\tid INTEGER NOT NULL,
+\tspool_id INTEGER NOT NULL,
+\tprinter_id INTEGER,
+\tprint_name VARCHAR(500),
+\tarchive_id INTEGER,
+\tweight_used FLOAT NOT NULL,
+\tPRIMARY KEY (id),
+\tFOREIGN KEY(spool_id) REFERENCES spool (id) ON DELETE CASCADE,
+\tFOREIGN KEY(printer_id) REFERENCES printers (id) ON DELETE SET NULL,
+\tFOREIGN KEY(archive_id) REFERENCES print_archives (id) ON DELETE SET NULL
+)
+"""
+
+# ... and as the usage-history migration's own CREATE TABLE made it.
+CREATED_BY_THE_MIGRATION = """
+CREATE TABLE spool_usage_history (
+    id INTEGER PRIMARY KEY AUTOINCREMENT,
+    spool_id INTEGER NOT NULL REFERENCES spool(id) ON DELETE CASCADE,
+    printer_id INTEGER REFERENCES printers(id) ON DELETE SET NULL,
+    print_name VARCHAR(500),
+    weight_used REAL NOT NULL DEFAULT 0
+)
+"""
+
+
+@pytest.fixture(autouse=True)
+def sqlite_dialect(monkeypatch):
+    monkeypatch.setattr(database, "is_sqlite", lambda: True)
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize("legacy", [CREATED_BY_THE_MODEL, CREATED_BY_THE_MIGRATION], ids=["model", "migration"])
+async def test_the_spool_column_takes_null_and_says_set_null(legacy):
+    engine = create_async_engine("sqlite+aiosqlite:///:memory:")
+    try:
+        async with engine.begin() as conn:
+            await conn.execute(text(legacy))
+            await conn.execute(text("INSERT INTO spool_usage_history (id, spool_id, weight_used) VALUES (1, 7, 5)"))
+
+            await database._migrate_spool_usage_outlives_its_spool(conn)
+            await database._migrate_spool_usage_outlives_its_spool(conn)  # a restart changes nothing
+
+            await conn.execute(text("UPDATE spool_usage_history SET spool_id = NULL WHERE id = 1"))
+            assert (await conn.execute(text("SELECT spool_id, weight_used FROM spool_usage_history"))).one() == (
+                None,
+                5,
+            )
+            sql = (
+                await conn.execute(text("SELECT sql FROM sqlite_master WHERE name = 'spool_usage_history'"))
+            ).scalar_one()
+            assert "CASCADE" not in sql
+            # Only the spool column changed.
+            assert "weight_used" in sql and "NOT NULL" in sql.split("weight_used", 1)[1].split(",", 1)[0]
+    finally:
+        await engine.dispose()

+ 2 - 1
frontend/src/api/client.ts

@@ -4023,7 +4023,8 @@ export interface CsvImportResult {
 
 export interface SpoolUsageRecord {
   id: number;
-  spool_id: number;
+  // null: the spool was deleted, its usage kept
+  spool_id: number | null;
   printer_id: number | null;
   print_name: string | null;
   weight_used: number;

+ 2 - 0
frontend/src/components/ForecastPanel.tsx

@@ -215,6 +215,8 @@ export function ForecastPanel({ spools }: { spools: InventorySpool[] }) {
   const usageBySpoolId = useMemo(() => {
     const m = new Map<number, SpoolUsageRecord[]>();
     for (const r of usageHistory) {
+      // Usage of a deleted spool belongs to no spool here.
+      if (r.spool_id === null) continue;
       const arr = m.get(r.spool_id) ?? [];
       arr.push(r);
       m.set(r.spool_id, arr);

File diff suppressed because it is too large
+ 0 - 0
static/assets/AdditionalSection-Bp4Eihhj.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/AmsUnitCard-CE34wK3W.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/ArchivesPage-BxREyRVk.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/AssignToAmsModal-CTQkyBPV.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/CamWallPage-wM3PkOT0.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/CameraDiagnoseModal-BUPP5f3P.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/CameraPage-6shN-dJ-.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/CameraWall-CSAwKLLj.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/ColumnConfigModal-CU306aok.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/ConfigureAmsSlotModal-hEfWEQjS.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/ConnectAuthorizePage-DeVFS7Ct.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/ContextMenu-nQPETN6B.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/ExternalLinkPage-CvGoV2BR.js


File diff suppressed because it is too large
+ 0 - 1
static/assets/FileManagerPage-C94svCZU.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/FileUploadModal-PApFWhZn.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/FinancePage-D4SYEupN.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/GCodeViewerPage-2aegkOcq.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/GroupEditPage-CxbwCRAq.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/ImagePreviewModal-Blg_NPf6.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/InventoryPage-B3GyYVHr.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/LibraryTrashPage-Bbs01jkZ.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/LoginPage-D_cJ0xE1.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/MaintenancePage-BzPU6OXI.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/ModelSourcesPage-mxxUE-ID.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/NotificationsPage-7qZENtHO.js


File diff suppressed because it is too large
+ 0 - 1
static/assets/PdfPreviewModal-DLA-u40Z.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/PhotoGalleryModal-BoHcSJtg.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/PrinterLocationsPage-FGn7xuxm.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/PrintersPage-CCnHdO33.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/ProfilesPage-Ckb9rEhU.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/ProjectDetailPage-BYRgsPpU.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/ProjectsPage-MMjTokAh.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/QueuePage-DwNr6xz0.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/RichTextEditor-BVIwKcsc.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/SettingsPage-DGzi3cjL.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/SetupPage-D-f4LlTf.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/SliceModal-D5MJJ2pF.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/SpoolBuddyAmsPage-CbmgPIux.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/SpoolBuddyCalibrationPage-BV09t1br.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/SpoolBuddyDashboard-DjqalX2f.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/SpoolBuddyInventoryPage-CpjrpSIP.js


File diff suppressed because it is too large
+ 1 - 1
static/assets/SpoolBuddySettingsPage-Da2K6nFy.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/SpoolBuddyWriteTagPage-BwjhnVJS.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/SpreadsheetPreviewModal-DzwnwXiY.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/StatsPage-CmNJYOiJ.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/StreamOverlayPage-DZad98pv.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/SystemInfoPage-CNADlEGd.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/index-5pmk7Nlq.js


+ 1 - 1
static/assets/inventorySearch-B2RXCD2R.js → static/assets/inventorySearch-Btiv8N-6.js

@@ -1 +1 @@
-import{ft as e}from"./index-CELnCUsn.js";function t(t,n){let r=n.trim().toLowerCase();if(!r)return!0;let i=r.match(/^#(\d+)$/);return i?t.id===Number(i[1]):String(t.id).includes(r)||t.material.toLowerCase().includes(r)||(t.brand?.toLowerCase().includes(r)??!1)||(t.color_name?.toLowerCase().includes(r)??!1)||(e(t.color_name,t.rgba,t.color_name_is_synthesized)?.toLowerCase().includes(r)??!1)||(t.subtype?.toLowerCase().includes(r)??!1)||(t.note?.toLowerCase().includes(r)??!1)||(t.slicer_filament_name?.toLowerCase().includes(r)??!1)||(t.storage_location?.toLowerCase().includes(r)??!1)||(t.material_number?.toLowerCase().includes(r)??!1)||(t.suppliers?.some(e=>e.supplier_name.toLowerCase().includes(r)||(e.supplier_article_number?.toLowerCase().includes(r)??!1))??!1)}function n(e,n){return n?e.filter(e=>t(e,n)):e}export{n as t};
+import{ft as e}from"./index-5pmk7Nlq.js";function t(t,n){let r=n.trim().toLowerCase();if(!r)return!0;let i=r.match(/^#(\d+)$/);return i?t.id===Number(i[1]):String(t.id).includes(r)||t.material.toLowerCase().includes(r)||(t.brand?.toLowerCase().includes(r)??!1)||(t.color_name?.toLowerCase().includes(r)??!1)||(e(t.color_name,t.rgba,t.color_name_is_synthesized)?.toLowerCase().includes(r)??!1)||(t.subtype?.toLowerCase().includes(r)??!1)||(t.note?.toLowerCase().includes(r)??!1)||(t.slicer_filament_name?.toLowerCase().includes(r)??!1)||(t.storage_location?.toLowerCase().includes(r)??!1)||(t.material_number?.toLowerCase().includes(r)??!1)||(t.suppliers?.some(e=>e.supplier_name.toLowerCase().includes(r)||(e.supplier_article_number?.toLowerCase().includes(r)??!1))??!1)}function n(e,n){return n?e.filter(e=>t(e,n)):e}export{n as t};

File diff suppressed because it is too large
+ 0 - 0
static/assets/locationSensorDefaults-BVdYIwqk.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/pdf-DeTA-F9L.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/utils-oU0m4kow.js


+ 1 - 1
static/index.html

@@ -26,7 +26,7 @@
 
     <!-- Splash screens for iOS -->
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
-    <script type="module" crossorigin src="/assets/index-CELnCUsn.js"></script>
+    <script type="module" crossorigin src="/assets/index-5pmk7Nlq.js"></script>
     <link rel="modulepreload" crossorigin href="/assets/chunk-aKtaBQYM.js">
     <link rel="modulepreload" crossorigin href="/assets/client-C3LdD0iY.js">
     <link rel="modulepreload" crossorigin href="/assets/QueryClientProvider-DzWmuIjj.js">

Some files were not shown because too many files changed in this diff