From e7ad00a5e581e5a03a0747adf60a95b2c92ce4f7 Mon Sep 17 00:00:00 2001 From: Tim Eertmoed Date: Wed, 9 Sep 2026 22:41:08 +0200 Subject: [PATCH] Version 2.3.0: Speichern mit Ordner und Namen --- apps/tesm-license/pyproject.toml | 2 +- packages/tesm-core/pyproject.toml | 2 +- .../tesm-core/src/tesm_core/app_factory.py | 2 +- .../static/tesm_core/css/components.css | 32 ++++++- .../src/tesm_core/static/tesm_core/js/app.js | 6 +- .../tesm_core/static/tesm_core/js/viewer.js | 93 +++++++++++++++---- packages/tesm-licensing-server/pyproject.toml | 4 +- packages/tesm-licensing/pyproject.toml | 2 +- 8 files changed, 118 insertions(+), 25 deletions(-) diff --git a/apps/tesm-license/pyproject.toml b/apps/tesm-license/pyproject.toml index 21cca14..1bc2d4f 100644 --- a/apps/tesm-license/pyproject.toml +++ b/apps/tesm-license/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "tesm-license" -version = "2.2.1" +version = "2.3.0" description = "TESM-Lizenzserver -- Kunden, Tickets, Lizenzen, Aktivierung" requires-python = ">=3.11" dependencies = [ diff --git a/packages/tesm-core/pyproject.toml b/packages/tesm-core/pyproject.toml index 207dcc0..6305969 100644 --- a/packages/tesm-core/pyproject.toml +++ b/packages/tesm-core/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "tesm-core" -version = "2.2.1" +version = "2.3.0" description = "Gemeinsames Anwendungsgeruest (Auth, RBAC, Navigation, Design-System, Migrationen) fuer TESM und TESM-Lizenzserver" requires-python = ">=3.11" dependencies = [ diff --git a/packages/tesm-core/src/tesm_core/app_factory.py b/packages/tesm-core/src/tesm_core/app_factory.py index 746fee6..1f9db68 100644 --- a/packages/tesm-core/src/tesm_core/app_factory.py +++ b/packages/tesm-core/src/tesm_core/app_factory.py @@ -135,7 +135,7 @@ def create_base_app( #: Wortmarke, ohne Zeile darunter; die Marke selbst zeigen beide #: Anwendungen. auth_tagline: str = "", - app_version: str = "2.2.1", + app_version: str = "2.3.0", built_in_trusted_keys: tuple[str, ...] = (), enable_license: bool = True, template_folder: str | None = None, diff --git a/packages/tesm-core/src/tesm_core/static/tesm_core/css/components.css b/packages/tesm-core/src/tesm_core/static/tesm_core/css/components.css index efc3762..8449884 100644 --- a/packages/tesm-core/src/tesm_core/static/tesm_core/css/components.css +++ b/packages/tesm-core/src/tesm_core/static/tesm_core/css/components.css @@ -754,11 +754,19 @@ user-select: none; -webkit-user-drag: none; } - /* pdf.js verlangt einen absolut positionierten, scrollenden Behaelter. */ + /* pdf.js verlangt einen absolut positionierten, scrollenden Behaelter -- + der liegt deshalb *innen*. Aussen ein gewoehnlicher Flex-Kasten, damit + Zoomleiste und Speicherzeile ihren Platz behalten. Vorher rechnete der + Bereich mit "bottom: var(--sp-10)", also mit genau einer Leiste; die + zweite Zeile lag dann unter dem Dialogrand. */ .viewer__pdf { + position: relative; + flex: 1; + min-height: 0; + } + .viewer__pdfscroll { position: absolute; inset: 0; - bottom: var(--sp-10); overflow: auto; -webkit-overflow-scrolling: touch; } @@ -773,6 +781,26 @@ .viewer__hint { margin-inline-start: auto; } + .viewer__save { + display: flex; + flex-wrap: wrap; + align-items: flex-end; + gap: var(--sp-3); + padding: var(--sp-3) var(--sp-4); + border-top: 1px solid var(--border); + background: var(--surface-2, var(--surface-1)); + } + .viewer__save .viewer__feld { + flex: 1 1 12rem; + margin: 0; + } + /* Eigener Name: ".field--inline" gibt es schon und steht dort fuer + "waagerecht" -- diese Regel haette sie app-weit umgestellt. */ + .viewer__feld { + display: flex; + flex-direction: column; + gap: 2px; + } /* Der Dateiname ist ein Knopf (er oeffnet die Vorschau), soll aber wie ein Link aussehen -- ein Knopf mit Rahmen mitten in einer Tabellenzeile waere unruhig. */ diff --git a/packages/tesm-core/src/tesm_core/static/tesm_core/js/app.js b/packages/tesm-core/src/tesm_core/static/tesm_core/js/app.js index 41076f1..40f006c 100644 --- a/packages/tesm-core/src/tesm_core/static/tesm_core/js/app.js +++ b/packages/tesm-core/src/tesm_core/static/tesm_core/js/app.js @@ -902,7 +902,11 @@ function boot() { einem PDF noch pdf.js nach. Beides erst dann, wenn es gebraucht wird. */ function initViewer() { if (!document.querySelector("[data-viewer]")) return; - import("./viewer.js") + // Dieselbe Version wie diese Datei: app.js wird als ".../app.js?v=" + // eingebunden, und nginx liefert /core-assets/ mit "expires 7d". Ohne das + // Anhaengsel bliebe der alte Betrachter nach einem Update im Cache liegen. + const version = new URL(import.meta.url).search; + import(`./viewer.js${version}`) .then((modul) => modul.initFilePreview()) .catch((fehler) => console.error("Dateibetrachter", fehler)); } diff --git a/packages/tesm-core/src/tesm_core/static/tesm_core/js/viewer.js b/packages/tesm-core/src/tesm_core/static/tesm_core/js/viewer.js index f84cf56..15a8876 100644 --- a/packages/tesm-core/src/tesm_core/static/tesm_core/js/viewer.js +++ b/packages/tesm-core/src/tesm_core/static/tesm_core/js/viewer.js @@ -146,21 +146,41 @@ function bildBetrachter(buehne, bild) { let pdfjsGeladen = null; +/** Die Adressen der pdf.js-Dateien -- alle mit derselben Version. + * + * Uebergeben wird die Adresse von pdf.min.js samt "?v="; die + * Geschwister liegen daneben. Ohne die Version bliebe nach einem Update die + * alte Fassung im Cache von nginx ("expires 7d") haengen. + */ +function pdfjsAdressen(adresse) { + const url = new URL(adresse, location.href); + const verzeichnis = url.pathname.replace(/[^/]*$/, ""); + const mit = (datei) => `${url.origin}${verzeichnis}${datei}${url.search}`; + return { + lib: mit("pdf.min.js"), + komponenten: mit("pdf_viewer.js"), + worker: mit("pdf.worker.min.js"), + stil: mit("pdf_viewer.css"), + schriften: `${url.origin}${verzeichnis}standard_fonts/`, + }; +} + /** Laedt pdf.js einmalig nach; das Paket liegt neben dieser Datei. */ async function pdfjsLaden(basis) { + const adressen = pdfjsAdressen(basis); if (!pdfjsGeladen) { pdfjsGeladen = (async () => { - const lib = await import(`${basis}pdf.min.js`); + const lib = await import(adressen.lib); // Der Komponentenbau von pdf.js sucht die Bibliothek unter diesem Namen. globalThis.pdfjsLib = lib; - const komponenten = await import(`${basis}pdf_viewer.js`); + const komponenten = await import(adressen.komponenten); // Das Stylesheet von pdf.js zeichnet die Formularfelder. 100 KB gehoeren // nicht in jeden Seitenaufruf -- also erst hier. const stil = document.createElement("link"); stil.rel = "stylesheet"; - stil.href = `${basis}pdf_viewer.css`; + stil.href = adressen.stil; document.head.append(stil); - lib.GlobalWorkerOptions.workerSrc = `${basis}pdf.worker.min.js`; + lib.GlobalWorkerOptions.workerSrc = adressen.worker; return { lib, komponenten }; })().catch((fehler) => { pdfjsGeladen = null; @@ -173,6 +193,7 @@ async function pdfjsLaden(basis) { /** Zeigt ein PDF mit ausfuellbaren Formularfeldern. */ async function pdfBetrachter(behaelter, seiten, url, basis) { const { lib, komponenten } = await pdfjsLaden(basis); + const adressen = pdfjsAdressen(basis); const eventBus = new komponenten.EventBus(); const linkService = new komponenten.PDFLinkService({ eventBus }); const viewer = new komponenten.PDFViewer({ @@ -190,7 +211,7 @@ async function pdfBetrachter(behaelter, seiten, url, basis) { url, // Ohne die Standardschriften bleibt jede Seite mit Text leer: Helvetica // und Verwandte liegen als Datei im Paket, nicht im Browser. - standardFontDataUrl: `${basis}standard_fonts/`, + standardFontDataUrl: adressen.schriften, isEvalSupported: false, }).promise; viewer.setDocument(dokument); @@ -261,11 +282,18 @@ export function initFilePreview(wurzel = document) { const bildBuehne = stueck("bildbuehne"); const bild = stueck("bild"); const pdfBehaelter = stueck("pdf"); + const pdfScroll = stueck("pdfscroll"); const pdfSeiten = stueck("pdfseiten"); const rahmen = stueck("rahmen"); const leiste = stueck("leiste"); const hinweis = stueck("hinweis"); const speichern = stueck("speichern"); + const zeile = stueck("speichernzeile"); + const ordnerFeld = stueck("ordner"); + const nameFeld = stueck("name"); + const ersetzenFeld = stueck("ersetzen"); + const jetzt = stueck("jetzt"); + const abbrechen = stueck("abbrechen"); const basis = rumpf.dataset.viewerPdfjs || ""; // Ein Format, das dieser Browser nicht kann (HEIC ausserhalb von Safari), @@ -309,6 +337,7 @@ export function initFilePreview(wurzel = document) { } pdfSeiten.replaceChildren(); if (hinweis) hinweis.textContent = ""; + if (zeile) zeile.hidden = true; aktuell = null; }; @@ -328,7 +357,7 @@ export function initFilePreview(wurzel = document) { } if (hinweis) hinweis.textContent = "PDF wird geladen ..."; try { - pdf = await pdfBetrachter(pdfBehaelter, pdfSeiten, quelle, basis); + pdf = await pdfBetrachter(pdfScroll, pdfSeiten, quelle, basis); if (hinweis) hinweis.textContent = ""; } catch (fehler) { // Kein weisses Feld: sagen, was los ist, und auf die einfache Anzeige @@ -337,8 +366,12 @@ export function initFilePreview(wurzel = document) { zeigen("text"); rahmen.src = quelle; if (hinweis) { + // Den Grund mitgeben: auf einem fremden Geraet ist die Meldung des + // Browsers die einzige Spur, die man hat. + const grund = String(fehler?.message || fehler || "").slice(0, 120); hinweis.textContent = - "Die Formularansicht liess sich nicht laden -- es bleibt bei der einfachen Anzeige."; + "Die Formularansicht liess sich nicht laden -- es bleibt bei der einfachen Anzeige." + + (grund ? ` (${grund})` : ""); // Die Leiste traegt den Hinweis. "zeigen('text')" hat sie gerade // verborgen -- ohne diese Zeile blieb der Fehlschlag unsichtbar, und // es sah aus, als koennte die Vorschau einfach keine Formulare. @@ -365,33 +398,61 @@ export function initFilePreview(wurzel = document) { dialog.addEventListener("close", aufraeumen); - /* Ausgefuelltes Formular zurueck in die Freigabe. */ - speichern?.addEventListener("click", async () => { + /* Ausgefuelltes Formular zurueck in die Freigabe -- erst fragen, wohin. */ + + const vorschlagen = () => { + if (!aktuell) return; + const teile = aktuell.pfad.split("/"); + const datei = teile.pop() || "formular.pdf"; + ordnerFeld.value = teile.join("/"); + // Der Name der Vorlage mit Zusatz -- die Vorlage selbst bleibt so heil, + // auch wenn jemand blind auf Speichern drueckt. + nameFeld.value = datei.replace(/\.pdf$/i, "") + "-ausgefuellt.pdf"; + ersetzenFeld.checked = false; + }; + + speichern?.addEventListener("click", () => { + if (!pdf || !aktuell) return; + vorschlagen(); + zeile.hidden = false; + nameFeld.focus(); + nameFeld.select(); + }); + + abbrechen?.addEventListener("click", () => { + zeile.hidden = true; + }); + + jetzt?.addEventListener("click", async () => { if (!pdf || !aktuell) return; const ziel = speichern.dataset.url; - speichern.disabled = true; - const vorher = speichern.textContent; - speichern.textContent = "Speichern ..."; + jetzt.disabled = true; + const vorher = jetzt.textContent; + jetzt.textContent = "Speichern ..."; try { const bytes = await pdf.speichern(); const daten = new FormData(); daten.append("pfad", aktuell.pfad); + daten.append("ordner", ordnerFeld.value.trim()); + daten.append("name", nameFeld.value.trim()); + if (ersetzenFeld.checked) daten.append("ersetzen", "1"); daten.append( "datei", new Blob([bytes], { type: "application/pdf" }), - (aktuell.pfad.split("/").pop() || "formular.pdf") + nameFeld.value.trim() || "formular.pdf" ); const antwort = await window.tesm.api(ziel, { method: "POST", body: daten }); window.tesm.showToast(antwort.message || "Gespeichert.", "success"); dialog.close(); window.location.reload(); } catch (fehler) { - // Die Meldung fuer den Benutzer, die Einzelheiten fuer die Konsole. + // Die Meldung fuer den Benutzer, die Einzelheiten fuer die Konsole. Die + // Zeile bleibt offen: meist ist es ein Name, der schon vergeben ist. console.error("Formular speichern", fehler); window.tesm.showToast(fehler.message || "Speichern fehlgeschlagen.", "danger"); } finally { - speichern.disabled = false; - speichern.textContent = vorher; + jetzt.disabled = false; + jetzt.textContent = vorher; } }); } diff --git a/packages/tesm-licensing-server/pyproject.toml b/packages/tesm-licensing-server/pyproject.toml index 5eee250..a75d864 100644 --- a/packages/tesm-licensing-server/pyproject.toml +++ b/packages/tesm-licensing-server/pyproject.toml @@ -4,10 +4,10 @@ build-backend = "setuptools.build_meta" [project] name = "tesm-licensing-server" -version = "2.2.1" +version = "2.3.0" description = "Serverhaelfte des TESM-Lizenzprotokolls: ausstellen, erneuern, antworten" requires-python = ">=3.11" -dependencies = ["tesm-licensing==2.2.1", "cryptography>=41"] +dependencies = ["tesm-licensing==2.3.0", "cryptography>=41"] [tool.setuptools.packages.find] where = ["src"] diff --git a/packages/tesm-licensing/pyproject.toml b/packages/tesm-licensing/pyproject.toml index 8e52480..4b0f305 100644 --- a/packages/tesm-licensing/pyproject.toml +++ b/packages/tesm-licensing/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "tesm-licensing" -version = "2.2.1" +version = "2.3.0" description = "Ed25519-basiertes Lizenz- und Aktivierungsprotokoll fuer TESM und TESM-Lizenzserver" requires-python = ">=3.11" dependencies = ["cryptography>=42"]