diff --git a/lib/api.js b/lib/api.js index b7e5eb0..2e3e714 100644 --- a/lib/api.js +++ b/lib/api.js @@ -12,7 +12,7 @@ const path = require('path'); const fs = require('fs'); const blacklist = require('./blacklist'); const { LABEL_OPTIONS, parseLabels, serializeLabels } = require('./labels'); -const { normalizeDokumente } = require('./documents'); +const { normalizeDokumente, standardDokumente } = require('./documents'); const { userContext, currentUserId } = require('./context'); const config = require('./config'); @@ -452,14 +452,18 @@ function createExternalApi(deps) { [id, uid()] ); - // Optional: IDs of static attachments (basis_anhaenge) to enclose; none - // by default. Also reflected in the cover letter's "Anlagen" list. + // Optional: IDs of static attachments (basis_anhaenge) to enclose. Omitted + // means the user's preselection from the settings (GEN_ANLAGEN_DEFAULT, + // default: none); an empty array means explicitly none. Also reflected in + // the cover letter's "Anlagen" list. const anlagenIds = Array.isArray((req.body || {}).anlagen) ? req.body.anlagen.map((v) => parseInt(v, 10)).filter((n) => !Number.isNaN(n)) - : []; + : config.anlagenDefaultIds(); // Optional: which documents to produce (["anschreiben"], ["lebenslauf"] or - // both). Omitted / empty means both. - const dokumente = normalizeDokumente((req.body || {}).dokumente); + // both). Omitted / empty falls back to the user's preselection from the + // settings (GEN_DOKUMENTE_DEFAULT, default: both). + const gewuenscht = (req.body || {}).dokumente; + const dokumente = gewuenscht == null ? standardDokumente() : normalizeDokumente(gewuenscht); await dbRun('UPDATE bewerbungen SET generierung_dokumente = ? WHERE id = ? AND user_id = ?', [dokumente.join(','), id, uid()]); runGeneration(id, { anlagenIds, dokumente }); res.status(202).json({ success: true, dokumente }); diff --git a/lib/config.js b/lib/config.js index 457296a..44b52ff 100644 --- a/lib/config.js +++ b/lib/config.js @@ -26,6 +26,12 @@ const DEFAULTS = { OLLAMA_MODEL: 'glm-5.2:cloud', OLLAMA_HOST: 'https://ollama.com', OLLAMA_TIMEOUT_MS: '300000', + // Which documents are preselected when a generation is started (comma separated, + // see DOKUMENT_TYPEN in lib/documents.js). Empty means both. + GEN_DOKUMENTE_DEFAULT: 'anschreiben,lebenslauf', + // Which static attachments (basis_anhaenge IDs, comma separated) are preselected. + // Empty = none, which is the default. + GEN_ANLAGEN_DEFAULT: '', // E-Mail (SMTP submission + IMAP receive). MAIL_HOST: '', MAIL_SMTP_PORT: '587', @@ -57,6 +63,32 @@ const FIELDS = [ { key: 'OLLAMA_TIMEOUT_MS', label: 'Timeout (ms)', help: 'Standard: 300000 (5 Min.)' }, ], }, + { + titel: 'Bewerbungsunterlagen (Vorauswahl)', + beschreibung: 'Was ist auf der Bewerbungsseite vorab angehakt, wenn die KI-Generierung gestartet wird? Pro Bewerbung lässt sich die Auswahl weiterhin ändern; die zuletzt gewählten Dokumente bleiben an der jeweiligen Bewerbung erhalten.', + items: [ + { + key: 'GEN_DOKUMENTE_DEFAULT', + label: 'Standardmäßig generieren', + type: 'checkboxes', + options: [ + { value: 'anschreiben', label: 'Anschreiben' }, + { value: 'lebenslauf', label: 'Lebenslauf' }, + ], + help: 'Standard: beides. Die E-Mail-Begleitnachricht wird immer erzeugt. Ohne Häkchen gilt wieder beides.', + }, + { + key: 'GEN_ANLAGEN_DEFAULT', + label: 'Standardmäßig beigelegte Anlagen', + type: 'checkboxes', + // Auswahlmöglichkeiten sind die eigenen Anlagen aus „Vorlagen“ und stehen + // erst zur Renderzeit fest — die Route reicht sie unter diesem Namen hinein. + optionsFrom: 'anlagen', + leerHinweis: 'Noch keine Anlagen hinterlegt – unter „Vorlagen“ kannst du Zeugnisse & Co. hochladen.', + help: 'Standard: keine. Angehakte Anlagen werden bei jeder Generierung vorausgewählt und im Anschreiben unter „Anlagen“ aufgeführt.', + }, + ], + }, { titel: 'E-Mail (SMTP-Versand + IMAP-Empfang)', beschreibung: 'Versand läuft über den eigenen Mailserver (DKIM/SPF/DMARC-Alignment). Ohne Host/Benutzer/Passwort ist der E-Mail-Teil deaktiviert. Pro Benutzer eigenes Postfach.', @@ -159,6 +191,16 @@ function getAll() { return out; } +// The current user's preselected static attachments (GEN_ANLAGEN_DEFAULT) as +// basis_anhaenge IDs. Deleted attachments may linger in the list; callers match +// them against the existing rows, so stale IDs simply never hit. +function anlagenDefaultIds() { + return String(get('GEN_ANLAGEN_DEFAULT') || '') + .split(',') + .map((v) => parseInt(v, 10)) + .filter((n) => !Number.isNaN(n)); +} + // Ollama bundle (shared by lib/documents.js + lib/chat.js), for the current user. function ollama() { return { @@ -213,5 +255,5 @@ async function setForUser(userId, key, value) { module.exports = { DEFAULTS, FIELDS, PREFIX, - get, getAll, ollama, init, saveAll, setForUser, ensureLoaded, invalidate, + get, getAll, ollama, anlagenDefaultIds, init, saveAll, setForUser, ensureLoaded, invalidate, }; \ No newline at end of file diff --git a/lib/documents.js b/lib/documents.js index 6d3ba04..439a81f 100644 --- a/lib/documents.js +++ b/lib/documents.js @@ -153,13 +153,20 @@ const OUTPUT_SCHEMA = { const DOKUMENT_TYPEN = ['anschreiben', 'lebenslauf']; // Accept anything (string, array, undefined) and return a clean, ordered list. -// Empty / unknown input means "both" — the default everywhere. +// Empty / unknown input means "both" — the fallback everywhere. function normalizeDokumente(v) { const gewaehlt = [].concat(v == null ? [] : v).map((x) => String(x).trim().toLowerCase()); const gefiltert = DOKUMENT_TYPEN.filter((d) => gewaehlt.includes(d)); return gefiltert.length ? gefiltert : DOKUMENT_TYPEN.slice(); } +// The current user's preselection (Einstellungen → GEN_DOKUMENTE_DEFAULT): which +// documents are ticked when a generation is started without an explicit choice. +// An empty / unknown setting falls back to both. +function standardDokumente() { + return normalizeDokumente(String(config.get('GEN_DOKUMENTE_DEFAULT') || '').split(',')); +} + // Ask the model only for the documents we actually want. Dropping a section from // the schema means the model never writes it — that saves a chunk of generation // time and tokens when only one document is needed. @@ -3403,4 +3410,5 @@ module.exports = { renderDesignVorschau, DOKUMENT_TYPEN, normalizeDokumente, + standardDokumente, }; diff --git a/lib/openapi.js b/lib/openapi.js index 444c5f2..1c5629c 100644 --- a/lib/openapi.js +++ b/lib/openapi.js @@ -373,15 +373,16 @@ function buildOpenApiSpec(baseUrl = '') { items: { type: 'integer' }, description: 'IDs der zusätzlich beizulegenden statischen Anlagen (basis_anhaenge). ' + - 'Standard: keine. Die Auswahl erscheint auch im Anschreiben unter "Anlagen".', + 'Weglassen = Vorauswahl aus den Einstellungen (GEN_ANLAGEN_DEFAULT, Standard: keine), ' + + 'leeres Array = ausdrücklich keine. Die Auswahl erscheint auch im Anschreiben unter "Anlagen".', }, dokumente: { type: 'array', items: { type: 'string', enum: ['anschreiben', 'lebenslauf'] }, description: - 'Welche Unterlagen erzeugt werden sollen. Weglassen oder leer = beide (Standard). ' + - 'Wird der Lebenslauf nicht erzeugt, führt ihn das Anschreiben auch nicht unter ' + - '"Anlagen" auf, und der E-Mail-Begleittext kündigt ihn nicht an.', + 'Welche Unterlagen erzeugt werden sollen. Weglassen = Vorauswahl aus den Einstellungen ' + + '(GEN_DOKUMENTE_DEFAULT, Standard: beide). Wird der Lebenslauf nicht erzeugt, führt ihn ' + + 'das Anschreiben auch nicht unter "Anlagen" auf, und der E-Mail-Begleittext kündigt ihn nicht an.', example: ['anschreiben'], }, }, diff --git a/server.js b/server.js index 39fb51a..538074a 100644 --- a/server.js +++ b/server.js @@ -31,7 +31,7 @@ const multer = require('multer'); const { generateApplicationDocuments, generateEmailReply, generateFeinschliff, renderDesignVorschau, - DOKUMENT_TYPEN, normalizeDokumente, + DOKUMENT_TYPEN, normalizeDokumente, standardDokumente, } = require('./lib/documents'); const chat = require('./lib/chat'); const websuche = require('./lib/websuche'); @@ -2564,16 +2564,19 @@ initializeDatabase().then(async () => { anhaenge, interneAnhaenge, emails, - // Last choice of documents (defaults to both), for pre-ticking the form. - dokumentAuswahl: normalizeDokumente( - application.generierung_dokumente ? String(application.generierung_dokumente).split(',') : null - ), + // Last choice of documents for this application; if there is none yet, the + // user's preselection from the settings (default: both) pre-ticks the form. + dokumentAuswahl: application.generierung_dokumente + ? normalizeDokumente(String(application.generierung_dokumente).split(',')) + : standardDokumente(), mailConfigured: mailer.isConfigured(), mailFrom: mailer.isConfigured() ? mailer.fromField() : '', mailError: req.query.mailerror ? String(req.query.mailerror) : '', mailOk: req.query.mailok ? String(req.query.mailok) : '', basisCount: basisCountRow ? basisCountRow.count : 0, basisAnhaenge, + // Vorausgewählte Anlagen aus den Einstellungen (Standard: keine). + anlagenAuswahl: config.anlagenDefaultIds(), termine, caldavConfigured: caldav.isConfigured(), caldavTz: caldav.TZ, @@ -3868,12 +3871,25 @@ initializeDatabase().then(async () => { // Alle Konfigurationswerte (Ollama, E-Mail, CalDAV, REST-API) liegen in der // app_state-Tabelle und sind hier editierbar. Speichern wirkt sofort, ein // Neustart ist nicht nötig (die Libs lesen per config.get() zur Laufzeit). - app.get('/einstellungen', (req, res) => { - res.render('settings', { - felder: config.FIELDS, - werte: config.getAll(), - hideSettings: true, - }); + app.get('/einstellungen', async (req, res) => { + try { + // Optionen, die erst zur Laufzeit feststehen (Feld-Definition: optionsFrom). + const anlagen = await dbAll( + 'SELECT id, name, dateiname FROM basis_anhaenge WHERE user_id = ? ORDER BY id ASC', + [uid()] + ); + res.render('settings', { + felder: config.FIELDS, + werte: config.getAll(), + auswahllisten: { + anlagen: anlagen.map((a) => ({ value: String(a.id), label: a.name || a.dateiname })), + }, + hideSettings: true, + }); + } catch (error) { + console.error('Error loading settings:', error); + res.status(500).send('Serverfehler'); + } }); app.post('/einstellungen', async (req, res) => { @@ -3881,7 +3897,18 @@ initializeDatabase().then(async () => { const werte = {}; for (const sektion of config.FIELDS) { for (const f of sektion.items) { - werte[f.key] = req.body[f.key] != null ? String(req.body[f.key]) : ''; + const roh = req.body[f.key]; + if (f.type === 'checkboxes') { + // Checkbox-Gruppe: Express liefert je nach Anzahl Häkchen einen String + // oder ein Array (inkl. des leeren Hidden-Werts) — als Komma-Liste ablegen. + werte[f.key] = [] + .concat(roh == null ? [] : roh) + .map((v) => String(v).trim()) + .filter(Boolean) + .join(','); + } else { + werte[f.key] = roh != null ? String(roh) : ''; + } } } diff --git a/views/bewerbung.ejs b/views/bewerbung.ejs index edd129f..8f5514d 100644 --- a/views/bewerbung.ejs +++ b/views/bewerbung.ejs @@ -343,11 +343,12 @@ class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-700 text-gray-800 dark:text-white leading-relaxed" placeholder="z. B. Musterfirma GmbH Musterstraße 12 10115 Berlin Ansprechpartner: Frau Müller"><%= application.llm_notizen || '' %> - +

- Standardmäßig werden Anschreiben und Lebenslauf erstellt. Wird der Lebenslauf abgewählt, + Vorbelegt ist deine Auswahl aus den Einstellungen + (Standard: Anschreiben und Lebenslauf). Wird der Lebenslauf abgewählt, führt ihn das Anschreiben auch nicht mehr unter „Anlagen“ auf.

@@ -369,18 +370,20 @@

- +
<% if (basisAnhaenge && basisAnhaenge.length) { %>

- Wähle aus, welche Anlagen (z. B. Zeugnisse) mitgeschickt werden. Standardmäßig - ist nichts ausgewählt; die Auswahl wird auch im Anschreiben unter „Anlagen“ berücksichtigt. + Wähle aus, welche Anlagen (z. B. Zeugnisse) mitgeschickt werden. Vorbelegt ist deine Auswahl + aus den Einstellungen + (Standard: keine); die Auswahl wird auch im Anschreiben unter „Anlagen“ berücksichtigt.

<% basisAnhaenge.forEach(function(ba){ %>