Round 43 made the merge report honest, and in doing so made its one real
limitation impossible to miss: every merge was a union, so a song removed from a
playlist on one machine was handed straight back by the other on the next sync,
and a track deleted from the library came back with it. A deletion you cannot
make stick is not a deletion.
The union was there for a real reason — two copies with no common ancestor
cannot tell "A added this" from "B removed it" — so the missing evidence is
written down instead of inferred. New lintunes/tombstones.py: a playlist keeps
track_events {track id: [when, add|remove]}, the library keeps deleted_tracks
{track id: when} in library_metadata.json, and a merge applies the newest event
per track across both copies. A removal beats a copy that merely still had the
song; a deliberate re-add afterwards beats the removal; a track nobody touched
still merges as a union, which stays the safe behavior where there is no
evidence either way.
Deliberately not "the newer copy wins wholesale": that one-liner silently drops
a song the other machine added while you were removing one, which
test_an_unrelated_addition_is_not_lost pins.
Events are recorded in the funnels that already exist — _set_track_ids diffs
before/after so a reorder records nothing, _remove_tracks stamps the library,
_restore_tracks clears it so Ctrl+Z takes the tombstone back — and pruned after
30 days at the save boundary.
Three edges worth naming:
* Track ids are never reused. The next id came from max(library.tracks), so
deleting the highest-numbered track freed its id, and the next import would be
dropped on sight by the dead id's own tombstone on every machine.
* library_metadata.json is merged before library.json, since it carries the
record the library merge is filtered against and rglob order is not a plan.
* A merge applying a deletion never touches a music file — it drops the library
entry only, and test_a_merge_never_touches_a_music_file fails the run if
send_to_trash is so much as called. Applied removals grade WARNING and name
the song.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y9ZEFi4qNJ39FMiBtiAxy2
207 lines
8.1 KiB
Python
207 lines
8.1 KiB
Python
"""A read-only summary of Syncthing conflicts LinTunes auto-merged, with the
|
|
option to open or restore the pre-merge backup.
|
|
|
|
One dialog per session, not one per merge: Syncthing can deliver conflicts every
|
|
few minutes, so ``add_event`` folds each new merge into the open window as a
|
|
timestamped entry instead of stacking another window on the desktop.
|
|
|
|
Entries are graded (see conflict_resolver's WARNING/CHANGE/INFO) and the window
|
|
shows one level and above. It opens at whatever the *highest* level in the batch
|
|
is, so there is always something to read: a batch of nothing but column-width
|
|
merges opens on the details view rather than blank, and a real divergence is
|
|
never hidden behind a filter the user left set to something quieter.
|
|
"""
|
|
from datetime import datetime
|
|
from pathlib import Path
|
|
|
|
from PyQt6.QtCore import QUrl
|
|
from PyQt6.QtGui import QDesktopServices
|
|
from PyQt6.QtWidgets import (
|
|
QDialog, QVBoxLayout, QHBoxLayout, QComboBox, QLabel, QTextEdit,
|
|
QPushButton, QMessageBox)
|
|
|
|
from lintunes.storage.conflict_resolver import (
|
|
WARNING, CHANGE, INFO, LEVEL_ORDER, at_least, max_level)
|
|
|
|
|
|
INTRO = ("LinTunes found changes made on more than one machine and merged them "
|
|
"(play counts kept highest, newest edits win, and a removal made on "
|
|
"either machine is applied on both). Both "
|
|
"versions were backed up first — restore them if a merge isn't what "
|
|
"you wanted. The backup folder holds what-changed.txt: the same merge "
|
|
"in full, every track named.")
|
|
|
|
# Coarsest first, so the combo reads top-down like a volume knob.
|
|
LEVEL_CHOICES = [
|
|
(WARNING, "Warnings only"),
|
|
(CHANGE, "Changes and warnings"),
|
|
(INFO, "Everything"),
|
|
]
|
|
|
|
MARKERS = {WARNING: "!", CHANGE: "●", INFO: "·"}
|
|
|
|
|
|
class ConflictSummaryDialog(QDialog):
|
|
def __init__(self, summaries, manager, parent=None):
|
|
super().__init__(parent)
|
|
self._manager = manager
|
|
self._events = [] # newest last: (datetime, list[ConflictSummary])
|
|
self._level = INFO
|
|
self._user_picked = False
|
|
|
|
self.setWindowTitle("Synced changes merged")
|
|
self.resize(560, 440)
|
|
layout = QVBoxLayout(self)
|
|
|
|
self._intro = QLabel(INTRO)
|
|
self._intro.setWordWrap(True)
|
|
layout.addWidget(self._intro)
|
|
|
|
filter_row = QHBoxLayout()
|
|
filter_row.addWidget(QLabel("Show:"))
|
|
self._level_box = QComboBox()
|
|
for level, label in LEVEL_CHOICES:
|
|
self._level_box.addItem(label, level)
|
|
self._level_box.activated.connect(self._on_level_picked)
|
|
filter_row.addWidget(self._level_box)
|
|
filter_row.addStretch(1)
|
|
layout.addLayout(filter_row)
|
|
|
|
self._body = QTextEdit()
|
|
self._body.setReadOnly(True)
|
|
layout.addWidget(self._body, 1)
|
|
|
|
buttons = QHBoxLayout()
|
|
self._open_btn = QPushButton("Open merge report")
|
|
self._open_btn.clicked.connect(self._open_backup)
|
|
self._restore_btn = QPushButton("Restore pre-merge backup")
|
|
self._restore_btn.clicked.connect(self._restore_backup)
|
|
keep_btn = QPushButton("Keep merged")
|
|
keep_btn.setDefault(True)
|
|
keep_btn.clicked.connect(self.accept)
|
|
buttons.addWidget(self._open_btn)
|
|
buttons.addWidget(self._restore_btn)
|
|
buttons.addStretch(1)
|
|
buttons.addWidget(keep_btn)
|
|
layout.addLayout(buttons)
|
|
|
|
self.add_event(summaries)
|
|
|
|
# ---- events ----
|
|
|
|
def add_event(self, summaries, when=None) -> bool:
|
|
"""Fold another merge into this window as its own timestamped entry.
|
|
|
|
Returns True if the new batch has anything to show at the level now on
|
|
screen — the caller uses that to decide whether to raise the window, so
|
|
a column resize on the other machine never steals focus."""
|
|
summaries = list(summaries or [])
|
|
self._events.append((when or datetime.now(), summaries))
|
|
self._set_level(self._level_for(summaries))
|
|
self._refresh()
|
|
return any(at_least(s.level, self._level) for s in summaries)
|
|
|
|
@property
|
|
def level(self) -> str:
|
|
return self._level
|
|
|
|
def _level_for(self, incoming) -> str:
|
|
"""Which level to display now that ``incoming`` has arrived.
|
|
|
|
Untouched by the user, the view follows the most serious thing seen so
|
|
far. Once the user has picked a level, their choice stands — except that
|
|
a batch more serious than what they are looking at pulls the view back
|
|
up to it."""
|
|
incoming_max = max_level(incoming)
|
|
if not self._user_picked:
|
|
return max(
|
|
(max_level(s) for _when, s in self._events),
|
|
key=lambda lvl: LEVEL_ORDER.get(lvl, 0), default=INFO)
|
|
if LEVEL_ORDER.get(incoming_max, 0) > LEVEL_ORDER.get(self._level, 0):
|
|
return incoming_max
|
|
return self._level
|
|
|
|
def _set_level(self, level: str):
|
|
self._level = level
|
|
index = self._level_box.findData(level)
|
|
if index >= 0:
|
|
self._level_box.blockSignals(True)
|
|
self._level_box.setCurrentIndex(index)
|
|
self._level_box.blockSignals(False)
|
|
|
|
def _on_level_picked(self, index: int):
|
|
self._user_picked = True
|
|
self._level = self._level_box.itemData(index)
|
|
self._refresh()
|
|
|
|
@property
|
|
def _backup_dir(self) -> str:
|
|
"""The newest event's backup snapshot. One resolve_conflicts() call makes
|
|
one timestamped dir, so every summary in an event shares it."""
|
|
for _when, summaries in reversed(self._events):
|
|
if summaries and summaries[0].backup_dir:
|
|
return summaries[0].backup_dir
|
|
return ""
|
|
|
|
def _refresh(self):
|
|
self._body.setPlainText(self._format())
|
|
count = len(self._events)
|
|
self._intro.setText(
|
|
INTRO if count < 2 else f"{INTRO}\n\n{count} merges this session.")
|
|
newest = self._events[-1][0] if self._events else None
|
|
self._restore_btn.setText(
|
|
f"Restore backup from {newest.strftime('%-I:%M %p')}"
|
|
if newest and count > 1 else "Restore pre-merge backup")
|
|
self._open_btn.setEnabled(bool(self._backup_dir))
|
|
self._restore_btn.setEnabled(bool(self._backup_dir))
|
|
|
|
def _format(self) -> str:
|
|
blocks = []
|
|
hidden = 0
|
|
shown = 0
|
|
multi = len(self._events) > 1
|
|
for when, summaries in reversed(self._events): # newest first
|
|
visible = [s for s in summaries if at_least(s.level, self._level)]
|
|
hidden += len(summaries) - len(visible)
|
|
shown += len(visible)
|
|
if not visible:
|
|
continue # no orphan timestamp headers for a filtered-out merge
|
|
lines = []
|
|
if multi:
|
|
lines.append(f"── {when.strftime('%-I:%M %p')} " + "─" * 30)
|
|
for s in visible:
|
|
lines.append(f"{MARKERS.get(s.level, '●')} {s.file}")
|
|
lines.extend(f" {line}" for line in s.lines)
|
|
lines.append("")
|
|
blocks.append("\n".join(lines).rstrip())
|
|
|
|
if not shown:
|
|
blocks.append("Nothing at this level.")
|
|
if hidden:
|
|
entries = "entry" if hidden == 1 else "entries"
|
|
blocks.append(f"({hidden} routine {entries} hidden — switch to "
|
|
f"“Everything” to see them.)")
|
|
return "\n\n".join(blocks)
|
|
|
|
# ---- actions ----
|
|
|
|
def _open_backup(self):
|
|
if self._backup_dir:
|
|
QDesktopServices.openUrl(QUrl.fromLocalFile(self._backup_dir))
|
|
|
|
def _restore_backup(self):
|
|
backup_dir = self._backup_dir
|
|
if not 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(backup_dir), self._manager.data_dir)
|
|
self._manager.reload_from_disk()
|
|
QMessageBox.information(self, "Restored", "Restored the pre-merge backup.")
|
|
self.accept()
|