Both machines now reflect each other's changes within a couple seconds, and
genuine Syncthing conflicts auto-merge with a backup and an alert.
- sync_watcher.py: QFileSystemWatcher (debounced, re-arms after atomic renames)
emits a single `changed`; the manager decides if it was external.
- library_manager: `reload_from_disk()` re-reads and reconciles disk into memory
(max play/skip counts, newest edit wins, playlist membership unioned), keeping
local unsaved edits and object identity so open views stay valid, then refreshes
the UI without touching the player. `flush()` records each file's (mtime, size)
so our own writes are never mistaken for an external change. New signals
library_reloaded / conflict_resolved.
- conflict_resolver: back up BOTH sides into .resolved/<ts>/{original,incoming}/
before merging, return list[ConflictSummary], add restore_backup(); runs at
startup and live.
- gui/conflict_dialog.py: modeless summary with Open/Restore backup; main_window
shows it from a status-bar notice and preserves scroll+selection on reload.
Tests: tests/test_round16.py (9) + full suite green (225).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
80 lines
2.9 KiB
Python
80 lines
2.9 KiB
Python
"""A read-only summary of Syncthing conflicts LinTunes auto-merged, with the
|
|
option to open or restore the pre-merge backup."""
|
|
from pathlib import Path
|
|
|
|
from PyQt6.QtCore import QUrl
|
|
from PyQt6.QtGui import QDesktopServices
|
|
from PyQt6.QtWidgets import (
|
|
QDialog, QVBoxLayout, QHBoxLayout, QLabel, QTextEdit, QPushButton,
|
|
QMessageBox)
|
|
|
|
|
|
class ConflictSummaryDialog(QDialog):
|
|
def __init__(self, summaries, manager, parent=None):
|
|
super().__init__(parent)
|
|
self._summaries = summaries
|
|
self._manager = manager
|
|
self._backup_dir = summaries[0].backup_dir if summaries else ""
|
|
|
|
self.setWindowTitle("Synced changes merged")
|
|
self.resize(560, 440)
|
|
layout = QVBoxLayout(self)
|
|
|
|
intro = QLabel(
|
|
"LinTunes found changes made on more than one machine and merged "
|
|
"them (play counts kept highest, newest edits win, nothing removed). "
|
|
"Both versions were backed up first — restore them if a merge isn't "
|
|
"what you wanted.")
|
|
intro.setWordWrap(True)
|
|
layout.addWidget(intro)
|
|
|
|
body = QTextEdit()
|
|
body.setReadOnly(True)
|
|
body.setPlainText(self._format(summaries))
|
|
layout.addWidget(body, 1)
|
|
|
|
buttons = QHBoxLayout()
|
|
open_btn = QPushButton("Open backup folder")
|
|
open_btn.clicked.connect(self._open_backup)
|
|
restore_btn = QPushButton("Restore pre-merge backup")
|
|
restore_btn.clicked.connect(self._restore_backup)
|
|
keep_btn = QPushButton("Keep merged")
|
|
keep_btn.setDefault(True)
|
|
keep_btn.clicked.connect(self.accept)
|
|
buttons.addWidget(open_btn)
|
|
buttons.addWidget(restore_btn)
|
|
buttons.addStretch(1)
|
|
buttons.addWidget(keep_btn)
|
|
layout.addLayout(buttons)
|
|
|
|
open_btn.setEnabled(bool(self._backup_dir))
|
|
restore_btn.setEnabled(bool(self._backup_dir))
|
|
|
|
@staticmethod
|
|
def _format(summaries) -> str:
|
|
blocks = []
|
|
for s in summaries:
|
|
lines = [f"● {s.file}"]
|
|
lines.extend(f" {line}" for line in s.lines)
|
|
blocks.append("\n".join(lines))
|
|
return "\n\n".join(blocks)
|
|
|
|
def _open_backup(self):
|
|
if self._backup_dir:
|
|
QDesktopServices.openUrl(QUrl.fromLocalFile(self._backup_dir))
|
|
|
|
def _restore_backup(self):
|
|
if not self._backup_dir:
|
|
return
|
|
if QMessageBox.question(
|
|
self, "Restore backup",
|
|
"Replace the current files with the pre-merge versions from this "
|
|
"backup? The other machine's changes may re-sync as a new "
|
|
"conflict later.") != QMessageBox.StandardButton.Yes:
|
|
return
|
|
from lintunes.storage import conflict_resolver
|
|
conflict_resolver.restore_backup(Path(self._backup_dir), self._manager.data_dir)
|
|
self._manager.reload_from_disk()
|
|
QMessageBox.information(self, "Restored", "Restored the pre-merge backup.")
|
|
self.accept()
|