Compare commits

...
3 Commits
11 changed files with 250 additions and 61 deletions

No files matched your search

+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "tesm"
version = "2.3.1"
version = "2.4.0"
description = "TESM -- Verwaltung und Ueberwachung von PoE-Endgeraeten und Aruba-Switchen"
requires-python = ">=3.11"
dependencies = [
+19 -2
View File
@@ -86,7 +86,10 @@ def browse():
@bp.get("/<token>/herunterladen")
@permission_required("fileshare.view")
# Herunterladen traegt Daten aus dem System heraus -- in der Rechtematrix ist
# das "Exportieren", nicht "Ansehen". Eine Vorschau bleibt im Haus, eine
# heruntergeladene Datei nicht.
@permission_required("fileshare.export")
def download(token: str):
mount = _mount_or_404(token)
relative = request.args.get("pfad") or ""
@@ -184,7 +187,7 @@ def upload(token: str):
@bp.post("/<token>/formular")
@permission_required("fileshare.create")
@permission_required("fileshare_pdf.create")
def save_form(token: str):
"""Nimmt ein in der Vorschau ausgefuelltes PDF und legt es daneben ab.
@@ -207,6 +210,17 @@ def save_form(token: str):
# waere ein Umweg, um beliebige Dateien in die Freigabe zu bekommen.
return {"error": "Das ist keine PDF-Datei."}, 415
# Geprueft wird die **Datei**, nicht ein mitgeschicktes Auswahlfeld: wer nur
# anlegen darf, legt endgueltige Fassungen ab. Traegt die Datei noch
# Formularfelder, ist sie weiter veraenderbar -- dafuer braucht es
# "PDF-Formulare: Bearbeiten". Ein umgebogenes Feld im Browser hilft damit
# niemandem weiter.
if service.hat_formularfelder(daten) and not current_user.has("fileshare_pdf.edit"):
return {
"error": "Sie duerfen ausgefuellte Formulare nur abgeschlossen speichern "
"(Felder gesperrt). Bitte im Auswahlfeld 'Abgeschlossen' waehlen."
}, 403
# Wohin und unter welchem Namen -- beides darf der Benutzer bestimmen.
ordner = (request.form.get("ordner") or "").strip().strip("/")
wunschname = (request.form.get("name") or "").strip()
@@ -224,6 +238,9 @@ def save_form(token: str):
if not zielordner.is_dir():
return {"error": f"Den Ordner '{ordner}' gibt es in dieser Freigabe nicht."}, 400
if wunschname:
# Eingegeben wird der Name ohne Endung; die kommt von hier. Wer
# sie mittippt, bekommt sie nicht doppelt -- und wer eine falsche
# tippt, bekommt trotzdem eine Datei, die sich oeffnen laesst.
name = service.safe_filename(wunschname)
if not name.lower().endswith(".pdf"):
name += ".pdf"
+21 -2
View File
@@ -85,11 +85,30 @@ DHCP = Resource(
FILESHARE = Resource(
key="fileshare",
label="Dateifreigaben",
actions=(A.VIEW, A.CREATE, A.EDIT, A.DELETE),
actions=(A.VIEW, A.CREATE, A.EDIT, A.DELETE, A.EXPORT),
hint="SMB-Freigaben aus dem Verzeichnisdienst (lizenzpflichtiges Modul)",
action_hints={
A.CREATE: "Ordner anlegen und Dateien hochladen.",
A.EDIT: "Umbenennen und verschieben.",
# Genau die Bedeutung aus der Legende: Daten aus dem System
# heraustragen. Ansehen und Mitnehmen sind zweierlei -- eine Vorschau
# bleibt im Haus, eine heruntergeladene Datei nicht.
A.EXPORT: "Dateien herunterladen.",
},
)
#: Eigene Zeile statt Anhaengsel von "Dateifreigaben": ein Formular auszufuellen
#: ist ein anderer Vorgang als eine Datei hochzuladen, und wer umbenennen darf,
#: soll deshalb nicht automatisch veraenderbare Formulare ablegen duerfen.
FILESHARE_PDF = Resource(
key="fileshare_pdf",
label="PDF-Formulare",
actions=(A.VIEW, A.CREATE, A.EDIT),
hint="Formulare in der Vorschau ausfuellen und in der Freigabe ablegen",
action_hints={
A.VIEW: "In der Vorschau ausfuellen (ohne Speichern).",
A.CREATE: "Ausgefuellt speichern -- abgeschlossen, Felder gesperrt.",
A.EDIT: "Ausgefuellt speichern -- weiter ausfuellbar, inklusive Bild.",
},
)
@@ -119,7 +138,7 @@ FILES_AREA = Area(
key="files",
label="Dateifreigaben",
icon="folder",
resources=(FILESHARE,),
resources=(FILESHARE, FILESHARE_PDF),
)
PERMISSIONS = PermissionTree(
+24
View File
@@ -250,8 +250,32 @@ CREATE INDEX IF NOT EXISTS idx_restart_events_method ON restart_events(method, t
"""
# Neue Rechte sind nach einem Update bei niemandem gesetzt -- ohne diesen
# Schritt koennte niemand mehr herunterladen oder ein Formular speichern,
# obwohl sich an den Gruppen nichts geaendert hat. Die Zuordnung bildet ab, was
# vorher galt.
V4 = """
INSERT OR IGNORE INTO group_permissions (group_id, permission)
SELECT group_id, 'fileshare.export' FROM group_permissions
WHERE permission = 'fileshare.view';
INSERT OR IGNORE INTO group_permissions (group_id, permission)
SELECT group_id, 'fileshare_pdf.view' FROM group_permissions
WHERE permission = 'fileshare.view';
INSERT OR IGNORE INTO group_permissions (group_id, permission)
SELECT group_id, 'fileshare_pdf.create' FROM group_permissions
WHERE permission = 'fileshare.create';
INSERT OR IGNORE INTO group_permissions (group_id, permission)
SELECT group_id, 'fileshare_pdf.edit' FROM group_permissions
WHERE permission = 'fileshare.edit';
"""
TESM_MIGRATIONS: tuple[Migration, ...] = (
Migration(version=1, name="baseline", sql=V1),
Migration(version=2, name="seed-dhcp-options", fn=seed_dhcp_options),
Migration(version=3, name="restart-events", sql=V3),
Migration(version=4, name="fileshare-pdf-rechte", sql=V4),
)
+47 -1
View File
@@ -21,6 +21,7 @@ Sicherheitsrelevante Aenderungen gegenueber dem Vorgaenger:
from __future__ import annotations
import zlib
import logging
import mimetypes
import os
@@ -548,6 +549,10 @@ def list_directory(mount: Mount, relative: str = "") -> dict[str, Any]:
else:
record["previewable"] = entry.suffix.lower() in PREVIEW_MIME
record["preview_kind"] = preview_kind(entry.name)
# Getrennt, damit die Oberflaeche nur den Namen zum Bearbeiten
# gibt und die Endung danebenstellt.
record["stem"] = entry.stem
record["suffix"] = entry.suffix
files.append(record)
except OSError as exc:
raise FileshareError(f"Verzeichnis nicht lesbar ({exc}).") from exc
@@ -618,6 +623,29 @@ def all_directories(mount: Mount, *, tiefe: int = 4, grenze: int = 400) -> list[
return gefunden
def hat_formularfelder(daten: bytes) -> bool:
"""Steckt in diesen PDF-Bytes noch ein ausfuellbares Formular?
Gesucht wird nach Formularfeldern (``/Subtype /Widget``) -- auch in
gepackten Objektstroemen, denn moderne Schreiber legen fast alles dort ab.
Ein flach gerechnetes Formular hat keine mehr.
Bewusst ohne Zusatzpaket: die Frage ist einfach genug, und eine
PDF-Bibliothek auf jedem Zielsystem waere ein hoher Preis dafuer.
"""
text = bytearray(daten)
for treffer in re.finditer(rb"stream\r?\n", daten):
start = treffer.end()
ende = daten.find(b"endstream", start)
if ende == -1:
continue
try:
text += b"\n" + zlib.decompress(daten[start:ende])
except zlib.error:
continue # kein zlib-Strom -- Bilder, Schriften, alles moegliche
return re.search(rb"/Subtype\s*/Widget", bytes(text)) is not None
def free_name(parent: Path, stem: str, zusatz: str, suffix: str) -> Path:
"""Ein freier Name neben dem Original: ``vorlage-ausgefuellt.pdf``.
@@ -656,8 +684,26 @@ def make_directory(mount: Mount, relative: str, name: str) -> Path:
def rename_entry(mount: Mount, relative: str, new_name: str) -> Path:
"""Benennt um und behaelt die Endung der Datei.
Eingegeben wird nur der Name ohne Endung. Eine Datei, die danach auf
``.pdf.txt`` oder auf gar nichts endet, waere auf einer Windows-Freigabe
praktisch kaputt -- sie oeffnet sich nicht mehr mit dem richtigen
Programm. Verzeichnisse haben keine Endung; dort bleibt der Name, wie er
eingegeben wurde.
Wer die Endung trotzdem mittippt, bekommt sie nicht doppelt.
"""
path = resolve_path(mount, relative)
target = path.parent / safe_filename(new_name)
endung = path.suffix if path.is_file() else ""
gewuenscht = (new_name or "").strip()
if endung and gewuenscht.lower().endswith(endung.lower()):
gewuenscht = gewuenscht[: -len(endung)]
if not gewuenscht:
raise FileshareError("Der Name darf nicht leer sein.")
target = path.parent / safe_filename(gewuenscht + endung)
if target == path:
return path
if target.exists():
raise FileshareError("Ein Eintrag mit diesem Namen existiert bereits.")
path.rename(target)
@@ -106,14 +106,22 @@
data-fill-Kind="{{ entry.preview_kind }}"
data-fill-Pfad="{{ entry.relative }}"
data-fill-Name="{{ entry.name }}"
data-fill-Download="{{ url_for('fileshare.download', token=token, pfad=entry.relative) }}">
{% if current_user.has('fileshare.export') %}data-fill-Download="{{ url_for('fileshare.download', token=token, pfad=entry.relative) }}"{% endif %}>
{{ icon('file-text', size=15) }} {{ entry.name }}
</button>
{% else %}
<a class="link-icon"
href="{{ url_for('fileshare.download', token=token, pfad=entry.relative) }}">
{{ icon('file-text', size=15) }} {{ entry.name }}
</a>
{% if current_user.has('fileshare.export') %}
<a class="link-icon"
href="{{ url_for('fileshare.download', token=token, pfad=entry.relative) }}">
{{ icon('file-text', size=15) }} {{ entry.name }}
</a>
{% else %}
{# Ohne Exportrecht bleibt der Name Text: es gibt hier
nichts zu holen, und ein toter Link waere Spott. #}
<span class="link-icon faint">
{{ icon('file-text', size=15) }} {{ entry.name }}
</span>
{% endif %}
{% endif %}
</td>
<td class="numeric fs-sm">{{ entry.size | bytes }}</td>
@@ -126,19 +134,22 @@
data-fill-Kind="{{ entry.preview_kind }}"
data-fill-Pfad="{{ entry.relative }}"
data-fill-Name="{{ entry.name }}"
data-fill-Download="{{ url_for('fileshare.download', token=token, pfad=entry.relative) }}"
{% if current_user.has('fileshare.export') %}data-fill-Download="{{ url_for('fileshare.download', token=token, pfad=entry.relative) }}"{% endif %}
aria-label="Vorschau">{{ icon('eye', size=15) }}</button>
{% endif %}
{% if current_user.has('fileshare.edit') %}
<button class="btn btn--sm btn--ghost" type="button"
data-dialog-open="file-rename"
data-fill-Pfad="{{ entry.relative }}"
data-fill-Name="{{ entry.name }}"
data-fill-Name="{{ entry.stem }}"
data-fill-Endung="{{ entry.suffix or '' }}"
aria-label="Umbenennen">{{ icon('pencil', size=15) }}</button>
{% endif %}
<a class="btn btn--sm btn--ghost"
href="{{ url_for('fileshare.download', token=token, pfad=entry.relative) }}"
aria-label="Herunterladen">{{ icon('download', size=15) }}</a>
{% if current_user.has('fileshare.export') %}
<a class="btn btn--sm btn--ghost"
href="{{ url_for('fileshare.download', token=token, pfad=entry.relative) }}"
aria-label="Herunterladen">{{ icon('download', size=15) }}</a>
{% endif %}
</td>
</tr>
{% endfor %}
@@ -209,6 +220,9 @@
iPad in Originalgrösse (also einen Ausschnitt) und ein PDF auf iPadOS
nur als Standbild -- Formularfelder liessen sich nicht anfassen. #}
<div class="modal__body modal__body--flush" data-viewer
{# Ohne das Recht zum Ausfuellen zeigt die Vorschau das Dokument so,
wie es ist -- mit seinen eigenen Marken, ohne Eingabefelder. #}
data-viewer-formulare="{{ '1' if current_user.has('fileshare_pdf.view') else '' }}"
data-viewer-pdfjs="{{ asset_url('vendor/pdfjs/pdf.min.js') }}"
data-viewer-pdflib="{{ asset_url('vendor/pdflib/pdf-lib.min.js') }}">
<div class="viewer">
@@ -226,7 +240,9 @@
<iframe class="file-preview" title="Vorschau" data-viewer-rahmen
src="about:blank" referrerpolicy="same-origin" hidden></iframe>
{# Gefragt wird erst, wenn gespeichert werden soll -- vorher waere es
eine Zeile Formular ueber jeder Bildvorschau. #}
eine Zeile Formular über jeder Bildvorschau. Und nur, wer Formulare
ablegen darf, sieht sie überhaupt. #}
{% if current_user.has('fileshare_pdf.create') %}
<div class="viewer__save" data-viewer-speichernzeile hidden>
<label class="viewer__feld">
<span class="field__label">Ordner</span>
@@ -234,9 +250,24 @@
placeholder="Wurzelverzeichnis der Freigabe" autocomplete="off">
</label>
<label class="viewer__feld">
<span class="field__label">Dateiname</span>
<span class="field__label">Dateiname (ohne Endung)</span>
<input class="input" data-viewer-name autocomplete="off">
</label>
<label class="viewer__feld">
<span class="field__label">Speichern als</span>
{# Zwei Wege, beide gewollt: weiter ausfüllbar (das Bild wird das
Aussehen des Knopfes und lässt sich später austauschen) oder
abgeschlossen (alle Felder werden Teil der Seite). #}
{# Nur, was das Recht hergibt: "weiter ausfüllbar" hängt an
PDF-Formulare/Bearbeiten, "abgeschlossen" an Anlegen. Wer nur
anlegen darf, sieht eine Zeile statt einer Auswahl. #}
<select class="select" data-viewer-modus>
{% if current_user.has('fileshare_pdf.edit') %}
<option value="formular">Formular – weiter ausfüllbar</option>
{% endif %}
<option value="fertig">Abgeschlossen – Felder gesperrt</option>
</select>
</label>
<label class="check">
<input type="checkbox" data-viewer-ersetzen>
<span>Vorhandene ersetzen</span>
@@ -244,6 +275,7 @@
<button class="btn btn--sm btn--primary" type="button" data-viewer-jetzt>Speichern</button>
<button class="btn btn--sm btn--ghost" type="button" data-viewer-abbrechen>Abbrechen</button>
</div>
{% endif %}
<div class="viewer__bar" data-viewer-leiste hidden>
<button class="btn btn--sm btn--ghost" type="button" data-viewer-zoom="aus"
aria-label="Verkleinern">{{ icon('minus', size=15) }}</button>
@@ -255,10 +287,12 @@
</div>
</div>
<div class="modal__footer">
<a class="btn btn--sm" data-fill-target="Download" data-fill-as="href" href="#" download>
{{ icon('download', size=15) }} Herunterladen
</a>
{% if current_user.has('fileshare.create') %}
{% if current_user.has('fileshare.export') %}
<a class="btn btn--sm" data-fill-target="Download" data-fill-as="href" href="#" download>
{{ icon('download', size=15) }} Herunterladen
</a>
{% endif %}
{% if current_user.has('fileshare_pdf.create') %}
<button class="btn btn--sm" type="button" data-viewer-speichern hidden
data-url="{{ url_for('fileshare.save_form', token=token) }}">
{{ icon('save', size=15) }} Ausgefüllt speichern
@@ -280,9 +314,12 @@
<input type="hidden" name="parent" value="{{ listing.relative }}">
<input type="hidden" name="pfad" data-fill-target="Pfad" value="">
<div class="modal__body">
{{ field('name', 'Neuer Name', required=True, attrs='data-fill-target="Name"') }}
{{ field('name', 'Neuer Name (ohne Endung)', required=True,
attrs='data-fill-target="Name"') }}
<p class="fs-xs faint">
Umbenannt wird auf der Freigabe selbst -- es gibt dort keinen Papierkorb.
Die Endung <code data-fill-target="Endung"></code> bleibt, wie sie ist --
eine Datei ohne passende Endung öffnet auf der Freigabe nichts mehr.
Umbenannt wird dort direkt; einen Papierkorb gibt es nicht.
</p>
</div>
<div class="modal__footer">
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "tesm-core"
version = "2.3.1"
version = "2.4.0"
description = "Gemeinsames Anwendungsgeruest (Auth, RBAC, Navigation, Design-System, Migrationen) fuer TESM und TESM-Lizenzserver"
requires-python = ">=3.11"
dependencies = [
@@ -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.3.1",
app_version: str = "2.4.0",
built_in_trusted_keys: tuple[str, ...] = (),
enable_license: bool = True,
template_folder: str | None = None,
@@ -720,6 +720,16 @@
min-height: 220px;
background: var(--surface-1);
}
/* Hochkant -- Tablet oder Monitor im Hochformat -- gibt es unter dem Dialog
viel Platz, den die Obergrenze verschenkt. Beim Ausfuellen eines
Formulars zaehlt jede sichtbare Zeile, also nehmen wir dort die Hoehe,
die da ist. Der Rest (Kopf, Dateiname, Fusszeile) braucht rund 200 Pixel;
die 240 lassen etwas Luft. */
@media (orientation: portrait) {
.viewer {
height: calc(100dvh - 240px);
}
}
.viewer > .file-preview {
flex: 1;
height: auto;
@@ -763,6 +773,11 @@
position: relative;
flex: 1;
min-height: 0;
/* Ein PDF ist weisses Papier. pdf.js malt Formularfelder mit Systemfarben
und ueberlaesst Ankreuzfelder dem Browser -- im dunklen Design heisst
CanvasText weiss, und die Haken waren auf dem Papier unsichtbar. Fuer
diesen Ausschnitt gilt deshalb immer das helle Schema. */
color-scheme: light;
}
/* pdf.js rechnet fuer die Seite mit dem Standard "content-box": ihr
9-Pixel-Rahmen liegt *ausserhalb* der angegebenen Breite. Unser globales
@@ -215,44 +215,59 @@ async function pdflibLaden(adresse) {
return pdflibGeladen;
}
/** Stempelt die gewaehlten Fotos in die Bildfelder.
/** Setzt die Fotos ein und schliesst das Formular auf Wunsch ab.
*
* Eingabe sind die Bytes, die pdf.js nach dem Ausfuellen geschrieben hat --
* die Formularwerte bleiben also erhalten.
* die Feldwerte bleiben also erhalten.
*
* Das Foto wird das **Aussehen des Bildknopfes** und nicht in die Seite
* gestempelt: so bleibt der Knopf erhalten, das Bild ist trotzdem zu sehen,
* und beim naechsten Oeffnen laesst es sich austauschen. Genau das tut
* Acrobat mit "buttonImportIcon".
*
* ``abschliessen`` rechnet danach alle Felder flach in die Seite: heraus kommt
* ein PDF ohne Formular -- nichts mehr zu verstellen.
*/
async function fotosEinsetzen(bytes, bilder, adresse) {
async function nachbearbeiten(bytes, bilder, adresse, { abschliessen = false } = {}) {
const PDFLib = await pdflibLaden(adresse);
const dokument = await PDFLib.PDFDocument.load(bytes);
const formular = dokument.getForm();
for (const eintrag of bilder) {
const seite = dokument.getPage(eintrag.seite - 1);
const jpeg = await dokument.embedJpg(eintrag.foto.bytes);
const knopf = eintrag.feldname ? formular.getFieldMaybe(eintrag.feldname) : null;
if (knopf && typeof knopf.setImage === "function") {
knopf.setImage(jpeg);
continue;
}
// Kein Knopf gefunden: dann eben in die Seite. Damit nichts darueber
// liegt, muss das Feld weichen.
const seite = dokument.getPage(eintrag.seite - 1);
const [x1, y1, x2, y2] = eintrag.rechteck;
const kastenBreite = Math.abs(x2 - x1);
const kastenHoehe = Math.abs(y2 - y1);
// Einpassen statt verzerren: das Seitenverhaeltnis des Fotos bleibt.
const faktor = Math.min(kastenBreite / jpeg.width, kastenHoehe / jpeg.height);
const breite = jpeg.width * faktor;
const hoehe = jpeg.height * faktor;
seite.drawImage(jpeg, {
x: Math.min(x1, x2) + (kastenBreite - breite) / 2,
y: Math.min(y1, y2) + (kastenHoehe - hoehe) / 2,
width: breite,
height: hoehe,
x: Math.min(x1, x2) + (kastenBreite - jpeg.width * faktor) / 2,
y: Math.min(y1, y2) + (kastenHoehe - jpeg.height * faktor) / 2,
width: jpeg.width * faktor,
height: jpeg.height * faktor,
});
// Den Knopf aus der fertigen Kopie nehmen: seine Flaeche liegt ueber dem
// Seiteninhalt und wuerde das Foto verdecken. Zu tun haette er dort auch
// nichts mehr -- seine Aktion fuehrt kein Browser aus, und das Bild steht
// jetzt in der Seite. Die Vorlage bleibt unberuehrt.
try {
const feld = formular.getFieldMaybe(eintrag.feldname);
if (feld) formular.removeField(feld);
} catch (fehler) {
// Bleibt der Knopf liegen, ist das Foto verdeckt -- aber die Datei ist
// heil. Das ist der bessere von zwei schlechten Ausgaengen.
console.warn("Bildknopf liess sich nicht entfernen", fehler);
if (knopf) {
try {
formular.removeField(knopf);
} catch (fehler) {
console.warn("Bildknopf liess sich nicht entfernen", fehler);
}
}
}
if (abschliessen) {
// Alle Felder werden Teil der Seite. Danach gibt es kein Formular mehr --
// gewollt: so kann niemand mehr etwas verstellen.
formular.flatten();
}
return dokument.save();
}
@@ -304,8 +319,13 @@ async function pdfjsLaden(basis) {
return pdfjsGeladen;
}
/** Zeigt ein PDF mit ausfuellbaren Formularfeldern. */
async function pdfBetrachter(behaelter, seiten, url, basis) {
/** Zeigt ein PDF -- mit ausfuellbaren Feldern, sofern das erlaubt ist.
*
* Ohne ``formulare`` bleibt es bei der Darstellung aus dem Dokument: pdf.js
* malt dann die Marken, die im PDF stehen (etwa rotes Kreuz und gruener
* Haken), statt Bedienelemente des Browsers einzusetzen.
*/
async function pdfBetrachter(behaelter, seiten, url, basis, { formulare = true } = {}) {
const { lib, komponenten } = await pdfjsLaden(basis);
const adressen = pdfjsAdressen(basis);
const eventBus = new komponenten.EventBus();
@@ -315,8 +335,11 @@ async function pdfBetrachter(behaelter, seiten, url, basis) {
viewer: seiten,
eventBus,
linkService,
// Der Kern der Sache: Formularfelder werden zu echten Eingabefeldern.
annotationMode: lib.AnnotationMode.ENABLE_FORMS,
// Der Kern der Sache: Formularfelder werden zu echten Eingabefeldern --
// wer nicht ausfuellen darf, bekommt die Darstellung aus dem Dokument.
annotationMode: formulare
? lib.AnnotationMode.ENABLE_FORMS
: lib.AnnotationMode.ENABLE,
textLayerMode: 1,
});
linkService.setViewer(viewer);
@@ -364,6 +387,7 @@ async function pdfBetrachter(behaelter, seiten, url, basis) {
const bilder = new Map();
const bildknoepfeAufsetzen = async (nurSeite) => {
if (!formulare) return;
for (let nummer = 1; nummer <= dokument.numPages; nummer++) {
if (nurSeite && nummer !== nurSeite) continue;
const seite = await dokument.getPage(nummer);
@@ -439,10 +463,11 @@ async function pdfBetrachter(behaelter, seiten, url, basis) {
* pdf.js schreibt die Feldwerte; gewaehlte Fotos kommen danach mit
* pdf-lib hinein -- pdf.js kann nichts in ein PDF zeichnen.
*/
speichern: async (pdflibAdresse) => {
speichern: async (pdflibAdresse, { abschliessen = false } = {}) => {
const bytes = await dokument.saveDocument();
if (!bilder.size) return bytes;
return fotosEinsetzen(bytes, [...bilder.values()], pdflibAdresse);
// Ohne Bild und ohne Abschluss braucht es pdf-lib gar nicht.
if (!bilder.size && !abschliessen) return bytes;
return nachbearbeiten(bytes, [...bilder.values()], pdflibAdresse, { abschliessen });
},
/** Wurde ueberhaupt etwas eingetragen? */
veraendert: () => {
@@ -482,6 +507,7 @@ export function initFilePreview(wurzel = document) {
const ersetzenFeld = stueck("ersetzen");
const jetzt = stueck("jetzt");
const abbrechen = stueck("abbrechen");
const modusFeld = stueck("modus");
const basis = rumpf.dataset.viewerPdfjs || "";
// Ein Format, das dieser Browser nicht kann (HEIC ausserhalb von Safari),
@@ -545,7 +571,9 @@ export function initFilePreview(wurzel = document) {
}
if (hinweis) hinweis.textContent = "PDF wird geladen ...";
try {
pdf = await pdfBetrachter(pdfScroll, pdfSeiten, quelle, basis);
pdf = await pdfBetrachter(pdfScroll, pdfSeiten, quelle, basis, {
formulare: rumpf.dataset.viewerFormulare === "1",
});
if (hinweis) hinweis.textContent = "";
} catch (fehler) {
// Kein weisses Feld: sagen, was los ist, und auf die einfache Anzeige
@@ -593,9 +621,10 @@ export function initFilePreview(wurzel = document) {
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";
// Ohne Endung: die haengt der Server an. Eine Datei, die auf ".pdf.txt"
// oder auf gar nichts endet, oeffnet auf einer Freigabe nichts mehr.
// Der Zusatz haelt die Vorlage heil, auch wenn jemand blind speichert.
nameFeld.value = datei.replace(/\.pdf$/i, "") + "-ausgefuellt";
ersetzenFeld.checked = false;
};
@@ -618,7 +647,9 @@ export function initFilePreview(wurzel = document) {
const vorher = jetzt.textContent;
jetzt.textContent = "Speichern ...";
try {
const bytes = await pdf.speichern(rumpf.dataset.viewerPdflib || "");
const bytes = await pdf.speichern(rumpf.dataset.viewerPdflib || "", {
abschliessen: modusFeld?.value === "fertig",
});
const daten = new FormData();
daten.append("pfad", aktuell.pfad);
daten.append("ordner", ordnerFeld.value.trim());
@@ -627,7 +658,7 @@ export function initFilePreview(wurzel = document) {
daten.append(
"datei",
new Blob([bytes], { type: "application/pdf" }),
nameFeld.value.trim() || "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");
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "tesm-licensing"
version = "2.3.1"
version = "2.4.0"
description = "Ed25519-basiertes Lizenz- und Aktivierungsprotokoll fuer TESM und TESM-Lizenzserver"
requires-python = ">=3.11"
dependencies = ["cryptography>=42"]