From ebaec63963aaf4dc78675ce9ddfebf9fda1040f1 Mon Sep 17 00:00:00 2001 From: dschlueter Date: Sun, 11 Oct 2026 23:26:14 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20abschliessen.py=20=E2=80=93=20fertige?= =?UTF-8?q?=20Abschrift=20nach=20fertig/,=20Arbeitsordner=20l=C3=B6schen?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Kopiert die korrigierte(n) Abschrift(en) eines Arbeitsordners mit Quellenkopf (Titel, Kanal, Datum, URL, Abschlussdatum) nach fertig/, listet Einträge des Video-Glossars, die in keinem gemeinsamen Glossar stehen, und löscht den Ordner nach Rückfrage (--ja ohne). Schutz: Abbruch bei offenen Prüfmarken (--trotzdem) und ungeklärten *.neu.korrigiert.txt; fertig/ wird nie überschrieben; nicht gelöscht werden Ordner mit fremden Dateien, das Aufrufverzeichnis und alles in testdaten/, glossare/, tests/, fertig/. Co-Authored-By: Claude Opus 5.5 --- abschliessen.py | 241 +++++++++++++++++++++++++++++++++++++ tests/test_abschliessen.py | 162 +++++++++++++++++++++++++ 2 files changed, 403 insertions(+) create mode 100755 abschliessen.py create mode 100644 tests/test_abschliessen.py diff --git a/abschliessen.py b/abschliessen.py new file mode 100755 index 0000000..d404e31 --- /dev/null +++ b/abschliessen.py @@ -0,0 +1,241 @@ +#!/usr/bin/env python3 +"""Schließt die Abschrift eines Videos ab: Die korrigierte Textdatei kommt mit einem +Quellenkopf nach fertig/, der Arbeitsordner abschriften// wird nach Rückfrage +gelöscht.""" +from datetime import date +from pathlib import Path +import argparse +import json +import shutil +import sys + +from abschrift_korrigieren import read_glossary, video_glossary +from aenderungen import REVIEW_MARK + +FINISHED_DIR = Path("fertig") +GLOSSARY_DIR = Path("glossare") +# What a working folder may hold; anything else stops the deletion +WORK_SUFFIXES = {".txt", ".vtt", ".html", ".json"} +# Never deleted, nor anything inside them +PROTECTED = {"testdaten", "glossare", "tests", "fertig"} +SHOWN_MARKS = 3 + + +def results(folder): + """Corrected texts of the folder (several for excerpts), without conflicts.""" + return sorted( + p for p in folder.glob("*.korrigiert.txt") + if not p.name.endswith(".neu.korrigiert.txt") + ) + + +def finished_name(result): + """rede_00-05-00_bis_00-06-00.korrigiert.txt -> rede_00-05-00_bis_00-06-00.txt""" + return result.name.removesuffix(".korrigiert.txt") + ".txt" + + +def read_source(folder): + """Contents of the folder's .quelle.json, None without one.""" + for path in sorted(folder.glob("*.quelle.json")): + return json.loads(path.read_text(encoding="utf-8")) + return None + + +def format_date(text): + """"20260328" -> "2026-03-28"; anything else unchanged.""" + if text and len(text) == 8 and text.isdigit(): + return f"{text[:4]}-{text[4:6]}-{text[6:]}" + return text + + +def header(source, result, today): + """Source lines in front of the finished text, then a blank line.""" + lines = [] + if source: + for label, value in ( + ("Titel", source.get("titel")), + ("Kanal", source.get("kanal")), + ("Datum", format_date(source.get("datum"))), + ("Quelle", source.get("url")), + ): + if value: + lines.append(f"{label}: {value}") + else: + lines.append(f"Datei: {result.name}") + lines.append(f"Abgeschlossen: {today}") + return "\n".join(lines) + "\n\n" + + +def already_finished(target, text): + """True if the target holds this text below a header.""" + if not target.exists(): + return False + _, _, body = target.read_text(encoding="utf-8").partition("\n\n") + return body == text + + +def open_marks(text): + """(line number, line) of every line with a review mark.""" + return [ + (number, line.strip()) + for number, line in enumerate(text.splitlines(), start=1) + if REVIEW_MARK.search(line) + ] + + +def unshared_entries(glossary, glossary_dir): + """Entries of the video glossary that are in no shared glossary.""" + shared_terms, shared_replacements = set(), set() + for path in sorted(glossary_dir.glob("*.glossar.txt")): + terms, replacements = read_glossary(path) + shared_terms.update(terms) + shared_replacements.update(replacements) + terms, replacements = read_glossary(glossary) + return [ + f"{wrong} => {right}" for wrong, right in replacements + if (wrong, right) not in shared_replacements + ] + [term for term in terms if term not in shared_terms] + + +def deletion_blockers(folder): + """Reasons not to delete the folder; empty for a plain working folder.""" + resolved = folder.resolve() + cwd = Path.cwd().resolve() + if resolved == cwd or resolved in cwd.parents: + return ["Er ist das Aufrufverzeichnis oder liegt darüber."] + for path in (resolved, *resolved.parents): + if path == cwd: + break + if path.name in PROTECTED: + return [f"Er ist oder liegt in „{path.name}“, das nie gelöscht wird."] + return [ + f"Fremde Datei: {entry.name}" + for entry in sorted(folder.iterdir()) + if not ( + (entry.is_dir() and entry.name.endswith(".parts")) + or (entry.is_file() and entry.suffix.lower() in WORK_SUFFIXES) + ) + ] + + +def size(path): + if path.is_file(): + return path.stat().st_size + return sum(p.stat().st_size for p in path.rglob("*") if p.is_file()) + + +def confirm(question): + try: + return input(question).strip().lower() in {"j", "ja", "y", "yes"} + except EOFError: + return False + + +def parse_args(): + parser = argparse.ArgumentParser( + description="Schließt die Abschrift eines Videos ab: Ergebnis mit Quellenkopf " + "nach fertig/, Arbeitsordner nach Rückfrage löschen." + ) + parser.add_argument("ordner", type=Path, help="Arbeitsordner, z. B. abschriften/") + parser.add_argument( + "--ziel", type=Path, default=FINISHED_DIR, + help=f"Verzeichnis für fertige Abschriften (Standard: {FINISHED_DIR})", + ) + parser.add_argument( + "--glossare", type=Path, default=GLOSSARY_DIR, + help=f"Verzeichnis der gemeinsamen Glossare (Standard: {GLOSSARY_DIR})", + ) + parser.add_argument( + "--ja", action="store_true", help="Arbeitsordner ohne Rückfrage löschen" + ) + parser.add_argument( + "--trotzdem", action="store_true", + help="auch abschließen, wenn noch Prüfmarken [prüfen: …] im Text stehen", + ) + args = parser.parse_args() + if not args.ordner.is_dir(): + parser.error(f"Ordner nicht gefunden: {args.ordner}") + return args + + +def main(): + args = parse_args() + folder = args.ordner + + found = results(folder) + if not found: + raise SystemExit(f"Keine korrigierte Abschrift (*.korrigiert.txt) in {folder}.") + conflicts = sorted(folder.glob("*.neu.korrigiert.txt")) + if conflicts: + names = ", ".join(p.name for p in conflicts) + raise SystemExit( + f"Ungeklärter Konflikt: {names}. Erst die Änderungen übertragen bzw. die " + "Datei löschen, dann abschließen." + ) + + texts = {result: result.read_text(encoding="utf-8") for result in found} + marked = {result: open_marks(text) for result, text in texts.items()} + if any(marked.values()): + for result, marks in marked.items(): + if marks: + print(f"{result.name}: {len(marks)} Zeile(n) mit Prüfmarken, z. B.") + for number, line in marks[:SHOWN_MARKS]: + print(f" Zeile {number}: {line[:120]}") + if not args.trotzdem: + raise SystemExit( + "Noch offene Prüfmarken. Erst klären, oder mit --trotzdem abschließen." + ) + + targets = {result: args.ziel / finished_name(result) for result in found} + # A finished text with the same body is from an earlier run (e.g. answered + # "n" when asked to delete); a different one is never overwritten + done = {result for result, target in targets.items() if already_finished(target, texts[result])} + existing = [str(t) for r, t in targets.items() if t.exists() and r not in done] + if existing: + raise SystemExit( + f"Gibt es schon in {args.ziel} mit anderem Text: {', '.join(existing)}. " + "Nichts überschrieben; vorhandene Datei umbenennen oder entfernen." + ) + + source = read_source(folder) + if source is None and len(done) < len(targets): + print("Hinweis: keine Quellenangabe (*.quelle.json); der Kopf nennt nur die Datei.") + today = date.today().isoformat() + args.ziel.mkdir(parents=True, exist_ok=True) + for result, target in targets.items(): + if result in done: + print(f"Bereits fertig: {target}") + continue + target.write_text(header(source, result, today) + texts[result], encoding="utf-8") + print(f"Fertig: {target}") + + glossary = video_glossary(folder) + if glossary is not None: + missing = unshared_entries(glossary, args.glossare) + if missing: + print( + f"\nDiese Einträge aus {glossary} stehen in keinem Glossar in " + f"{args.glossare}/; vor dem Löschen ggf. in ein gemeinsames übernehmen:" + ) + for entry in missing: + print(f" {entry}") + + blockers = deletion_blockers(folder) + if blockers: + print(f"\nArbeitsordner {folder} bleibt erhalten:") + for reason in blockers: + print(f" {reason}") + return + + print(f"\nArbeitsordner {folder}:") + for entry in sorted(folder.iterdir()): + print(f" {entry.name}{'/' if entry.is_dir() else ''} ({size(entry) / 1024:.0f} KB)") + if not args.ja and not confirm(f"Arbeitsordner {folder} löschen? [j/N] "): + print("Nicht gelöscht.") + return + shutil.rmtree(folder) + print(f"Gelöscht: {folder}") + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/test_abschliessen.py b/tests/test_abschliessen.py new file mode 100644 index 0000000..40496a1 --- /dev/null +++ b/tests/test_abschliessen.py @@ -0,0 +1,162 @@ +"""Tests for abschliessen.py (finish a video: result to fertig/, delete working folder).""" +import json +import sys +from datetime import date + +import pytest + +import abschliessen as ab + +TEXT = "Der Kreml ist in Moskau.\n\nDas ist bekannt.\n" +SOURCE = { + "titel": "Eine Rede", "kanal": "Kanal", "datum": "20260328", + "url": "https://www.youtube.com/watch?v=abc123", "id": "abc123", +} + + +@pytest.fixture +def work(tmp_path, monkeypatch): + """abschriften/rede/ with everything a run leaves behind; cwd is tmp_path.""" + monkeypatch.chdir(tmp_path) + folder = tmp_path / "abschriften" / "rede" + parts = folder / "rede.korrigiert.parts" + parts.mkdir(parents=True) + (parts / "part001-abc.txt").write_text("x", encoding="utf-8") + (folder / "rede.txt").write_text("der kremmel ist in moskau\n", encoding="utf-8") + (folder / "rede.korrigiert.txt").write_text(TEXT, encoding="utf-8") + (folder / "rede.aenderungen.html").write_text("

", encoding="utf-8") + (folder / "rede.quelle.json").write_text(json.dumps(SOURCE), encoding="utf-8") + return folder + + +def run_main(monkeypatch, *args, answer=None): + monkeypatch.setattr(sys, "argv", ["abschliessen.py", *map(str, args)]) + if answer is not None: + monkeypatch.setattr("builtins.input", lambda question: answer) + ab.main() + + +def test_finish_writes_header_and_deletes(work, tmp_path, monkeypatch, capsys): + run_main(monkeypatch, work, "--ja") + + finished = (tmp_path / "fertig" / "rede.txt").read_text(encoding="utf-8") + assert finished == ( + "Titel: Eine Rede\nKanal: Kanal\nDatum: 2026-03-28\n" + "Quelle: https://www.youtube.com/watch?v=abc123\n" + f"Abgeschlossen: {date.today().isoformat()}\n\n" + TEXT + ) + assert not work.exists() + out = capsys.readouterr().out + assert "rede.korrigiert.parts/" in out and f"Gelöscht: {work}" in out + + +def test_answer_no_keeps_folder_and_rerun_deletes(work, tmp_path, monkeypatch, capsys): + run_main(monkeypatch, work, answer="n") + assert work.is_dir() + assert (tmp_path / "fertig" / "rede.txt").is_file() + assert "Nicht gelöscht." in capsys.readouterr().out + + # Later: the result is already in fertig/, only the deletion is left + run_main(monkeypatch, work, answer="j") + assert not work.exists() + assert "Bereits fertig" in capsys.readouterr().out + + +def test_open_review_marks_stop(work, tmp_path, monkeypatch, capsys): + (work / "rede.korrigiert.txt").write_text( + "Der Kreml [prüfen: Kremmel] ist in Moskau.\n", encoding="utf-8" + ) + with pytest.raises(SystemExit, match="offene Prüfmarken"): + run_main(monkeypatch, work, "--ja") + assert "Zeile 1: Der Kreml [prüfen: Kremmel]" in capsys.readouterr().out + assert not (tmp_path / "fertig").exists() and work.is_dir() + + run_main(monkeypatch, work, "--ja", "--trotzdem") + assert (tmp_path / "fertig" / "rede.txt").is_file() + + +def test_conflict_stops(work, monkeypatch): + (work / "rede.neu.korrigiert.txt").write_text(TEXT, encoding="utf-8") + with pytest.raises(SystemExit, match="Ungeklärter Konflikt: rede.neu.korrigiert.txt"): + run_main(monkeypatch, work, "--ja") + assert work.is_dir() + + +def test_no_result_stops(work, monkeypatch): + (work / "rede.korrigiert.txt").unlink() + with pytest.raises(SystemExit, match="Keine korrigierte Abschrift"): + run_main(monkeypatch, work, "--ja") + + +def test_existing_different_target_is_never_overwritten(work, tmp_path, monkeypatch): + (tmp_path / "fertig").mkdir() + (tmp_path / "fertig" / "rede.txt").write_text("Anderer Text\n", encoding="utf-8") + with pytest.raises(SystemExit, match="mit anderem Text"): + run_main(monkeypatch, work, "--ja") + assert (tmp_path / "fertig" / "rede.txt").read_text(encoding="utf-8") == "Anderer Text\n" + assert work.is_dir() + + +def test_without_source_header_names_the_file(work, tmp_path, monkeypatch, capsys): + (work / "rede.quelle.json").unlink() + run_main(monkeypatch, work, "--ja") + finished = (tmp_path / "fertig" / "rede.txt").read_text(encoding="utf-8") + assert finished.startswith("Datei: rede.korrigiert.txt\nAbgeschlossen: ") + assert "keine Quellenangabe" in capsys.readouterr().out + + +def test_every_excerpt_is_finished(work, tmp_path, monkeypatch): + (work / "rede_ab_00-05-00.korrigiert.txt").write_text("[00:05:00 - 00:06:00]\n\nB.\n") + run_main(monkeypatch, work, "--ja") + assert (tmp_path / "fertig" / "rede.txt").is_file() + assert (tmp_path / "fertig" / "rede_ab_00-05-00.txt").read_text().endswith( + "[00:05:00 - 00:06:00]\n\nB.\n" + ) + + +def test_foreign_file_prevents_deletion(work, tmp_path, monkeypatch, capsys): + (work / "notizen.docx").write_bytes(b"x") + run_main(monkeypatch, work, "--ja") + assert work.is_dir() + assert (tmp_path / "fertig" / "rede.txt").is_file() + assert "Fremde Datei: notizen.docx" in capsys.readouterr().out + + +@pytest.mark.parametrize("protected", ["testdaten", "glossare", "tests", "fertig"]) +def test_protected_folders_are_never_deleted(tmp_path, monkeypatch, capsys, protected): + monkeypatch.chdir(tmp_path) + folder = tmp_path / protected / "rede" + folder.mkdir(parents=True) + (folder / "rede.korrigiert.txt").write_text(TEXT, encoding="utf-8") + run_main(monkeypatch, folder, "--ja", "--ziel", tmp_path / "ziel") + assert folder.is_dir() + assert f"„{protected}“, das nie gelöscht wird" in capsys.readouterr().out + + +def test_current_directory_is_never_deleted(tmp_path, monkeypatch, capsys): + monkeypatch.chdir(tmp_path) + (tmp_path / "rede.korrigiert.txt").write_text(TEXT, encoding="utf-8") + run_main(monkeypatch, ".", "--ja") + assert (tmp_path / "rede.korrigiert.txt").is_file() + assert "Aufrufverzeichnis" in capsys.readouterr().out + + +def test_video_glossary_entries_not_shared_are_listed(work, tmp_path, monkeypatch, capsys): + (work / "rede.glossar.txt").write_text( + "Kremmel => Kreml\nNetano => Netanjahu\nEgon Bahr\nMoskau\n", encoding="utf-8" + ) + (tmp_path / "glossare").mkdir() + (tmp_path / "glossare" / "gemeinsam.glossar.txt").write_text( + "Kremmel => Kreml\nMoskau\n", encoding="utf-8" + ) + run_main(monkeypatch, work, "--ja") + out = capsys.readouterr().out + listed = out.split("gemeinsames übernehmen:")[1].split("\n\n")[0] + assert "Netano => Netanjahu" in listed and "Egon Bahr" in listed + assert "Kremmel" not in listed and "Moskau" not in listed + + +def test_missing_folder(tmp_path, monkeypatch, capsys): + with pytest.raises(SystemExit): + run_main(monkeypatch, tmp_path / "fehlt") + assert "Ordner nicht gefunden" in capsys.readouterr().err