diff --git a/CHANGELOG.md b/CHANGELOG.md index f57fe0e..97682c5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,7 @@ Format basiert auf [Keep a Changelog](https://keepachangelog.com/de/1.1.0/). - Neue automatisierte Vertragstests in `tests/test_metadata.py` zur Verifikation von NOTICE, bilingualer 18-Punkte-Parität, dualen Mermaid-Diagrammen, 20/20 Keywords und Version Freeze (`1.0.1`). ### Behoben / Fixed +- Kein Snippet-Verlust mehr beim Umbenennen: `edit_library_item` löschte den alten Eintrag, bevor der neue gespeichert war, und ignorierte das Speicherergebnis. Lehnt die Namensprüfung den neuen Namen ab (z. B. `Regex: Gruppen` oder `Kap. 1.`), war das Snippet weg. `LibraryManager.rename_snippet` speichert jetzt zuerst und entfernt den alten Eintrag erst nach Erfolg; ein Fehler wird im Dialog gemeldet. - Sicherheit: Snippet-Bibliothek akzeptiert nur noch einzelne, harmlose Pfadbestandteile als Themen- und Snippet-Namen (`LibraryManager._snippet_path`). Zuvor konnte eine importierte `pythonbox-snippets-v1`-Datei über Namen wie `../../ordner` oder absolute Pfade beliebige `.py`-Dateien außerhalb der Bibliothek überschreiben; solche Einträge werden jetzt übersprungen und als `skipped` gezählt. - Zeilenenden bleiben beim Speichern erhalten: `MultiTabEditor.open_file` merkt sich das Zeilenende der Datei (`detect_newline`), `save_tab` schreibt es unverändert zurück. Zuvor wurden LF-Dateien unter Windows beim Speichern still nach CRLF umgeschrieben, wodurch Git jede Zeile als geändert meldete. Neue, noch nie gespeicherte Dateien verwenden weiterhin den Plattformstandard. - Keine `libpyside: Failed to disconnect`-Warnung mehr bei jedem Tab-Wechsel: `on_editor_changed` verbindet `cursorPositionInfo` jetzt mit `Qt.ConnectionType.UniqueConnection` statt vorab zu trennen. diff --git a/PythonBox_v8.py b/PythonBox_v8.py index 5431dfb..11d1eb3 100644 --- a/PythonBox_v8.py +++ b/PythonBox_v8.py @@ -3005,6 +3005,27 @@ def save_snippet(self, topic, name, content): except Exception as e: return False, str(e) + def rename_snippet(self, old_topic, old_name, topic, name, content): + """Speichert unter dem neuen Namen und entfernt erst danach den alten Eintrag. + + Scheitert das Speichern (ungültiger Name, gesperrtes Ziel, Schreibfehler), + bleibt der alte Eintrag unverändert erhalten. + """ + old_path = self._snippet_path(old_topic, old_name) + ok, msg = self.save_snippet(topic, name, content) + if not ok: + return False, msg + new_path = self._snippet_path(topic, name) + same_file = False + if old_path is not None and new_path is not None and old_path.exists(): + try: + same_file = os.path.samefile(old_path, new_path) + except OSError: + same_file = False + if not same_file: + self.delete_item(old_topic, old_name) + return True, msg + def delete_item(self, topic, name): if self.is_locked(topic, name): return False @@ -4197,9 +4218,11 @@ def edit_library_item(self, item): if data['locked']: QMessageBox.warning(self, "Gesperrt", "Namen ändern bei gesperrten Items nicht erlaubt.") return - self.lib_manager.delete_item(data['topic'], data['name']) - - self.lib_manager.save_snippet(t, n, c) + ok, msg = self.lib_manager.rename_snippet(data['topic'], data['name'], t, n, c) + else: + ok, msg = self.lib_manager.save_snippet(t, n, c) + if not ok: + QMessageBox.warning(self, "Fehler", msg) self.load_library_tree() def delete_library_item(self, item): diff --git a/tests/test_pythonbox_regressions.py b/tests/test_pythonbox_regressions.py index cc739cf..99359e8 100644 --- a/tests/test_pythonbox_regressions.py +++ b/tests/test_pythonbox_regressions.py @@ -1261,5 +1261,61 @@ def test_tree_drop_has_try_block(self): "tree_drop must use try/except when reading dropped .py file content") +class LibraryRenameRegressionTests(unittest.TestCase): + """Umbenennen darf ein Snippet nie verlieren, wenn der neue Name abgelehnt wird.""" + + def _manager(self, root): + module = load_pythonbox_module() + return module.LibraryManager(root=Path(root)) + + def test_rename_to_rejected_name_keeps_old_snippet(self): + with tempfile.TemporaryDirectory() as root: + manager = self._manager(root) + self.assertTrue(manager.save_snippet("Thema", "Alpha", "x = 1\n")[0]) + for bad in ("Regex: Gruppen", "Kap. 1.", "../flucht", ""): + with self.subTest(name=bad): + ok, msg = manager.rename_snippet("Thema", "Alpha", "Thema", bad, "x = 2\n") + self.assertFalse(ok) + self.assertTrue(msg) + self.assertEqual("x = 1\n", manager.get_content("Thema", "Alpha")) + + def test_rename_to_valid_name_moves_snippet(self): + with tempfile.TemporaryDirectory() as root: + manager = self._manager(root) + manager.save_snippet("Thema", "Alpha", "x = 1\n") + ok, _msg = manager.rename_snippet("Thema", "Alpha", "Anderes", "Beta", "x = 2\n") + self.assertTrue(ok) + self.assertEqual("x = 2\n", manager.get_content("Anderes", "Beta")) + self.assertFalse((Path(root) / "Thema" / "Alpha.py").exists()) + + def test_rename_to_locked_target_keeps_both(self): + with tempfile.TemporaryDirectory() as root: + manager = self._manager(root) + manager.save_snippet("Thema", "Alpha", "a\n") + manager.save_snippet("Thema", "Beta", "b\n") + manager.toggle_lock("Thema", "Beta") + ok, _msg = manager.rename_snippet("Thema", "Alpha", "Thema", "Beta", "neu\n") + self.assertFalse(ok) + self.assertEqual("a\n", manager.get_content("Thema", "Alpha")) + self.assertEqual("b\n", manager.get_content("Thema", "Beta")) + + def test_rename_onto_same_file_does_not_delete_it(self): + with tempfile.TemporaryDirectory() as root: + manager = self._manager(root) + manager.save_snippet("Thema", "Alpha", "a\n") + with mock.patch.object(os.path, "samefile", return_value=True): + ok, _msg = manager.rename_snippet("Thema", "Alpha", "Thema", "Alpha", "neu\n") + self.assertTrue(ok) + self.assertEqual("neu\n", manager.get_content("Thema", "Alpha")) + + def test_edit_dialog_uses_rename_snippet_and_reports_failure(self): + source = SOURCE.read_text(encoding="utf-8") + start = source.index("def edit_library_item") + body = source[start:source.index("def delete_library_item", start)] + self.assertIn("rename_snippet(", body) + self.assertNotIn("delete_item(", body) + self.assertIn("QMessageBox.warning(self, \"Fehler\", msg)", body) + + if __name__ == "__main__": unittest.main()