diff --git a/src/dataloom/ui/main_window.py b/src/dataloom/ui/main_window.py
index a0d79c1..61b0020 100644
--- a/src/dataloom/ui/main_window.py
+++ b/src/dataloom/ui/main_window.py
@@ -4,8 +4,9 @@ from pathlib import Path
from PySide6.QtWidgets import (
QAbstractItemView, QApplication, QFileDialog, QLabel, QListWidget,
- QMainWindow, QMessageBox, QPushButton, QTabWidget, QTextEdit,
- QCheckBox, QVBoxLayout, QWidget,
+ QComboBox, QMainWindow, QMessageBox, QPushButton, QTabWidget,
+ QTableWidget, QTableWidgetItem, QTextEdit, QCheckBox, QVBoxLayout,
+ QHBoxLayout, QWidget,
)
from dataloom.core.audit import AuditLog
@@ -21,9 +22,19 @@ class MainWindow(QMainWindow):
self.records = []
self.result = None
self.audit = None
+ self.review_decisions = {}
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.duplicate_table = QTableWidget()
+ self.duplicate_table.setEditTriggers(QAbstractItemView.EditTrigger.NoEditTriggers)
+ self.duplicate_table.setAlternatingRowColors(True)
+ self.keep_selected = QPushButton("Keep selected record")
+ self.keep_both = QPushButton("Keep both records")
+ self.decision_choice = QComboBox()
+ self.decision_choice.addItem("Choose a record…", None)
+ self.keep_selected.clicked.connect(self.choose_selected_record)
+ self.keep_both.clicked.connect(self.choose_both_records)
+ self.duplicate_status = QLabel("Select a duplicate group to compare it.")
self.duplicates.currentRowChanged.connect(self.show_duplicate_detail)
self.log_view = QTextEdit(); self.log_view.setReadOnly(True)
self.entries_view = QListWidget()
@@ -31,7 +42,8 @@ class MainWindow(QMainWindow):
tabs.addTab(self._page("Import Summary", self.summary), "Import Summary")
tabs.addTab(self._page("Merged Entries", self.entries_view), "Merged Entries")
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)
+ duplicate_layout.addWidget(QLabel("Duplicate Review
")); duplicate_layout.addWidget(self.duplicates); duplicate_layout.addWidget(self.duplicate_table)
+ controls = QHBoxLayout(); controls.addWidget(self.decision_choice); controls.addWidget(self.keep_selected); controls.addWidget(self.keep_both); duplicate_layout.addLayout(controls); duplicate_layout.addWidget(self.duplicate_status)
tabs.addTab(duplicate_page, "Duplicate Review")
tabs.addTab(self._page("Audit Log", self.log_view), "Audit Log")
import_button = QPushButton("Import Bitwarden exports…")
@@ -61,6 +73,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.review_decisions = {}
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:
@@ -76,6 +89,7 @@ class MainWindow(QMainWindow):
for group in self.result.review_groups:
selected = f"; selected: {group.selected.name}" if group.selected else "; no automatic selection"
self.duplicates.addItem(f"[{group.classification.upper()}] {len(group.records)} record(s) — {group.reason}{selected}")
+ self.duplicates.setCurrentRow(0 if self.result.review_groups else -1)
self.log_view.setPlainText("\n".join(self.audit.entries))
except (OSError, ValueError, ImportError) as exc:
QMessageBox.critical(self, "Import failed", str(exc))
@@ -92,7 +106,9 @@ class MainWindow(QMainWindow):
warnings = csv_loss_warnings(self.result.included)
if warnings:
self.summary.append("\nCSV loss warnings:\n" + "\n".join(f"- {warning}" for warning in warnings))
- output = export_json(self.result.included, path) if fmt == "json" else export_csv(self.result.included, path)
+ records = self.resolved_records()
+ output = export_json(records, path) if fmt == "json" else export_csv(records, path)
+ self.audit.record("DECISIONS", f"Export used {len(records)} record(s) after duplicate review decisions: {self.review_decisions}")
self.audit.record("EXPORT", f"Wrote merged {fmt.upper()} export to {output}")
self.audit.save()
self.log_view.setPlainText("\n".join(self.audit.entries))
@@ -101,16 +117,84 @@ class MainWindow(QMainWindow):
except OSError as exc:
QMessageBox.critical(self, "Export failed", str(exc))
+ def resolved_records(self):
+ """Apply review choices to the records that will be exported."""
+ if not self.result:
+ return []
+ excluded = set()
+ for index, group in enumerate(self.result.review_groups):
+ if group.classification == "exact":
+ continue
+ decision = self.review_decisions.get(index)
+ if decision is None and group.selected is not None:
+ decision = group.records.index(group.selected)
+ if isinstance(decision, int):
+ excluded.update(id(record) for record in group.records if record is not group.records[decision])
+ return [record for record in self.result.included if id(record) not in excluded]
+
+ def choose_selected_record(self) -> None:
+ row = self.duplicates.currentRow()
+ if not self.result or row < 0: return
+ group = self.result.review_groups[row]
+ if group.classification == "exact":
+ self.duplicate_status.setText("Exact duplicates are removed automatically.")
+ return
+ selected_index = self.decision_choice.currentData()
+ selected = group.records[selected_index] if isinstance(selected_index, int) else (group.selected or group.records[0])
+ self.review_decisions[row] = group.records.index(selected)
+ self.audit.record("DECISION", f"Group {row}: keep {selected.source_file}:{selected.source_index}")
+ self.refresh_duplicate_status(row)
+
+ def choose_both_records(self) -> None:
+ row = self.duplicates.currentRow()
+ if not self.result or row < 0: return
+ group = self.result.review_groups[row]
+ if group.classification == "exact":
+ self.duplicate_status.setText("Exact duplicates are removed automatically.")
+ return
+ self.review_decisions[row] = "both"
+ self.audit.record("DECISION", f"Group {row}: keep all {len(group.records)} records")
+ self.refresh_duplicate_status(row)
+
+ def refresh_duplicate_status(self, row: int) -> None:
+ decision = self.review_decisions.get(row, "automatic/newest" if self.result.review_groups[row].selected else "both")
+ self.duplicate_status.setText(f"Export decision: {decision}")
+
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
+ self.duplicate_table.clear(); self.duplicate_table.setRowCount(0); 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))
+ self.decision_choice.blockSignals(True)
+ self.decision_choice.clear()
+ self.decision_choice.addItem("Choose a record…", None)
+ for index, record in enumerate(group.records):
+ label = f"Keep record {index + 1}: {record.source_file}:{record.source_index}"
+ if group.selected is record:
+ label += " (newest/recommended)"
+ self.decision_choice.addItem(label, index)
+ decision = self.review_decisions.get(row)
+ if isinstance(decision, int):
+ self.decision_choice.setCurrentIndex(decision + 1)
+ elif group.selected is not None:
+ self.decision_choice.setCurrentIndex(group.records.index(group.selected) + 1)
+ self.decision_choice.blockSignals(False)
+ field_rows = ["Name", "Folder", "Username", "Password", "URLs", "TOTP", "Notes", "Custom fields", "Revision date", "Source"]
+ self.duplicate_table.clear(); self.duplicate_table.setRowCount(len(field_rows)); self.duplicate_table.setColumnCount(len(group.records));
+ self.duplicate_table.setVerticalHeaderLabels(field_rows)
+ self.duplicate_table.setHorizontalHeaderLabels([f"Record {i + 1}: {r.source_file}" for i, r in enumerate(group.records)])
+ for column, record in enumerate(group.records):
+ login = record.data.get("login") or {}
+ uris = login.get("uris") or []
+ values = [record.name, record.data.get("folderId") or "(none)", login.get("username", ""), login.get("password", ""),
+ "\n".join(str(uri.get("uri", "")) for uri in uris), login.get("totp", ""), record.data.get("notes", ""),
+ repr(record.data.get("fields", [])), record.data.get("revisionDate", ""), f"{record.source_file}:{record.source_index}"]
+ for row_index, value in enumerate(values):
+ item = QTableWidgetItem(str(value))
+ if any(str(value) != str(other.data.get("name", "")) for other in group.records if other is not record) and row_index == 0:
+ item.setBackground("#fff2cc")
+ self.duplicate_table.setItem(row_index, column, item)
+ self.duplicate_table.resizeColumnsToContents(); self.duplicate_table.resizeRowsToContents()
+ self.refresh_duplicate_status(row)
def run() -> int: