Skip to content
Open
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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,10 @@ 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 zurück (Dateien mit gemischten Zeilenenden werden auf das häufigere vereinheitlicht; ein UTF-8-BOM bleibt wie bisher nicht erhalten). 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.
- Sichere Literalersetzung in `SearchReplaceBar.replace_all`: Case-insensitive Textersetzung verwendet jetzt einen Callable-Ersatz (`lambda _m: replace_text`) in `re.sub()`. Dies verhindert Abstürze mit `re.error: bad escape \u` bei Pfadangaben mit Backslashes (z. B. Windows-Pfade wie `C:\Users\...`) sowie Fehlinterpretationen von Regex-Gruppenreferenzen und stellt identisches Literal-Verhalten wie bei case-sensitiver Ersetzung sicher.

## [1.0.1] - 2026-09-18
Expand Down
116 changes: 97 additions & 19 deletions PythonBox_v8.py
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,19 @@ def build_external_python_command(script_path: Path) -> List[str]:
)


def detect_newline(text: str) -> Optional[str]:
"""Ermittelt das vorherrschende Zeilenende eines Textes.

Gibt ``"\r\n"`` oder ``"\n"`` zurück, bzw. ``None``, wenn der Text keinen
Zeilenumbruch enthält (dann gilt beim Speichern der Plattformstandard).
"""
crlf = text.count('\r\n')
lf = text.count('\n') - crlf
if crlf == 0 and lf == 0:
return None
return '\r\n' if crlf >= lf else '\n'


def _utc_export_timestamp() -> str:
return datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z")

Expand Down Expand Up @@ -2608,6 +2621,8 @@ def __init__(self, editor: CodeEditor, highlighter: PythonSyntaxHighlighter,
self.highlighter = highlighter
self.file_path = file_path
self.is_modified = False
# Zeilenende der geöffneten Datei ("\n" oder "\r\n"); None = Plattformstandard.
self.newline: Optional[str] = None


class MultiTabEditor(QWidget):
Expand Down Expand Up @@ -2754,7 +2769,14 @@ def save_tab(self, index: int) -> bool:
tab.file_path = file_path

try:
Path(tab.file_path).write_text(tab.editor.toPlainText(), encoding='utf-8')
text = tab.editor.toPlainText()
if tab.newline:
# Zeilenenden der Originaldatei beibehalten (kein stilles LF -> CRLF).
Path(tab.file_path).write_text(
text.replace('\n', tab.newline), encoding='utf-8', newline=''
)
else:
Path(tab.file_path).write_text(text, encoding='utf-8')
tab.editor.document().setModified(False)
self.tab_widget.setTabText(index, Path(tab.file_path).name)
return True
Expand All @@ -2771,7 +2793,10 @@ def open_file(self, file_path: str, replace_current_untitled: bool = False) -> i
return idx

try:
content = Path(file_path).read_text(encoding='utf-8')
with open(file_path, 'r', encoding='utf-8', newline='') as handle:
raw = handle.read()
newline = detect_newline(raw)
content = raw.replace('\r\n', '\n').replace('\r', '\n')
if replace_current_untitled and self.tab_widget.count() == 1:
current_index = self.tab_widget.currentIndex()
current_tab = self.tabs.get(current_index)
Expand All @@ -2784,11 +2809,15 @@ def open_file(self, file_path: str, replace_current_untitled: bool = False) -> i
current_tab.editor.setPlainText(content)
current_tab.editor.document().setModified(False)
current_tab.file_path = file_path
current_tab.newline = newline
self.tab_widget.setTabText(current_index, Path(file_path).name)
self.tab_widget.setCurrentIndex(current_index)
return current_index

return self.new_tab(file_path, content)
index = self.new_tab(file_path, content)
if index in self.tabs:
self.tabs[index].newline = newline
return index
except Exception as e:
QMessageBox.critical(self, "Fehler", f"Öffnen fehlgeschlagen: {e}")
return -1
Expand Down Expand Up @@ -2913,6 +2942,31 @@ def _save_locks(self):
json.dump(list(self.locked_items), f)
os.replace(tmp, self.lock_file)

@staticmethod
def _is_safe_component(value) -> bool:
"""Prüft, ob ``value`` ein einzelner, harmloser Pfadbestandteil ist.

Themen- und Snippet-Namen stammen aus Dialogen und aus importierten
JSON-Dateien. Ohne diese Prüfung könnte ein Name wie ``../../x`` oder
ein absoluter Pfad Dateien außerhalb der Bibliothek überschreiben.
"""
if not isinstance(value, str) or not value or value != value.strip():
return False
if value in (".", "..") or value.endswith("."):
return False
return not any(ch in '/\\:' or ch < ' ' for ch in value)

def _snippet_path(self, topic, name) -> Optional[Path]:
"""Liefert den Snippet-Pfad oder ``None``, wenn er die Bibliothek verließe."""
if not (self._is_safe_component(topic) and self._is_safe_component(name)):
return None
path = self.root / topic / f"{name}.py"
try:
path.resolve().relative_to(self.root.resolve())
except (OSError, ValueError):
return None
return path

def is_locked(self, topic, name):
return f"{topic}/{name}" in self.locked_items

Expand All @@ -2934,26 +2988,49 @@ def get_items(self, topic):
return sorted([f.stem for f in path.glob("*.py")])

def get_content(self, topic, name):
path = self.root / topic / f"{name}.py"
if path.exists(): return path.read_text(encoding='utf-8')
path = self._snippet_path(topic, name)
if path is not None and path.exists(): return path.read_text(encoding='utf-8')
return ""

def save_snippet(self, topic, name, content):
if self.is_locked(topic, name):
return False, "Eintrag ist gesperrt."
path = self._snippet_path(topic, name)
if path is None:
return False, "Ungültiger Themen- oder Snippet-Name."
try:
topic_dir = self.root / topic
topic_dir.mkdir(exist_ok=True)
(topic_dir / f"{name}.py").write_text(content, encoding='utf-8')
path.parent.mkdir(exist_ok=True)
path.write_text(content, encoding='utf-8')
return True, "Gespeichert."
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
path = self.root / topic / f"{name}.py"
if path.exists():
path = self._snippet_path(topic, name)
if path is not None and path.exists():
os.remove(path)
key = f"{topic}/{name}"
if key in self.locked_items:
Expand Down Expand Up @@ -3439,12 +3516,11 @@ def on_editor_changed(self, editor: CodeEditor):
"""Wird aufgerufen wenn der aktive Editor wechselt"""
if editor:
self.search_bar.set_editor(editor)
# Disconnect first to avoid duplicate connections when switching back
try:
editor.cursorPositionInfo.disconnect(self.update_cursor_position)
except RuntimeError:
pass
editor.cursorPositionInfo.connect(self.update_cursor_position)
# UniqueConnection verhindert Doppelverbindungen beim Zurückwechseln,
# ohne die libpyside-Warnung eines fehlgeschlagenen disconnect().
editor.cursorPositionInfo.connect(
self.update_cursor_position, Qt.ConnectionType.UniqueConnection
)

# NEU v7: Git-Status aktualisieren (nur wenn git_label existiert)
if hasattr(self, 'git_label'):
Expand Down Expand Up @@ -4142,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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
[![Python](https://img.shields.io/badge/Python-3.10+-blue.svg)](https://www.python.org/)
[![PySide6](https://img.shields.io/badge/PySide6-6.5+-green.svg)](https://pypi.org/project/PySide6/)
[![PythonBox tests](https://github.com/dev-bricks/pythonbox/actions/workflows/tests.yml/badge.svg)](https://github.com/dev-bricks/pythonbox/actions/workflows/tests.yml)
[![Tests](https://img.shields.io/badge/Tests-121%20passed%20%7C%20100%25%20green-brightgreen.svg)](tests/)
[![Tests](https://img.shields.io/badge/Tests-131%20passed%20%7C%20100%25%20green-brightgreen.svg)](tests/)
[![Security](https://img.shields.io/badge/Security-RunAsInvoker-success.svg)](SECURITY.md)
[![Zero-Egress](https://img.shields.io/badge/Zero--Egress-100%25%20Offline-success.svg)](SECURITY.md)
[![SLA](https://img.shields.io/badge/SLA-48h%20Response-blue.svg)](SECURITY.md)
Expand Down
2 changes: 1 addition & 1 deletion README_de.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
[![Python](https://img.shields.io/badge/Python-3.10+-blue.svg)](https://www.python.org/)
[![PySide6](https://img.shields.io/badge/PySide6-6.5+-green.svg)](https://pypi.org/project/PySide6/)
[![PythonBox tests](https://github.com/dev-bricks/pythonbox/actions/workflows/tests.yml/badge.svg)](https://github.com/dev-bricks/pythonbox/actions/workflows/tests.yml)
[![Tests](https://img.shields.io/badge/Tests-121%20passed%20%7C%20100%25%20green-brightgreen.svg)](tests/)
[![Tests](https://img.shields.io/badge/Tests-131%20passed%20%7C%20100%25%20green-brightgreen.svg)](tests/)
[![Security](https://img.shields.io/badge/Security-RunAsInvoker-success.svg)](SECURITY.md)
[![Zero-Egress](https://img.shields.io/badge/Zero--Egress-100%25%20Offline-success.svg)](SECURITY.md)
[![SLA](https://img.shields.io/badge/SLA-48h%20Response-blue.svg)](SECURITY.md)
Expand Down
Loading
Loading