368 lines
14 KiB
Python
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()
|