backup_paths.py 2.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263
  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. rows = (await conn.execute(text(f"SELECT id, {', '.join(columns)} FROM {table}"))).all()
  38. for row in rows:
  39. updates = {}
  40. for column, value in zip(columns, row[1:], strict=True):
  41. if not value or "\\" not in value:
  42. continue
  43. # Only stat()ed, to decide whether to rewrite the row; nothing is read or written through it.
  44. if (base_dir / value).exists(): # SEC-PATH-OK: stat only, see above
  45. continue
  46. updates[column] = value.replace("\\", "/")
  47. if updates:
  48. assignments = ", ".join(f"{column} = :{column}" for column in updates)
  49. await conn.execute(text(f"UPDATE {table} SET {assignments} WHERE id = :id"), {**updates, "id": row[0]})
  50. changed += 1
  51. if changed:
  52. logger.info("Converted Windows file paths in %d restored row(s) to this platform's separator", changed)
  53. return changed