Browse Source

Restore: a Windows backup finds its files on Linux (#3501)

- Windows stores archive, library and print log paths with
  backslashes, which Linux and macOS read as part of the file name,
  so a restored Windows backup showed no thumbnails and refused
  preview and reprint
- the restore now turns them into slashes; it does nothing on
  Windows, leaves a path alone when a file with that exact name
  exists, and a failure only logs a warning
- runs only during a restore, never at startup
maziggy 1 day ago
parent
commit
34cf784647

+ 1 - 0
CHANGELOG.md

@@ -20,6 +20,7 @@ All notable changes to Bambuddy will be documented in this file.
 - **"Any model" jobs go to the printer with the fewest print hours (#3480, sponsor vote Q4 2026)** — When several printers of a model can take a job, the queue used to pick the first one in its list, so on a farm the same printer got most of the work. It now picks the one with the fewest print hours (the Maintenance counter, including baseline hours entered there), so jobs and wear spread across the farm. Preferred colours still come first, and the queue powers on the least used printer too when it has to switch one on with an Auto Power On plug.
 
 ### Fixed
+- **A backup from Windows restored on Linux or macOS finds its files (#3501, reported by @Tech0verlord)** — A Windows install stores the location of each archive, library file and print log entry with backslashes, which Linux and macOS read as part of the file name. After restoring a Windows backup on Docker or another Linux install, every archive showed no thumbnail and refused its 3D preview and reprint with "Archive data unavailable". The restore now turns those backslashes into slashes. It runs only during a restore, does nothing on Windows, and leaves a path alone when a file with that exact name exists. An install already restored from a Windows backup is fixed by restoring the backup again after updating.
 - **Tags written by SpoolBuddy can be read by phones and other readers (#3500, reported by @pleasantone; likely #3076)** — SpoolBuddy's **Write Tag** wrote a copy of the tag's Capability Container in front of the OpenTag3D data. On an NTAG tag that copy landed in the data area, where a phone, NFC Tools or another OpenTag3D reader took it for unknown data, skipped over the spool record and reported an empty tag, although the write had succeeded. Bambuddy itself was unaffected, since it links spools by the tag's ID. The data now starts with the spool record, and the tag's own Capability Container is left as it is. Tags written before need writing again for other readers to see them.
 - **An A2L with an AMS and an AMS Lite shows both (#3499, reported by @auberonkai)** — With an AMS 2 Pro and an AMS Lite attached to an A2L at the same time, the printer card showed only the external spool, until one of them was unplugged and the printer reconnected. Bambuddy failed to sort the two units, because the printer names a regular AMS with text and the AMS Lite with a number, and from then on it could not read the AMS data of any later update. Both units now show, in order, and an AMS update that can't be read no longer blocks the ones after it. The connection diagnostic also no longer fails an A2L on "Store sent files on external storage": neither the printer nor the slicers offer that option for it, so the check is skipped, as on the P1 series, and the explanation no longer names the P1 series only.
 - **Sliced projects keep their changed settings in Bambu Studio (#3497, reported by @mdbook)** — A 3MF sliced in Bambuddy with a system process preset carries the settings changed on top of it: what was set in the slice dialog's process settings, the designer's settings that were kept ticked, and the source file's supports. The slicer wrote their values into the project but did not mark them as changed from the preset, so Bambu Studio opened the project with the preset's own values and a re-slice there lost them. They are now marked as changed. Slices with a cloud or local preset, and those that use the file's own settings, are unchanged.

+ 13 - 2
backend/app/api/routes/settings.py

@@ -1784,11 +1784,22 @@ async def restore_backup(
             await reinitialize_database()
             await init_db()
 
+            # 8b. A backup from a Windows install stores its paths with
+            # backslashes, which Linux and macOS read as part of a file name
+            # (#3501). After init_db, so the tables are current, and after the
+            # files are copied, so a path that exists as written is kept.
+            from backend.app.core import database as _db_mod
+            from backend.app.services.backup_paths import convert_windows_paths
+
+            try:
+                async with _db_mod.engine.begin() as conn:
+                    await convert_windows_paths(conn, base_dir)
+            except Exception as e:
+                logger.warning("Could not convert Windows file paths from the backup: %s", e, exc_info=True)
+
             # 9. Sign everyone out. The restored users and auth tokens come from
             # another point in time, possibly written by older code, so no
             # session issued before the restore should stay valid.
-            from backend.app.core import database as _db_mod
-
             async with _db_mod.engine.begin() as conn:
                 await _db_mod.invalidate_all_sessions(conn)
             # Open dashboards of signed-in users too: they are never signed in

+ 63 - 0
backend/app/services/backup_paths.py

@@ -0,0 +1,63 @@
+"""Make file paths from a Windows backup usable on Linux and macOS (#3501).
+
+Bambuddy stores file locations relative to its data folder, written with the
+platform's separator: a Windows install stores ``archive\\12\\print.3mf``.
+Windows reads either separator, Linux and macOS read the backslashes as part
+of one file name, so after a Windows backup is restored there every archive,
+library file and print log entry points at nothing.
+
+Runs once at the end of a restore, never at startup.
+"""
+
+import logging
+import os
+from pathlib import Path
+
+from sqlalchemy import text
+from sqlalchemy.ext.asyncio import AsyncConnection
+
+logger = logging.getLogger(__name__)
+
+# Columns holding a path relative to the data folder. Photos and project
+# attachments store bare file names; pending uploads and external folders
+# hold absolute host paths, which mean nothing on another machine anyway.
+PATH_COLUMNS: dict[str, tuple[str, ...]] = {
+    "print_archives": ("file_path", "thumbnail_path", "timelapse_path", "source_3mf_path", "f3d_path"),
+    "library_files": ("file_path", "thumbnail_path"),
+    "print_log_entries": ("thumbnail_path", "timelapse_path"),
+}
+
+
+async def convert_windows_paths(conn: AsyncConnection, base_dir: Path, *, windows: bool | None = None) -> int:
+    """Turn the backslashes in stored paths into slashes; returns the rows changed.
+
+    A path that exists as written is left alone: on Linux a backslash can be
+    part of a real file name. Nothing happens on Windows, where backslashes
+    are the separator.
+    """
+    if windows is None:
+        windows = os.name == "nt"
+    if windows:
+        return 0
+
+    changed = 0
+    for table, columns in PATH_COLUMNS.items():
+        # Filtered here rather than with LIKE: Postgres reads a backslash in a
+        # LIKE pattern as an escape, SQLite does not.
+        rows = (await conn.execute(text(f"SELECT id, {', '.join(columns)} FROM {table}"))).all()
+        for row in rows:
+            updates = {}
+            for column, value in zip(columns, row[1:], strict=True):
+                if not value or "\\" not in value:
+                    continue
+                # Only stat()ed, to decide whether to rewrite the row; nothing is read or written through it.
+                if (base_dir / value).exists():  # SEC-PATH-OK: stat only, see above
+                    continue
+                updates[column] = value.replace("\\", "/")
+            if updates:
+                assignments = ", ".join(f"{column} = :{column}" for column in updates)
+                await conn.execute(text(f"UPDATE {table} SET {assignments} WHERE id = :id"), {**updates, "id": row[0]})
+                changed += 1
+    if changed:
+        logger.info("Converted Windows file paths in %d restored row(s) to this platform's separator", changed)
+    return changed

+ 61 - 0
backend/tests/integration/test_restore_windows_paths_3501.py

@@ -0,0 +1,61 @@
+"""The restore converts a Windows backup's paths, and a failure there does not fail it (#3501)."""
+
+import sqlite3
+from unittest.mock import AsyncMock, patch
+
+import pytest
+
+from backend.app.core.config import settings as app_settings
+
+
+async def _restore(async_client, monkeypatch, tmp_path, convert):
+    from backend.app.api.routes.settings import create_backup_zip
+
+    # Keep the restored database separate from the integration-test engine.
+    db_path = tmp_path / "bambuddy.db"
+    with sqlite3.connect(db_path) as db:
+        db.execute("CREATE TABLE backup_marker (id INTEGER PRIMARY KEY)")
+    monkeypatch.setenv("DATA_DIR", str(tmp_path))
+    monkeypatch.setattr(app_settings, "base_dir", tmp_path)
+    monkeypatch.setattr(app_settings, "database_url", f"sqlite+aiosqlite:///{db_path}")
+    zip_path, _ = await create_backup_zip(output_path=tmp_path)
+
+    with (
+        patch("backend.app.core.database.close_all_connections", new_callable=AsyncMock),
+        patch("backend.app.core.database.reinitialize_database", new_callable=AsyncMock),
+        patch("backend.app.core.database.init_db", new_callable=AsyncMock),
+        patch("backend.app.core.database.invalidate_all_sessions", new_callable=AsyncMock),
+        patch("backend.app.services.print_scheduler.scheduler.stop"),
+        patch("backend.app.services.smart_plug_manager.smart_plug_manager.stop_scheduler"),
+        patch("backend.app.services.notification_service.notification_service.stop_digest_scheduler"),
+        patch("backend.app.services.backup_paths.convert_windows_paths", convert),
+    ):
+        return await async_client.post(
+            "/api/v1/settings/restore",
+            files={"file": ("backup.zip", zip_path.read_bytes(), "application/zip")},
+        )
+
+
+@pytest.mark.asyncio
+@pytest.mark.integration
+@pytest.mark.sqlite_only  # backs up and restores the app's own SQLite database file
+async def test_the_restore_converts_the_paths(async_client, monkeypatch, tmp_path):
+    convert = AsyncMock(return_value=0)
+
+    response = await _restore(async_client, monkeypatch, tmp_path, convert)
+
+    assert response.status_code == 200, response.text
+    convert.assert_awaited_once()
+    assert convert.await_args.args[1] == tmp_path
+
+
+@pytest.mark.asyncio
+@pytest.mark.integration
+@pytest.mark.sqlite_only
+async def test_a_failed_conversion_does_not_fail_the_restore(async_client, monkeypatch, tmp_path):
+    convert = AsyncMock(side_effect=RuntimeError("boom"))
+
+    response = await _restore(async_client, monkeypatch, tmp_path, convert)
+
+    assert response.status_code == 200, response.text
+    assert response.json()["success"] is True

+ 116 - 0
backend/tests/unit/test_windows_backup_paths_3501.py

@@ -0,0 +1,116 @@
+"""A backup from a Windows install restored on Linux finds its files (#3501).
+
+Windows stores paths relative to the data folder with backslashes; Linux reads
+them as part of one file name. The restore turns them into slashes.
+"""
+
+import pytest
+from sqlalchemy import select
+
+from backend.app.models.archive import PrintArchive
+from backend.app.models.library import LibraryFile
+from backend.app.models.print_log import PrintLogEntry
+from backend.app.services.backup_paths import convert_windows_paths
+
+
+def _windows_rows():
+    archive = PrintArchive(
+        filename="Brachy.3mf",
+        file_path="archive\\12\\Brachy.3mf",
+        file_size=1,
+        thumbnail_path="archive\\12\\thumbnail.png",
+        timelapse_path="archive\\12\\timelapse.mp4",
+        source_3mf_path="archive\\12\\source\\Brachy.3mf",
+        f3d_path="archive\\12\\design.f3d",
+    )
+    library_file = LibraryFile(
+        filename="Brachy.3mf",
+        file_path="archive\\library\\files\\abc.3mf",
+        file_type="3mf",
+        file_size=1,
+        thumbnail_path="archive\\library\\thumbnails\\abc.png",
+    )
+    log_entry = PrintLogEntry(
+        status="completed",
+        thumbnail_path="archive\\12\\thumbnail.png",
+        timelapse_path="archive\\12\\timelapse.mp4",
+    )
+    return archive, library_file, log_entry
+
+
+async def _convert(db_session, base_dir, **kwargs) -> int:
+    await db_session.flush()
+    changed = await convert_windows_paths(await db_session.connection(), base_dir, **kwargs)
+    db_session.expire_all()
+    return changed
+
+
+@pytest.mark.asyncio
+async def test_windows_paths_get_slashes(db_session, tmp_path):
+    db_session.add_all(_windows_rows())
+
+    assert await _convert(db_session, tmp_path, windows=False) == 3
+
+    archive = (await db_session.execute(select(PrintArchive))).scalar_one()
+    assert (
+        archive.file_path,
+        archive.thumbnail_path,
+        archive.timelapse_path,
+        archive.source_3mf_path,
+        archive.f3d_path,
+    ) == (
+        "archive/12/Brachy.3mf",
+        "archive/12/thumbnail.png",
+        "archive/12/timelapse.mp4",
+        "archive/12/source/Brachy.3mf",
+        "archive/12/design.f3d",
+    )
+    library_file = (await db_session.execute(select(LibraryFile))).scalar_one()
+    assert (library_file.file_path, library_file.thumbnail_path) == (
+        "archive/library/files/abc.3mf",
+        "archive/library/thumbnails/abc.png",
+    )
+    log_entry = (await db_session.execute(select(PrintLogEntry))).scalar_one()
+    assert (log_entry.thumbnail_path, log_entry.timelapse_path) == (
+        "archive/12/thumbnail.png",
+        "archive/12/timelapse.mp4",
+    )
+
+
+@pytest.mark.asyncio
+async def test_a_file_whose_name_has_a_backslash_is_kept(db_session, tmp_path):
+    # On Linux a backslash can be part of a real file name.
+    (tmp_path / "archive" / "7").mkdir(parents=True)
+    (tmp_path / "archive" / "7" / "a\\b.3mf").write_bytes(b"x")
+    db_session.add(PrintArchive(filename="a\\b.3mf", file_path="archive/7/a\\b.3mf", file_size=1))
+
+    assert await _convert(db_session, tmp_path, windows=False) == 0
+
+    archive = (await db_session.execute(select(PrintArchive))).scalar_one()
+    assert archive.file_path == "archive/7/a\\b.3mf"
+
+
+@pytest.mark.asyncio
+async def test_nothing_changes_on_windows(db_session, tmp_path):
+    db_session.add_all(_windows_rows())
+
+    assert await _convert(db_session, tmp_path, windows=True) == 0
+
+    archive = (await db_session.execute(select(PrintArchive))).scalar_one()
+    assert archive.file_path == "archive\\12\\Brachy.3mf"
+
+
+@pytest.mark.asyncio
+async def test_linux_paths_and_empty_columns_are_left_alone(db_session, tmp_path):
+    db_session.add(PrintArchive(filename="x.3mf", file_path="archive/3/x.3mf", file_size=1, thumbnail_path=None))
+    db_session.add(PrintLogEntry(status="completed"))
+
+    assert await _convert(db_session, tmp_path, windows=False) == 0
+
+
+@pytest.mark.asyncio
+async def test_a_second_run_changes_nothing(db_session, tmp_path):
+    db_session.add_all(_windows_rows())
+    await _convert(db_session, tmp_path, windows=False)
+
+    assert await _convert(db_session, tmp_path, windows=False) == 0