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