Files
lintunes/lintunes/gui/conflict_dialog.py
T
travandClaude Opus 5 8c097faacc v0.14.0: a removal you make sticks
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
2026-08-27 18:31:33 -04:00

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()