Next.js 15 App Router mit öffentlicher Übersicht und geschütztem Admin-Bereich zur Pflege von Datenschutzerklärungen nach Art. 12ff. DSGVO. - Oberfläche auf Basis von shadcn/ui, Inter lokal eingebunden - Prisma/SQLite, Auth.js mit Credentials-Provider und Rollen - Massenimport der Merkblätter des Amtes Leezen aus PDF - Docker-Image (standalone) und Gitea-Workflow zur Veröffentlichung Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
214 lines
6.2 KiB
JavaScript
214 lines
6.2 KiB
JavaScript
/**
|
||
* Liest die Datenschutz-Merkblätter des Amtes Leezen (Art. 12ff. DSGVO) aus.
|
||
*
|
||
* Die Dokumente folgen einer festen Vorlage: ein über alle Merkblätter
|
||
* identischer Textbaustein-Teil (Verantwortlicher, Datenschutzbeauftragte,
|
||
* Betroffenen-Rechte, Beschwerde-, Widerrufsrecht) und ein variabler Teil
|
||
* (Verarbeitungstätigkeit, Zweck, Rechtsgrundlage, Empfänger, Speicherdauer,
|
||
* Quelle, Bereitstellungspflicht und deren Folgen).
|
||
*/
|
||
const fs = require("fs")
|
||
const { PDFParse } = require("pdf-parse")
|
||
|
||
/** Alle in den 117 Merkblättern vorkommenden Überschriften. */
|
||
const BOILERPLATE_HEADINGS = [
|
||
"Name und Kontaktdaten des Verantwortlichen",
|
||
"Kontaktdaten der Datenschutzbeauftragten",
|
||
"Betroffenen-Rechte",
|
||
"Beschwerderecht bei der Aufsichtsbehörde",
|
||
"Widerrufsrecht bei Einwilligung",
|
||
]
|
||
|
||
/** Reine Gliederungsüberschrift ohne eigenen Inhalt. */
|
||
const STRUCTURAL_HEADINGS = ["Zwecke und Rechtsgrundlagen der Verarbeitung"]
|
||
|
||
const VARIABLE_HEADINGS = [
|
||
"Bezeichnung der Verarbeitungstätigkeit",
|
||
"Ihre Daten werden zu folgendem Zweck erhoben",
|
||
"Ihre Daten wurden aufgrund folgender Rechtsgrundlage erhoben",
|
||
"Ihre Pflicht zur Bereitstellung der Daten",
|
||
"Folgen, wenn Sie die Daten nicht angeben",
|
||
"Wir beabsichtigen, Ihre Daten an folgende Empfänger weiterzuleiten",
|
||
"Dauer der Speicherung der personenbezogenen Daten",
|
||
"Wir haben Ihre Daten aus folgender Quelle erhalten",
|
||
]
|
||
|
||
const HEADINGS = [
|
||
...BOILERPLATE_HEADINGS,
|
||
...STRUCTURAL_HEADINGS,
|
||
...VARIABLE_HEADINGS,
|
||
]
|
||
|
||
/** Kopf-/Fußzeilen, die auf jeder Seite wiederholt werden. */
|
||
const FOOTER_START = "Besuchszeiten:"
|
||
const FOOTER_END = "Verfahren: XRechnung"
|
||
|
||
/**
|
||
* Zeilenbreite des PDF-Fließtexts (gemessenes Maximum über alle 117 Dokumente).
|
||
* Passt das erste Wort der Folgezeile nicht mehr in diese Breite, wurde die
|
||
* Zeile vom Layout umbrochen und gehört zur selben logischen Zeile.
|
||
*/
|
||
const WRAP_WIDTH = 78
|
||
|
||
async function extractText(filePath) {
|
||
const parser = new PDFParse({ data: fs.readFileSync(filePath) })
|
||
const result = await parser.getText()
|
||
return result.text
|
||
}
|
||
|
||
/** Entfernt Seitenmarker, Fußzeilenblöcke und den Briefkopf. */
|
||
function stripChrome(text) {
|
||
const lines = text.split(/\r?\n/)
|
||
const kept = []
|
||
let inFooter = false
|
||
|
||
for (const line of lines) {
|
||
const trimmed = line.trim()
|
||
|
||
if (trimmed === FOOTER_START) {
|
||
inFooter = true
|
||
continue
|
||
}
|
||
if (inFooter) {
|
||
if (trimmed === FOOTER_END) inFooter = false
|
||
continue
|
||
}
|
||
if (/^--\s*\d+\s+of\s+\d+\s*--$/.test(trimmed)) continue
|
||
if (trimmed === "Amt Leezen" || trimmed === "Der Amtsvorsteher") continue
|
||
if (trimmed === "Information über die Erhebung von Daten") continue
|
||
if (/^\(Art\. 12ff\. EU-Datenschutz-Grundverordnung\)$/.test(trimmed)) continue
|
||
|
||
kept.push(trimmed)
|
||
}
|
||
|
||
return kept
|
||
}
|
||
|
||
/** Zerlegt das Dokument an den Vorlagen-Überschriften. */
|
||
function splitSections(lines) {
|
||
const headingSet = new Set(HEADINGS)
|
||
const intro = []
|
||
const sections = {}
|
||
const order = []
|
||
let current = null
|
||
|
||
for (const line of lines) {
|
||
if (headingSet.has(line)) {
|
||
current = line
|
||
if (!sections[current]) {
|
||
sections[current] = []
|
||
order.push(current)
|
||
}
|
||
continue
|
||
}
|
||
|
||
if (current) sections[current].push(line)
|
||
else if (line) intro.push(line)
|
||
}
|
||
|
||
for (const key of Object.keys(sections)) {
|
||
sections[key] = trimBlanks(sections[key])
|
||
}
|
||
|
||
return { intro: trimBlanks(intro), sections, order }
|
||
}
|
||
|
||
function trimBlanks(lines) {
|
||
const copy = [...lines]
|
||
while (copy.length && !copy[0]) copy.shift()
|
||
while (copy.length && !copy[copy.length - 1]) copy.pop()
|
||
return copy
|
||
}
|
||
|
||
/**
|
||
* Setzt die im PDF hart umbrochenen Zeilen wieder zu logischen Zeilen zusammen.
|
||
*
|
||
* Entscheidend ist die Frage, ob eine Zeile vom Layout umbrochen wurde oder
|
||
* bewusst endete: Hätte das erste Wort der Folgezeile noch in die Zeilenbreite
|
||
* gepasst, war der Umbruch gewollt und bleibt erhalten.
|
||
*
|
||
* Leerzeilen werden ignoriert – sie stammen aus Seitenumbrüchen und trennen im
|
||
* Original keine Absätze; ein über einen Seitenumbruch laufender Satz wird so
|
||
* korrekt wieder zusammengefügt.
|
||
*/
|
||
function reflow(lines, wrapWidth = WRAP_WIDTH) {
|
||
const result = []
|
||
let buffer = ""
|
||
|
||
for (const raw of lines) {
|
||
const line = raw.trim()
|
||
if (!line) continue
|
||
|
||
if (!buffer) {
|
||
buffer = line
|
||
continue
|
||
}
|
||
|
||
const firstWord = line.split(/\s+/)[0] || ""
|
||
|
||
// 1. Das erste Wort der Folgezeile hätte nicht mehr in die Zeile gepasst.
|
||
const tooLong = buffer.length + 1 + firstWord.length > wrapWidth
|
||
|
||
// 2. Die Folgezeile beginnt klein – im Deutschen ein Fortsetzungssignal,
|
||
// sofern die vorige Zeile nicht ohnehin satzweise endet.
|
||
const continuesLowercase =
|
||
/^[a-zäöüß]/.test(line) && !/[.:;!?]$/.test(buffer)
|
||
|
||
const wasWrapped = tooLong || continuesLowercase
|
||
|
||
if (wasWrapped) {
|
||
// Am Zeilenende getrenntes Kompositum ("IT-" + "Infrastruktur") ohne
|
||
// Leerzeichen zusammenziehen. Nur wenn der Bindestrich direkt am Wort
|
||
// hängt – "StK 30 -" ist ein Trennzeichen, keine Worttrennung.
|
||
const hyphenated = /[\wäöüÄÖÜß]-$/.test(buffer) && /^[A-ZÄÖÜ]/.test(line)
|
||
buffer += hyphenated ? line : " " + line
|
||
} else {
|
||
result.push(buffer)
|
||
buffer = line
|
||
}
|
||
}
|
||
|
||
if (buffer) result.push(buffer)
|
||
return result
|
||
}
|
||
|
||
/** Fügt Überschriften-/Titelzeilen zu einer Zeile zusammen. */
|
||
function joinTitle(lines) {
|
||
return lines
|
||
.join(" ")
|
||
.replace(/\s+\//g, "/")
|
||
.replace(/\s{2,}/g, " ")
|
||
.trim()
|
||
}
|
||
|
||
/** Normalisiert Text für den Vergleich der Textbausteine. */
|
||
function normalize(lines) {
|
||
const value = Array.isArray(lines) ? lines.join(" ") : String(lines)
|
||
return value.replace(/\s+/g, " ").trim().toLowerCase()
|
||
}
|
||
|
||
async function parsePolicyPdf(filePath) {
|
||
const text = await extractText(filePath)
|
||
const lines = stripChrome(text)
|
||
const { intro, sections, order } = splitSections(lines)
|
||
const unknown = order.filter((heading) => !HEADINGS.includes(heading))
|
||
|
||
return { text, lines, intro, sections, order, unknown }
|
||
}
|
||
|
||
module.exports = {
|
||
HEADINGS,
|
||
BOILERPLATE_HEADINGS,
|
||
VARIABLE_HEADINGS,
|
||
STRUCTURAL_HEADINGS,
|
||
WRAP_WIDTH,
|
||
parsePolicyPdf,
|
||
extractText,
|
||
stripChrome,
|
||
splitSections,
|
||
reflow,
|
||
joinTitle,
|
||
normalize,
|
||
trimBlanks,
|
||
}
|