Compare commits
2
Commits
8eca982e68
...
082e8b8b55
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
082e8b8b55 | ||
|
|
6dfff5c99d |
@@ -29,6 +29,29 @@ rot heißt: nicht deployen). Nie freihändig per SSH auf der VM bauen.
|
||||
Bibliotheks-APIs: `--help`/Doku prüfen und die Fundstelle im Commit nennen.
|
||||
(Vorgefallen: erfundene Celery-Methode `self.send_task`, erfundene abcde-Flags.)
|
||||
|
||||
### Zwei Schreibfallen, die am 28.08.2026 mehrfach gekostet haben
|
||||
|
||||
**1. Deutsche Anführungszeichen in doppelt gequoteten Zeichenketten.**
|
||||
Das öffnende `„` ist U+201E und harmlos. Das schließende ist das **gerade
|
||||
ASCII-Zeichen** `"` — es beendet die Python-Zeichenkette. Der Fehler erscheint
|
||||
dann am Gedankenstrich dahinter, also an einer Stelle, an der nichts falsch
|
||||
ist. An einem Tag vier Mal passiert.
|
||||
|
||||
falsch: "einen Ordner unter „Benutzer" wählen"
|
||||
richtig: 'einen Ordner unter „Benutzer" wählen'
|
||||
|
||||
**Regel: Steht ein `„` in der Zeichenkette, gehört sie in EINFACHE
|
||||
Anführungszeichen.**
|
||||
|
||||
**2. Bash-Heredocs mit Windows-Pfaden.** `<<'EOF'` sollte literal sein,
|
||||
halbiert in dieser Umgebung aber Backslashes — aus `C:\\Users` wird `C:\Users`,
|
||||
und das ist eine ungültige Escape-Sequenz. An einem Tag fünf Mal passiert,
|
||||
zuletzt beim Schreiben genau dieses Absatzes.
|
||||
|
||||
**Regel: Dateien mit Windows-Pfaden oder Umlauten mit dem Write-Werkzeug
|
||||
schreiben, nicht per Heredoc.** Wo es doch sein muss: Backslashes über
|
||||
`chr(92)` zusammensetzen.
|
||||
|
||||
## Workflow
|
||||
|
||||
1. **Read:** KONZEPT.md + ROADMAP.md lesen. Verstehen, welche Etappe dran ist.
|
||||
|
||||
+53
-3
@@ -51,10 +51,45 @@ function AppInhalt() {
|
||||
// ihn statt des Dashboards. null = /setup noch nicht geprüft (kein Aufblitzen).
|
||||
const [setupDone, setSetupDone] = useState<boolean | null>(null)
|
||||
|
||||
/*
|
||||
* ⚠️ Ein fehlgeschlagener Abruf ist KEINE Aussage über die Einrichtung.
|
||||
*
|
||||
* Hier stand `.catch(() => setSetupDone(true))` mit der Begründung
|
||||
* „API/Setup nicht erreichbar → nicht blockieren". Die Folge hat der
|
||||
* Commander am 28.08.2026 gemeldet: **Nach einer frischen Installation
|
||||
* fehlte der Ersteinrichtungs-Assistent.**
|
||||
*
|
||||
* Und zwar zwangsläufig: Das Fenster geht unmittelbar nach dem Setup auf,
|
||||
* der Dienst braucht ein bis zwei Sekunden bis zur ersten Antwort — der
|
||||
* erste Abruf scheitert also fast immer. Aus „ich konnte nicht nachsehen"
|
||||
* wurde „ist schon erledigt", und der Assistent war für immer weg.
|
||||
*
|
||||
* Dasselbe Prinzip steht nebenan in `useEventStream.tsx`: Wer nichts Neues
|
||||
* weiß, behält, was er wusste — und behauptet nichts.
|
||||
*
|
||||
* Deshalb: nachfragen, bis der Server ANTWORTET. Erst eine Antwort
|
||||
* entscheidet.
|
||||
*/
|
||||
useEffect(() => {
|
||||
let abgemeldet = false
|
||||
let versuche = 0
|
||||
|
||||
const fragen = () => {
|
||||
api.get('/setup')
|
||||
.then((r) => setSetupDone(Boolean(r.data?.done)))
|
||||
.catch(() => setSetupDone(true)) // API/Setup nicht erreichbar → nicht blockieren
|
||||
.then((r) => { if (!abgemeldet) setSetupDone(Boolean(r.data?.done)) })
|
||||
.catch(() => {
|
||||
if (abgemeldet) return
|
||||
versuche += 1
|
||||
// Zwei Minuten lang alle zwei Sekunden. Antwortet der Server bis
|
||||
// dahin nicht, hat der Nutzer ein anderes Problem als den
|
||||
// Assistenten — dann zeigen wir die Oberfläche, damit er die Logs
|
||||
// sehen kann.
|
||||
if (versuche < 60) setTimeout(fragen, 2000)
|
||||
else setSetupDone(true)
|
||||
})
|
||||
}
|
||||
fragen()
|
||||
return () => { abgemeldet = true }
|
||||
}, [])
|
||||
|
||||
const navItems: { id: Page; label: string; icon: any }[] = [
|
||||
@@ -64,7 +99,22 @@ function AppInhalt() {
|
||||
{ id: 'settings', label: 'Einstellungen', icon: SettingsIcon },
|
||||
]
|
||||
|
||||
if (setupDone === null) return null // kurzer Moment, bis /setup geantwortet hat
|
||||
// Solange /setup noch nicht geantwortet hat: sagen, dass gewartet wird.
|
||||
// Ein leeres Fenster sieht aus wie ein Absturz — und nach einer frischen
|
||||
// Installation ist genau das der erste Eindruck.
|
||||
if (setupDone === null) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-[#0b0f19]">
|
||||
<div className="text-center">
|
||||
<div className="text-amber-400 text-3xl mb-3">◉</div>
|
||||
<p className="text-slate-300 text-sm">Rippy startet …</p>
|
||||
<p className="text-slate-500 text-xs mt-1 font-mono">
|
||||
warte auf den Dienst
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
if (!setupDone) return <FirstRunWizard onDone={() => setSetupDone(true)} />
|
||||
|
||||
return (
|
||||
|
||||
+124
-4
@@ -124,12 +124,93 @@ def pruefe_platz(ziel: str, frei_mb=None) -> dict:
|
||||
"Platz schaffen oder einen anderen Zielordner wählen.")
|
||||
|
||||
|
||||
def pruefe_schreibrecht(ziel: str, probe=None) -> dict:
|
||||
def laeuft_erhoeht(erhoeht=None) -> bool:
|
||||
"""Läuft dieser Prozess mit Administratorrechten?
|
||||
|
||||
Wichtig für die Diagnose, nicht als Anforderung: **Rippy braucht keine.**
|
||||
Erhöht gestartet handelt es sich sogar Nachteile ein — siehe unten.
|
||||
"""
|
||||
if erhoeht is not None:
|
||||
return bool(erhoeht)
|
||||
if os.name != "nt":
|
||||
return False
|
||||
import ctypes
|
||||
|
||||
try:
|
||||
return bool(ctypes.WinDLL("shell32").IsUserAnAdmin())
|
||||
except (AttributeError, OSError):
|
||||
return False
|
||||
|
||||
|
||||
def netzlaufwerke(pruefer=None) -> list:
|
||||
"""Die eingebundenen Netzlaufwerke, wie DIESER Prozess sie sieht."""
|
||||
if pruefer is not None:
|
||||
return list(pruefer)
|
||||
if os.name != "nt":
|
||||
return []
|
||||
import ctypes
|
||||
import string
|
||||
|
||||
DRIVE_REMOTE = 4
|
||||
try:
|
||||
k = ctypes.WinDLL("kernel32")
|
||||
maske = k.GetLogicalDrives()
|
||||
return [b + ":" for i, b in enumerate(string.ascii_uppercase)
|
||||
if (maske & (1 << i))
|
||||
and k.GetDriveTypeW(b + ":\\") == DRIVE_REMOTE]
|
||||
except (AttributeError, OSError):
|
||||
return []
|
||||
|
||||
|
||||
def pruefe_administrator(erhoeht=None, laufwerke=None) -> dict:
|
||||
r"""Als Administrator gestartet? Dann fehlen die Netzlaufwerke.
|
||||
|
||||
## Der Befund des Commanders (28.08.2026)
|
||||
|
||||
> „Wenn man das Tool als Admin startet, kann man im explorer kein
|
||||
> netzlaufwerk wählen. Wenn man im explorer ein via windows eingebundenes
|
||||
> netzlaufwerk einbindet dann kommt der fehler das man nicht installieren
|
||||
> kann, weil die schreibrechte fehlen."
|
||||
|
||||
Beides ist Windows-Verhalten, kein Rippy-Fehler — aber Rippy hat ihn
|
||||
hineinlaufen lassen. Ein erhöhter Prozess bekommt ein ANDERES
|
||||
Zugriffstoken, und eingebundene Netzlaufwerke hängen am Token der
|
||||
Sitzung. `X:` existiert dort schlicht nicht. (Auf diesem Rechner
|
||||
nachgesehen: `EnableLinkedConnections` ist nicht gesetzt — der Standard.)
|
||||
|
||||
Der Teufelskreis, in den das führt:
|
||||
|
||||
C:\Program Files\… verlangt Administrator
|
||||
Administrator versteckt die Netzlaufwerke
|
||||
Netzlaufwerk wirkt dann wie „keine Schreibrechte"
|
||||
|
||||
**Rippy braucht überhaupt keine Administratorrechte.** Der Vorgabeordner
|
||||
liegt unter `%LOCALAPPDATA%`, der Autostart unter `HKCU`.
|
||||
"""
|
||||
if not laeuft_erhoeht(erhoeht):
|
||||
return _eintrag("Rechte", OK, "normal gestartet (so ist es richtig)")
|
||||
gefunden = netzlaufwerke(laufwerke)
|
||||
text = "als Administrator gestartet"
|
||||
return _eintrag(
|
||||
"Rechte", WARNUNG, text,
|
||||
"Rippy braucht keine Administratorrechte — und erhöht sieht es die "
|
||||
"eingebundenen Netzlaufwerke NICHT" +
|
||||
(" (%s fehlen hier)" % ", ".join(gefunden) if gefunden else "") +
|
||||
". Für ein Netzlaufwerk den UNC-Pfad eintippen "
|
||||
"(\\\\Server\\Freigabe) oder Rippy ohne Administrator starten.")
|
||||
|
||||
|
||||
def pruefe_schreibrecht(ziel: str, probe=None, erhoeht=None) -> dict:
|
||||
"""Darf Rippy dorthin schreiben? Wirklich probiert, nicht geraten.
|
||||
|
||||
Ein `os.access(..., W_OK)` lügt unter Windows: Es beantwortet die Frage
|
||||
nach dem Schreibrecht anhand des Nur-Lesen-Flags, nicht anhand der
|
||||
Zugriffsrechte. Also wird eine Datei angelegt und wieder gelöscht.
|
||||
nach dem Nur-Lesen-Flag, nicht nach den Zugriffsrechten. Also wird eine
|
||||
Datei angelegt und wieder gelöscht.
|
||||
|
||||
Und der Grund wird BENANNT. „Schreibrechte fehlen" war die Meldung, die
|
||||
der Commander bei einem Netzlaufwerk bekam — richtig im Wortlaut,
|
||||
unbrauchbar als Auskunft: Das Laufwerk war für den erhöhten Prozess gar
|
||||
nicht da.
|
||||
"""
|
||||
if probe is not None:
|
||||
geklappt, grund = probe
|
||||
@@ -137,11 +218,49 @@ def pruefe_schreibrecht(ziel: str, probe=None) -> dict:
|
||||
geklappt, grund = _schreibprobe(ziel)
|
||||
if geklappt:
|
||||
return _eintrag("Schreibrecht", OK, ziel)
|
||||
|
||||
laufwerk = os.path.splitdrive(ziel or "")[0]
|
||||
if laufwerk and len(laufwerk) == 2 and not os.path.isdir(laufwerk + "\\"):
|
||||
# Ein Laufwerksbuchstabe, den es in DIESEM Prozess nicht gibt.
|
||||
zusatz = (" Als Administrator gestartete Programme sehen die "
|
||||
"eingebundenen Netzlaufwerke der Sitzung nicht."
|
||||
if laeuft_erhoeht(erhoeht) else "")
|
||||
return _eintrag(
|
||||
"Schreibrecht", FEHLER,
|
||||
"%s — Laufwerk %s ist hier nicht vorhanden" % (ziel, laufwerk),
|
||||
("Statt des Laufwerksbuchstabens den UNC-Pfad eintragen "
|
||||
"(\\\\Server\\Freigabe)." + zusatz))
|
||||
|
||||
if (ziel or "").startswith("\\\\") and laeuft_erhoeht(erhoeht):
|
||||
# Auch Netz-ANMELDUNGEN hängen am Token der Sitzung, nicht nur die
|
||||
# Laufwerksbuchstaben. Ein erhöhter Prozess kennt die Zugangsdaten
|
||||
# zur Freigabe deshalb nicht und bekommt „Zugriff verweigert" —
|
||||
# dieselbe Meldung wie bei einem echten Rechteproblem.
|
||||
return _eintrag(
|
||||
"Schreibrecht", FEHLER, "%s (%s)" % (ziel, grund),
|
||||
"Als Administrator gestartete Programme kennen die Anmeldung an "
|
||||
"der Freigabe nicht. Rippy ohne Administrator starten — dann "
|
||||
"geht der Netzwerkpfad.")
|
||||
|
||||
if _unter_programme(ziel):
|
||||
return _eintrag(
|
||||
"Schreibrecht", FEHLER, "%s (%s)" % (ziel, grund),
|
||||
'Der Ordner „Programme" verlangt Administratorrechte — und die '
|
||||
'verstecken wiederum die Netzlaufwerke. Rippy braucht sie nicht: '
|
||||
'Der Vorgabeordner unter „Benutzer" funktioniert ohne.')
|
||||
|
||||
return _eintrag("Schreibrecht", FEHLER, "%s (%s)" % (ziel, grund),
|
||||
'Einen Ordner unterhalb von „Benutzer" wählen — dort '
|
||||
'braucht Rippy keine Administratorrechte.')
|
||||
|
||||
|
||||
def _unter_programme(ziel: str) -> bool:
|
||||
"""Liegt das Ziel unter „Programme"? (reine Funktion)"""
|
||||
unten = (ziel or "").lower().replace("/", "\\")
|
||||
return ("\\program files" in unten or unten.startswith("program files")
|
||||
or "\\programme\\" in unten)
|
||||
|
||||
|
||||
def _schreibprobe(ziel: str):
|
||||
import tempfile
|
||||
|
||||
@@ -227,7 +346,8 @@ def pruefe_werkzeuge(bestand=None) -> list:
|
||||
# ── Alles zusammen ──────────────────────────────────────────────────────
|
||||
def alle_pruefungen(ziel: str, port: int = STANDARD_PORT) -> list:
|
||||
"""Der vollständige Voraussetzungs-Bericht, in Anzeigereihenfolge."""
|
||||
return ([pruefe_windows(), pruefe_webview(), pruefe_laufwerk()]
|
||||
return ([pruefe_windows(), pruefe_webview(), pruefe_administrator(),
|
||||
pruefe_laufwerk()]
|
||||
+ pruefe_werkzeuge()
|
||||
+ [pruefe_platz(ziel), pruefe_schreibrecht(ziel), pruefe_port(port)])
|
||||
|
||||
|
||||
+20
-12
@@ -145,23 +145,31 @@ class Bruecke:
|
||||
def _ablage_speichern(pfad: str) -> None:
|
||||
"""Die gewählte Ablage in die Einstellungen schreiben.
|
||||
|
||||
Über die laufende Datenbank, nicht über eine Konfigurationsdatei: Das UI
|
||||
liest `outputDir` aus den Einstellungen, und genau dort muss der Wert
|
||||
landen, damit die Wahl im Assistenten überhaupt etwas bewirkt.
|
||||
## Der Fehler, der das Setup abgebrochen hat (28.08.2026)
|
||||
|
||||
Hier stand `import db as api_db`, nachdem `daemon._api_pfad()` in den
|
||||
Suchpfad gehängt worden war. Beim Commander brach die Installation ab mit:
|
||||
|
||||
Fehlgeschlagen: ModuleNotFoundError: No module named 'db'
|
||||
|
||||
Und zwar zu Recht: **`docker/api/db.py` gibt es nicht.** Seit der
|
||||
Zusammenlegung in V2-1 ist der Store `rippy.store`; `main.py` schreibt
|
||||
seitdem `from rippy import store as db`. Ich habe ein Modul importiert,
|
||||
das ich mir aus einem Alias zusammengereimt habe — genau die Sorte
|
||||
erfundener Schnittstelle, vor der AGENTS.md Regel D warnt, nur diesmal
|
||||
im eigenen Code.
|
||||
|
||||
Der Store wird direkt benutzt. Ein Umweg über den Suchpfad der API war
|
||||
ohnehin überflüssig.
|
||||
"""
|
||||
from rippy import config, daemon, store
|
||||
from rippy import config, store
|
||||
|
||||
werte = config.laden()
|
||||
store.verbinden(config.datenbank_url(werte))
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, daemon._api_pfad())
|
||||
import db as api_db # noqa: E402
|
||||
|
||||
api_db.init_db()
|
||||
aktuell = api_db.get_settings() or {}
|
||||
store.init_db()
|
||||
aktuell = store.get_settings() or {}
|
||||
aktuell["outputDir"] = pfad
|
||||
api_db.save_settings(aktuell)
|
||||
store.save_settings(aktuell)
|
||||
|
||||
|
||||
def starten() -> dict:
|
||||
|
||||
@@ -90,6 +90,57 @@ def test_schreibprobe_am_echten_ordner(tmp_path):
|
||||
assert list(tmp_path.iterdir()) == []
|
||||
|
||||
|
||||
# ── Administrator und Netzlaufwerke ─────────────────────────────────────
|
||||
def test_normal_gestartet_ist_der_richtige_zustand():
|
||||
"""Rippy braucht KEINE Administratorrechte — Vorgabeordner unter
|
||||
%LOCALAPPDATA%, Autostart unter HKCU."""
|
||||
befund = e.pruefe_administrator(erhoeht=False)
|
||||
assert befund["zustand"] == e.OK
|
||||
|
||||
|
||||
def test_als_administrator_wird_vor_den_fehlenden_netzlaufwerken_gewarnt():
|
||||
"""DER Befund des Commanders (28.08.2026): „Wenn man das Tool als Admin
|
||||
startet, kann man im explorer kein netzlaufwerk waehlen."
|
||||
|
||||
Ein erhoehter Prozess bekommt ein anderes Zugriffstoken; eingebundene
|
||||
Netzlaufwerke haengen am Token der Sitzung. Windows-Verhalten, kein
|
||||
Rippy-Fehler — aber Rippy hat ihn hineinlaufen lassen."""
|
||||
befund = e.pruefe_administrator(erhoeht=True, laufwerke=["X:", "Y:", "Z:"])
|
||||
assert befund["zustand"] == e.WARNUNG
|
||||
assert "X:" in befund["abhilfe"]
|
||||
assert "UNC" in befund["abhilfe"] or "Server" in befund["abhilfe"]
|
||||
|
||||
|
||||
def test_fehlendes_laufwerk_wird_beim_namen_genannt():
|
||||
"""„Schreibrechte fehlen" war richtig im Wortlaut und unbrauchbar als
|
||||
Auskunft: Das Laufwerk war fuer den erhoehten Prozess gar nicht da."""
|
||||
befund = e.pruefe_schreibrecht(
|
||||
"Q:" + chr(92) + "Rippy", probe=(False, "Pfad nicht gefunden"),
|
||||
erhoeht=True)
|
||||
assert befund["zustand"] == e.FEHLER
|
||||
assert "Q:" in befund["text"]
|
||||
assert "nicht vorhanden" in befund["text"]
|
||||
assert "Administrator" in befund["abhilfe"]
|
||||
|
||||
|
||||
def test_programme_ordner_erklaert_den_teufelskreis():
|
||||
"""Program Files verlangt Administrator, Administrator versteckt die
|
||||
Netzlaufwerke — und Rippy braucht beides nicht."""
|
||||
befund = e.pruefe_schreibrecht(
|
||||
"C:" + chr(92) + "Program Files (x86)" + chr(92) + "Rippy",
|
||||
probe=(False, "Zugriff verweigert"))
|
||||
assert befund["zustand"] == e.FEHLER
|
||||
assert "Administratorrechte" in befund["abhilfe"]
|
||||
assert "Vorgabeordner" in befund["abhilfe"]
|
||||
|
||||
|
||||
def test_programme_wird_erkannt():
|
||||
B = chr(92)
|
||||
assert e._unter_programme("C:" + B + "Program Files" + B + "Rippy") is True
|
||||
assert e._unter_programme("C:" + B + "Program Files (x86)" + B + "R") is True
|
||||
assert e._unter_programme("C:" + B + "Users" + B + "X" + B + "Rippy") is False
|
||||
|
||||
|
||||
def test_belegter_port_ist_nur_eine_warnung():
|
||||
"""Sehr oft ist es ein bereits laufendes Rippy — und dann ist eine
|
||||
Neuinstallation genau richtig."""
|
||||
@@ -164,6 +215,8 @@ def test_alles_gruen_sagt_es_auch_so():
|
||||
def test_jede_pruefung_hat_dieselbe_form():
|
||||
"""Ein fehlendes Feld waere in der Oberflaeche ein `undefined`."""
|
||||
for befund in ([e.pruefe_windows(22631), e.pruefe_webview(version=""),
|
||||
e.pruefe_administrator(erhoeht=True, laufwerke=["X:"]),
|
||||
e.pruefe_administrator(erhoeht=False),
|
||||
e.pruefe_laufwerk(laufwerke=[]),
|
||||
e.pruefe_platz("C:\\x", frei_mb=9999),
|
||||
e.pruefe_port(7788, belegt=False)]
|
||||
@@ -186,3 +239,25 @@ def test_vorgaben_sind_vollstaendig():
|
||||
assert v["port"] == 7788
|
||||
assert v["ziel"] and v["ablage"]
|
||||
assert "/app/" not in v["ablage"], "Container-Pfad in einer Windows-Vorgabe"
|
||||
|
||||
|
||||
def test_netzwerkpfad_als_administrator_wird_erklaert():
|
||||
"""Der zweite Teil des Befunds: Auch Netz-ANMELDUNGEN haengen am Token
|
||||
der Sitzung. Ein erhoehter Prozess kennt die Zugangsdaten zur Freigabe
|
||||
nicht und bekommt 'Zugriff verweigert' -- dieselbe Meldung wie bei einem
|
||||
echten Rechteproblem."""
|
||||
B = chr(92)
|
||||
befund = e.pruefe_schreibrecht(
|
||||
B + B + "NAS" + B + "Filme", probe=(False, "Zugriff verweigert"),
|
||||
erhoeht=True)
|
||||
assert befund["zustand"] == e.FEHLER
|
||||
assert "ohne Administrator" in befund["abhilfe"]
|
||||
|
||||
|
||||
def test_netzwerkpfad_ohne_administrator_bekommt_die_normale_meldung():
|
||||
B = chr(92)
|
||||
befund = e.pruefe_schreibrecht(
|
||||
B + B + "NAS" + B + "Filme", probe=(False, "Netzwerkpfad nicht gefunden"),
|
||||
erhoeht=False)
|
||||
assert befund["zustand"] == e.FEHLER
|
||||
assert "Administrator" not in befund["abhilfe"] or "keine" in befund["abhilfe"]
|
||||
|
||||
@@ -649,6 +649,17 @@ def main(argv=None) -> int:
|
||||
if "--still" not in argv and "--ohne-assistent" not in argv:
|
||||
ergebnis = assistent_starten()
|
||||
if ergebnis.get("ok"):
|
||||
# ⚠️ Und jetzt Rippy auch WIRKLICH starten.
|
||||
#
|
||||
# Der Knopf am Ende des Assistenten heisst „Rippy oeffnen". Bis
|
||||
# hierher schloss er nur das Fenster, und danach passierte nichts
|
||||
# mehr: Der Dienst lief nicht, kein Fenster ging auf. Ein Knopf,
|
||||
# der etwas anderes tut als sein Beschriftung sagt, ist schlimmer
|
||||
# als keiner.
|
||||
programm = os.path.join(ergebnis.get("ziel") or
|
||||
reg.standard_installationsordner(),
|
||||
"Rippy.exe")
|
||||
starte_fensterprozess(programm)
|
||||
return 0
|
||||
if ergebnis.get("abgebrochen"):
|
||||
melden("Abgebrochen — es wurde nichts veraendert.")
|
||||
|
||||
Reference in New Issue
Block a user