From a9cdb704f4c04ba646d0cb54b6673fb330c7460e Mon Sep 17 00:00:00 2001 From: angushushu Date: Sun, 27 Sep 2026 15:49:59 -0400 Subject: [PATCH 1/5] fix: parse SRT cues by blank-line blocks The cue regex ended each cue at the first newline followed by a digit, so a text line starting with a number ("20 dollars.") was cut off with the rest of its cue, and timestamps with '.' milliseconds dropped the whole cue. Cues are now split on blank lines; the index line is optional, cue settings after the timing are ignored, and the BOM is stripped. Output is identical on well-formed files. Removes a debug print. Co-Authored-By: Claude Opus 5.5 --- pytest.ini | 7 ++++ src/core/parsers.py | 71 +++++++++++++++++++++------------ tests/test_parsers.py | 92 +++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 144 insertions(+), 26 deletions(-) create mode 100644 pytest.ini create mode 100644 tests/test_parsers.py diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 0000000..77f6e5e --- /dev/null +++ b/pytest.ini @@ -0,0 +1,7 @@ +[pytest] +pythonpath = . +testpaths = tests +# Same as CIGA: keep pytest's temporary directories inside the checkout, because an +# unreadable entry in the system temp directory makes every tmp_path test error. +addopts = --basetemp=.pytest_tmp +tmp_path_retention_policy = none diff --git a/src/core/parsers.py b/src/core/parsers.py index fc18b95..2ece5f4 100644 --- a/src/core/parsers.py +++ b/src/core/parsers.py @@ -1,43 +1,62 @@ import re import chardet -def parse_srt(srt_file): - print('srt_file', srt_file) - pattern = re.compile( - r'(\d+)\s+([\d:,]+)\s*-->\s*([\d:,]+)\s*(.*?)\s*(?=\n\d+|\Z)', - re.DOTALL - ) - - with open(srt_file, 'rb') as f: - raw_data = f.read() +# "00:00:01,000 --> 00:00:02,000", with "." also accepted as the millisecond separator +# and anything after the end time (cue settings such as "X1:40") ignored. +TIMING_PATTERN = re.compile( + r'^\s*(\d+:\d{1,2}:\d{1,2}[,.]\d+)\s*-->\s*(\d+:\d{1,2}:\d{1,2}[,.]\d+)' +) +BLOCK_SEPARATOR = re.compile(r'\n[ \t]*\n') - result = chardet.detect(raw_data) - encoding = result['encoding'] - if not encoding: - encoding = 'utf-8' +def _decode(raw_data): + encoding = chardet.detect(raw_data)['encoding'] or 'utf-8' try: content = raw_data.decode(encoding) except (UnicodeDecodeError, LookupError): content = raw_data.decode('latin-1') + return content.lstrip('').replace('\r\n', '\n').replace('\r', '\n') - content = content.replace('\r\n', '\n').replace('\r', '\n') - matches = re.findall(pattern, content) - subtitles = [] - for match in matches: - index = int(match[0]) - start_time = srt_time_to_milliseconds(match[1]) - end_time = srt_time_to_milliseconds(match[2]) - text = match[3].replace('\n', ' ').strip() - subtitles.append({ +def _parse_block(block, fallback_index): + """Parse one cue: an optional index line, the timing line, then the text lines.""" + lines = block.strip('\n').split('\n') + for position, line in enumerate(lines[:2]): + timing = TIMING_PATTERN.match(line) + if not timing: + continue + index = fallback_index + if position == 1 and lines[0].strip().isdigit(): + index = int(lines[0].strip()) + text = ' '.join(part.strip() for part in lines[position + 1:] if part.strip()) + return { 'index': index, - 'start_time': start_time, - 'end_time': end_time, - 'text': text - }) + 'start_time': srt_time_to_milliseconds(timing.group(1)), + 'end_time': srt_time_to_milliseconds(timing.group(2)), + 'text': text, + } + return None + + +def parse_srt(srt_file): + """Parse an SRT file into cues. + + Cues are split on blank lines rather than on "a newline followed by digits", + so a text line that starts with a number stays part of its cue. + """ + with open(srt_file, 'rb') as f: + content = _decode(f.read()) + + subtitles = [] + for block in BLOCK_SEPARATOR.split(content): + if not block.strip(): + continue + cue = _parse_block(block, len(subtitles) + 1) + if cue is not None: + subtitles.append(cue) return subtitles + def srt_time_to_milliseconds(srt_time): try: h, m, s_ms = srt_time.split(':') diff --git a/tests/test_parsers.py b/tests/test_parsers.py new file mode 100644 index 0000000..1f515f8 --- /dev/null +++ b/tests/test_parsers.py @@ -0,0 +1,92 @@ +import pytest + +from src.core.parsers import milliseconds_to_srt_time, parse_srt, srt_time_to_milliseconds + + +def _write(tmp_path, text, encoding="utf-8"): + path = tmp_path / "subtitles.srt" + path.write_bytes(text.encode(encoding)) + return str(path) + + +def test_parses_basic_cues(tmp_path): + srt = _write(tmp_path, "1\n00:00:01,000 --> 00:00:02,500\nHello\n\n2\n00:00:03,000 --> 00:00:04,000\nWorld\n") + + subs = parse_srt(srt) + + assert subs == [ + {"index": 1, "start_time": 1000, "end_time": 2500, "text": "Hello"}, + {"index": 2, "start_time": 3000, "end_time": 4000, "text": "World"}, + ] + + +def test_keeps_continuation_lines_that_start_with_a_digit(tmp_path): + srt = _write(tmp_path, "1\n00:00:01,000 --> 00:00:02,000\nI owe you\n20 dollars.\n\n2\n00:00:03,000 --> 00:00:04,000\nNext\n") + + subs = parse_srt(srt) + + assert [s["text"] for s in subs] == ["I owe you 20 dollars.", "Next"] + + +def test_accepts_dot_millisecond_separator(tmp_path): + srt = _write(tmp_path, "1\n00:00:05.000 --> 00:00:06.250\nDot separated\n") + + subs = parse_srt(srt) + + assert len(subs) == 1 + assert (subs[0]["start_time"], subs[0]["end_time"]) == (5000, 6250) + + +def test_handles_crlf_bom_and_extra_blank_lines(tmp_path): + text = "1\r\n00:00:01,000 --> 00:00:02,000\r\nFirst\r\n\r\n\r\n2\r\n00:00:03,000 --> 00:00:04,000\r\nSecond\r\n" + srt = _write(tmp_path, text) + + subs = parse_srt(srt) + + assert [(s["index"], s["text"]) for s in subs] == [(1, "First"), (2, "Second")] + + +def test_ignores_cue_settings_after_the_timing(tmp_path): + srt = _write(tmp_path, "1\n00:00:01,000 --> 00:00:02,000 X1:40 X2:600 Y1:20 Y2:50\nPositioned\n") + + assert parse_srt(srt)[0]["text"] == "Positioned" + + +def test_numbers_cues_without_an_index_line(tmp_path): + srt = _write(tmp_path, "00:00:01,000 --> 00:00:02,000\nNo index\n\n00:00:03,000 --> 00:00:04,000\nAlso none\n") + + assert [(s["index"], s["text"]) for s in parse_srt(srt)] == [(1, "No index"), (2, "Also none")] + + +def test_keeps_cues_with_empty_text(tmp_path): + srt = _write(tmp_path, "1\n00:00:01,000 --> 00:00:02,000\n\n2\n00:00:03,000 --> 00:00:04,000\nAfter\n") + + assert [s["text"] for s in parse_srt(srt)] == ["", "After"] + + +def test_skips_blocks_without_timing(tmp_path): + srt = _write(tmp_path, "garbage header\n\n1\n00:00:01,000 --> 00:00:02,000\nKept\n") + + assert [s["text"] for s in parse_srt(srt)] == ["Kept"] + + +def test_decodes_non_utf8_files(tmp_path): + srt = _write(tmp_path, "1\n00:00:01,000 --> 00:00:02,000\n你好,世界。这是一段比较长的中文字幕,用来测试编码检测。\n", encoding="gb18030") + + assert parse_srt(srt)[0]["text"].startswith("你好") + + +@pytest.mark.parametrize("value, expected", [ + ("00:00:01,000", 1000), + ("01:02:03,456", 3723456), + ("00:00:01.5", 1500), + ("0:00:01,000", 1000), + ("bad", 0), +]) +def test_srt_time_to_milliseconds(value, expected): + assert srt_time_to_milliseconds(value) == expected + + +def test_milliseconds_round_trip(): + assert milliseconds_to_srt_time(3723456) == "01:02:03,456" + assert srt_time_to_milliseconds(milliseconds_to_srt_time(3723456)) == 3723456 From 68a6396a1abf68dea143f23030432cffbaadf163 Mon Sep 17 00:00:00 2001 From: angushushu Date: Sun, 27 Sep 2026 15:49:59 -0400 Subject: [PATCH 2/5] fix: keep hotkey defaults intact so Reset to Default works AppSettings shallow-copied DEFAULT_SETTINGS, sharing the nested hotkeys dict, so rebinding a key also rewrote the default and Reset restored the user's own binding. A settings file with only some hotkeys also replaced the whole dict, dropping the defaults for the rest. Removes an unused Qt import so the core modules no longer need Qt. Co-Authored-By: Claude Opus 5.5 --- src/core/app_settings.py | 16 +++++++++------- tests/test_app_settings.py | 39 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 48 insertions(+), 7 deletions(-) create mode 100644 tests/test_app_settings.py diff --git a/src/core/app_settings.py b/src/core/app_settings.py index daa8b51..887af5e 100644 --- a/src/core/app_settings.py +++ b/src/core/app_settings.py @@ -1,8 +1,8 @@ +import copy import json import os import sys from pathlib import Path -from PySide6.QtCore import Qt def get_base_dir(): if getattr(sys, 'frozen', False): @@ -35,7 +35,9 @@ def get_base_dir(): class AppSettings: def __init__(self): - self.settings = DEFAULT_SETTINGS.copy() + # Deep copy: a shallow copy shares the nested "hotkeys" dict, so rebinding a + # key would also rewrite DEFAULT_SETTINGS and break "Reset to Default". + self.settings = copy.deepcopy(DEFAULT_SETTINGS) self.load() def load(self): @@ -43,11 +45,11 @@ def load(self): try: with open(SETTINGS_FILE, "r", encoding="utf-8") as f: loaded = json.load(f) - # Merge loaded into default to preserve missing keys - self.settings.update(loaded) - if "hotkeys" in loaded: - for k, v in loaded["hotkeys"].items(): - self.settings["hotkeys"][k] = v + # Merge loaded into the defaults so missing keys (including individual + # hotkeys) keep their default values. + loaded_hotkeys = loaded.pop("hotkeys", {}) + self.settings.update(loaded) + self.settings["hotkeys"].update(loaded_hotkeys) except Exception as e: print(f"Failed to load settings: {e}") diff --git a/tests/test_app_settings.py b/tests/test_app_settings.py new file mode 100644 index 0000000..3b4927a --- /dev/null +++ b/tests/test_app_settings.py @@ -0,0 +1,39 @@ +import json + +import pytest + +import src.core.app_settings as app_settings + + +@pytest.fixture +def settings_file(tmp_path, monkeypatch): + path = tmp_path / "settings.json" + monkeypatch.setattr(app_settings, "SETTINGS_FILE", str(path)) + return path + + +def test_rebinding_a_hotkey_leaves_the_defaults_untouched(settings_file): + settings = app_settings.AppSettings() + + settings.set_hotkey("play_pause", "P") + + assert settings.get_hotkey("play_pause") == "P" + assert app_settings.DEFAULT_SETTINGS["hotkeys"]["play_pause"] == "Space" + + +def test_loading_a_settings_file_leaves_the_defaults_untouched(settings_file): + settings_file.write_text(json.dumps({"auto_pause": False, "hotkeys": {"next_uncoded": "M"}}), encoding="utf-8") + + settings = app_settings.AppSettings() + + assert settings.get("auto_pause") is False + assert settings.get_hotkey("next_uncoded") == "M" + assert settings.get_hotkey("play_pause") == "Space" + assert app_settings.DEFAULT_SETTINGS["hotkeys"]["next_uncoded"] == "N" + assert app_settings.DEFAULT_SETTINGS["auto_pause"] is True + + +def test_settings_persist_between_instances(settings_file): + app_settings.AppSettings().set("inherit_listener", True) + + assert app_settings.AppSettings().get("inherit_listener") is True From 68b82559903ba2c3919cb8ce8c8b89bdd3cd2a57 Mon Sep 17 00:00:00 2001 From: angushushu Date: Sun, 27 Sep 2026 15:50:00 -0400 Subject: [PATCH 3/5] fix: autosave to a recovery file instead of the project With a project open, autosave overwrote the .vat every 15 seconds, so answering No to "Save before exit?" did not discard anything. The autosave file was also never removed, so every launch offered to restore it, and restoring made the hidden file the working project. Autosave now writes ..autosave.vat next to the project (or the subtitles before the first save), recording which project it belongs to. Save and discard remove it; a restore is offered only when it is newer than the project and continues on the original project. Save reports failure, and a failed or cancelled save keeps the window open. Autosave failures show in the status bar instead of a modal dialog every 15 s. Also in the window: - reject commas in character names; commas separate characters in the exported CSV and in CIGA's input, so such a name became two characters - cancelling the save prompt no longer orphans the window when opening another project or starting a new one Co-Authored-By: Claude Opus 5.5 --- src/core/characters.py | 13 ++ src/core/project.py | 60 ++++++++++ src/main.py | 30 +++-- src/ui/annotator_window.py | 212 ++++++++++++++++++--------------- tests/test_annotator_window.py | 185 ++++++++++++++++++++++++++++ tests/test_characters.py | 28 +++++ tests/test_project.py | 71 +++++++++++ 7 files changed, 489 insertions(+), 110 deletions(-) create mode 100644 src/core/project.py create mode 100644 tests/test_annotator_window.py create mode 100644 tests/test_characters.py create mode 100644 tests/test_project.py diff --git a/src/core/characters.py b/src/core/characters.py index fe0e9f5..bb1d04d 100644 --- a/src/core/characters.py +++ b/src/core/characters.py @@ -8,6 +8,19 @@ def get_base_dir(): else: return Path(__file__).parent.parent.parent +def validate_character_name(name): + """Return an error message for an unusable character name, or None if it is valid. + + Commas separate characters in exported CSV cells and in CIGA's speaker/listener + columns, so a name containing one would silently become two characters. + """ + if not name or not name.strip(): + return "Character name cannot be empty." + if ',' in name: + return "Character names cannot contain commas; commas separate characters in the exported CSV." + return None + + def read_characters(char_file): if not char_file or not os.path.exists(char_file): return [] diff --git a/src/core/project.py b/src/core/project.py new file mode 100644 index 0000000..b5b35b0 --- /dev/null +++ b/src/core/project.py @@ -0,0 +1,60 @@ +"""Project (.vat) file storage and crash-recovery autosave. + +Autosave never writes to the user's project file. It writes a hidden sibling file +that records which project it belongs to, so choosing "don't save" really discards +changes, and an explicit save or discard removes the autosave. +""" +import json +import os +import tempfile +from pathlib import Path + +# Key stored in an autosave file: absolute path of the project it recovers, or "" +# when the session had never been saved to a project. +AUTOSAVE_OF_KEY = "autosave_of" + + +def autosave_path_for(vat_file, srt_file): + """Hidden autosave location: next to the project if there is one, else the subtitles.""" + anchor = Path(vat_file) if vat_file else Path(srt_file) + return str(anchor.parent / f".{anchor.name}.autosave.vat") + + +def write_project_file(file_path, project_data): + """Write JSON atomically; on any failure the previous file is left untouched. + + Errors (serialization or I/O) propagate so callers decide how to report them. + """ + target = Path(file_path) + target.parent.mkdir(parents=True, exist_ok=True) + fd, tmp_path = tempfile.mkstemp(prefix=f".{target.name}.", suffix=".tmp", dir=str(target.parent)) + os.close(fd) + try: + with open(tmp_path, 'w', encoding='utf-8') as f: + json.dump(project_data, f, indent=4) + os.replace(tmp_path, target) + finally: + if os.path.exists(tmp_path): + os.remove(tmp_path) + + +def read_project_file(file_path): + with open(file_path, 'r', encoding='utf-8') as f: + return json.load(f) + + +def autosave_is_recoverable(autosave_path, vat_file): + """An autosave is worth offering only if it exists and is newer than its project.""" + if not os.path.exists(autosave_path): + return False + if vat_file and os.path.exists(vat_file): + return os.path.getmtime(autosave_path) > os.path.getmtime(vat_file) + return True + + +def remove_file_quietly(file_path): + """Delete a file if present. Used for autosaves, whose loss is never fatal.""" + try: + os.remove(file_path) + except FileNotFoundError: + pass diff --git a/src/main.py b/src/main.py index 1495b40..6c4997b 100644 --- a/src/main.py +++ b/src/main.py @@ -9,10 +9,8 @@ def get_resource_path(filename): if hasattr(sys, "_MEIPASS"): return os.path.join(sys._MEIPASS, filename) - else: - current_dir = os.path.dirname(os.path.abspath(__file__)) - ciga_root = os.path.dirname(os.path.dirname(current_dir)) - return os.path.join(ciga_root, filename) + repo_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + return os.path.join(repo_root, "assets", filename) def main(): app = QApplication(sys.argv) @@ -28,11 +26,21 @@ def main(): app.main_window = MainWindow() app.main_window.show() + def close_annotator(): + """Close the current annotator; False if the user cancelled at the save prompt.""" + annotator = getattr(app, 'video_annotator', None) + if annotator is None: + return True + if not annotator.close(): + return False + app.removeEventFilter(annotator) + app.video_annotator = None + return True + def start_annotation(video_file, srt_file, char_file, vat_file): - if hasattr(app, 'video_annotator') and app.video_annotator: - app.removeEventFilter(app.video_annotator) - app.video_annotator.close() - + if not close_annotator(): + return + app.video_annotator = VideoAnnotator(video_file, srt_file, char_file, vat_file) # Hook up "New Project" signal from main window to reset app.video_annotator.request_new_project.connect(reset_to_main_window) @@ -42,10 +50,8 @@ def start_annotation(video_file, srt_file, char_file, vat_file): app.installEventFilter(app.video_annotator) def reset_to_main_window(): - app.removeEventFilter(app.video_annotator) - app.video_annotator.close() - app.video_annotator = None - app.main_window.show() + if close_annotator(): + app.main_window.show() app.main_window.start_annotation.connect(start_annotation) sys.exit(app.exec()) diff --git a/src/ui/annotator_window.py b/src/ui/annotator_window.py index ea11a58..4b15968 100644 --- a/src/ui/annotator_window.py +++ b/src/ui/annotator_window.py @@ -1,13 +1,9 @@ import csv -import json -import os -import sys -import tempfile from pathlib import Path from PySide6.QtWidgets import ( QApplication, QMainWindow, QWidget, QVBoxLayout, QLabel, QFileDialog, QPushButton, QLineEdit, QHBoxLayout, QTableWidget, - QTableWidgetItem, QSizePolicy, QMessageBox, QHeaderView, + QTableWidgetItem, QMessageBox, QHeaderView, QSplitter, QAbstractItemView, QSlider, QInputDialog ) from PySide6.QtMultimedia import QMediaPlayer, QAudioOutput @@ -17,10 +13,13 @@ from src.core.app_settings import AppSettings from src.ui.settings_dialog import SettingsDialog -from src.ui.char_dialog import ManageCharactersDialog from src.core.parsers import parse_srt, milliseconds_to_srt_time -from src.core.characters import read_characters, save_characters +from src.core.characters import get_base_dir, read_characters, save_characters, validate_character_name from src.core.csv_utils import write_rows_to_csv_atomic +from src.core.project import ( + AUTOSAVE_OF_KEY, autosave_is_recoverable, autosave_path_for, read_project_file, + remove_file_quietly, write_project_file, +) class VideoAnnotator(QMainWindow): request_new_project = Signal() @@ -37,14 +36,14 @@ def __init__(self, video_file, srt_file, char_file, vat_file=""): self.vat_file = vat_file self.is_dirty = False + self._recovered_from_autosave = False self.annotations = {} self.custom_columns = ['Note'] if self.vat_file: # Load project details from .vat JSON try: - with open(self.vat_file, 'r', encoding='utf-8') as f: - project_data = json.load(f) + project_data = read_project_file(self.vat_file) video_file = project_data.get('video_file', '') srt_file = project_data.get('srt_file', '') self.char_file = project_data.get('char_file', '') @@ -116,20 +115,21 @@ def __init__(self, video_file, srt_file, char_file, vat_file=""): else: project_data['char_file'] = "" - with open(self.vat_file, 'w', encoding='utf-8') as f: - json.dump(project_data, f, indent=4) + write_project_file(self.vat_file, project_data) + self.is_dirty = True + + if AUTOSAVE_OF_KEY in project_data: + # Recovered from an autosave: keep working on the project it belongs + # to (or an unsaved session), never on the hidden autosave file. + self.vat_file = project_data[AUTOSAVE_OF_KEY] self.is_dirty = True + self._recovered_from_autosave = True except Exception as e: QMessageBox.critical(self, "Error", f"Failed to load project file: {e}") QTimer.singleShot(0, self.request_new_project.emit) return else: if not char_file: - def get_base_dir(): - if getattr(sys, 'frozen', False): - return Path(sys.executable).parent - else: - return Path(__file__).parent.parent.parent char_file = str(get_base_dir() / "characters.txt") self.char_file = char_file self.characters = read_characters(self.char_file) @@ -137,10 +137,7 @@ def get_base_dir(): self.video_file = video_file self.srt_file = srt_file - if self.vat_file: - self.setWindowTitle(f"CIGA Annotator - {Path(self.vat_file).name}") - else: - self.setWindowTitle(f"CIGA Annotator - {Path(self.srt_file).name}") + self.update_window_title() self.subtitles = parse_srt(self.srt_file) if not self.subtitles: @@ -154,9 +151,9 @@ def get_base_dir(): self.active_role = 'speakers' self._updating_table = False self.slider_is_dragging = False - - srt_base = Path(self.srt_file).name - self.autosave_path = str(Path(self.srt_file).parent / f".{srt_base}.autosave.vat") + # Autosave files this session wrote or recovered from; all are removed once the + # work is saved or explicitly discarded (the location moves on "Save As"). + self._autosave_files = {self.autosave_path} # Video Section first to ensure seek_slider exists before duration_changed triggers self.play_pause_btn = QPushButton("Pause") @@ -351,6 +348,14 @@ def table_key_press(event): self.mediaPlayer.play() self.jump_to_subtitle(self.current_subtitle_index) + @property + def autosave_path(self): + return autosave_path_for(self.vat_file, self.srt_file) + + def update_window_title(self): + name = Path(self.vat_file).name if self.vat_file else Path(self.srt_file).name + self.setWindowTitle(f"CIGA Annotator - {name}") + def create_menu_bar(self): menu_bar = self.menuBar() file_menu = menu_bar.addMenu("File") @@ -551,6 +556,13 @@ def on_character_table_item_changed(self, item): # If they typed in Name col for new character if col == 0: + error = validate_character_name(text) + if error: + QMessageBox.warning(self, "Invalid Name", error) + self._updating_table = True + item.setText("") + self._updating_table = False + return name = text # auto-assign shortcut used_keys = {str(c.get('key', '')).upper() for c in self.characters if c.get('key')} @@ -572,8 +584,11 @@ def on_character_table_item_changed(self, item): else: # Editing existing if col == 0: - if not text: - # Restore previous if emptied, or maybe delete? Let's just restore. + error = validate_character_name(text) + if error: + # Emptied or invalid: restore the previous name. + if text: + QMessageBox.warning(self, "Invalid Name", error) self._updating_table = True item.setText(self.characters[row]['name']) self._updating_table = False @@ -1101,57 +1116,55 @@ def open_project_dialog(self): self.request_open_project.emit(vat_file) def save_project(self): + """Save to the current project, asking for a location first if there is none. + + Returns True only if the project was written. + """ self.record_annotation(self.current_subtitle_index, clear_selections=False) - if hasattr(self, 'vat_file') and self.vat_file: - self._write_project_file(self.vat_file) - self.is_dirty = False - self.update_progress_status() - else: - self.save_project_as_dialog() + if not self.vat_file: + return self.save_project_as_dialog() + return self._save_to(self.vat_file) def save_project_as_dialog(self): self.record_annotation(self.current_subtitle_index, clear_selections=False) save_path, _ = QFileDialog.getSaveFileName(self, "Save Project", "project.vat", "VAT Projects (*.vat)") - if save_path: - self.vat_file = save_path - self._write_project_file(self.vat_file) - self.is_dirty = False - self.update_progress_status() - - def _write_project_file(self, file_path): + if not save_path: + return False + return self._save_to(save_path) + + def _save_to(self, file_path): try: - # We must use paths relative to the vat file where possible, or just store absolute. - # Storing absolute is easier but less portable. Let's use pure filenames if in same dir, or absolute. - vat_dir = Path(file_path).parent - - def rel_path(p): - if not p: return "" - try: - return str(Path(p).relative_to(vat_dir)) - except ValueError: - return str(Path(p).absolute()) - - project_data = { - "video_file": rel_path(self.video_file), - "srt_file": rel_path(self.srt_file), - "char_file": rel_path(self.char_file), - "characters": self.characters, - "custom_columns": getattr(self, 'custom_columns', ['Note']), - "annotations": self.annotations - } - target = Path(file_path) - target.parent.mkdir(parents=True, exist_ok=True) - fd, tmp_path = tempfile.mkstemp(prefix=f".{target.name}.", suffix=".tmp", dir=str(target.parent)) - os.close(fd) - try: - with open(tmp_path, 'w', encoding='utf-8') as f: - json.dump(project_data, f, indent=4) - os.replace(tmp_path, file_path) - finally: - if os.path.exists(tmp_path): - os.remove(tmp_path) + write_project_file(file_path, self._project_data(file_path)) except Exception as e: QMessageBox.critical(self, "Error", f"Failed to save project: {e}") + return False + self.vat_file = file_path + self.is_dirty = False + self.discard_autosave() + self.update_window_title() + self.update_progress_status() + return True + + def _project_data(self, file_path): + # Paths are stored relative to the project file where possible, so a project + # folder can be moved; files on another drive stay absolute. + vat_dir = Path(file_path).parent + + def rel_path(p): + if not p: return "" + try: + return str(Path(p).relative_to(vat_dir)) + except ValueError: + return str(Path(p).absolute()) + + return { + "video_file": rel_path(self.video_file), + "srt_file": rel_path(self.srt_file), + "char_file": rel_path(self.char_file), + "characters": self.characters, + "custom_columns": getattr(self, 'custom_columns', ['Note']), + "annotations": self.annotations + } def export_csv_dialog(self): self.record_annotation(self.current_subtitle_index, clear_selections=False) @@ -1193,10 +1206,6 @@ def export_csv(self, file_path): if spk or lst or tgt or has_custom: rows.append(row_data) - if rows: - fieldnames = ['line', 'start_time', 'end_time', 'speakers', 'listeners', 'targets'] + getattr(self, 'custom_columns', ['Note']) - # write_rows_to_csv_atomic assumes dicts with standard keys, but we can override it if we write it locally here. - # Actually write_rows_to_csv_atomic in utils checks keys of dicts. Let's just use it and rely on its dict handling. if rows: fieldnames = ['line', 'start_time', 'end_time', 'speakers', 'listeners', 'targets'] + getattr(self, 'custom_columns', ['Note']) else: @@ -1287,35 +1296,46 @@ def update_progress_status(self): ) def autosave_annotations(self): + """Write a crash-recovery copy; the user's project file is only written on save.""" if not self.is_dirty: return - - # Determine the target autosave path - save_target = self.vat_file if hasattr(self, 'vat_file') and self.vat_file else self.autosave_path - + path = self.autosave_path try: self.record_annotation(self.current_subtitle_index, clear_selections=False) - self._write_project_file(save_target) - except Exception: - # Autosave should never interrupt annotation flow. - pass + project_data = self._project_data(path) + project_data[AUTOSAVE_OF_KEY] = str(Path(self.vat_file).absolute()) if self.vat_file else "" + write_project_file(path, project_data) + self._autosave_files.add(path) + except Exception as e: + # Autosave must never interrupt annotation, but a failure should be visible. + self.statusBar().showMessage(f"Autosave failed: {e}", 10000) + + def discard_autosave(self): + """Remove the crash-recovery copies once the work is saved or discarded.""" + for path in getattr(self, '_autosave_files', set()): + try: + remove_file_quietly(path) + except OSError as e: + self.statusBar().showMessage(f"Could not remove autosave {path}: {e}", 10000) + if hasattr(self, '_autosave_files'): + self._autosave_files.clear() def try_restore_autosave(self): - # We don't need to restore autosave if we are explicitly opening a vat project, - # since the vat project is the autosave target itself if it exists. - if hasattr(self, 'vat_file') and self.vat_file: + if self._recovered_from_autosave: return - - if not os.path.exists(self.autosave_path): + path = self.autosave_path + if not autosave_is_recoverable(path, self.vat_file): return reply = QMessageBox.question( self, "Restore Autosave", - f"Found autosave project:\n{self.autosave_path}\n\nRestore it now?", + f"Found unsaved work from a previous session:\n{path}\n\nRestore it now?", QMessageBox.Yes | QMessageBox.No ) if reply == QMessageBox.Yes: - self.request_open_project.emit(self.autosave_path) + # This window is closed to open the recovered one; keep the file it reads. + self._autosave_files.discard(path) + self.request_open_project.emit(path) def closeEvent(self, event): if self.is_dirty: @@ -1328,17 +1348,13 @@ def closeEvent(self, event): if reply == QMessageBox.Cancel: event.ignore() return - if reply == QMessageBox.Yes: - if hasattr(self, 'vat_file') and self.vat_file: - self.save_project() - else: - save_path, _ = QFileDialog.getSaveFileName(self, "Save Project", "project.vat", "VAT Projects (*.vat)") - if not save_path: - event.ignore() - return - self.vat_file = save_path - self.save_project() - self.autosave_timer.stop() + if reply == QMessageBox.Yes and not self.save_project(): + event.ignore() + return + # Saved, discarded, or nothing to save: the recovery copy is no longer needed. + self.discard_autosave() + if hasattr(self, 'autosave_timer'): + self.autosave_timer.stop() event.accept() @Slot(str) diff --git a/tests/test_annotator_window.py b/tests/test_annotator_window.py new file mode 100644 index 0000000..e713819 --- /dev/null +++ b/tests/test_annotator_window.py @@ -0,0 +1,185 @@ +"""Save/autosave/restore behavior of the real annotator window, run offscreen. + +Dialogs are replaced with scripted answers so nothing blocks; the video file is an +empty placeholder, which only makes the media player report an error. +""" +import json +import os + +import pytest + +pytest.importorskip("PySide6.QtMultimedia") +os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") + +from PySide6.QtWidgets import QApplication, QFileDialog, QMessageBox # noqa: E402 + +from src.core.project import AUTOSAVE_OF_KEY # noqa: E402 +from src.ui.annotator_window import VideoAnnotator # noqa: E402 + +SRT = "1\n00:00:01,000 --> 00:00:02,000\nHello\n\n2\n00:00:03,000 --> 00:00:04,000\nWorld\n" + + +@pytest.fixture(scope="module") +def qapp(): + return QApplication.instance() or QApplication([]) + + +@pytest.fixture +def answers(monkeypatch): + """Scripted dialog answers; tests set answers['question'] etc. as needed.""" + script = {"question": QMessageBox.No, "save_path": ""} + shown = [] + for name in ("critical", "warning", "information"): + monkeypatch.setattr(QMessageBox, name, staticmethod(lambda *a, _n=name, **k: shown.append((_n, a[2] if len(a) > 2 else "")))) + monkeypatch.setattr(QMessageBox, "question", staticmethod(lambda *a, **k: script["question"])) + monkeypatch.setattr(QFileDialog, "getSaveFileName", staticmethod(lambda *a, **k: (script["save_path"], ""))) + script["shown"] = shown + return script + + +@pytest.fixture +def media(tmp_path): + (tmp_path / "ep1.srt").write_text(SRT, encoding="utf-8") + (tmp_path / "ep1.mp4").write_bytes(b"") + (tmp_path / "chars.txt").write_text("Sheldon,1\nPenny,2\n", encoding="utf-8") + return tmp_path + + +def _saved_project(media): + vat = media / "study.vat" + vat.write_text(json.dumps({ + "video_file": "ep1.mp4", "srt_file": "ep1.srt", "char_file": "chars.txt", + "characters": [{"name": "Sheldon", "key": "1"}], "custom_columns": ["Note"], + "annotations": {}, + }), encoding="utf-8") + return vat + + +def _open(vat="", media=None): + if vat: + return VideoAnnotator("", "", "", str(vat)) + return VideoAnnotator(str(media / "ep1.mp4"), str(media / "ep1.srt"), str(media / "chars.txt"), "") + + +def _code_current_line(window, speaker="Sheldon"): + window.current_speakers = [speaker] + window.is_dirty = True + + +def _close(window): + window.close() + window.deleteLater() + + +def test_autosave_never_touches_the_project_file(qapp, answers, media): + vat = _saved_project(media) + before = vat.read_text(encoding="utf-8") + window = _open(vat) + _code_current_line(window) + + window.autosave_annotations() + + autosave = media / ".study.vat.autosave.vat" + assert vat.read_text(encoding="utf-8") == before + data = json.loads(autosave.read_text(encoding="utf-8")) + assert data[AUTOSAVE_OF_KEY] == str(vat.absolute()) + assert data["annotations"]["0"]["speakers"] == ["Sheldon"] + window.is_dirty = False + _close(window) + + +def test_saving_writes_the_project_and_removes_the_autosave(qapp, answers, media): + vat = _saved_project(media) + window = _open(vat) + _code_current_line(window) + window.autosave_annotations() + + assert window.save_project() is True + + assert json.loads(vat.read_text(encoding="utf-8"))["annotations"]["0"]["speakers"] == ["Sheldon"] + assert not (media / ".study.vat.autosave.vat").exists() + assert window.is_dirty is False + _close(window) + + +def test_declining_to_save_on_close_discards_changes_and_autosave(qapp, answers, media): + vat = _saved_project(media) + before = vat.read_text(encoding="utf-8") + window = _open(vat) + _code_current_line(window) + window.autosave_annotations() + answers["question"] = QMessageBox.No + + assert window.close() is True + + assert vat.read_text(encoding="utf-8") == before + assert not (media / ".study.vat.autosave.vat").exists() + + +def test_cancelling_close_keeps_the_window_and_autosave(qapp, answers, media): + window = _open(media=media) + _code_current_line(window) + window.autosave_annotations() + answers["question"] = QMessageBox.Cancel + + assert window.close() is False + assert (media / ".ep1.srt.autosave.vat").exists() + window.is_dirty = False + _close(window) + + +def test_failed_save_as_keeps_the_window_open(qapp, answers, media): + window = _open(media=media) + _code_current_line(window) + answers["question"] = QMessageBox.Yes + answers["save_path"] = "" # user cancels the Save As dialog + + assert window.close() is False + window.is_dirty = False + _close(window) + + +def test_recovered_session_saves_to_its_project_not_the_autosave(qapp, answers, media): + vat = _saved_project(media) + window = _open(vat) + _code_current_line(window) + window.autosave_annotations() + window.is_dirty = False # simulate a crash: the window never got to save + window.deleteLater() + autosave = media / ".study.vat.autosave.vat" + + recovered = _open(autosave) + + assert recovered.vat_file == str(vat.absolute()) + assert recovered.is_dirty is True + assert recovered.annotations[0]["speakers"] == ["Sheldon"] + assert recovered.save_project() is True + assert json.loads(vat.read_text(encoding="utf-8"))["annotations"]["0"]["speakers"] == ["Sheldon"] + assert not autosave.exists() + _close(recovered) + + +def test_unsaved_session_autosave_sits_next_to_the_subtitles(qapp, answers, media): + window = _open(media=media) + _code_current_line(window) + + window.autosave_annotations() + + data = json.loads((media / ".ep1.srt.autosave.vat").read_text(encoding="utf-8")) + assert data[AUTOSAVE_OF_KEY] == "" + window.is_dirty = False + _close(window) + assert not (media / ".ep1.srt.autosave.vat").exists() + + +def test_character_names_with_commas_are_rejected(qapp, answers, media): + window = _open(media=media) + table = window.character_table + new_row = len(window.characters) + names_before = [c["name"] for c in window.characters] + + table.item(new_row, 0).setText("Cooper, Sheldon") + + assert [c["name"] for c in window.characters] == names_before + assert any(kind == "warning" for kind, _ in answers["shown"]) + _close(window) diff --git a/tests/test_characters.py b/tests/test_characters.py new file mode 100644 index 0000000..129b55a --- /dev/null +++ b/tests/test_characters.py @@ -0,0 +1,28 @@ +import pytest + +from src.core.characters import read_characters, save_characters, validate_character_name + + +def test_round_trips_names_and_shortcuts(tmp_path): + path = str(tmp_path / "characters.txt") + characters = [{"name": "Monica", "key": "1"}, {"name": "Joey", "key": None}] + + save_characters(path, characters) + + assert read_characters(path) == characters + + +def test_missing_file_yields_no_characters(tmp_path): + assert read_characters(str(tmp_path / "absent.txt")) == [] + + +@pytest.mark.parametrize("name", ["Sheldon", "Dr. Cooper", "Mary Cooper"]) +def test_accepts_plain_names(name): + assert validate_character_name(name) is None + + +@pytest.mark.parametrize("name", ["", " ", "Cooper, Sheldon"]) +def test_rejects_empty_names_and_commas(name): + # Commas separate characters in exported CSV cells and in CIGA's input, + # so a name containing one would be split into two characters. + assert validate_character_name(name) diff --git a/tests/test_project.py b/tests/test_project.py new file mode 100644 index 0000000..bd4dbf4 --- /dev/null +++ b/tests/test_project.py @@ -0,0 +1,71 @@ +import json +import os +from pathlib import Path + +import pytest + +from src.core import project + + +def test_autosave_of_an_unsaved_session_sits_next_to_the_subtitles(tmp_path): + srt = tmp_path / "ep1.srt" + + assert Path(project.autosave_path_for("", str(srt))) == tmp_path / ".ep1.srt.autosave.vat" + + +def test_autosave_of_a_saved_project_sits_next_to_the_project(tmp_path): + vat = tmp_path / "work" / "study.vat" + + path = project.autosave_path_for(str(vat), str(tmp_path / "ep1.srt")) + + assert Path(path) == tmp_path / "work" / ".study.vat.autosave.vat" + assert Path(path) != vat + + +def test_write_then_read_round_trips(tmp_path): + target = tmp_path / "nested" / "p.vat" + data = {"annotations": {"0": {"speakers": ["A"]}}, "characters": []} + + project.write_project_file(str(target), data) + + assert project.read_project_file(str(target)) == data + + +def test_failed_write_keeps_the_previous_file_and_leaves_no_temp_files(tmp_path): + target = tmp_path / "p.vat" + project.write_project_file(str(target), {"version": 1}) + + with pytest.raises(TypeError): + project.write_project_file(str(target), {"bad": object()}) + + assert json.loads(target.read_text(encoding="utf-8")) == {"version": 1} + assert sorted(os.listdir(tmp_path)) == ["p.vat"] + + +def test_autosave_newer_than_project(tmp_path): + vat, autosave = tmp_path / "p.vat", tmp_path / ".p.vat.autosave.vat" + vat.write_text("{}") + autosave.write_text("{}") + os.utime(vat, (1000, 1000)) + os.utime(autosave, (2000, 2000)) + + assert project.autosave_is_recoverable(str(autosave), str(vat)) + assert not project.autosave_is_recoverable(str(vat), str(autosave)) + + +def test_autosave_without_a_project_is_recoverable(tmp_path): + autosave = tmp_path / ".ep1.srt.autosave.vat" + autosave.write_text("{}") + + assert project.autosave_is_recoverable(str(autosave), "") + assert not project.autosave_is_recoverable(str(tmp_path / "missing"), "") + + +def test_remove_file_quietly(tmp_path): + path = tmp_path / "x" + path.write_text("x") + + project.remove_file_quietly(str(path)) + project.remove_file_quietly(str(path)) + + assert not path.exists() From 13bc17ed711ec91657518a873075ab8a2f215635 Mon Sep 17 00:00:00 2001 From: angushushu Date: Sun, 27 Sep 2026 15:50:00 -0400 Subject: [PATCH 4/5] chore: remove dead code, stop tracking user state, bundle logos - delete extract.py, a one-off migration script that would overwrite annotator_window.py if run, and the unused ManageCharactersDialog - stop tracking settings.json and characters.txt, which the app rewrites as it is used; characters.example.txt documents the format - bundle the logos in assets/ so the app and build work outside a CIGA checkout - README: the clone step is 'cd ciga-annotator' and the launch command is 'python run.py' ('python src/main.py' fails with 'No module named src') - move pyinstaller to requirements-dev.txt with pytest - CI runs the tests on Windows and Linux Co-Authored-By: Claude Opus 5.5 --- .github/workflows/tests.yml | 33 +++++++++++++ .gitignore | 7 +++ README.md | 27 +++++++--- assets/logo_black.png | Bin 0 -> 39803 bytes assets/logo_white.png | Bin 0 -> 43467 bytes build.py | 7 ++- characters.txt => characters.example.txt | 0 extract.py | 39 --------------- requirements-dev.txt | 3 ++ requirements.txt | 1 - settings.json | 20 -------- src/ui/char_dialog.py | 60 ----------------------- 12 files changed, 66 insertions(+), 131 deletions(-) create mode 100644 .github/workflows/tests.yml create mode 100644 assets/logo_black.png create mode 100644 assets/logo_white.png rename characters.txt => characters.example.txt (100%) delete mode 100644 extract.py create mode 100644 requirements-dev.txt delete mode 100644 settings.json delete mode 100644 src/ui/char_dialog.py diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..35f59e7 --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,33 @@ +name: Tests + +on: + push: + branches: [main] + pull_request: + workflow_dispatch: + +jobs: + test: + name: Tests (${{ matrix.os }}, py${{ matrix.python-version }}) + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, windows-latest] + python-version: ["3.11", "3.13"] + env: + QT_QPA_PLATFORM: offscreen + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + - name: Install Qt runtime libraries + if: runner.os == 'Linux' + run: sudo apt-get update && sudo apt-get install -y libegl1 libgl1 libxkbcommon0 libpulse0 libpulse-mainloop-glib0 + - name: Install dependencies + run: | + python -m pip install --upgrade pip + python -m pip install -r requirements.txt pytest + - name: Run tests + run: python -m pytest -v diff --git a/.gitignore b/.gitignore index 2fd984d..ec98a7f 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,8 @@ *.srt *.csv *.txt +!requirements*.txt +!characters.example.txt *.vat *.exe @@ -39,3 +41,8 @@ Thumbs.db *.backup *.spec __pycache__/ + +# Per-user state written by the app +settings.json +.pytest_cache/ +.pytest_tmp/ diff --git a/README.md b/README.md index 1427cb1..a505448 100644 --- a/README.md +++ b/README.md @@ -26,8 +26,8 @@ - **Editable Note column:** Fill per-line notes either from right panel Note input or directly in the table. - **Player controls:** Built-in play/pause button, timeline slider, and current/total time display. - **Coding template options:** Optional inheritance of Listener/Target from the previous line. -- **Autosave + restore:** Periodic autosave with optional restore prompt on startup. -- **Unsaved-change protection:** Save/discard/cancel prompt when closing. +- **Autosave + restore:** Every 15 seconds unsaved work is copied to a hidden recovery file (`..autosave.vat`) next to the project, or next to the subtitles before the first save. Your project file is only written when you save. After a crash, the next session offers to restore the recovery file. +- **Unsaved-change protection:** Save/discard/cancel prompt when closing. Choosing not to save discards the changes and the recovery file. - **Safer writes:** Manual save and autosave use atomic write replacement to reduce data corruption risk. - **CSV import/export:** Save and load annotation progress in UTF-8 BOM CSV. @@ -39,7 +39,7 @@ 1. **Clone the Repository** ``` git clone https://github.com/MediaCompLab/ciga-annotator.git - cd ciga/ciga-annotator + cd ciga-annotator ``` 2. **Install Dependencies** @@ -49,22 +49,26 @@ ## Getting Started -1. **Run the Application** +1. **Run the Application** (from the repository root) ``` - python src/main.py + python run.py ``` If you are on Windows and get a Qt DLL load error in a Conda terminal, run with your system Python explicitly, for example: ``` - c:/python313/python.exe src/main.py + c:/python313/python.exe run.py ``` 2. **Select Files** - Select your video and SRT files. Character file is optional. + Select your video and SRT files. Character file is optional; see `characters.example.txt`. If provided, each line can be: - `CharacterName` - `CharacterName,ShortcutKey` + Character names cannot contain commas, because commas separate characters in the exported CSV + and in CIGA's input. Without a character file, the annotator keeps its list in `characters.txt` + next to the application. + 3. **Start Annotation** Click "Start Annotation" to begin. The player auto-pauses at subtitle boundaries for coding. @@ -85,6 +89,15 @@ 5. Use `Inherit Listener/Target from previous line` to reduce repeated coding in dialogue runs. 6. Use `N` (or `Next Uncoded` button) to jump through remaining work quickly. +## Development + +``` +pip install -r requirements-dev.txt +python -m pytest +``` + +To build the Windows executable: `python build.py`. + ## Contributing Contributions are welcome! To contribute: diff --git a/assets/logo_black.png b/assets/logo_black.png new file mode 100644 index 0000000000000000000000000000000000000000..828fd0dfd30b31fb78325e945b6dcc8f0a23187d GIT binary patch literal 39803 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 rEtvw#_l^tIA@=|f4y)1w6Zi4SSzs>AtZ2M z|6Xf^cy!?3aXub+;u;g{4gXu?yWchdAvFc;A1)-{BnTm{pB~3-$To)#>O1>*sX3kX zIpd-h;^hmg5i&Fh@pW=O??RS4O*}zcRb5YCT|*z<|Leca zaGd{HSAFZfrvEw&elyzaPA2>61Juc4s|D^vqQy~$1? zs@?$#|K4G*OMtV#hcDT~$6F5D(dmp&AlYa$fcg&(UcUd?);r)|bOMUuL!5l^>S_cG zr`3gLo&U4WH_+d6_3*RKco$C>FBfle0IXI2&stx1AF@w?yU+j2*8h3^-wpuU9y;`& zGyX?dyuAK%L;!hT5TNldg8YxG1CE9Iy5Ox{0(=7fon7_?0hkIHHop3M{9T;LKK{pi zd_4cnDXV{jET^HZrmilxX=&|Lb7=Lk@9+2eI0s^^{rC50|9^ge*xv(m%gOV9dml{W zFqzQb?-2mp3jLQ#9Ch*g*H4}va;svX@8pb$x6x*2jIS1poiUarNDQ(b|762UCFk_#Yz#zx>CH zxp;%^@&_Zu=e2eZLU%44*t_dk$j!0kM!(}78N5F%EnB@TTyXju_jeReh3!x!4(TJC zE2Ld9+=@~m6z(+}@5G-+ErEUeH>S0QCAIa{KDw!2eB?)CkKOsl>MIeBHG0hHmmBCw3Q%w#}{%X9LpJv%GH2r24hrK-3|89P=H}5sVBMbAIQ1Zlf~nEHD;0+o^NV&1!e?I$ugRoE8y`}%=V{|alIy+cEJ2H+ zxAuAWWCkMF=ow~lAVkyO*;f!D=>4~p?-5g3*-|ZDqVh#a+M}ZNLFc=e56G7W5jQ~R zSFV!lCb=|OrtEeS6~i74lm6-tnJ zO-q%jl1>0??3mCUfM+0a*GI7~V?+Nx{EaJr^dRaveH~I%sOt#6YG;I^e4M=$EQ*|H z{q?&4*Zshyo7Vb&|LR?Xt_ge7mx^3}?+<*vgE8Q*!;7uT3Md+|wIvFo&*~X#zaKv; z>EnIi_lb-oPK26X#rlVOJs~M{>iiI=oLZwpGRC{PugQ7ilW!lYQz8tXzpB_j|M;R% zz}H0}m|-w_ z-dSbCKi`JE{@`EkUY7oL7`}aMpj2i0*f}|OpBO?9FG|uQjqju-=Ow_a;nT*!%MNPS zap=6THxgp!zYq6{?Ai$MdIeb&nHmSo_egS zyqjihDh5QgVv9~(dtOI#$-5bVbHC`z`CYw5szppOzcK%<1dz0H(61s|tHjZ{RgoKM z?FN#ID|XfAWZu}%0g6H~iBuVg2v`5ACq`)iagkx~TXCObRD0)ajUqB2k+AYAA&@Re{fi%J!fDzG}6jTRRXy-&g(hXp}0~x z)bSi(&(j_Lb+xl#$POuO>!O4XJ-mB?GuhDY1Ra1+>?`0xMk<1phbByMln3G#+|&Ap z6^#x#hi6)cN!kN7=?5KxUg<7ruVh=7B&u_wZ zozUKXc!0KFigFG>Vl#QG4#R8LN~yJlI`CRmT)n-1$fk-1eQqhnG3xEtM(-6na_b@M zrCyR6;MFI{yS2UjfHT{zLw*f9j;+yEWE8TSE!kfOs{sL`m#Kg@i+vn01D?~Q6574D z_W@w%w9e5|%ag#<%A8TzprYIx5h`K7H$SlezB#AvPn&vq^TR*p#n!vB>kuz6$SO7! z)U|j6W!rEUg=sIibfwIjx6XmB4;)bOm?7xB}q1#pCSFCGnR9r9ycCdwQDN^XAy3QNLp`0_Zvz zsxu=!HodmyBTaiCDJ0t!h4H(788zT7`_#d&+lmCNq=V8gzQq&0T;E{)_e$JU@jpNes__4whW zdtcvM{{8ojSa~@8220njs@9QJgFv$EfoHSt@S`s+e7;+lkL)Pn12d=;nmArGyjjWL zwGfYtnqftA`Lp67m&-siq7r^;`mOqJiIE$nHi#HkiePjY2tx^Y}fQDjRkIh~m>K=?2tF=Mr6(Hj` zXcQ5XB+G0r2WppW>7p!TCJf!s_B6#z!vMErsE};*yHcs7F>b0IfZ7>qyHxvqj)4P_ z9$vA|A`O_ieY}chK7JH%-!rj0WTXAwJmd5eow(ZIuvPk-~IAMxB$WD;==4aUv zu@aLRaHBlJz6`>LEv(}Pc6Tq zFnN^}aOwpPQH6M2$di_2`3(~t^=>%~&=S&`Hv;aH^1toKB1ybbye>x!zz55~1~&td zc=EGjO&bU9%WRjW7~e|^%HxN_wYRmu?o1_dQknrO?M;Vge+7+45I}Ig((GachNzX{ z*R1uE!OmrX@O?e2H#acInYuL`cIBz6UFNUhmPG3gyvB_Oc@K8#c}`y1144W3FwHO8 ztR%q)tz+0Ix!Xn6M*}Ga97etYLP~42CxVWcjysr&aiAj?C7WKAx4d<_)ibbkTJR7^ zgwg5mB{wwJp=!;HwO@lkT&3;z@*3L_1Q9y^uKROLQm1|T3pun7{8*&nO!S{>xBU#5 zUygc*0~5YA>V}%h6oo5yAq1onD`ob733*V5A2iS^0{8ibPkUnim|eW+k}u~%YMoMfirQ_?#)ex^-LVO3y-FI0DtV71VJ}%F zi6_4KIiyu@0I{mfRLpnGODO2Nk=Eh?(r2~aK!<7)&b-z3JE<-_KLJdytB|%a*xKU5 z%uz1%NZ6a0DJFKKBx7gjgt7D3H=Zm z`@LqoNrtlGfOsy+*=S=tC;dA3GM+`9yIV1toL!^+#koxudD7vF*k#F2Ve&w5%F8i$ zp3&$Dx?^+Am$TMsrMA$R576kV5@Z=}K1rLRoeWyS(OnbsW2PchvUv}F(vGr;QGf+? zB!$XHy(wyUaJ4-g^o?+sOkASJ8thxMk5V^Og&V`Q5k&yvfrA?t4vpT9I5h3O9(3Qn z;+H~uNLBzBGK|TVBuqFB1Oi7xPrbPvu?G@9Q`uPMcAKZ1hInByPejjvedlfsfOV1X zz%xW~h;(dj@iBg~o$z%dlG*hd=NxYDTBecY#w3C;<%xJcn#@i(f_{bsd85#@mf~f} zYYo!swNW@kuoin02XvphA5o4dW}@EczRI&j5OTLwAt#}jq*>+@_8x$mBvEUPyZ=5c zay`6d;Tn@v5DMGY-tKwfHHi~#yA!}^<6=2s$Sa<}fkVU378h$mI$!FFA^JM9H+rZd zp8Lg8?u*6-3CuPc{kKHZ5pI-UoR1*%lcYB+UbcH&3O7MBM7ax1rbqMEGH^S?Y8_=MwTNYxxNwTlV{g9A1S_2 zLyl7B1@~IOzWoWKC(ANZ8>tLo8J5_R39pt4uT&*CyIaxeZ$X@`uEnOc95D9)_hdR4 zc}WeAqsIUkcU8X>4^T$jSm*3{5p`oJgfSpVBB6(G8A&1h!EkQTs=~hDyMA^!8Ycjc=;9g&v)`Eq}q_cpj zI+xV+uVb~*I7AnnGv|#NT9^%S;H9jE(9*Eh!{e>%DSX2tXV7IknS=#f+Ot=NIc=Nx zkdj;%l2NX_7!6``4;Qxwq&?sTF(Oh7w2^28VEvJ7abEk=!l6>I@R|(<3OPWh_=jmN zV9U`Dry(9fep&Q@Er|NU$xUFV(9w7JAJJga0*aLwQs`rL3)Ibb>0Z zWp}hB7tJ!&%Rqk%5B*62$V$GS zhtPJ27C{pAJy=O6M0ia;X0!HJl*=6&R0k2OgwFMzi_ zjr-dTZpq>R2~>~nMMW6(01ir!l-c95D)EQ_49Q%Jj!1Qp%iRncP)G<3~CT4kC1 zImp~QmAjh^RZ`w=@I=JzU~gXjx@jE-_WxdCHz%6$DB_Zw^E4cLz$J-E-Rgo6+fy}v z_dva4H4n_{dPZ{2o4yPj(Z3zQS@1aDSXLKoR-Ljl_|%dq{rnj$68R~$4oL$_FW)`> z23be5IAW!&kB; ziH~2zP75f1_p=s!3%00;Xc*kTR$C_Mg#Kwnu>?bRE~AuRW&h2(@K1rnUBl#c>b*ElBW%?!({vXMCl7S)Ta!>|3t#EcK5BH#_Y&U798_`{=k zc{{kd0y~HJWeb2lB;Q~Yss(*K7ko-^$9sj8Wh`)S9i@`Je$s+nwy!}11l&9^xTgW* z*WCRsJ}yL+DRn8k%Kc4f+K?Z`ig+W{XQT)R!HZCmm|7~$dC}6(f!5X7C{ed=Ol(vR zt`!dj`*wKfIcdO2Y;Fce&jNy203{tAj@=LEL~>uwE7-PO#T!ef2XG*Qct1~*1b^+& z{4NkI;cbrJ*`PkAQt8tmW^(ZETtZZjP*oYu%vgm3(QcOMdcw&_u~g}s%?`oB z_c3qTolDanb`|v_^u5T=;Y)Z$q)o-}oloM3XL}bqWrA`bNW9HIl11S}q)o-n1ECO> z|GHf*j_MQhIZnA&{aOOieIvC64oz{csy+3Q454lt06yw(R&@hNfsrxmaFp!|kWnD; zk0E|bkYa0`z3QfD5&IgX!<5>ALwD`va`36*FTzantZ|e|AXPby;DZZMpjRl@-=RJrh zb*jQ`eoM5&%=6snrw*`Jhs|@7dw{_u^%N_!xLZYCey;3Y?pF}np^xZ~!4-tf>gCTE zmc-+-qSql>MVHG`H~_Az9NxnkwnY@XH98arTSA2t1pM@B!Lbe-#&Py3X6g%sfkz#b%-Vkcq1w^m%{*&$R|`>mkOCx?T}v#rPd{?3Boaq?%%?+X z9^86qGA}{AhTlRSN5Ms^h#9@^JyIOOg=kO#BiwGw=gwh6?L>;dLE#VDt#xdVxO40Fwme$xiNi| zeQdWJ1XvjO9Uh_mA2p2AIvNl%>d-vnp&F|4C|LlJz?q@nW83OW<+FN^L%Pk{kEnS^ z?&-DDh)Ypo`K3$^s54S!-w04ln3>~(`3zxD-|}Cc7kxtb=?_Bd> zenj`gjwrIzUkfXeISI(hpfP?_Pg(4w8kV9hZ0ka=v+Y6|>jCVYjKw!9!j--IHg#@o zhs?rlh=)E7pE<~I&uK%tO|g^W*Onn4&&kDL-wYcEzaT|~u6jA8Yq^J_AYa0n$%yK2iU_{v;TGN7gioOmA-*5VI_Bmyk)&6=nZi3{lNPCWk=3&%gy!kpUz5mE9{^F}Or+GjpOBkOXai$Ez-z{bS7u61Mz5Bff*kv}6} z65n8-KBQ6Fj3`#X#SgXw88Kh+Bi!iyRui`faKkv0OgATZ2^e{PjF}Q&%s8X*Rv-sD2MJ1hGWUQwOP2sJ5du$6)IA?fD2-%E@t{_SSZU?g{KJ!&oXJI4wWs@+ zXV-9SMOx$*gg#@un^F-^NsZlF#EYZU{gf)|>3LDDaeV`#N&q+>H%q3(b_3Hrm4i1& zKqbdNw5}@$)Vd(mmy=Xt`yo^)&h`Ui@%}{%ghm@mKVeSq0!bm*Gvp%&dTvL9N>n%z%W75?Jar&EePmYCimE z1(?*&V0;cL_i{wY+SgEm8rqC2X$Zak;9V>{rB!mJA<|q?+6*eQKZOl+(DziI5~CEg zGekER!>s&FL_fsdTmx=PY5t|51rAbo04GB8ujz!KjF*nyXF1TLFy;1;CR^J=LDa<6 z&qFr^Ses&|vMAM8tq^Ip<=s=LGEO{*$R<~Epbwx+B!|#HpbOS0xgr^bvBR1IAP#w}l{y_Asxx|O3Euv0941zv@^?#2~*&ha&P+5FyyCG4@ z=}v1IN^5~KM>?aipf;2P9nyeP^w^YTFx3})w&=g+r0YTf4+1!)`L`T?i0T4taq$t( zNxRghQn_Wish%5&zp2y6xmuc??xw(BgQK{v(Loux%QqcqlBqO95Q!OlO5PHtv5k8} zn-@aK@{k{c(AYQ_AA~^fOkZ_`U{;qk7v=Y$hy|9CP;yU002M~MrAud7G6eZDijJDb zGDVya(J|9(tS!tYiU&i;9_sWp^>LHIz&xc%LlIv@j1K>hxLoEKHTMU?t(LUpGA?EM z8K)O_A>v|{X+;^myCUtGJxC$;t-o9fJX&|@&{qa_O+*P zpU=oFbzL>sudYU4=GUGGKCZ8gNlq|G&J>r_v#92B9QqJy8+AFLaw#!X2Gp1?1@8iN zXJd?7c^#vX`;SYJzdJrGJEXgT<5UCU{J6epd0R<*m6@H?UXY!xQL3S)U*)`FehQ`_ z@89kAqbx+Il*-xk94{95k)eQE-HMhHqa^J2Gb_>{??U#_&uLYZ?ShC^{FtEboJtQD z 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.10,<7 chardet>=5,<8 -pyinstaller diff --git a/settings.json b/settings.json deleted file mode 100644 index c9697c2..0000000 --- a/settings.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "auto_pause": false, - "inherit_listener": false, - "inherit_target": false, - "only_uncoded": false, - "hotkeys": { - "play_pause": "Space", - "next_uncoded": "N", - "prev_subtitle": "Up", - "next_subtitle": "Down", - "seek_back": "Left", - "seek_forward": "Right", - "role_speakers": "Ctrl+1", - "role_listeners": "Ctrl+2", - "role_targets": "Ctrl+3", - "cycle_role": "Ctrl+Q", - "focus_search": "Ctrl+F", - "shortcuts_help": "F1" - } -} \ No newline at end of file diff --git a/src/ui/char_dialog.py b/src/ui/char_dialog.py deleted file mode 100644 index 1213257..0000000 --- a/src/ui/char_dialog.py +++ /dev/null @@ -1,60 +0,0 @@ -from PySide6.QtWidgets import ( - QDialog, QVBoxLayout, QHBoxLayout, QTableWidget, - QTableWidgetItem, QPushButton, QDialogButtonBox, QHeaderView -) - -class ManageCharactersDialog(QDialog): - def __init__(self, characters, parent=None): - super().__init__(parent) - self.setWindowTitle("Manage Characters & Shortcuts") - self.resize(300, 400) - self.characters = characters.copy() - - self.layout = QVBoxLayout(self) - self.table = QTableWidget(len(self.characters), 2) - self.table.setHorizontalHeaderLabels(["Name", "Shortcut (e.g. 1, Q)"]) - self.table.horizontalHeader().setSectionResizeMode(QHeaderView.Stretch) - - for row, char in enumerate(self.characters): - self.table.setItem(row, 0, QTableWidgetItem(char['name'])) - self.table.setItem(row, 1, QTableWidgetItem(char.get('key', ''))) - - self.layout.addWidget(self.table) - - btn_layout = QHBoxLayout() - self.add_btn = QPushButton("Add New Row") - self.add_btn.clicked.connect(self.add_row) - self.del_btn = QPushButton("Delete Selected Row") - self.del_btn.clicked.connect(self.delete_row) - btn_layout.addWidget(self.add_btn) - btn_layout.addWidget(self.del_btn) - self.layout.addLayout(btn_layout) - - self.buttons = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel, self) - self.buttons.accepted.connect(self.accept) - self.buttons.rejected.connect(self.reject) - self.layout.addWidget(self.buttons) - - def add_row(self): - row = self.table.rowCount() - self.table.insertRow(row) - self.table.setItem(row, 0, QTableWidgetItem("")) - self.table.setItem(row, 1, QTableWidgetItem("")) - - def delete_row(self): - row = self.table.currentRow() - if row >= 0: - self.table.removeRow(row) - - def get_data(self): - data = [] - for row in range(self.table.rowCount()): - name_item = self.table.item(row, 0) - key_item = self.table.item(row, 1) - name = name_item.text().strip() if name_item else "" - key = key_item.text().strip().upper() if key_item else "" - if len(key) > 1: - key = key[0] - if name: - data.append({'name': name, 'key': key if key else None}) - return data From 92385a62c39e9793a44ff023b6a43b68c06f5337 Mon Sep 17 00:00:00 2001 From: angushushu Date: Sun, 27 Sep 2026 16:16:48 -0400 Subject: [PATCH 5/5] fix: offer an autosave only for the project it belongs to Copying or moving a project folder carries its hidden autosave along, and that file still names the original project. Restoring it in the copy set the working project to the original's path, so Save could overwrite a different project. An autosave is now trusted only when its recorded owner's autosave location is that very file; otherwise, or if it is malformed, no restore is offered, and opening it directly continues as an unsaved session. Reported by Copilot review on #1. Co-Authored-By: Claude Opus 5.5 --- src/core/project.py | 22 +++++++++++++++++ src/ui/annotator_window.py | 18 +++++++++++--- tests/test_annotator_window.py | 44 ++++++++++++++++++++++++++++++++++ tests/test_project.py | 33 +++++++++++++++++++++++++ 4 files changed, 114 insertions(+), 3 deletions(-) diff --git a/src/core/project.py b/src/core/project.py index b5b35b0..c432104 100644 --- a/src/core/project.py +++ b/src/core/project.py @@ -20,6 +20,28 @@ def autosave_path_for(vat_file, srt_file): return str(anchor.parent / f".{anchor.name}.autosave.vat") +def _same_path(a, b): + return os.path.normcase(os.path.abspath(a)) == os.path.normcase(os.path.abspath(b)) + + +def recovery_target(autosave_file, project_data, srt_file): + """Project a recovered autosave should be saved to, or None if it cannot be trusted. + + Returns the recorded project path ("" for a session never saved to a project) only + when that project's autosave location is this very file. An autosave carried along + by copying or moving a project folder still names the original project, and saving + the recovered work there would overwrite a different project. + """ + if not isinstance(project_data, dict): + return None + owner = project_data.get(AUTOSAVE_OF_KEY) + if not isinstance(owner, str): + return None + if not _same_path(autosave_path_for(owner, srt_file), autosave_file): + return None + return owner + + def write_project_file(file_path, project_data): """Write JSON atomically; on any failure the previous file is left untouched. diff --git a/src/ui/annotator_window.py b/src/ui/annotator_window.py index 4b15968..1dff359 100644 --- a/src/ui/annotator_window.py +++ b/src/ui/annotator_window.py @@ -17,7 +17,7 @@ from src.core.characters import get_base_dir, read_characters, save_characters, validate_character_name from src.core.csv_utils import write_rows_to_csv_atomic from src.core.project import ( - AUTOSAVE_OF_KEY, autosave_is_recoverable, autosave_path_for, read_project_file, + AUTOSAVE_OF_KEY, autosave_is_recoverable, autosave_path_for, read_project_file, recovery_target, remove_file_quietly, write_project_file, ) @@ -120,8 +120,12 @@ def __init__(self, video_file, srt_file, char_file, vat_file=""): if AUTOSAVE_OF_KEY in project_data: # Recovered from an autosave: keep working on the project it belongs - # to (or an unsaved session), never on the hidden autosave file. - self.vat_file = project_data[AUTOSAVE_OF_KEY] + # to, never on the hidden autosave file. If its recorded owner does + # not match where it lives (a copied folder), continue as an unsaved + # session so Save asks for a location instead of overwriting another + # project. + target = recovery_target(self.vat_file, project_data, srt_file) + self.vat_file = target or "" self.is_dirty = True self._recovered_from_autosave = True except Exception as e: @@ -1326,6 +1330,14 @@ def try_restore_autosave(self): path = self.autosave_path if not autosave_is_recoverable(path, self.vat_file): return + # Offer only an autosave recorded for this very project (or unsaved session); + # one carried in by copying a project folder names the original project. + try: + owner = recovery_target(path, read_project_file(path), self.srt_file) + except (OSError, ValueError): + owner = None # unreadable or malformed: nothing trustworthy to restore + if owner is None: + return reply = QMessageBox.question( self, "Restore Autosave", diff --git a/tests/test_annotator_window.py b/tests/test_annotator_window.py index e713819..e8c8d6f 100644 --- a/tests/test_annotator_window.py +++ b/tests/test_annotator_window.py @@ -5,6 +5,7 @@ """ import json import os +import shutil import pytest @@ -183,3 +184,46 @@ def test_character_names_with_commas_are_rejected(qapp, answers, media): assert [c["name"] for c in window.characters] == names_before assert any(kind == "warning" for kind, _ in answers["shown"]) _close(window) + + +def test_autosave_copied_from_another_project_is_not_offered(qapp, answers, media, monkeypatch): + original = _saved_project(media) + window = _open(original) + _code_current_line(window) + window.autosave_annotations() + window.is_dirty = False + window.deleteLater() + # Copy the project folder, hidden autosave included, then open the copy. + copy_dir = media.parent / "copied_project" + shutil.copytree(media, copy_dir) + copy_vat = copy_dir / "study.vat" + os.utime(copy_vat, (1000, 1000)) + offered = [] + answers["question"] = QMessageBox.Yes + monkeypatch.setattr(QMessageBox, "question", staticmethod(lambda *a, **k: offered.append(a[2]) or QMessageBox.Yes)) + + copied = _open(copy_vat) + requested = [] + copied.request_open_project.connect(requested.append) + copied.try_restore_autosave() + + assert offered == [] and requested == [] + copied.is_dirty = False + _close(copied) + + +def test_opening_a_mismatched_autosave_never_targets_the_other_project(qapp, answers, media): + original = _saved_project(media) + window = _open(original) + _code_current_line(window) + window.autosave_annotations() + window.is_dirty = False + window.deleteLater() + copy_dir = media.parent / "copied_project2" + shutil.copytree(media, copy_dir) + + recovered = _open(copy_dir / ".study.vat.autosave.vat") + + assert recovered.vat_file == "" # unsaved: Save asks where, instead of writing the original + recovered.is_dirty = False + _close(recovered) diff --git a/tests/test_project.py b/tests/test_project.py index bd4dbf4..d1b2d00 100644 --- a/tests/test_project.py +++ b/tests/test_project.py @@ -69,3 +69,36 @@ def test_remove_file_quietly(tmp_path): project.remove_file_quietly(str(path)) assert not path.exists() + + +def test_recovery_target_accepts_matching_metadata(tmp_path): + vat = tmp_path / "study.vat" + srt = tmp_path / "ep1.srt" + autosave = project.autosave_path_for(str(vat), str(srt)) + + assert project.recovery_target(autosave, {project.AUTOSAVE_OF_KEY: str(vat)}, str(srt)) == str(vat) + + +def test_recovery_target_accepts_an_unsaved_session(tmp_path): + srt = tmp_path / "ep1.srt" + autosave = project.autosave_path_for("", str(srt)) + + assert project.recovery_target(autosave, {project.AUTOSAVE_OF_KEY: ""}, str(srt)) == "" + + +def test_recovery_target_rejects_an_autosave_pointing_at_another_project(tmp_path): + # A copied project folder carries an autosave whose metadata still names the + # original project; saving there would overwrite the wrong project. + original = tmp_path / "original" / "study.vat" + copy = tmp_path / "copy" / "study.vat" + srt = tmp_path / "copy" / "ep1.srt" + autosave_in_copy = project.autosave_path_for(str(copy), str(srt)) + + assert project.recovery_target(autosave_in_copy, {project.AUTOSAVE_OF_KEY: str(original)}, str(srt)) is None + + +@pytest.mark.parametrize("data", [{}, {project.AUTOSAVE_OF_KEY: None}, {project.AUTOSAVE_OF_KEY: 3}, []]) +def test_recovery_target_rejects_malformed_metadata(tmp_path, data): + srt = tmp_path / "ep1.srt" + + assert project.recovery_target(project.autosave_path_for("", str(srt)), data, str(srt)) is None