182 lines
8.4 KiB
Markdown
182 lines
8.4 KiB
Markdown
623-
|
|
624-def _fallback_subtitle(rec: Record, title: str, prefix: str) -> str:
|
|
625- blocked = {_semantic_key(title), _semantic_key(prefix), _semantic_key(rec.topic)}
|
|
626- blocked |= _prefix_alias_keys(rec.destination)
|
|
627- blocked |= CATEGORY_NOISE_LABELS
|
|
628-
|
|
629- for value in (rec.subtopic, rec.detail):
|
|
630- candidate = _component_for_record(value, rec)
|
|
631- key = _semantic_key(candidate)
|
|
632- if candidate and key and key not in blocked and key not in GENERIC_FILENAME_LABELS:
|
|
633- return candidate
|
|
634-
|
|
635- return _source_remainder(rec, title, prefix, rec.topic)
|
|
636-
|
|
637-
|
|
638:def build_structured_filename(rec: Record) -> str:
|
|
639- """Build YYYY-MM-DD_PREFIX_Title_Subtitle[_vN].ext deterministically."""
|
|
640- prefix = _filename_component(_filename_prefix(rec.destination), title_case_first=True)
|
|
641- prefix_aliases = _prefix_alias_keys(rec.destination)
|
|
642-
|
|
643- raw_title = _component_for_record(rec.name_title, rec, title_case_first=True)
|
|
644- raw_subtitle = _component_for_record(rec.name_subtitle, rec)
|
|
645- title_key = _semantic_key(raw_title)
|
|
646- subtitle_key = _semantic_key(raw_subtitle)
|
|
647-
|
|
648- # Promote a useful subtitle when the model supplied a generic label like
|
|
649- # `Info`; this avoids names such as Allgemein_Info_SurvStat... In that
|
|
650- # special case, do not immediately add another model-derived subtitle: only
|
|
651- # recover a genuinely distinct remainder from the original filename.
|
|
652- promoted_generic_title = False
|
|
653- if title_key in GENERIC_FILENAME_LABELS and raw_subtitle:
|
|
--
|
|
1117- try:
|
|
1118- conf = float(result.get("confidence", 0))
|
|
1119- except (TypeError, ValueError):
|
|
1120- conf = 0.0
|
|
1121- if dest not in rec.candidates:
|
|
1122- dest = rec.current_folder
|
|
1123- conf = min(conf, 0.4)
|
|
1124- rec.destination = dest
|
|
1125- rec.topic = str(result.get("topic", "")).strip()
|
|
1126- rec.subtopic = str(result.get("subtopic", "")).strip()
|
|
1127- rec.document_type = str(result.get("document_type", "")).strip()
|
|
1128- rec.detail = str(result.get("detail", "")).strip()
|
|
1129- rec.version = str(result.get("version", "")).strip()
|
|
1130- rec.name_title = str(result.get("title", "")).strip()
|
|
1131- rec.name_subtitle = str(result.get("subtitle", "")).strip()
|
|
1132: rec.proposed_name = build_structured_filename(rec)
|
|
1133- # If no usable content was extracted from a generic filename, the model
|
|
1134- # cannot reliably recover a hidden event/project name. Force human review.
|
|
1135- if is_generic_filename(rec.filename) and (not rec.excerpt.strip() or "no-text" in rec.content_status or rec.content_status.startswith("missing:")):
|
|
1136- conf = min(conf, 0.69)
|
|
1137-
|
|
1138- # Every V7 proposal follows the structured naming scheme. If its date
|
|
1139- # comes only from filesystem mtime, the date is useful but historically
|
|
1140- # unverified, so require explicit human review before apply.
|
|
1141- if rec.date_source == "mtime":
|
|
1142- conf = min(conf, 0.69)
|
|
1143: rec.confidence = max(0.0, min(1.0, conf))
|
|
1144- rec.reason = str(result.get("reason", ""))[:120]
|
|
1145: rec.status = "suggested" if rec.confidence >= 0.70 else "review"
|
|
1146-
|
|
1147-
|
|
1148-# ---------------- planning + apply ----------------
|
|
1149-
|
|
1150-_HASH_CACHE: dict[str, tuple[int, int, str]] = {}
|
|
1151-
|
|
1152-
|
|
1153-def sha256_file(path: Path) -> str:
|
|
1154- """Full-file SHA-256. Used only when two files compete for the same target.
|
|
1155-
|
|
1156- The cache is keyed by path + size + mtime_ns so routine re-planning in the GUI
|
|
1157- does not repeatedly hash unchanged files.
|
|
1158- """
|
|
1159- st = path.stat()
|
|
1160- key = str(path)
|
|
--
|
|
1424- else:
|
|
1425- new.parent.mkdir(parents=True, exist_ok=True)
|
|
1426- if old.parent == new.parent:
|
|
1427- old.rename(new)
|
|
1428- else:
|
|
1429- shutil.move(str(old), str(new))
|
|
1430- result = "moved" if row["result"] == "would move" else "moved as variant"
|
|
1431- if rec:
|
|
1432- rec.path = new
|
|
1433- try:
|
|
1434- rec.rel_path = str(new.relative_to(rec.root))
|
|
1435- except ValueError:
|
|
1436- rec.rel_path = str(new.relative_to(rec.target_root))
|
|
1437- rec.current_folder = str(new.parent.relative_to(rec.target_root))
|
|
1438- rec.filename = new.name
|
|
1439: rec.proposed_name = new.name
|
|
1440- rec.collision = ""
|
|
1441- rec.status = "done"
|
|
1442-
|
|
1443- elif result == "EXACT duplicate -> would remove source":
|
|
1444- # Only reached after a full SHA-256 equality check in plan().
|
|
1445- try:
|
|
1446- old.unlink()
|
|
1447- result = "removed exact duplicate"
|
|
1448- if rec:
|
|
1449- rec.collision = ""
|
|
1450- rec.status = "done"
|
|
1451- except OSError as e:
|
|
1452- result = f"SKIPPED - could not remove duplicate: {e}"
|
|
1453-
|
|
1454- out.append({"timestamp": timestamp, **row, "result": result})
|
|
--
|
|
1637- if rec in batch:
|
|
1638- self.refresh(iid)
|
|
1639- except queue.Empty:
|
|
1640- pass
|
|
1641- self.win.after(120, self.poll)
|
|
1642-
|
|
1643- def set_status(self, status):
|
|
1644- for iid, rec in self.selected():
|
|
1645- rec.status = status
|
|
1646- self.refresh(iid)
|
|
1647- self.recompute_collisions()
|
|
1648- self._counts()
|
|
1649-
|
|
1650- def confirm_confident(self):
|
|
1651- for iid, rec in self.by_iid.items():
|
|
1652: if rec.status == "suggested" and rec.confidence >= 0.85:
|
|
1653- rec.status = "confirmed"
|
|
1654- self.refresh(iid)
|
|
1655- self.recompute_collisions()
|
|
1656- self._counts()
|
|
1657-
|
|
1658- def set_folder(self):
|
|
1659- chosen = self.selected()
|
|
1660- if not chosen:
|
|
1661- return
|
|
1662- top = tk.Toplevel(self.win)
|
|
1663- top.title("Set destination")
|
|
1664- var = tk.StringVar()
|
|
1665- combo = ttk.Combobox(top, values=self.destinations, textvariable=var, width=55)
|
|
1666- combo.pack(padx=10, pady=10)
|
|
1667- combo.focus()
|
|
--
|
|
1684- col = int(self.tree.identify_column(event.x)[1:]) - 1
|
|
1685- if not iid or col not in (5, 7): # destination/name only
|
|
1686- return
|
|
1687- bbox = self.tree.bbox(iid, f"#{col + 1}")
|
|
1688- if not bbox:
|
|
1689- return
|
|
1690- x, y, w, h = bbox
|
|
1691- rec = self.by_iid[iid]
|
|
1692- if self.active_editor:
|
|
1693- self.active_editor.destroy()
|
|
1694- if col == 5:
|
|
1695- editor = ttk.Combobox(self.tree, values=self.destinations)
|
|
1696- editor.set(rec.destination)
|
|
1697- else:
|
|
1698- editor = ttk.Entry(self.tree)
|
|
1699: editor.insert(0, rec.proposed_name)
|
|
1700- editor.select_range(0, tk.END)
|
|
1701- editor.place(x=x, y=y, width=w, height=h)
|
|
1702- editor.focus()
|
|
1703- self.active_editor = editor
|
|
1704- def commit(_e=None):
|
|
1705- value = editor.get().strip()
|
|
1706- if col == 5 and value in self.destinations:
|
|
1707- rec.destination = value
|
|
1708- elif col == 7 and value:
|
|
1709: rec.proposed_name = Path(value).name
|
|
1710- rec.status = "review"
|
|
1711- editor.destroy()
|
|
1712- self.active_editor = None
|
|
1713- self.refresh(iid)
|
|
1714- editor.bind("<Return>", commit)
|
|
1715- editor.bind("<FocusOut>", commit)
|
|
1716- editor.bind("<Escape>", lambda _e: editor.destroy())
|
|
1717-
|
|
1718- def preview(self):
|
|
1719- self.recompute_collisions()
|
|
1720- rows = plan(self.records)
|
|
1721- if not rows:
|
|
1722- messagebox.showinfo("Preview", "No confirmed rows.")
|
|
1723- return
|
|
1724- text = "\n".join(f"{r['result']}:\n {r['old_path']}\n → {r['new_path']}" for r in rows[:30])
|