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
11 changes: 7 additions & 4 deletions src/core/source_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -244,18 +244,21 @@ def search_sources(self, query: str) -> List[LitSource]:

for source in self.get_all_sources():
# Titel
if query in source.meta.title.lower():
# Bugsweep (2026-06-23, Hardening): title/authors/tags defensiv gegen None
# absichern. Ueber den Load-Pfad sind sie zwar nie None (from_dict defaultet),
# aber programmatisch erzeugte LiMeta(title=None) wuerden hier crashen.
if query in (source.meta.title or "").lower():
results.append(source)
continue

# Autoren
for author in source.meta.authors:
for author in (source.meta.authors or []):
if query in author.lower():
results.append(source)
break
else:
# Tags
for tag in source.meta.tags:
for tag in (source.meta.tags or []):
if query in tag.lower():
results.append(source)
break
Expand Down
44 changes: 44 additions & 0 deletions src/formats/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,50 @@ def load(cls: Type[T], path: Path) -> T:
raise LitFormatError(f"Datei konnte nicht interpretiert werden: {path}: {e}")


def to_optional_year(value: Any) -> Optional[int]:
"""Konvertiert Jahresangaben sicher zu int oder None.

Erweitert to_optional_int um Float-String-Unterstützung ("2023.0" -> 2023),
die bei manuell bearbeiteten oder aus Tabellentools importierten JSON-Dateien
auftreten kann.

Disambiguierungs-Suffixe ("2023a") liefern None — kein verifizierter Bedarf;
der CAVEAT in AUFGABEN.txt bleibt aktiv bis ein konkreter Einsatz gemeldet wird.
Bekannte "kein Datum"-Marker ("n.d.", "o.J." usw.) liefern ebenfalls None.
"""
if value is None or value == "":
return None
# Direkte int-Konvertierung (native int / float wie 2023.0)
try:
return int(value)
except (TypeError, ValueError):
pass
# Float-String: "2023.0" -> 2023
try:
return int(float(str(value).strip()))
except (TypeError, ValueError):
pass
# Alle anderen Strings (inkl. "n.d.", "o.J.", "2023a") -> None
return None


def to_optional_int(value: Any) -> Optional[int]:
"""Konvertiert einen Wert sicher zu int oder None.

Bugsweep (2026-06-23): Integer-Felder (z.B. page, page_end, year) wurden in
from_dict() ungeprueft uebernommen. Eine alte/handeditierte/importierte Datei
mit "page": "10" (String) liess from_dict sauber durchlaufen, crashte aber
spaeter bei Seitenvergleichen ("10" <= 7 -> TypeError). Hier wird robust
konvertiert.
"""
if value is None or value == "":
return None
try:
return int(value)
except (TypeError, ValueError):
return None


def generate_id(prefix: str = "") -> str:
"""Generates a unique timestamp-based ID with an optional prefix."""
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S_%f")
Expand Down
4 changes: 2 additions & 2 deletions src/formats/limeta.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
from pathlib import Path
from typing import List, Optional

from .base import LitFormat, now_iso
from .base import LitFormat, now_iso, to_optional_int, to_optional_year


@dataclass
Expand Down Expand Up @@ -78,7 +78,7 @@ def from_dict(cls, data: dict) -> "LiMeta":
return cls(
title=data.get("title") or "Untitled",
authors=data.get("authors") or [],
year=data.get("year"),
year=to_optional_year(data.get("year")),
doi=data.get("doi"),
isbn=data.get("isbn"),
publisher=data.get("publisher"),
Expand Down
6 changes: 3 additions & 3 deletions src/formats/liquote.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
from dataclasses import dataclass, field
from typing import List, Optional

from .base import LitFormat, generate_id, now_iso
from .base import LitFormat, generate_id, now_iso, to_optional_int


@dataclass
Expand Down Expand Up @@ -40,8 +40,8 @@ def from_dict(cls, data: dict) -> "Quote":
id=data.get("id", generate_id("q_")),
type=data.get("type", "direct"),
text=data.get("text", ""),
page=data.get("page"),
page_end=data.get("page_end"),
page=to_optional_int(data.get("page")),
page_end=to_optional_int(data.get("page_end")),
comment=data.get("comment"),
tags=data.get("tags") or [],
used_in=data.get("used_in") or [],
Expand Down
41 changes: 31 additions & 10 deletions src/modules/bibliography/bibtex.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,30 @@
from formats import LiMeta


def escape_bibtex(value: str) -> str:
"""Escaped LaTeX/BibTeX-Sonderzeichen in Freitextfeldern.

Bugsweep (2026-06-23): Feldwerte wurden ungeescaped in ``{...}`` eingefügt.
Ein Titel wie ``AT&T Research`` oder ``cost_analysis`` (auch ``10% Anteil``)
erzeugte unkompilierbares BibTeX. Der Backslash wird über einen Sentinel
geschützt, damit die nachfolgende Klammer-Escapierung das eingefügte
``\\textbackslash{}`` nicht erneut verändert.
"""
if not value:
return value
sentinel = "\x00"
value = value.replace("\\", sentinel)
for ch in ("&", "%", "$", "#", "_", "{", "}"):
value = value.replace(ch, "\\" + ch)
value = value.replace("~", r"\textasciitilde{}")
value = value.replace("^", r"\textasciicircum{}")
value = value.replace(sentinel, r"\textbackslash{}")
return value


class BibTeXGenerator:
"""Generiert BibTeX-Einträge"""

TYPE_MAP = {
"article": "article",
"book": "book",
Expand All @@ -25,26 +46,26 @@ class BibTeXGenerator:
def generate_entry(self, meta: LiMeta) -> str:
"""Generiert einen BibTeX-Eintrag"""
entry_type = self.TYPE_MAP.get(meta.source_type, "misc")
key = meta.citation_key
key = meta.bibtex_key

lines = [f"@{entry_type}{{{key},"]

# Titel
if meta.title:
lines.append(f' title = {{{meta.title}}},')
lines.append(f' title = {{{escape_bibtex(meta.title)}}},')

# Autoren
if meta.authors:
authors = " and ".join(meta.authors)
authors = " and ".join(escape_bibtex(a) for a in meta.authors)
lines.append(f' author = {{{authors}}},')

# Jahr
if meta.year:
lines.append(f' year = {{{meta.year}}},')

# Journal (für Artikel)
if meta.journal:
lines.append(f' journal = {{{meta.journal}}},')
lines.append(f' journal = {{{escape_bibtex(meta.journal)}}},')

# Volume
if meta.volume:
Expand All @@ -60,7 +81,7 @@ def generate_entry(self, meta: LiMeta) -> str:

# Verlag
if meta.publisher:
lines.append(f' publisher = {{{meta.publisher}}},')
lines.append(f' publisher = {{{escape_bibtex(meta.publisher)}}},')

# DOI
if meta.doi:
Expand All @@ -76,7 +97,7 @@ def generate_entry(self, meta: LiMeta) -> str:

# Abstract
if meta.abstract:
abstract = meta.abstract.replace("\n", " ")
abstract = escape_bibtex(meta.abstract.replace("\n", " "))
lines.append(f' abstract = {{{abstract}}},')

# Schließende Klammer
Expand Down
45 changes: 23 additions & 22 deletions src/modules/bibliography/bibtex_generator.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
from datetime import datetime

from formats import LiMeta
from modules.bibliography.bibtex import escape_bibtex


class BibTeXGenerator:
Expand Down Expand Up @@ -34,66 +35,66 @@ def add_entry(self, meta: LiMeta) -> str:
def _generate_entry(self, meta: LiMeta) -> str:
"""Generiert einen einzelnen BibTeX-Eintrag"""
bib_type = self.TYPE_MAP.get(meta.source_type, "misc")
key = meta.citation_key
key = meta.bibtex_key

lines = [f"@{bib_type}{{{key},"]

# Titel
if meta.title:
lines.append(f" title = {{{meta.title}}},")
lines.append(f" title = {{{escape_bibtex(meta.title)}}},")

# Autoren
if meta.authors:
authors_str = " and ".join(meta.authors)
authors_str = " and ".join(escape_bibtex(a) for a in meta.authors)
lines.append(f" author = {{{authors_str}}},")

# Jahr
if meta.year:
lines.append(f" year = {{{meta.year}}},")

# Journal/Booktitle
if meta.journal:
if bib_type == "article":
lines.append(f" journal = {{{meta.journal}}},")
lines.append(f" journal = {{{escape_bibtex(meta.journal)}}},")
else:
lines.append(f" booktitle = {{{meta.journal}}},")
lines.append(f" booktitle = {{{escape_bibtex(meta.journal)}}},")

# Volume
if meta.volume:
lines.append(f" volume = {{{meta.volume}}},")

# Number/Issue
if meta.issue:
lines.append(f" number = {{{meta.issue}}},")

# Pages
if meta.pages:
lines.append(f" pages = {{{meta.pages}}},")

# Publisher
if meta.publisher:
lines.append(f" publisher = {{{meta.publisher}}},")
# DOI
lines.append(f" publisher = {{{escape_bibtex(meta.publisher)}}},")

# DOI (verbatim — Escaping wuerde \url/hyperref brechen)
if meta.doi:
lines.append(f" doi = {{{meta.doi}}},")

# ISBN
if meta.isbn:
lines.append(f" isbn = {{{meta.isbn}}},")
# URL

# URL (verbatim — Escaping wuerde \url/hyperref brechen)
if meta.url:
lines.append(f" url = {{{meta.url}}},")

# Abstract
if meta.abstract:
abstract = meta.abstract.replace("\n", " ")
abstract = escape_bibtex(meta.abstract.replace("\n", " "))
lines.append(f" abstract = {{{abstract}}},")

# Keywords/Tags
if meta.tags:
keywords = ", ".join(meta.tags)
keywords = escape_bibtex(", ".join(meta.tags))
lines.append(f" keywords = {{{keywords}}},")

lines.append("}")
Expand Down
74 changes: 56 additions & 18 deletions src/modules/sync/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -165,7 +165,7 @@ def create_backup(self, name: str = None) -> Optional[Path]:
)
return backup_path
except Exception as e:
print(f"Backup-Fehler: {e}")
logging.error(f"Backup-Fehler: {e}")
return None

def list_backups(self) -> List[Path]:
Expand All @@ -184,27 +184,65 @@ def list_backups(self) -> List[Path]:
return backups

def restore_backup(self, backup_path: Path) -> bool:
"""Stellt Backup wieder her"""
"""Stellt Backup wieder her.

Bugsweep (2026-06-23): Vorher wurde das Live-Projekt ZUERST umbenannt und
dann das Backup per copytree hineinkopiert. Schlug copytree fehl (Disk voll,
OneDrive-Lock, Permissions), war das Live-Projekt weg und es gab kein Rollback.
Jetzt: erst in einen Staging-Ordner kopieren (der fehleranfaellige Schritt),
und nur bei Erfolg in zwei nahezu atomaren Renames austauschen. Bei einem
Fehler bleibt das Live-Projekt unangetastet bzw. wird zurueckgerollt.
"""
if not backup_path.exists():
return False


parent = self.project_path.parent
name = self.project_path.name

# Liegengebliebene Sicherungen frueherer, fehlgeschlagener Restores sind unter
# Umstaenden die EINZIGE Kopie des Projekts: nie loeschen, nie ueberschreiben.
leftovers = sorted(parent.glob(f"{name}_before_restore*"))
if not self.project_path.exists() and leftovers:
logging.error(
"Restore abgebrochen: Projektordner fehlt, aber es gibt Reste eines "
f"frueheren Restores ({', '.join(p.name for p in leftovers)}). "
"Bitte zuerst manuell wiederherstellen."
)
return False

staging = parent / f"{name}_restore_staging"
stamp = datetime.now().strftime("%Y%m%d-%H%M%S")
old_tmp = parent / f"{name}_before_restore_{stamp}"
counter = 1
while old_tmp.exists():
counter += 1
old_tmp = parent / f"{name}_before_restore_{stamp}-{counter}"

# Schritt 1: Backup in Staging kopieren (Live-Projekt bleibt unberuehrt).
try:
if staging.exists():
shutil.rmtree(staging)
shutil.copytree(backup_path, staging)
except (OSError, shutil.Error) as e:
logging.error(f"Restore-Fehler (Staging-Kopie): {e}")
shutil.rmtree(staging, ignore_errors=True)
return False

# Schritt 2: Live-Projekt gegen Staging austauschen (zwei Renames).
try:
# Aktuelles Projekt sichern
temp_backup = self.project_path.parent / f"{self.project_path.name}_before_restore"
if temp_backup.exists():
shutil.rmtree(temp_backup)

self.project_path.rename(temp_backup)

# Backup wiederherstellen
shutil.copytree(backup_path, self.project_path)

# Temporäres Backup löschen
shutil.rmtree(temp_backup)

self.project_path.rename(old_tmp)
staging.rename(self.project_path)
shutil.rmtree(old_tmp, ignore_errors=True)
return True
except Exception as e:
print(f"Restore-Fehler: {e}")
except OSError as e:
logging.error(f"Restore-Fehler (Austausch): {e}")
# Rollback: falls das Live-Projekt bereits weg ist, zuruecksetzen.
if not self.project_path.exists() and old_tmp.exists():
try:
old_tmp.rename(self.project_path)
except OSError as rollback_err:
logging.error(f"Restore-Rollback fehlgeschlagen: {rollback_err}")
shutil.rmtree(staging, ignore_errors=True)
return False

def delete_backup(self, backup_path: Path) -> bool:
Expand Down
2 changes: 1 addition & 1 deletion start.bat
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ REM Wechsle zum Projektverzeichnis
cd /d "%~dp0"

REM Prüfe ob Abhängigkeiten installiert sind
python -c "import PyQt6" >nul 2>&1
python -c "import PySide6" >nul 2>&1
if errorlevel 1 (
echo [INFO] Installiere Abhängigkeiten...
pip install -r requirements.txt
Expand Down
Loading
Loading