backup_paths.py 2.9 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465
  1. """Make file paths from a Windows backup usable on Linux and macOS (#3501).
  2. Bambuddy stores file locations relative to its data folder, written with the
  3. platform's separator: a Windows install stores ``archive\\12\\print.3mf``.
  4. Windows reads either separator, Linux and macOS read the backslashes as part
  5. of one file name, so after a Windows backup is restored there every archive,
  6. library file and print log entry points at nothing.
  7. Runs once at the end of a restore, never at startup.
  8. """
  9. import logging
  10. import os
  11. from pathlib import Path
  12. from sqlalchemy import text
  13. from sqlalchemy.ext.asyncio import AsyncConnection
  14. logger = logging.getLogger(__name__)
  15. # Columns holding a path relative to the data folder. Photos and project
  16. # attachments store bare file names; pending uploads and external folders
  17. # hold absolute host paths, which mean nothing on another machine anyway.
  18. PATH_COLUMNS: dict[str, tuple[str, ...]] = {
  19. "print_archives": ("file_path", "thumbnail_path", "timelapse_path", "source_3mf_path", "f3d_path"),
  20. "library_files": ("file_path", "thumbnail_path"),
  21. "print_log_entries": ("thumbnail_path", "timelapse_path"),
  22. }
  23. async def convert_windows_paths(conn: AsyncConnection, base_dir: Path, *, windows: bool | None = None) -> int:
  24. """Turn the backslashes in stored paths into slashes; returns the rows changed.
  25. A path that exists as written is left alone: on Linux a backslash can be
  26. part of a real file name. Nothing happens on Windows, where backslashes
  27. are the separator.
  28. """
  29. if windows is None:
  30. windows = os.name == "nt"
  31. if windows:
  32. return 0
  33. changed = 0
  34. for table, columns in PATH_COLUMNS.items():
  35. # Filtered here rather than with LIKE: Postgres reads a backslash in a
  36. # LIKE pattern as an escape, SQLite does not.
  37. # SQL from the table and column names in PATH_COLUMNS only; values are bound.
  38. rows = (await conn.execute(text(f"SELECT id, {', '.join(columns)} FROM {table}"))).all() # nosec B608
  39. for row in rows:
  40. updates = {}
  41. for column, value in zip(columns, row[1:], strict=True):
  42. if not value or "\\" not in value:
  43. continue
  44. # Only stat()ed, to decide whether to rewrite the row; nothing is read or written through it.
  45. if (base_dir / value).exists(): # SEC-PATH-OK: stat only, see above
  46. continue
  47. updates[column] = value.replace("\\", "/")
  48. if updates:
  49. assignments = ", ".join(f"{column} = :{column}" for column in updates)
  50. # Column names from PATH_COLUMNS, values bound.
  51. await conn.execute(text(f"UPDATE {table} SET {assignments} WHERE id = :id"), {**updates, "id": row[0]}) # nosec B608
  52. changed += 1
  53. if changed:
  54. logger.info("Converted Windows file paths in %d restored row(s) to this platform's separator", changed)
  55. return changed