From bbb369b65be448510e8f81d56d2076fd5be9cef6 Mon Sep 17 00:00:00 2001 From: ledadmin Date: Thu, 3 Sep 2026 12:32:35 -0400 Subject: [PATCH] Prepare Windows portable packaging --- .gitignore | 1 + DataLoom.spec | 33 +++++++++++++++++++++++++++ build-windows.ps1 | 13 +++++++++++ docs/windows-build.md | 41 ++++++++++++++++++++++++++++++++++ pyproject.toml | 1 + src/dataloom/ui/main_window.py | 25 +++++++++++++++++---- 6 files changed, 110 insertions(+), 4 deletions(-) create mode 100644 DataLoom.spec create mode 100644 build-windows.ps1 create mode 100644 docs/windows-build.md diff --git a/.gitignore b/.gitignore index 4e45676..06f7595 100644 --- a/.gitignore +++ b/.gitignore @@ -7,6 +7,7 @@ __pycache__/ .ruff_cache/ dist/ build/ +dist/ # Sensitive vault exports, logs, and local runtime state *.json diff --git a/DataLoom.spec b/DataLoom.spec new file mode 100644 index 0000000..39aa33d --- /dev/null +++ b/DataLoom.spec @@ -0,0 +1,33 @@ +# PyInstaller one-file Windows build configuration. +# Build from the repository root with: pyinstaller --clean --noconfirm DataLoom.spec + +from PyInstaller.utils.hooks import collect_submodules + +hiddenimports = collect_submodules("PySide6") + +a = Analysis( + ["src/dataloom/app.py"], + pathex=["src"], + binaries=[], + datas=[], + hiddenimports=hiddenimports, + hookspath=[], + hooksconfig={}, + runtime_hooks=[], + excludes=[], + noarchive=False, +) +pyz = PYZ(a.pure) +exe = EXE( + pyz, + a.scripts, + a.binaries, + a.datas, + [], + name="DataLoom", + debug=False, + bootloader_ignore_signals=False, + strip=False, + upx=True, + console=False, +) diff --git a/build-windows.ps1 b/build-windows.ps1 new file mode 100644 index 0000000..94d1a70 --- /dev/null +++ b/build-windows.ps1 @@ -0,0 +1,13 @@ +$ErrorActionPreference = "Stop" + +Write-Host "Creating or reusing the DataLoom Windows build environment..." +if (-not (Test-Path ".venv\\Scripts\\python.exe")) { + py -3 -m venv .venv +} + +& .venv\\Scripts\\python.exe -m pip install --upgrade pip +& .venv\\Scripts\\python.exe -m pip install -e ".[build,test]" +& .venv\\Scripts\\python.exe -m pytest -q +& .venv\\Scripts\\python.exe -m PyInstaller --clean --noconfirm DataLoom.spec + +Write-Host "Portable executable created at dist\\DataLoom.exe" diff --git a/docs/windows-build.md b/docs/windows-build.md new file mode 100644 index 0000000..8d1e9fd --- /dev/null +++ b/docs/windows-build.md @@ -0,0 +1,41 @@ +# Windows 11 portable build + +The final portable executable must be built on Windows 11 because PyInstaller produces a platform-specific executable. + +## Requirements + +- Windows 11 64-bit +- Python 3.11 or newer from python.org, with the Python launcher (`py`) available +- Git, or a ZIP copy of this repository +- Internet access during the one-time dependency installation only + +## Build + +Open PowerShell in the repository root and run: + +```powershell +Set-ExecutionPolicy -Scope Process Bypass +.\\build-windows.ps1 +``` + +The script creates a project-local virtual environment, installs the application and build/test dependencies, runs the synthetic tests, and creates: + +```text +dist\\DataLoom.exe +``` + +The executable is intended to be portable and does not require Python on the destination Windows machine. + +## Validation checklist + +- Launch `dist\\DataLoom.exe` on Windows 11. +- Import `tests\\fixtures\\sample-export-a.json` and `sample-export-b.json`. +- Confirm 8 loaded, 7 included, 1 exact duplicate removed, and 2 review groups. +- Confirm secret logging is unchecked by default. +- Confirm the duplicate detail view shows both records and the newer revision preference. +- Export a merged JSON file and verify the displayed output and audit-log paths. +- Test on a second Windows 11 machine or clean user profile if practical. + +## Sensitive data + +Do not copy real Bitwarden exports, merged vaults, audit logs, `.venv`, `build`, or `dist` contents into the repository. Use copies of vault exports only for local testing. diff --git a/pyproject.toml b/pyproject.toml index 09dffb5..eaae520 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -12,6 +12,7 @@ dependencies = ["PySide6>=6.6"] [project.optional-dependencies] test = ["pytest>=8.0"] +build = ["pyinstaller>=6.0"] [project.scripts] dataloom = "dataloom.app:main" diff --git a/src/dataloom/ui/main_window.py b/src/dataloom/ui/main_window.py index 6702fbf..a0d79c1 100644 --- a/src/dataloom/ui/main_window.py +++ b/src/dataloom/ui/main_window.py @@ -5,7 +5,7 @@ from pathlib import Path from PySide6.QtWidgets import ( QAbstractItemView, QApplication, QFileDialog, QLabel, QListWidget, QMainWindow, QMessageBox, QPushButton, QTabWidget, QTextEdit, - QVBoxLayout, QWidget, + QCheckBox, QVBoxLayout, QWidget, ) from dataloom.core.audit import AuditLog @@ -23,20 +23,26 @@ class MainWindow(QMainWindow): self.audit = None self.summary = QTextEdit(); self.summary.setReadOnly(True) self.duplicates = QListWidget(); self.duplicates.setSelectionMode(QAbstractItemView.SelectionMode.SingleSelection) + self.duplicate_detail = QTextEdit(); self.duplicate_detail.setReadOnly(True) + self.duplicates.currentRowChanged.connect(self.show_duplicate_detail) self.log_view = QTextEdit(); self.log_view.setReadOnly(True) self.entries_view = QListWidget() tabs = QTabWidget() tabs.addTab(self._page("Import Summary", self.summary), "Import Summary") tabs.addTab(self._page("Merged Entries", self.entries_view), "Merged Entries") - tabs.addTab(self._page("Duplicate Review", self.duplicates), "Duplicate Review") + duplicate_page = QWidget(); duplicate_layout = QVBoxLayout(duplicate_page) + duplicate_layout.addWidget(QLabel("

Duplicate Review

")); duplicate_layout.addWidget(self.duplicates); duplicate_layout.addWidget(self.duplicate_detail) + tabs.addTab(duplicate_page, "Duplicate Review") tabs.addTab(self._page("Audit Log", self.log_view), "Audit Log") import_button = QPushButton("Import Bitwarden exports…") import_button.clicked.connect(self.import_files) + self.secret_logging = QCheckBox("Enable full secret logging (passwords, TOTP, notes, and vault contents)") + self.secret_logging.setChecked(False) export_button = QPushButton("Export merged vault…") export_button.setEnabled(False) export_button.clicked.connect(self.export_merged) self.export_button = export_button - layout = QVBoxLayout(); layout.addWidget(QLabel("DataLoom processes exports locally. Do not import encrypted files.")); layout.addWidget(import_button); layout.addWidget(export_button); layout.addWidget(tabs) + layout = QVBoxLayout(); layout.addWidget(QLabel("DataLoom processes exports locally. Do not import encrypted files.")); layout.addWidget(self.secret_logging); layout.addWidget(import_button); layout.addWidget(export_button); layout.addWidget(tabs) root = QWidget(); root.setLayout(layout); self.setCentralWidget(root) @staticmethod @@ -55,7 +61,7 @@ class MainWindow(QMainWindow): if len(set(formats)) != 1: raise ImportError("All selected files must use the same format") self.records = records self.result = merge_records(records) - self.audit = AuditLog(Path(paths[0]).parent / "dataloom-audit.log", include_secrets=True) + self.audit = AuditLog(Path(paths[0]).parent / "dataloom-audit.log", include_secrets=self.secret_logging.isChecked()) self.audit.record("IMPORT", f"Loaded {len(paths)} {formats[0]} file(s) and {len(records)} record(s)") for record in records: self.audit.record("VIEW", f"{record.source_file} record {record.source_index} ({record.item_type}) {record.name}", repr(record.data)) @@ -95,6 +101,17 @@ class MainWindow(QMainWindow): except OSError as exc: QMessageBox.critical(self, "Export failed", str(exc)) + def show_duplicate_detail(self, row: int) -> None: + if not self.result or row < 0 or row >= len(self.result.review_groups): + self.duplicate_detail.clear(); return + group = self.result.review_groups[row] + lines = [f"Classification: {group.classification}", f"Reason: {group.reason}"] + if group.selected: + lines.append(f"Automatic preference: {group.selected.source_file}:{group.selected.source_index} ({group.selected.name})") + for index, record in enumerate(group.records, 1): + lines.extend(["", f"Record {index}: {record.source_file}:{record.source_index} — {record.name}", repr(record.data)]) + self.duplicate_detail.setPlainText("\n".join(lines)) + def run() -> int: app = QApplication([])