Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
29 changes: 26 additions & 3 deletions PythonBox_v8.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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):
Expand Down
56 changes: 56 additions & 0 deletions tests/test_pythonbox_regressions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Loading