diff --git a/.beads/.gitignore b/.beads/.gitignore new file mode 100644 index 0000000..1ce0e03 --- /dev/null +++ b/.beads/.gitignore @@ -0,0 +1,82 @@ +# Dolt database (managed by Dolt, not git) +dolt/ +embeddeddolt/ +proxieddb/ + +# Runtime files +bd.sock +bd.sock.startlock +sync-state.json +last-touched +.exclusive-lock + +# Daemon runtime (lock, log, pid) +daemon.* + +# Push state (runtime, per-machine) +push-state.json + +# Lock files (various runtime locks) +*.lock + +# Credential key (encryption key for federation peer auth — never commit) +.beads-credential-key + +# Local version tracking (prevents upgrade notification spam after git ops) +.local_version + +proxied_server_client_info.json + +# Worktree redirect file (contains relative path to main repo's .beads/) +# Must not be committed as paths would be wrong in other clones +redirect + +# Sync state (local-only, per-machine) +# These files are machine-specific and should not be shared across clones +.sync.lock + +# Workspace operation gate (internal/workspacegate): physical-root gate +# files live beside the guarded root inside .beads (e.g. dolt.gate.lock) +*.gate.lock* +export-state/ +export-state.json +last_pull + +# Ephemeral store (SQLite - wisps/molecules, intentionally not versioned) +ephemeral.sqlite3 +ephemeral.sqlite3-journal +ephemeral.sqlite3-wal +ephemeral.sqlite3-shm + +# Dolt server management (auto-started by bd) +dolt-server.pid +dolt-server.log +dolt-server.lock +dolt-server.port +dolt-server.activity +dolt-server-config.yaml + +# Debug-mode pprof artifacts (written when dolt.debug: true in config.yaml) +dolt-pprof/ + +# Corrupt backup directories (created by bd doctor --fix recovery) +*.corrupt.backup/ + +# Backup data (auto-exported JSONL, local-only) +backup/ + +# Per-project environment file (Dolt connection config, GH#2520) +.env + +# Legacy files (from pre-Dolt versions) +*.db +*.db?* +*.db-journal +*.db-wal +*.db-shm +db.sqlite +bd.db +# NOTE: Do NOT add negation patterns here. +# They would override fork protection in .git/info/exclude. +# Config files (metadata.json, config.yaml) are tracked by git by default +# since no pattern above ignores them. diff --git a/.beads/README.md b/.beads/README.md new file mode 100644 index 0000000..1497043 --- /dev/null +++ b/.beads/README.md @@ -0,0 +1,11 @@ +# Beads-Kontext für userscripts + +Diese lokale Beads-Datenbank speichert Aufgaben, Abhängigkeiten und ausdrücklich hinterlegtes Projektwissen. Harness Router lädt ihren Kontext vor jedem Auftrag automatisch; eine Aufgabenauswahl ist optional. + +- Kontext prüfen: `bd prime --full --readonly` +- Verfügbare Aufgaben lesen: `bd ready --json --readonly` +- Wissen ausdrücklich hinterlegen: `bd remember "Projektwissen"` + +Projektregeln bleiben in `AGENTS.md`. Initialisierung ohne Änderungen an Regeln und Git-Hooks. Aufgaben werden vom Router weder automatisch übernommen noch geschlossen. + +Die Datenbank unter `embeddeddolt/` bleibt lokal und wird nicht durch normale Git-Commits gesichert. Versionierte Konfigurationsdateien ersetzen kein Datenbank-Backup; Datenübertragung und Sicherung erfolgen separat über Beads/Dolt. diff --git a/.beads/config.yaml b/.beads/config.yaml new file mode 100644 index 0000000..4920446 --- /dev/null +++ b/.beads/config.yaml @@ -0,0 +1,75 @@ +# Beads Configuration File +# This file configures default behavior for all bd commands in this repository +# All settings can also be set via environment variables (BD_* prefix) +# or overridden with command-line flags + +# Issue prefix for this repository (used by bd init) +# If not set, bd init will auto-detect from directory name +# Example: issue-prefix: "myproject" creates issues like "myproject-1", "myproject-2", etc. +# issue-prefix: "" + +# Use no-db mode: JSONL-only, no Dolt database +# When true, .beads/issues.jsonl is the only local store +# no-db: false + +# Enable JSON output by default +# json: false + +# Feedback title formatting for mutating commands (create/update/close/dep/edit) +# 0 = hide titles, N > 0 = truncate to N characters +# output: +# title-length: 255 + +# Default actor for audit trails (overridden by BEADS_ACTOR or --actor) +# actor: "" + +# Optional JSONL sidecar for explicit agent/tool interaction audit records. +# Issue history is always recorded in the database and is visible with +# bd history --events; this only controls .beads/interactions.jsonl. +# audit: +# enabled: false + +# Export events (audit trail) to .beads/events.jsonl on each flush/sync +# When enabled, new events are appended incrementally using a high-water mark. +# Use 'bd export --events' to trigger manually regardless of this setting. +# events-export: false + +# Multi-repo configuration (experimental - bd-307) +# Allows hydrating from multiple repositories and routing writes to the correct database +# repos: +# primary: "." # Primary repo (where this database lives) +# additional: # Additional repos to hydrate from (read-only) +# - ~/beads-planning # Personal planning repo +# - ~/work-planning # Work planning repo + +# Dolt-native backup (periodic backup for off-machine recovery) +# This is full database backup only. Cross-machine sync uses Dolt remotes. +# backup: +# enabled: false # Disable auto-backup entirely +# interval: 15m # Minimum time between auto-backups +# git-push: false # Disable git push (backup locally only) +# git-repo: "" # Separate git repo for backups (default: project repo) + +# Optional JSONL auto-export for viewers, interchange, and issue-level migration. +# Disabled by default; enable only when an integration needs fresh .beads/issues.jsonl. +# Use relative paths under .beads/ for JSONL import/export filenames. +# export: +# auto: false +# path: issues.jsonl +# interval: 60s +# git-add: false +# import: +# path: issues.jsonl + +# Integration settings (access with 'bd config get/set') +# Non-secret keys (stored in the database): +# - jira.url, jira.project +# - linear.team_id +# - github.org, github.repo +# +# Secret keys (stored in this file but prefer env vars to avoid git exposure): +# - linear.api_key → use LINEAR_API_KEY env var instead +# - github.token → use GITHUB_TOKEN env var instead + +sync: + remote: "git+https://github.com/ysamjo/userscripts.git" diff --git a/.beads/metadata.json b/.beads/metadata.json new file mode 100644 index 0000000..2117bd8 --- /dev/null +++ b/.beads/metadata.json @@ -0,0 +1,7 @@ +{ + "database": "dolt", + "backend": "dolt", + "dolt_mode": "embedded", + "dolt_database": "userscripts", + "project_id": "e17ae08b-405b-4833-9545-2a71288c4f43" +} \ No newline at end of file diff --git a/.github/workflows/suchkuerzel-transfer.yml b/.github/workflows/suchkuerzel-transfer.yml new file mode 100644 index 0000000..0154bc1 --- /dev/null +++ b/.github/workflows/suchkuerzel-transfer.yml @@ -0,0 +1,44 @@ +name: Suchkürzel-Transfer App + +on: + push: + paths: + - "browser-suchmigration/app/**" + - "browser-suchmigration/Suchkuerzel-Transfer.command" + - ".github/workflows/suchkuerzel-transfer.yml" + pull_request: + paths: + - "browser-suchmigration/app/**" + - "browser-suchmigration/Suchkuerzel-Transfer.command" + - ".github/workflows/suchkuerzel-transfer.yml" + workflow_dispatch: + +jobs: + package: + strategy: + fail-fast: false + matrix: + os: [windows-latest, macos-latest, ubuntu-latest] + runs-on: ${{ matrix.os }} + defaults: + run: + working-directory: browser-suchmigration/app + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + - name: Linux Qt-Laufzeit installieren + if: runner.os == 'Linux' + run: sudo apt-get update && sudo apt-get install -y libegl1 libgl1 libxkbcommon-x11-0 libdbus-1-3 libxcb-cursor0 libxcb-icccm4 libxcb-keysyms1 libxcb-shape0 libxcb-xinerama0 libxcb-xkb1 + - run: python -m pip install -r requirements.txt + - name: Core-Selbsttest + run: python main.py --self-test + env: + QT_QPA_PLATFORM: offscreen + - name: Plattformpaket bauen + run: pyinstaller --noconfirm --clean SuchkuerzelTransfer.spec + - uses: actions/upload-artifact@v4 + with: + name: SuchkuerzelTransfer-${{ matrix.os }} + path: browser-suchmigration/app/dist/ diff --git a/.gitignore b/.gitignore index b9286eb..5cf9ef6 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,10 @@ # Userscripts .DS_Store *.log + +# Beads / Dolt files (added by bd init) +.dolt/ +*.db +.beads-credential-key +.beads/proxieddb/ +*.gate.lock* diff --git a/browser-suchmigration/AGENTS.md b/browser-suchmigration/AGENTS.md new file mode 100644 index 0000000..fec0857 --- /dev/null +++ b/browser-suchmigration/AGENTS.md @@ -0,0 +1,26 @@ +# Browser-Suchmigration + +Lokale Werkzeuge zum Übertragen benutzerdefinierter Suchmaschinen und Suchkürzel zwischen Chromium-Profilen. Die Desktop-App in `app/` lässt Quellbrowser, Zielbrowser und bei Bedarf die Profile direkt auswählen; sie nutzt PySide6 und bindet die vorhandene Transferlogik aus `Suchkuerzel-Transfer.command` ein. Die übrigen Skripte sind bestehende macOS-Werkzeuge. + +## Grenzen + +- Browserprofil-Daten bleiben lokal. Nie ein gesamtes Profil ersetzen. +- Vor jedem Import oder Wiederherstellen in der Desktop-App Browserbeendigung prüfen, Änderungen bestätigen lassen und eine SQLite-Sicherung anlegen. +- Nicht zugängliche Browserprofilordner bei der automatischen Suche überspringen und im UI melden, damit andere Browserprofile weiter nutzbar bleiben. +- Beim Wiederherstellen nur eigene Suchkürzel ändern; Browser- und andere Profiltabellen unverändert lassen. +- Exportformat in der bestehenden Transferdatei kompatibel halten. Standardsuche nur übernehmen, wenn die Quelle eine hat; die Auswahl muss der Nutzer im Browser bestätigen. +- Keine Zugangsdaten oder Browserinhalte außerhalb der Suchmaschinendaten erfassen. + +## App-Befehle + +Im Verzeichnis `app/`: + +```bash +python -m venv .venv +python -m pip install -r requirements.txt +python main.py +python main.py --self-test +pyinstaller --noconfirm --clean SuchkuerzelTransfer.spec +``` + +Pakete für Windows, macOS und Linux jeweils auf dem Zielsystem bauen. Der GitHub-Workflow erledigt das für alle drei Systeme und führt den Kern-Selbsttest aus. diff --git a/browser-suchmigration/README.md b/browser-suchmigration/README.md new file mode 100644 index 0000000..5273115 --- /dev/null +++ b/browser-suchmigration/README.md @@ -0,0 +1,11 @@ +# Browser-Suchmigration + +Werkzeuge zum lokalen Übertragen und Sichern eigener Browser-Suchkürzel. + +- [Suchkürzel-Transfer Desktop-App](app/README.md): Windows, macOS und Linux +- `Suchkuerzel-Transfer.command`: bisheriges macOS-Einzeldateiwerkzeug +- `Sicheres-Migrationsskript/`: bisherige macOS-GUI und CLI + +Die neue Desktop-App verwendet dasselbe XML-Format wie `Suchkuerzel-Transfer.command`. Sie zeigt Änderungen vor dem Import an, erstellt vor dem Schreiben eine SQLite-Sicherung und ändert nur Suchkürzel. Änderungen an der Standardsuche müssen anschließend in den Browser-Einstellungen bestätigt werden. + +Entwicklung und plattformspezifische Paketierung sind in [app/README.md](app/README.md) beschrieben. Das CI baut unter Windows, macOS und Ubuntu. diff --git a/browser-suchmigration/Sicheres-Migrationsskript/__pycache__/sichere_suchkuerzel_migration.cpython-314.pyc b/browser-suchmigration/Sicheres-Migrationsskript/__pycache__/sichere_suchkuerzel_migration.cpython-314.pyc index 400a3ea..f5aaf3d 100644 Binary files a/browser-suchmigration/Sicheres-Migrationsskript/__pycache__/sichere_suchkuerzel_migration.cpython-314.pyc and b/browser-suchmigration/Sicheres-Migrationsskript/__pycache__/sichere_suchkuerzel_migration.cpython-314.pyc differ diff --git a/browser-suchmigration/Sicheres-Migrationsskript/__pycache__/sichere_suchkuerzel_migration_gui.cpython-314.pyc b/browser-suchmigration/Sicheres-Migrationsskript/__pycache__/sichere_suchkuerzel_migration_gui.cpython-314.pyc index 4e48f42..981f969 100644 Binary files a/browser-suchmigration/Sicheres-Migrationsskript/__pycache__/sichere_suchkuerzel_migration_gui.cpython-314.pyc and b/browser-suchmigration/Sicheres-Migrationsskript/__pycache__/sichere_suchkuerzel_migration_gui.cpython-314.pyc differ diff --git a/browser-suchmigration/Suchkuerzel-Transfer.command b/browser-suchmigration/Suchkuerzel-Transfer.command index da134c2..0951ffb 100755 --- a/browser-suchmigration/Suchkuerzel-Transfer.command +++ b/browser-suchmigration/Suchkuerzel-Transfer.command @@ -26,6 +26,7 @@ MAX_XML_SIZE = 750_000 MAX_FIREFOX_HTML_SIZE = 1_500_000 MAX_SHORTCUTS = 1_000 BACKUP_DIR = Path.home() / "Documents" / "Suchkuerzel-Backups" +DISCOVERY_WARNINGS: list[str] = [] BROWSERS = { "chrome": { "label": "Google Chrome", @@ -251,8 +252,11 @@ def with_read_db(path: Path, operation): if connection is not None: connection.close() with stable_snapshot(path) as snapshot: - with open_db(snapshot, read_only=True) as connection: + connection = open_db(snapshot, read_only=True) + try: return operation(connection) + finally: + connection.close() def custom_conditions(existing_columns: set[str]) -> list[str]: @@ -378,14 +382,22 @@ def choose_default_search(profile: dict[str, Any]) -> dict[str, str]: def discover_profiles() -> list[dict[str, Any]]: result = [] + DISCOVERY_WARNINGS.clear() for browser_key, browser in BROWSERS.items(): root = browser["root"] - if not root.is_dir(): + try: + if not root.is_dir(): + continue + candidates = sorted( + (item for item in root.iterdir() if item.is_dir() and (item / "Web Data").is_file()), + key=profile_sort_key, + ) + except PermissionError as error: + DISCOVERY_WARNINGS.append(f"{browser['label']}: kein Zugriff auf {root} ({error})") + continue + except OSError as error: + DISCOVERY_WARNINGS.append(f"{browser['label']}: Profilordner nicht lesbar: {error}") continue - candidates = sorted( - (item for item in root.iterdir() if item.is_dir() and (item / "Web Data").is_file()), - key=profile_sort_key, - ) for directory in candidates: try: count = len(read_shortcuts(directory / "Web Data")) @@ -846,11 +858,15 @@ def self_test() -> None: enforced_by_policy INTEGER DEFAULT 0, featured_by_policy INTEGER DEFAULT 0, url_hash BLOB ); """ - with sqlite3.connect(db_path) as connection: + connection = sqlite3.connect(db_path) + try: connection.executescript(schema) connection.execute( "INSERT INTO keywords (short_name,keyword,favicon_url,url,input_encodings,is_active,sync_guid) VALUES ('Test','t','','https://example.test/?q={searchTerms}','UTF-8',1,'test-guid')" ) + connection.commit() + finally: + connection.close() (profile_dir / "Preferences").write_text( json.dumps({"default_search_provider": {"guid": "test-guid"}}), encoding="utf-8" ) diff --git a/browser-suchmigration/app/.gitignore b/browser-suchmigration/app/.gitignore new file mode 100644 index 0000000..9b22b07 --- /dev/null +++ b/browser-suchmigration/app/.gitignore @@ -0,0 +1,5 @@ +.venv/ +build/ +dist/ +__pycache__/ +*.py[cod] diff --git a/browser-suchmigration/app/README.md b/browser-suchmigration/app/README.md new file mode 100644 index 0000000..d3ef025 --- /dev/null +++ b/browser-suchmigration/app/README.md @@ -0,0 +1,68 @@ +# Suchkürzel-Transfer + +Desktop-App zum Sichern und Übertragen eigener Suchkürzel in Chromium-Browsern. Die Daten bleiben auf dem Rechner. Die Oberfläche und Profil-Erkennung laufen unter Windows, macOS und Linux. + +## Unterstützte Browser + +- Google Chrome, Brave, Microsoft Edge, Vivaldi und Chromium auf Windows, macOS und Linux +- Ego Lite auf macOS +- Firefox erhält Suchkürzel über eine exportierte Lesezeichen-HTML-Datei. Ein direkter Firefox-Datenbankimport wird nicht vorgenommen. + +Die App erkennt übliche Profilordner automatisch. Über „Profilordner manuell auswählen“ lassen sich abweichende Installationspfade und Linux-Flatpak-Profile angeben; dazu den konkreten `Default`- oder `Profile N`-Ordner mit der Datei `Web Data` auswählen. + +## Funktionen + +- Quellbrowser und Zielbrowser auswählen und Suchkürzel direkt zwischen Chromium-Browsern übertragen +- Quellprofil und Zielprofil auswählen, wenn ein Browser mehrere Profile enthält +- Vor dem Import Änderungen und Konflikte anzeigen +- Vor einem Schreibzugriff die Browserdatenbank sichern +- Bestehende Kürzel aktualisieren, neue hinzufügen und nichts löschen +- Suchkürzel für Firefox als HTML-Lesezeichen exportieren +- Profile über den üblichen Browserpfad erkennen oder manuell auswählen +- Standardsuche aus der Quelle in der Zielprofil-Datenbank verfügbar machen, sofern die Quelle eine hat; die Auswahl als Standardsuche bestätigt der Nutzer anschließend im Browser + +Der Zielbrowser muss für den Import vollständig beendet sein. Das Programm bricht bei geschützten oder mehrdeutigen Kürzel-Kollisionen ab. Backups liegen unter `Documents/Suchkuerzel-Backups`. + +### macOS: Zugriff auf Browserprofile + +macOS vergibt den Profilzugriff pro App. Wenn ein Browserordner wie Brave gesperrt ist, öffnet die App beim Start automatisch **Systemeinstellungen → Datenschutz & Sicherheit → Festplattenvollzugriff**. Füge dort `SuchkuerzelTransfer.app` hinzu und starte die App danach neu. Zugängliche Profile bleiben währenddessen auswählbar. + +## Start aus dem Quellcode + +Python 3.11 oder neuer wird benötigt. + +```bash +cd app +python -m venv .venv +``` + +macOS/Linux: + +```bash +source .venv/bin/activate +python -m pip install -r requirements.txt +python main.py +``` + +Windows PowerShell: + +```powershell +.\.venv\Scripts\Activate.ps1 +python -m pip install -r requirements.txt +python main.py +``` + +## Eigenständige Pakete bauen + +PyInstaller muss auf dem jeweiligen Zielbetriebssystem ausgeführt werden. Auf jedem System im `app`-Ordner: + +```bash +python -m pip install -r requirements.txt +pyinstaller --noconfirm --clean SuchkuerzelTransfer.spec +``` + +Das Paket liegt danach in `dist/`. Die GitHub-Aktion baut getrennte Pakete auf Windows, macOS und Ubuntu und führt dabei den vorhandenen Kern-Selbsttest aus. macOS-Signierung/Notarisierung und Windows-Signierung sind für eine öffentliche Verteilung separat einzurichten. + +## Datenformat und Grenzen + +Das XML-Format der App bleibt mit dem bisherigen `Suchkuerzel-Transfer.command` kompatibel. SQLite-Backups werden vor Änderungen erstellt. Die App ersetzt keine Systemeinstellung für die Standardsuche: Chromium-Browser verlangen dafür eine Bestätigung in ihren Einstellungen. Flatpak-Sandboxen können den Zugriff auf Browserprofile verhindern; in diesem Fall muss die App mit passendem Profilzugriff gestartet oder ein XML-Transfer verwendet werden. diff --git a/browser-suchmigration/app/SuchkuerzelTransfer.spec b/browser-suchmigration/app/SuchkuerzelTransfer.spec new file mode 100644 index 0000000..bb7801f --- /dev/null +++ b/browser-suchmigration/app/SuchkuerzelTransfer.spec @@ -0,0 +1,54 @@ +# PyInstaller-Spezifikation: auf Windows, macOS und Linux jeweils lokal bauen. +from pathlib import Path + +app_dir = Path(SPECPATH) +source_root = app_dir.parent + +a = Analysis( + [str(app_dir / "main.py")], + pathex=[str(app_dir)], + binaries=[], + datas=[(str(source_root / "Suchkuerzel-Transfer.command"), ".")], + hiddenimports=[ + "argparse", + "contextlib", + "datetime", + "hashlib", + "html", + "json", + "os", + "re", + "shutil", + "sqlite3", + "subprocess", + "sys", + "tempfile", + "time", + "typing", + "uuid", + "xml.etree.ElementTree", + ], + hookspath=[], + hooksconfig={}, + runtime_hooks=[], + excludes=[], + noarchive=False, +) +pyz = PYZ(a.pure) +exe = EXE( + pyz, + a.scripts, + [], + [], + name="SuchkuerzelTransfer", + debug=False, + bootloader_ignore_signals=False, + strip=False, + upx=False, + console=False, + exclude_binaries=True, + disable_windowed_traceback=False, +) +coll = COLLECT(exe, a.binaries, a.datas, strip=False, upx=False, name="SuchkuerzelTransfer") +if __import__("sys").platform == "darwin": + app = BUNDLE(coll, name="SuchkuerzelTransfer.app", bundle_identifier="de.suchkuerzel.transfer") diff --git a/browser-suchmigration/app/main.py b/browser-suchmigration/app/main.py new file mode 100644 index 0000000..e9d2739 --- /dev/null +++ b/browser-suchmigration/app/main.py @@ -0,0 +1,471 @@ +#!/usr/bin/env python3 +"""Plattformübergreifende Oberfläche für den vorhandenen Suchkürzel-Transfer.""" + +from __future__ import annotations + +import importlib.machinery +import importlib.util +import os +import sqlite3 +import subprocess +import sys +from pathlib import Path + +from PySide6.QtCore import QUrl +from PySide6.QtGui import QDesktopServices +from PySide6.QtWidgets import QApplication, QFileDialog, QInputDialog, QMessageBox + + +ROOT = Path(__file__).resolve().parents[1] +bundle_root = Path(getattr(sys, "_MEIPASS", ROOT)) +transfer_candidates = ( + bundle_root / "Suchkuerzel-Transfer.command", + Path(sys.executable).resolve().parent.parent / "Resources" / "Suchkuerzel-Transfer.command", +) +TRANSFER_FILE = next((path for path in transfer_candidates if path.is_file()), transfer_candidates[0]) + + +def browser_roots() -> dict[str, dict[str, object]]: + home = Path.home() + if sys.platform == "win32": + local = Path(os.environ.get("LOCALAPPDATA", home / "AppData/Local")) + roaming = Path(os.environ.get("APPDATA", home / "AppData/Roaming")) + entries = { + "chrome": ("Google Chrome", "chrome.exe", local / "Google/Chrome/User Data", "chrome://settings/search"), + "brave": ("Brave", "brave.exe", local / "BraveSoftware/Brave-Browser/User Data", "brave://settings/search"), + "edge": ("Microsoft Edge", "msedge.exe", local / "Microsoft/Edge/User Data", "edge://settings/search"), + "vivaldi": ("Vivaldi", "vivaldi.exe", local / "Vivaldi/User Data", "vivaldi://settings/search"), + "chromium": ("Chromium", "chrome.exe", local / "Chromium/User Data", "chrome://settings/search"), + } + elif sys.platform == "darwin": + support = home / "Library/Application Support" + entries = { + "chrome": ("Google Chrome", "Google Chrome", support / "Google/Chrome", "chrome://settings/search"), + "brave": ("Brave", "Brave Browser", support / "BraveSoftware/Brave-Browser", "brave://settings/search"), + "edge": ("Microsoft Edge", "Microsoft Edge", support / "Microsoft Edge", "edge://settings/search"), + "vivaldi": ("Vivaldi", "Vivaldi", support / "Vivaldi", "vivaldi://settings/search"), + "chromium": ("Chromium", "Chromium", support / "Chromium", "chrome://settings/search"), + "ego-lite": ("Ego Lite", "ego lite", support / "Citro Labs/ego lite", "chrome://settings/search"), + } + else: + config = Path(os.environ.get("XDG_CONFIG_HOME", home / ".config")) + entries = { + "chrome": ("Google Chrome", "chrome", config / "google-chrome", "chrome://settings/search"), + "brave": ("Brave", "brave", config / "BraveSoftware/Brave-Browser", "brave://settings/search"), + "edge": ("Microsoft Edge", "msedge", config / "microsoft-edge", "edge://settings/search"), + "vivaldi": ("Vivaldi", "vivaldi", config / "vivaldi", "vivaldi://settings/search"), + "chromium": ("Chromium", "chromium", config / "chromium", "chrome://settings/search"), + "chromium-snap": ("Chromium (Snap)", "chromium", home / "snap/chromium/common/chromium", "chrome://settings/search"), + } + return { + key: {"label": label, "process": process, "root": path, "settings": settings} + for key, (label, process, path, settings) in entries.items() + } + + +def process_is_running(executable: str) -> bool: + if sys.platform == "win32": + result = subprocess.run(["tasklist", "/FI", f"IMAGENAME eq {executable}", "/NH"], capture_output=True, text=True) + return executable.casefold() in result.stdout.casefold() + if sys.platform == "darwin": + return subprocess.run(["pgrep", "-f", executable], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL).returncode == 0 + proc = Path("/proc") + if proc.is_dir(): + for entry in proc.iterdir(): + if entry.name.isdigit(): + try: + if executable.casefold() in (entry / "comm").read_text().casefold(): + return True + except OSError: + continue + return False + return subprocess.run(["pgrep", "-f", executable], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL).returncode == 0 + + +def load_transfer(): + loader = importlib.machinery.SourceFileLoader("suchkuerzel_transfer_core", str(TRANSFER_FILE)) + spec = importlib.util.spec_from_loader(loader.name, loader) + if spec is None: + raise RuntimeError("Die Transferlogik konnte nicht geladen werden.") + module = importlib.util.module_from_spec(spec) + loader.exec_module(module) + module.BROWSERS = browser_roots() + module.BACKUP_DIR = Path.home() / "Documents" / "Suchkuerzel-Backups" + original_wait_until_import_ready = module.wait_until_import_ready + module.wait_until_import_ready = lambda profile, require_closed: original_wait_until_import_ready(profile, require_closed=True) + + def choose_from_list(title: str, prompt: str, choices: list[str]) -> str: + choice, accepted = QInputDialog.getItem(None, title, prompt, choices, 0, False) + if not accepted: + raise module.Cancelled + return choice + + def choose_manual_profile(): + browser_labels = [str(item["label"]) for item in module.BROWSERS.values()] + browser_label, accepted = QInputDialog.getItem(None, "Browser auswählen", "Zu welchem Browser gehört das Profil?", browser_labels, 0, False) + if not accepted: + raise module.Cancelled + browser_key = next(key for key, item in module.BROWSERS.items() if item["label"] == browser_label) + directory = QFileDialog.getExistingDirectory(None, "Browser-Profilordner auswählen", str(Path.home())) + if not directory: + raise module.Cancelled + profile_dir = Path(directory) + database = profile_dir / "Web Data" + if not database.is_file(): + raise module.TransferError("Der ausgewählte Ordner enthält keine Chromium-Suchdatenbank („Web Data“). Bitte direkt den Ordner Default oder Profile N auswählen.") + count = len(module.read_shortcuts(database)) + return {"token": f"{browser_key}:{profile_dir}", "browser_key": browser_key, "label": f"{browser_label} — {module.profile_name(profile_dir)}", "directory": profile_dir, "path": database, "count": count} + + def choose_profile(profiles, purpose: str): + labels = [f"{item['label']} — {item['count']} Kürzel" for item in profiles] + manual = "Profilordner manuell auswählen …" + choice, accepted = QInputDialog.getItem(None, "Suchkürzel-Transfer", purpose, labels + [manual], 0, False) + if not accepted: + raise module.Cancelled + if choice != manual: + return profiles[labels.index(choice)] + return choose_manual_profile() + + def choose_source_profile(profiles): + labels = [f"{item['label']} — {item['count']} Kürzel" for item in profiles] + actions = [ + "Profilordner manuell auswählen …", + "Aus XML-Datei importieren …", + "Sicherung wiederherstellen …", + ] + choice, accepted = QInputDialog.getItem( + None, + "Suchkürzel-Transfer", + "Quell-Browserprofil auswählen:", + labels + actions, + 0, + False, + ) + if not accepted: + raise module.Cancelled + if choice in labels: + return "profile", profiles[labels.index(choice)] + return choice, None + + def choose_target(profiles, source): + source_token = str(source.get("token")) + candidate_profiles = [ + p for p in profiles if str(p.get("token")) != source_token and p.get("path") != source.get("path") + ] + labels = [f"{item['label']} — {item['count']} Kürzel" for item in candidate_profiles] + options = [ + "Firefox (Lesezeichen-HTML)", + "Als XML exportieren", + "Profilordner manuell auswählen …", + ] + choice, accepted = QInputDialog.getItem( + None, + "Suchkürzel-Transfer", + "Ziel für die Suchkürzel auswählen:", + labels + options, + 0, + False, + ) + if not accepted: + raise module.Cancelled + if choice in labels: + return "profile", candidate_profiles[labels.index(choice)] + return choice, None + + def choose_browser(profiles, purpose: str, allow_firefox: bool = False) -> str: + keys = list(dict.fromkeys(str(profile["browser_key"]) for profile in profiles)) + choices = [ + f"{module.BROWSERS[key]['label']} ({sum(profile['browser_key'] == key for profile in profiles)} {'Profil' if sum(profile['browser_key'] == key for profile in profiles) == 1 else 'Profile'})" + for key in keys + ] + if allow_firefox: + keys.append("firefox") + choices.append("Firefox (Lesezeichen-HTML)") + picked = choose_from_list("Suchkürzel-Transfer", purpose, choices) + return keys[choices.index(picked)] + + def choose_browser_profile(profiles, browser_key: str, purpose: str): + matching = [profile for profile in profiles if profile["browser_key"] == browser_key] + if not matching: + raise module.TransferError(f"Für {module.BROWSERS[browser_key]['label']} wurde kein zugängliches Profil gefunden.") + if len(matching) == 1: + return matching[0] + return choose_profile(matching, purpose) + + def choose_save_path(default_name: str) -> Path: + filename, _ = QFileDialog.getSaveFileName(None, "XML-Export speichern", str(Path.home() / default_name), "Suchkürzel-XML (*.xml)") + if not filename: + raise module.Cancelled + path = Path(filename) + return path if path.suffix.lower() == ".xml" else path.with_suffix(".xml") + + def choose_firefox_save_path(default_name: str) -> Path: + filename, _ = QFileDialog.getSaveFileName(None, "Firefox-Importdatei speichern", str(Path.home() / default_name), "Firefox-Lesezeichen (*.html)") + if not filename: + raise module.Cancelled + path = Path(filename) + return path if path.suffix.lower() in {".html", ".htm"} else path.with_suffix(".html") + + def choose_xml_file() -> Path: + filename, _ = QFileDialog.getOpenFileName(None, "Suchkürzel-XML auswählen", str(Path.home()), "Suchkürzel-XML (*.xml)") + if not filename: + raise module.Cancelled + return Path(filename) + + def confirm(title: str, message: str, action: str) -> None: + result = QMessageBox.question(None, title, message, QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.Cancel, QMessageBox.StandardButton.Cancel) + if result != QMessageBox.StandardButton.Yes: + raise module.Cancelled + + def alert(title: str, message: str, kind: str = "informational") -> None: + box = QMessageBox() + box.setWindowTitle(title) + box.setText(message) + box.setIcon(QMessageBox.Icon.Critical if kind == "error" else QMessageBox.Icon.Information) + box.exec() + + def ask_retry_locked(label: str) -> None: + result = QMessageBox.warning(None, "Browser schließen", f"{label} ist noch geöffnet.\n\nBitte vollständig beenden und erneut prüfen.", QMessageBox.StandardButton.Retry | QMessageBox.StandardButton.Cancel, QMessageBox.StandardButton.Retry) + if result != QMessageBox.StandardButton.Retry: + raise module.Cancelled + + module.choose_from_list = choose_from_list + module.choose_profile = choose_profile + module.choose_source_profile = choose_source_profile + module.choose_target = choose_target + module.choose_save_path = choose_save_path + module.choose_firefox_save_path = choose_firefox_save_path + module.choose_xml_file = choose_xml_file + module.confirm = confirm + module.alert = alert + module.ask_retry_locked = ask_retry_locked + module.browser_running = lambda profile: process_is_running(module.BROWSERS[profile["browser_key"]]["process"]) + module.open_search_settings = lambda profile: QDesktopServices.openUrl(QUrl(module.BROWSERS[profile["browser_key"]]["settings"])) + + def export_xml_flow(source, entries) -> None: + default_search = module.read_default_search(source) or module.choose_default_search(source) + date = module.dt.date.today().isoformat() + filename = f"Suchkuerzel-{module.safe_component(source['label'])}-{date}.xml" + destination = choose_save_path(filename) + destination.write_text(module.to_xml(entries, source["label"], default_search), encoding="utf-8") + QDesktopServices.openUrl(QUrl.fromLocalFile(str(destination.parent))) + alert( + "XML-Export abgeschlossen", + f"Von {source['label']} exportiert:\n{len(entries)} Suchkürzel gespeichert\n\n" + f"Standardsuche: {default_search['short_name']} ({default_search['keyword']})\n\n" + f"Datei:\n{destination}", + ) + + def export_firefox_flow(source, entries) -> None: + filename = f"Firefox-Suchkuerzel-{module.safe_component(source['label'])}-{module.dt.date.today().isoformat()}.html" + destination = choose_firefox_save_path(filename) + destination.write_text(module.to_firefox_html(entries, source["label"]), encoding="utf-8") + QDesktopServices.openUrl(QUrl.fromLocalFile(str(destination.parent))) + alert( + "Firefox-Importdatei erstellt", + f"Von {source['label']} exportiert:\n{len(entries)} Suchkürzel\n\n" + f"Datei: {destination}\n\n" + "In Firefox die Lesezeichenverwaltung öffnen und „Lesezeichen von HTML importieren“ wählen.", + ) + + def firefox_export_flow(profiles) -> None: + source = choose_profile(profiles, "Chrome-/Brave-Quellprofil für Firefox auswählen:") + entries = module.read_shortcuts(source["path"]) + if not entries: + raise module.TransferError("Dieses Profil enthält keine aktiven eigenen Suchkürzel.") + export_firefox_flow(source, entries) + + def export_flow(profiles) -> None: + source = choose_profile(profiles, "Quellprofil für den XML-Export auswählen:") + entries = module.read_shortcuts(source["path"]) + if not entries: + raise module.TransferError("Dieses Profil enthält keine aktiven eigenen Suchkürzel.") + export_xml_flow(source, entries) + + def import_xml_flow(profiles) -> None: + xml_path = choose_xml_file() + entries, default_search = module.parse_transfer_xml(xml_path.read_text(encoding="utf-8")) + target = choose_profile(profiles, "Zielprofil für den XML-Import auswählen:") + entries = module.entries_for_import(target["path"], entries, default_search) + result = module.preview(target["path"], entries) + if result["conflicts"]: + raise module.TransferError("Geschützte oder mehrdeutige Kürzel-Kollisionen: " + ", ".join(result["conflicts"][:5])) + default_message = ( + f"\nStandardsuche: {default_search['short_name']} ({default_search['keyword']})\n" + if default_search + else "\nStandardsuche: nicht in dieser XML enthalten\n" + ) + confirm( + "Import bestätigen", + f"Ziel: {target['label']}\n\n{result['added']} neu\n{result['updated']} zu aktualisieren\n" + f"{result['unchanged']} unverändert\n{default_message}\n" + "Nichts wird gelöscht. Vorher wird eine Sicherung des Zielprofils erstellt.", + "Importieren", + ) + module.wait_until_import_ready(target, require_closed=bool(default_search)) + final, backup_path = module.apply_import(target["path"], target["label"], entries) + default_finish = ( + f"\n\nAls Nächstes öffnen sich die Browser-Einstellungen. Dort bitte „{default_search['short_name']}“ als Standardsuche auswählen." + if default_search + else "" + ) + alert( + "Import abgeschlossen", + f"{final['added']} neu, {final['updated']} aktualisiert, {final['unchanged']} unverändert.\n\n" + f"Sicherung:\n{backup_path}{default_finish}", + ) + if default_search: + module.open_search_settings(target) + + def restore_flow(profiles) -> None: + target = choose_profile(profiles, "Zielprofil für die Wiederherstellung auswählen:") + filename, _ = QFileDialog.getOpenFileName(None, "Suchkürzel-Sicherung auswählen", str(module.BACKUP_DIR), "SQLite-Sicherung (*.sqlite)") + if not filename: + raise module.Cancelled + backup_path = Path(filename) + try: + entries = module.read_shortcuts(backup_path) + except (OSError, sqlite3.Error, module.TransferError) as error: + raise module.TransferError("Die ausgewählte Datei ist keine lesbare Suchkürzel-Sicherung.") from error + if not entries: + raise module.TransferError("Die Sicherung enthält keine eigenen Suchkürzel.") + result = module.preview(target["path"], entries) + if result["conflicts"]: + raise module.TransferError("Geschützte oder doppelte Kürzel kollidieren: " + ", ".join(result["conflicts"][:5])) + confirm( + "Sicherung wiederherstellen", + f"Ziel: {target['label']}\nSicherung: {backup_path.name}\n\n{result['added']} neu\n{result['updated']} werden zurückgesetzt\n{result['unchanged']} unverändert\n\nAndere Profil- und Browserdaten bleiben unberührt.", + "Wiederherstellen", + ) + module.wait_until_import_ready(target, require_closed=True) + final, current_backup = module.apply_import(target["path"], target["label"], entries) + alert( + "Wiederherstellung abgeschlossen", + f"{final['added']} neu, {final['updated']} zurückgesetzt, {final['unchanged']} unverändert.\n\nSicherung des vorherigen Stands:\n{current_backup}", + ) + + def direct_transfer_flow(profiles) -> None: + source_kind, source = choose_source_profile(profiles) + if source_kind == "Profilordner manuell auswählen …": + source = choose_manual_profile() + elif source_kind == "Aus XML-Datei importieren …": + import_xml_flow(profiles) + return + elif source_kind == "Sicherung wiederherstellen …": + restore_flow(profiles) + return + + entries = module.read_shortcuts(source["path"]) + if not entries: + raise module.TransferError(f"Im Quellprofil {source['label']} gibt es keine eigenen Suchkürzel.") + + target_kind, target = choose_target(profiles, source) + if target_kind == "Firefox (Lesezeichen-HTML)": + export_firefox_flow(source, entries) + return + elif target_kind == "Als XML exportieren": + export_xml_flow(source, entries) + return + elif target_kind == "Profilordner manuell auswählen …": + target = choose_manual_profile() + if target["path"] == source["path"]: + raise module.TransferError("Quell- und Zielprofil dürfen nicht identisch sein.") + + # Standardsuche nur übernehmen, wenn die Quelle überhaupt eine hat. Sonst bleibt die + # Standardsuche des Zielprofils unangetastet, statt eine erfundene einzutragen. + default_search = module.read_default_search(source) + import_entries = module.entries_for_import(target["path"], entries, default_search) + result = module.preview(target["path"], import_entries) + if result["conflicts"]: + raise module.TransferError("Geschützte oder mehrdeutige Kürzel-Kollisionen: " + ", ".join(result["conflicts"][:5])) + + default_info = ( + f"\nStandardsuche: {default_search['short_name']} ({default_search['keyword']})\n" + if default_search + else "" + ) + confirm( + "Übertragung bestätigen", + f"Von: {source['label']}\nNach: {target['label']}\n\n" + f"{result['added']} neu\n{result['updated']} aktualisiert\n" + f"{result['unchanged']} unverändert{default_info}\n\n" + "Nichts wird gelöscht. Vorher wird eine Sicherung des Zielprofils erstellt.", + "Übertragen", + ) + module.wait_until_import_ready(target, require_closed=True) + final, backup_path = module.apply_import(target["path"], target["label"], import_entries) + default_note = ( + f"\n\nÖffne im Zielbrowser die Sucheinstellungen und wähle dort „{default_search['short_name']}“ als Standardsuche." + if default_search + else "" + ) + alert( + "Übertragung abgeschlossen", + f"Von {source['label']} nach {target['label']} übertragen:\n" + f"{final['added']} neu, {final['updated']} aktualisiert, {final['unchanged']} unverändert.\n\n" + f"Sicherung des Zielprofils:\n{backup_path}{default_note}", + ) + if default_search: + module.open_search_settings(target) + + def app_main() -> int: + try: + profiles = module.discover_profiles() + if module.DISCOVERY_WARNINGS: + settings_opened = False + if sys.platform == "darwin": + settings_url = "x-apple.systempreferences:com.apple.preference.security?Privacy_AllFiles" + settings_opened = subprocess.run( + ["open", settings_url], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + check=False, + ).returncode == 0 + settings_hint = ( + "Falls „SuchkuerzelTransfer.app“ bereits in der Liste steht und aktiv ist:\n" + "Bitte den Eintrag mit „-“ entfernen und mit „+“ neu hinzufügen (macOS erfordert dies nach App-Updates), danach die App neu starten.\n\n" + "Alternativ: Du kannst Chrome- und Brave-Profile jederzeit direkt über „Profilordner manuell auswählen …“ anwählen." + ) + QMessageBox.warning( + None, + "Browserzugriff eingeschränkt", + "Einige Browserprofile sind für diese App durch macOS gesperrt und wurden übersprungen.\n\n" + + "\n".join(module.DISCOVERY_WARNINGS) + + "\n\n" + + settings_hint, + ) + direct_transfer_flow(profiles) + return 0 + except module.Cancelled: + return 0 + except Exception as error: + try: + alert("Suchkürzel-Transfer", str(error), "error") + except module.Cancelled: + pass + return 1 + + module.main = app_main + return module + + +def main() -> int: + app = QApplication(sys.argv) + app.setApplicationName("Suchkürzel-Transfer") + app.setOrganizationName("Suchkürzel-Transfer") + try: + transfer = load_transfer() + if "--self-test" in sys.argv: + transfer.self_test() + return 0 + return transfer.main() + except Exception as error: + if "--self-test" in sys.argv: + print(f"Selbsttest fehlgeschlagen: {type(error).__name__}: {error}", file=sys.stderr) + return 1 + QMessageBox.critical(None, "Suchkürzel-Transfer", str(error)) + return 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/browser-suchmigration/app/requirements.txt b/browser-suchmigration/app/requirements.txt new file mode 100644 index 0000000..64407e9 --- /dev/null +++ b/browser-suchmigration/app/requirements.txt @@ -0,0 +1,2 @@ +PySide6-Essentials>=6.8,<7 +pyinstaller>=6.10,<7 diff --git a/paketda/Paketda lokaler Loader.user.js b/paketda/Paketda lokaler Loader.user.js index ed9e50c..4a131eb 100644 --- a/paketda/Paketda lokaler Loader.user.js +++ b/paketda/Paketda lokaler Loader.user.js @@ -1,10 +1,10 @@ // ==UserScript== // @name Paketda Packlink & Klick-n-Schick (lokaler Loader) // @namespace http://tampermonkey.net/ -// @version 1.0.0 +// @version 1.0.1 // @description Lädt das Paketda-Skript direkt aus dem lokalen Ordner. // @match https://www.paketda.de/paket-preis-rechner.php* -// @require file:///Users/family/Documents/Code/Paketda/Paketda.user.js +// @require file:///Users/family/Developer/userscripts/paketda/Paketda.user.js // @run-at document-idle // @grant none // @noframes diff --git a/paketda/Paketda.user.js b/paketda/Paketda.user.js index b3a86dc..59d0268 100644 --- a/paketda/Paketda.user.js +++ b/paketda/Paketda.user.js @@ -1,7 +1,7 @@ // ==UserScript== // @name Paketda Packlink Button & Adblock Remover // @namespace http://tampermonkey.net/ -// @version 2.0 +// @version 2.2 // @description Ergänzt Packlink-Buttons, Klick-n-Schick als passendes Angebot und entfernt den Adblock-Hinweis. // @match https://www.paketda.de/paket-preis-rechner.php* // @icon https://www.google.com/s2/favicons?sz=64&domain=paketda.de @@ -55,7 +55,7 @@ const [longest, middle, shortest] = dimensions; const girth = longest + (2 * middle) + (2 * shortest); const hasPackageData = [weight, longest, middle, shortest].every(Number.isFinite); - const isGermany = !land; + const isGermany = !land || String(land).trim() === '' || String(land).toLowerCase() === 'de' || String(land).toLowerCase() === 'deutschland'; const fitsKlickNSchick = hasPackageData && isGermany && weight > 0 @@ -235,14 +235,14 @@ } function addKlickNSchickOffer() { - if (!fitsKlickNSchick || document.querySelector('.kns-offer-row')) return; + if (!fitsKlickNSchick) return; const price = weight <= 2 ? 4.90 : 5.80; const netPrice = price / 1.19; const productName = `Klick-n-Schick Paket bis ${weight <= 2 ? 2 : 10} kg`; const desktopBody = document.querySelector('#desktop_tabelle tbody'); - if (desktopBody) { + if (desktopBody && !desktopBody.querySelector('.kns-offer-row')) { const row = document.createElement('tr'); row.className = 'kns-offer-row'; @@ -266,7 +266,7 @@ } const mobileBody = document.querySelector('#tabelle_porto_online tbody'); - if (mobileBody) { + if (mobileBody && !mobileBody.querySelector('.kns-offer-row')) { const row = document.createElement('tr'); row.className = 'kns-offer-row kns-offer-row-mobile'; @@ -283,6 +283,19 @@ addKlickNSchickOffer(); + // Falls die Tabelle noch nicht im DOM ist (Seite noch nicht fertig), nochmal versuchen + if (fitsKlickNSchick) { + const knsRetry = new MutationObserver(() => { + addKlickNSchickOffer(); + const hasDesktop = !document.querySelector('#desktop_tabelle') || document.querySelector('#desktop_tabelle .kns-offer-row'); + const hasMobile = !document.querySelector('#tabelle_porto_online') || document.querySelector('#tabelle_porto_online .kns-offer-row'); + if (hasDesktop && hasMobile && document.querySelector('.kns-offer-row')) { + knsRetry.disconnect(); + } + }); + knsRetry.observe(document.body, { childList: true, subtree: true }); + } + // --- 5. Öffentlichen Packlink-Ab-Preis in die Angebotsliste einfügen --- function packlinkOfferDetails(productName) { const wrapper = document.createDocumentFragment(); @@ -307,14 +320,14 @@ } function addPacklinkOffer() { - if (!fitsPacklink || document.querySelector('.packlink-offer-row')) return; + if (!fitsPacklink) return; const netPrice = packlinkDpdTariff.netPrice; const price = netPrice * 1.19; const productName = `Packlink DPD Paketshop ${packlinkDpdTariff.size} (bis 20 kg)`; const desktopBody = document.querySelector('#desktop_tabelle tbody'); - if (desktopBody) { + if (desktopBody && !desktopBody.querySelector('.packlink-offer-row')) { const row = document.createElement('tr'); row.className = 'packlink-offer-row'; @@ -338,7 +351,7 @@ } const mobileBody = document.querySelector('#tabelle_porto_online tbody'); - if (mobileBody) { + if (mobileBody && !mobileBody.querySelector('.packlink-offer-row')) { const row = document.createElement('tr'); row.className = 'packlink-offer-row packlink-offer-row-mobile'; @@ -355,6 +368,19 @@ addPacklinkOffer(); + // Falls die Tabelle noch nicht im DOM ist, nochmal versuchen + if (fitsPacklink) { + const plRetry = new MutationObserver(() => { + addPacklinkOffer(); + const hasDesktop = !document.querySelector('#desktop_tabelle') || document.querySelector('#desktop_tabelle .packlink-offer-row'); + const hasMobile = !document.querySelector('#tabelle_porto_online') || document.querySelector('#tabelle_porto_online .packlink-offer-row'); + if (hasDesktop && hasMobile && document.querySelector('.packlink-offer-row')) { + plRetry.disconnect(); + } + }); + plRetry.observe(document.body, { childList: true, subtree: true }); + } + // --- 6. Novapost-Preise zusätzlich mit 25 % Rabatt anzeigen --- function parseEuro(value) { const normalized = String(value || '') @@ -414,8 +440,6 @@ addNovapostDiscounts(); // --- 7. Vorhandenen Brief-Link durch die drei Schnellbuttons ersetzen --- - if (!hasPackageData) return; - const briefLink = document.querySelector('a[href*="deutschepost.de/de/p/portoberater.html"]'); const container = briefLink?.parentElement; if (!briefLink || !container || document.querySelector('.custom-btn-modern')) return;