cleanup/gui/app.py
2026-09-05 15:24:52 +02:00

368 lines
14 KiB
Python

"""
Tkinter front end for the vault renamer.
Behaviour worth knowing before using this:
- Confirming a file moves/renames it IMMEDIATELY (not queued for later).
You've already seen the exact new name and destination in the preview
before clicking Confirm, so this behaves like doing the rename
yourself by hand, just faster.
- Every move is appended to a running CSV log next to the folder you
scanned, so there's a record of everything even if the app or your
session gets interrupted partway through.
- Progress is saved automatically to a small JSON file in the scanned
folder. Closing the app mid-batch and reopening later picks up
exactly where you left off — reviewed files won't be shown again.
- "Skip" marks a file as deliberately left alone, distinct from just
not having gotten to it yet. Tick "show skipped too" to bring them
back into view.
- "Keep current filename" is pre-ticked for files that already match
the naming convention, so opening one just to move it doesn't blank
out a name that was already correct. Untick it if you actually want
to rename that particular file too.
"""
import csv
import json
import sys
import tkinter as tk
from pathlib import Path
from tkinter import messagebox, ttk
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from core import scan, discover_folders, suggest_folders, apply_batch, find_duplicate_identities
from core.model import FileRecord
from core.config import STATE_FILENAME, LOG_FILENAME
EDITABLE_FIELDS = ["date_override", "topic", "subtopic", "extra", "more", "version", "dest_folder"]
FIELD_LABELS = {
"date_override": "Date (YYYY-MM-DD)",
"topic": "Main topic",
"subtopic": "Subtopic",
"extra": "Extra",
"more": "More explanation",
"version": "Version (e.g. v1)",
}
class VaultRenamerApp:
def __init__(self, win: tk.Tk, vault_root: Path):
self.win = win
self.vault_root = vault_root
self.win.title(f"Vault Renamer — {vault_root}")
self.win.geometry("1150x680")
self.records: list[FileRecord] = []
self.folders: list[str] = []
self.current: FileRecord | None = None
self.show_skipped = tk.BooleanVar(value=False)
self._suppress_trace = False
self._build_ui()
self._load_and_scan()
# ---------- persistence ----------
def _state_path(self) -> Path:
return self.vault_root / STATE_FILENAME
def _log_path(self) -> Path:
return self.vault_root / LOG_FILENAME
def _load_state(self) -> dict:
p = self._state_path()
if p.exists():
try:
return json.loads(p.read_text(encoding="utf-8"))
except Exception:
return {}
return {}
def _save_state(self):
state = {
rec.identity: {
"status": rec.status,
"date_override": rec.date_override,
"topic": rec.topic,
"subtopic": rec.subtopic,
"extra": rec.extra,
"more": rec.more,
"version": rec.version,
"dest_folder": rec.dest_folder,
"keep_filename": rec.keep_filename,
}
for rec in self.records
if rec.status != "pending"
}
self._state_path().write_text(
json.dumps(state, indent=2, ensure_ascii=False), encoding="utf-8"
)
def _load_and_scan(self):
self.records = scan(self.vault_root)
self.folders = discover_folders(self.vault_root)
saved = self._load_state()
if saved:
for rec in self.records:
saved_rec = saved.get(rec.identity)
if saved_rec:
for k, v in saved_rec.items():
setattr(rec, k, v)
duplicates = find_duplicate_identities(self.records)
if duplicates:
groups = "\n".join(
" " + ", ".join(r.rel_path for r in recs) for recs in duplicates.values()
)
messagebox.showwarning(
"Identical files found",
f"{len(duplicates)} group(s) of byte-identical files were found:\n\n{groups}\n\n"
f"These can't be told apart by content, so progress-tracking may not "
f"distinguish them correctly if you process one and not the other.",
)
self._refresh_list()
# ---------- UI construction ----------
def _build_ui(self):
left = ttk.Frame(self.win, padding=8)
left.pack(side="left", fill="y")
ttk.Label(left, text="Pending files", font=("", 11, "bold")).pack(anchor="w")
self.progress_label = ttk.Label(left, text="")
self.progress_label.pack(anchor="w", pady=(0, 6))
self.listbox = tk.Listbox(left, width=50, height=34, exportselection=False)
self.listbox.pack(fill="y", expand=True)
self.listbox.bind("<<ListboxSelect>>", self._on_select)
ttk.Checkbutton(
left, text="Show skipped too", variable=self.show_skipped,
command=self._refresh_list,
).pack(anchor="w", pady=(6, 0))
right = ttk.Frame(self.win, padding=12)
right.pack(side="left", fill="both", expand=True)
self.path_label = ttk.Label(right, text="", wraplength=650, font=("", 10, "bold"))
self.path_label.pack(anchor="w")
self.flag_label = ttk.Label(right, text="", foreground="#555", wraplength=650)
self.flag_label.pack(anchor="w", pady=(0, 10))
form = ttk.Frame(right)
form.pack(anchor="w", fill="x")
self.vars = {key: tk.StringVar() for key in EDITABLE_FIELDS}
self.keep_filename_var = tk.BooleanVar()
row = 0
for key in ["date_override", "topic", "subtopic", "extra", "more", "version"]:
ttk.Label(form, text=FIELD_LABELS[key]).grid(row=row, column=0, sticky="w", pady=3)
entry = ttk.Entry(form, textvariable=self.vars[key], width=48)
entry.grid(row=row, column=1, sticky="w", pady=3, padx=(8, 0))
self.vars[key].trace_add("write", self._on_field_edit)
row += 1
ttk.Checkbutton(
form, text="Keep current filename (only move — don't rename)",
variable=self.keep_filename_var, command=self._update_preview,
).grid(row=row, column=0, columnspan=2, sticky="w", pady=(6, 3))
row += 1
ttk.Label(form, text="Destination folder").grid(row=row, column=0, sticky="w", pady=3)
self.dest_combo = ttk.Combobox(form, textvariable=self.vars["dest_folder"], width=45)
self.dest_combo.grid(row=row, column=1, sticky="w", pady=3, padx=(8, 0))
self.vars["dest_folder"].trace_add("write", lambda *_: self._update_preview())
row += 1
ttk.Button(right, text="Suggest folders", command=self._show_suggestions).pack(anchor="w", pady=(8, 0))
self.suggestion_frame = ttk.Frame(right)
self.suggestion_frame.pack(anchor="w", pady=(4, 10))
ttk.Separator(right).pack(fill="x", pady=10)
ttk.Label(right, text="Preview:", font=("", 10, "bold")).pack(anchor="w")
self.preview_label = ttk.Label(right, text="", foreground="#0a6", wraplength=650)
self.preview_label.pack(anchor="w", pady=(0, 14))
btns = ttk.Frame(right)
btns.pack(anchor="w")
ttk.Button(btns, text="Confirm && Move", command=self._confirm_move).pack(side="left", padx=(0, 8))
ttk.Button(btns, text="Skip (leave alone)", command=self._skip).pack(side="left", padx=(0, 8))
ttk.Button(btns, text="Save progress", command=self._save_state).pack(side="left")
# ---------- list handling ----------
def _pending_records(self):
if self.show_skipped.get():
return [r for r in self.records if r.status in ("pending", "skip")]
return [r for r in self.records if r.status == "pending"]
def _refresh_list(self):
self.listbox.delete(0, tk.END)
for rec in self._pending_records():
flag = "OK " if rec.name_matches_convention else ("~ " if rec.near_miss else " ")
skip_mark = " [skipped]" if rec.status == "skip" else ""
self.listbox.insert(tk.END, f"[{flag}] {rec.rel_path}{skip_mark}")
self._update_progress_label()
def _update_progress_label(self):
total = len(self.records)
done = sum(1 for r in self.records if r.status == "done")
skipped = sum(1 for r in self.records if r.status == "skip")
pending = total - done - skipped
self.progress_label.config(text=f"{pending} pending / {done} done / {skipped} skipped / {total} total")
def _on_select(self, _event):
sel = self.listbox.curselection()
if not sel:
return
rec = self._pending_records()[sel[0]]
self.current = rec
self._load_record_into_form(rec)
def _load_record_into_form(self, rec: FileRecord):
self._suppress_trace = True
self.path_label.config(text=rec.rel_path)
flags = []
if rec.name_matches_convention:
flags.append("already matches naming convention")
if rec.near_miss:
flags.append("near-miss date format")
flags.append(f"filesystem date guess: {rec.fs_date} (starting point only — often wrong)")
self.flag_label.config(text=" · ".join(flags))
self.vars["date_override"].set(rec.date_override or rec.fs_date)
self.vars["topic"].set(rec.topic)
self.vars["subtopic"].set(rec.subtopic)
self.vars["extra"].set(rec.extra)
self.vars["more"].set(rec.more)
self.vars["version"].set(rec.version)
self.vars["dest_folder"].set(rec.dest_folder)
self.keep_filename_var.set(rec.keep_filename)
self.dest_combo["values"] = self.folders
for child in self.suggestion_frame.winfo_children():
child.destroy()
self._suppress_trace = False
self._update_preview()
# ---------- live preview / suggestions ----------
def _on_field_edit(self, *_args):
# Typing into a name field while "keep current filename" is ticked
# means you've changed your mind about that file — untick it
# automatically rather than silently ignoring what was typed.
if not self._suppress_trace and self.keep_filename_var.get():
self.keep_filename_var.set(False)
self._update_preview()
def _sync_form_to_record(self):
rec = self.current
if not rec:
return
for key in EDITABLE_FIELDS:
setattr(rec, key, self.vars[key].get().strip())
rec.keep_filename = self.keep_filename_var.get()
def _update_preview(self):
if not self.current or self._suppress_trace:
return
self._sync_form_to_record()
rec = self.current
new_name = rec.new_filename_preview
new_path = Path(rec.dest_folder) / new_name if rec.dest_folder else Path(new_name)
self.preview_label.config(text=f"{new_name}\n→ {new_path}")
def _show_suggestions(self):
if not self.current:
return
query = " ".join([self.vars["topic"].get(), self.vars["subtopic"].get(), self.current.filename])
suggestions = suggest_folders(query, self.folders, top_n=5)
for child in self.suggestion_frame.winfo_children():
child.destroy()
if not suggestions:
ttk.Label(self.suggestion_frame, text="No keyword matches — pick from the dropdown instead.",
foreground="#888").pack(anchor="w")
return
ttk.Label(self.suggestion_frame, text="Suggestions (click to use):").pack(anchor="w")
for folder in suggestions:
ttk.Button(self.suggestion_frame, text=folder,
command=lambda f=folder: self.vars["dest_folder"].set(f)).pack(anchor="w")
# ---------- actions ----------
def _confirm_move(self):
rec = self.current
if not rec:
return
self._sync_form_to_record()
rec.status = "confirmed"
log_rows = apply_batch([rec], dry_run=False, log_path=None)
self._append_log(log_rows)
result = log_rows[0]["result"] if log_rows else "no-op"
if result.startswith("SKIPPED"):
messagebox.showwarning("Not moved", f"{result}\n\n{log_rows[0]['new_path']}")
rec.status = "pending"
return
self._save_state()
self._refresh_list()
self._select_next()
def _skip(self):
rec = self.current
if not rec:
return
rec.status = "skip"
self._save_state()
self._refresh_list()
self._select_next()
def _select_next(self):
pending = self._pending_records()
if pending:
self.listbox.selection_set(0)
self._on_select(None)
else:
self.current = None
self.path_label.config(text="")
self.flag_label.config(text="")
self.preview_label.config(text="")
messagebox.showinfo("Done", "No pending files left in this view.")
def _append_log(self, rows):
if not rows:
return
log_path = self._log_path()
write_header = not log_path.exists()
with open(log_path, "a", newline="", encoding="utf-8") as f:
writer = csv.DictWriter(f, fieldnames=list(rows[0].keys()))
if write_header:
writer.writeheader()
writer.writerows(rows)
def main():
if len(sys.argv) != 2:
print("Usage: python3 gui/app.py /path/to/vault-root")
sys.exit(1)
vault_root = Path(sys.argv[1]).expanduser().resolve()
if not vault_root.is_dir():
print(f"Not a directory: {vault_root}")
sys.exit(1)
win = tk.Tk()
VaultRenamerApp(win, vault_root)
win.mainloop()
if __name__ == "__main__":
main()