889 lines
36 KiB
JavaScript
889 lines
36 KiB
JavaScript
/**
|
||
* Image Importer
|
||
* Owns the Bild → Dienste import workflow via OpenRouter Vision-LLM.
|
||
* Loaded AFTER app.js so window.app is available.
|
||
*/
|
||
class ImageImporter {
|
||
constructor(app) {
|
||
this.app = app || null;
|
||
this.storage = app ? app.storage : null;
|
||
this.holidayProvider = app ? app.holidayProvider : null;
|
||
this.session = null;
|
||
this.abortController = null;
|
||
}
|
||
|
||
/**
|
||
* Resize image so the longest edge is <= 2048 px, re-encode as JPEG q=0.85.
|
||
* @param {File|Blob} file
|
||
* @returns {Promise<{blob: Blob, dataUrl: string, width: number, height: number}>}
|
||
*/
|
||
async compressImage(file) {
|
||
const objUrl = URL.createObjectURL(file);
|
||
try {
|
||
const img = await new Promise((resolve, reject) => {
|
||
const i = new Image();
|
||
i.onload = () => resolve(i);
|
||
i.onerror = () => reject(new Error('Bild konnte nicht geladen werden'));
|
||
i.src = objUrl;
|
||
});
|
||
|
||
const longest = Math.max(img.width, img.height);
|
||
let newW = img.width;
|
||
let newH = img.height;
|
||
if (longest > 2048) {
|
||
const scale = 2048 / longest;
|
||
newW = Math.round(img.width * scale);
|
||
newH = Math.round(img.height * scale);
|
||
}
|
||
|
||
const canvas = document.createElement('canvas');
|
||
canvas.width = newW;
|
||
canvas.height = newH;
|
||
const ctx = canvas.getContext('2d');
|
||
ctx.drawImage(img, 0, 0, newW, newH);
|
||
|
||
const blob = await new Promise((resolve, reject) => {
|
||
canvas.toBlob(
|
||
(b) => b ? resolve(b) : reject(new Error('toBlob fehlgeschlagen')),
|
||
'image/jpeg',
|
||
0.85
|
||
);
|
||
});
|
||
|
||
const dataUrl = await new Promise((resolve, reject) => {
|
||
const reader = new FileReader();
|
||
reader.onload = () => resolve(reader.result);
|
||
reader.onerror = () => reject(new Error('FileReader fehlgeschlagen'));
|
||
reader.readAsDataURL(blob);
|
||
});
|
||
|
||
return { blob, dataUrl, width: newW, height: newH };
|
||
} finally {
|
||
URL.revokeObjectURL(objUrl);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* POST to OpenRouter chat/completions and return the assistant message content (raw string).
|
||
* @param {string} dataUrl - 'data:image/jpeg;base64,...'
|
||
* @param {string} apiKey
|
||
* @param {string} modelId
|
||
* @param {AbortSignal} [signal]
|
||
* @returns {Promise<string>} raw assistant content (still markdown-fenced/JSON; parse later)
|
||
*/
|
||
async callVisionAPI(dataUrl, apiKey, modelId, signal) {
|
||
const body = {
|
||
model: modelId,
|
||
temperature: 0,
|
||
response_format: { type: 'json_object' },
|
||
messages: [
|
||
{
|
||
role: 'system',
|
||
content: ImageImporter.SYSTEM_PROMPT
|
||
},
|
||
{
|
||
role: 'user',
|
||
content: [
|
||
{ type: 'text', text: 'Extrahiere alle Assistenzarzt-Dienste aus dieser Dienstplan-Tabelle.' },
|
||
{ type: 'image_url', image_url: { url: dataUrl } }
|
||
]
|
||
}
|
||
]
|
||
};
|
||
|
||
const response = await fetch('https://openrouter.ai/api/v1/chat/completions', {
|
||
method: 'POST',
|
||
headers: {
|
||
'Authorization': `Bearer ${apiKey}`,
|
||
'Content-Type': 'application/json',
|
||
'HTTP-Referer': (typeof window !== 'undefined' && window.location) ? window.location.origin : '',
|
||
'X-Title': 'Dienstplan-Pro'
|
||
},
|
||
body: JSON.stringify(body),
|
||
signal: signal
|
||
});
|
||
|
||
if (!response.ok) {
|
||
const err = new Error(`OpenRouter HTTP ${response.status}`);
|
||
err.name = 'OpenRouterError';
|
||
err.status = response.status;
|
||
throw err;
|
||
}
|
||
|
||
const json = await response.json();
|
||
const content = json && json.choices && json.choices[0] && json.choices[0].message
|
||
? json.choices[0].message.content
|
||
: '';
|
||
return typeof content === 'string' ? content : JSON.stringify(content);
|
||
}
|
||
|
||
/**
|
||
* Strip markdown fences, brace-slice, JSON.parse, schema-validate.
|
||
* Invalid entries are dropped with console warnings.
|
||
* @param {string} rawContent
|
||
* @returns {{ month: number|null, year: number|null, entries: Array<{name:string,date:string,share:number}>, notes: string[] }}
|
||
*/
|
||
parseResponse(rawContent) {
|
||
if (typeof rawContent !== 'string') {
|
||
throw new SyntaxError('Antwort ist kein String');
|
||
}
|
||
|
||
let text = rawContent.trim();
|
||
|
||
// Strip ```json ... ``` or ``` ... ``` fences
|
||
text = text.replace(/^```(?:json)?\s*/i, '').replace(/\s*```$/i, '');
|
||
|
||
// Brace-slice: find first { and last }
|
||
const firstBrace = text.indexOf('{');
|
||
const lastBrace = text.lastIndexOf('}');
|
||
if (firstBrace === -1 || lastBrace === -1 || lastBrace <= firstBrace) {
|
||
throw new SyntaxError('Kein JSON-Objekt in der Antwort gefunden');
|
||
}
|
||
text = text.slice(firstBrace, lastBrace + 1);
|
||
|
||
const parsed = JSON.parse(text); // may throw SyntaxError
|
||
|
||
if (!parsed || typeof parsed !== 'object' || !Array.isArray(parsed.entries)) {
|
||
throw new Error('Schema-Fehler: entries fehlt oder ist kein Array');
|
||
}
|
||
|
||
const validEntries = [];
|
||
for (const entry of parsed.entries) {
|
||
if (!entry || typeof entry.name !== 'string' || entry.name.trim().length === 0) {
|
||
console.warn('parseResponse: Eintrag mit leerem Namen verworfen', entry);
|
||
continue;
|
||
}
|
||
if (typeof entry.date !== 'string' || !/^\d{4}-\d{2}-\d{2}$/.test(entry.date)) {
|
||
console.warn('parseResponse: Eintrag mit ungueltigem Datum verworfen', entry);
|
||
continue;
|
||
}
|
||
const d = new Date(entry.date + 'T12:00:00');
|
||
if (isNaN(d.getTime())) {
|
||
console.warn('parseResponse: Datum nicht parsebar', entry);
|
||
continue;
|
||
}
|
||
if (entry.share !== 0.5 && entry.share !== 1.0) {
|
||
console.warn('parseResponse: Eintrag mit ungueltigem share verworfen', entry);
|
||
continue;
|
||
}
|
||
validEntries.push({ name: entry.name.trim(), date: entry.date, share: entry.share });
|
||
}
|
||
|
||
const month = (typeof parsed.month === 'number' && parsed.month >= 1 && parsed.month <= 12) ? parsed.month : null;
|
||
const year = (typeof parsed.year === 'number' && parsed.year >= 2000) ? parsed.year : null;
|
||
const notes = Array.isArray(parsed.notes) ? parsed.notes.filter(n => typeof n === 'string') : [];
|
||
|
||
return { month, year, entries: validEntries, notes };
|
||
}
|
||
|
||
/**
|
||
* Levenshtein distance (O(m*n) DP, inline).
|
||
* Inputs are expected to already be normalized.
|
||
* @param {string} a
|
||
* @param {string} b
|
||
* @returns {number}
|
||
*/
|
||
levenshtein(a, b) {
|
||
if (a === b) return 0;
|
||
if (!a.length) return b.length;
|
||
if (!b.length) return a.length;
|
||
const m = a.length, n = b.length;
|
||
const dp = Array.from({ length: m + 1 }, () => new Array(n + 1).fill(0));
|
||
for (let i = 0; i <= m; i++) dp[i][0] = i;
|
||
for (let j = 0; j <= n; j++) dp[0][j] = j;
|
||
for (let i = 1; i <= m; i++) {
|
||
for (let j = 1; j <= n; j++) {
|
||
const cost = a[i - 1] === b[j - 1] ? 0 : 1;
|
||
dp[i][j] = Math.min(
|
||
dp[i - 1][j] + 1,
|
||
dp[i][j - 1] + 1,
|
||
dp[i - 1][j - 1] + cost
|
||
);
|
||
}
|
||
}
|
||
return dp[m][n];
|
||
}
|
||
|
||
/**
|
||
* Normalize: lowercase, trim, collapse internal whitespace.
|
||
* No umlaut folding (per spec section 10.1).
|
||
* @param {string} name
|
||
* @returns {string}
|
||
*/
|
||
normalizeName(name) {
|
||
return String(name).toLowerCase().trim().replace(/\s+/g, ' ');
|
||
}
|
||
|
||
/**
|
||
* For each extracted entry, try exact-normalized match against existing employees;
|
||
* else compute Levenshtein nearest with distance <= 2.
|
||
* @param {Array<{name:string,date:string,share:number}>} extractedEntries
|
||
* @param {string[]} existingEmployees
|
||
* @returns {{ matched: Array<{entry:object, resolvedName:string}>, unknowns: Array<{candidate:string, suggested:string|null}> }}
|
||
*/
|
||
matchNames(extractedEntries, existingEmployees) {
|
||
const normalizedMap = new Map();
|
||
for (const emp of existingEmployees) {
|
||
normalizedMap.set(this.normalizeName(emp), emp);
|
||
}
|
||
const sortedEmployees = [...existingEmployees].sort();
|
||
|
||
const matched = [];
|
||
const unknownsByCandidate = new Map();
|
||
|
||
for (const entry of extractedEntries) {
|
||
const normCandidate = this.normalizeName(entry.name);
|
||
if (normalizedMap.has(normCandidate)) {
|
||
matched.push({ entry, resolvedName: normalizedMap.get(normCandidate) });
|
||
continue;
|
||
}
|
||
|
||
let best = null;
|
||
let bestDist = Infinity;
|
||
for (const emp of sortedEmployees) {
|
||
const d = this.levenshtein(normCandidate, this.normalizeName(emp));
|
||
if (d < bestDist) {
|
||
bestDist = d;
|
||
best = emp;
|
||
}
|
||
}
|
||
const suggested = (best !== null && bestDist <= 2) ? best : null;
|
||
|
||
if (!unknownsByCandidate.has(entry.name)) {
|
||
unknownsByCandidate.set(entry.name, { candidate: entry.name, suggested });
|
||
}
|
||
}
|
||
|
||
return { matched, unknowns: Array.from(unknownsByCandidate.values()) };
|
||
}
|
||
|
||
/**
|
||
* Slot classification, duplicated from Feature B per spec section 9.3 (independent feature).
|
||
* @param {Date} date
|
||
* @returns {'fr'|'sa'|'so'|'weekday'}
|
||
*/
|
||
classify(date) {
|
||
const wd = date.getDay();
|
||
if (wd === 5) return 'fr';
|
||
if (wd === 6) return 'sa';
|
||
if (wd === 0) return 'so';
|
||
const isFeiertag = this.holidayProvider && this.holidayProvider.isHoliday(date);
|
||
const isTagVorFeiertag = this.holidayProvider && this.holidayProvider.isDayBeforeHoliday(date);
|
||
if (isFeiertag && isTagVorFeiertag) return 'sa';
|
||
if (isTagVorFeiertag) return 'fr';
|
||
if (isFeiertag) return 'so';
|
||
return 'weekday';
|
||
}
|
||
|
||
/**
|
||
* Entry point. Ensure API key, then open the modal on Stage 1.
|
||
*/
|
||
openImportDialog() {
|
||
let key = this.storage.getApiKey();
|
||
if (!key) {
|
||
const promptText =
|
||
'Fuer die Bilderkennung wird ein OpenRouter-API-Key benoetigt.\n' +
|
||
'Der Key wird ausschliesslich lokal in Ihrem Browser gespeichert\n' +
|
||
'und nur an openrouter.ai gesendet.\n\n' +
|
||
'Key auf https://openrouter.ai/keys anlegen und hier eintragen:';
|
||
const input = window.prompt(promptText, '');
|
||
if (!input || !input.trim()) {
|
||
if (this.app) this.app.showToast('Kein API-Key gespeichert - Import abgebrochen', 'info');
|
||
return;
|
||
}
|
||
this.storage.setApiKey(input.trim());
|
||
key = input.trim();
|
||
}
|
||
|
||
this.session = {
|
||
file: null,
|
||
thumbnailUrl: null,
|
||
dataUrl: null,
|
||
raw: null,
|
||
entries: [],
|
||
unknowns: [],
|
||
resolvedNames: new Map(),
|
||
targetYear: this.app ? this.app.currentYear : new Date().getFullYear(),
|
||
targetMonth: this.app ? this.app.currentMonth : (new Date().getMonth() + 1),
|
||
detectedMonth: null,
|
||
detectedYear: null,
|
||
notes: []
|
||
};
|
||
|
||
this.wireEventsOnce();
|
||
this.showModal();
|
||
this.showStage(1);
|
||
}
|
||
|
||
close() {
|
||
if (this.session && this.session.thumbnailUrl) {
|
||
URL.revokeObjectURL(this.session.thumbnailUrl);
|
||
}
|
||
this.session = null;
|
||
if (this.abortController) {
|
||
try { this.abortController.abort(); } catch (e) { /* ignore */ }
|
||
this.abortController = null;
|
||
}
|
||
const modal = document.getElementById('image-import-modal');
|
||
if (modal) modal.hidden = true;
|
||
|
||
const recognizeBtn = document.getElementById('image-import-recognize-btn');
|
||
if (recognizeBtn) recognizeBtn.disabled = true;
|
||
const thumbWrap = document.getElementById('image-import-thumb-wrap');
|
||
if (thumbWrap) thumbWrap.hidden = true;
|
||
const fileInput = document.getElementById('image-import-file-input');
|
||
if (fileInput) fileInput.value = '';
|
||
}
|
||
|
||
showModal() {
|
||
const modal = document.getElementById('image-import-modal');
|
||
if (modal) modal.hidden = false;
|
||
}
|
||
|
||
showStage(stageId) {
|
||
const modal = document.getElementById('image-import-modal');
|
||
if (!modal) return;
|
||
modal.querySelectorAll('.modal-stage').forEach(s => {
|
||
s.hidden = (parseInt(s.dataset.stage, 10) !== stageId);
|
||
});
|
||
}
|
||
|
||
/**
|
||
* Attach DOM event listeners. Called lazily on first openImportDialog.
|
||
* Safe to call multiple times (idempotent via this._wired flag).
|
||
*/
|
||
wireEventsOnce() {
|
||
if (this._wired) return;
|
||
this._wired = true;
|
||
|
||
const closeBtn = document.getElementById('image-import-close-btn');
|
||
const cancel1 = document.getElementById('image-import-cancel-1-btn');
|
||
const cancel3 = document.getElementById('image-import-cancel-3-btn');
|
||
[closeBtn, cancel1, cancel3].forEach(b => {
|
||
if (b) b.addEventListener('click', () => this.close());
|
||
});
|
||
|
||
const cancel2 = document.getElementById('image-import-cancel-2-btn');
|
||
if (cancel2) cancel2.addEventListener('click', () => {
|
||
if (this.abortController) {
|
||
try { this.abortController.abort(); } catch (e) { /* ignore */ }
|
||
}
|
||
this.close();
|
||
});
|
||
|
||
const fileInput = document.getElementById('image-import-file-input');
|
||
const pickBtn = document.getElementById('image-import-pick-btn');
|
||
if (pickBtn) pickBtn.addEventListener('click', () => fileInput && fileInput.click());
|
||
if (fileInput) fileInput.addEventListener('change', (e) => {
|
||
const f = e.target.files && e.target.files[0];
|
||
if (f) this.onFileSelected(f);
|
||
});
|
||
|
||
const cameraInput = document.getElementById('image-import-camera-input');
|
||
const cameraBtn = document.getElementById('image-import-camera-btn');
|
||
if (cameraBtn) cameraBtn.addEventListener('click', () => cameraInput && cameraInput.click());
|
||
if (cameraInput) cameraInput.addEventListener('change', (e) => {
|
||
const f = e.target.files && e.target.files[0];
|
||
if (f) this.onFileSelected(f);
|
||
});
|
||
|
||
const dz = document.getElementById('image-import-dropzone');
|
||
if (dz) {
|
||
dz.addEventListener('dragover', (e) => { e.preventDefault(); dz.classList.add('drag-over'); });
|
||
dz.addEventListener('dragleave', () => dz.classList.remove('drag-over'));
|
||
dz.addEventListener('drop', (e) => {
|
||
e.preventDefault();
|
||
dz.classList.remove('drag-over');
|
||
const f = e.dataTransfer && e.dataTransfer.files && e.dataTransfer.files[0];
|
||
if (f) this.onFileSelected(f);
|
||
});
|
||
}
|
||
|
||
const recognizeBtn = document.getElementById('image-import-recognize-btn');
|
||
if (recognizeBtn) recognizeBtn.addEventListener('click', () => this.runRecognition());
|
||
|
||
const confirmBtn = document.getElementById('image-import-confirm-btn');
|
||
if (confirmBtn) confirmBtn.addEventListener('click', () => this.commitImport());
|
||
}
|
||
|
||
/**
|
||
* Render Stage 3 from this.session. Idempotent (clears and rebuilds DOM).
|
||
* Uses createElement + textContent only — never innerHTML with user data.
|
||
*/
|
||
renderPreview() {
|
||
const monthNames = ['Januar', 'Februar', 'Maerz', 'April', 'Mai', 'Juni',
|
||
'Juli', 'August', 'September', 'Oktober', 'November', 'Dezember'];
|
||
const weekdayFmt = new Intl.DateTimeFormat('de-DE', { weekday: 'long' });
|
||
|
||
// ---- Notes box ----
|
||
const notesBox = document.getElementById('image-import-notes-box');
|
||
if (notesBox) {
|
||
while (notesBox.firstChild) notesBox.removeChild(notesBox.firstChild);
|
||
const detected = (this.session.detectedMonth && this.session.detectedYear)
|
||
? `${monthNames[this.session.detectedMonth - 1]} ${this.session.detectedYear}`
|
||
: null;
|
||
const target = `${monthNames[this.session.targetMonth - 1]} ${this.session.targetYear}`;
|
||
if (detected && (this.session.detectedMonth !== this.session.targetMonth
|
||
|| this.session.detectedYear !== this.session.targetYear)) {
|
||
const p = document.createElement('p');
|
||
p.className = 'text-warning';
|
||
p.textContent = `Erkannter Monat: ${detected}, aktuell ausgewaehlt: ${target}. Import laeuft auf den ausgewaehlten Monat.`;
|
||
notesBox.appendChild(p);
|
||
}
|
||
for (const note of this.session.notes) {
|
||
const p = document.createElement('p');
|
||
p.className = 'text-muted';
|
||
p.textContent = note;
|
||
notesBox.appendChild(p);
|
||
}
|
||
}
|
||
|
||
// ---- Unknown names box ----
|
||
const unknownsBox = document.getElementById('image-import-unknowns-box');
|
||
const unknownsList = document.getElementById('image-import-unknowns-list');
|
||
if (unknownsBox && unknownsList) {
|
||
while (unknownsList.firstChild) unknownsList.removeChild(unknownsList.firstChild);
|
||
if (this.session.unknowns.length === 0) {
|
||
unknownsBox.hidden = true;
|
||
} else {
|
||
unknownsBox.hidden = false;
|
||
const employees = [...this.storage.getEmployees()].sort();
|
||
for (const unk of this.session.unknowns) {
|
||
const row = document.createElement('div');
|
||
row.className = 'unknown-name-row';
|
||
|
||
const nameSpan = document.createElement('span');
|
||
nameSpan.className = 'unknown-candidate';
|
||
nameSpan.textContent = unk.candidate;
|
||
row.appendChild(nameSpan);
|
||
|
||
const select = document.createElement('select');
|
||
const optNew = document.createElement('option');
|
||
optNew.value = 'new';
|
||
optNew.textContent = 'Neuer Mitarbeiter anlegen';
|
||
select.appendChild(optNew);
|
||
for (const emp of employees) {
|
||
const o = document.createElement('option');
|
||
o.value = `assign:${emp}`;
|
||
o.textContent = `Zuordnen zu ${emp}`;
|
||
select.appendChild(o);
|
||
}
|
||
const optIgnore = document.createElement('option');
|
||
optIgnore.value = 'ignore';
|
||
optIgnore.textContent = 'Ignorieren';
|
||
select.appendChild(optIgnore);
|
||
|
||
select.value = unk.choice;
|
||
select.addEventListener('change', (e) => {
|
||
this.onUnknownChoiceChange(unk.candidate, e.target.value);
|
||
});
|
||
row.appendChild(select);
|
||
|
||
if (unk.suggested) {
|
||
const hint = document.createElement('div');
|
||
hint.className = 'fuzzy-hint';
|
||
hint.textContent = `moeglicher Match: ${unk.suggested}`;
|
||
row.appendChild(hint);
|
||
}
|
||
unknownsList.appendChild(row);
|
||
}
|
||
}
|
||
}
|
||
|
||
// ---- Preview table grouped by resolved employee ----
|
||
const tableHost = document.getElementById('image-import-preview-table');
|
||
if (tableHost) {
|
||
while (tableHost.firstChild) tableHost.removeChild(tableHost.firstChild);
|
||
const grouped = this.groupEntriesByResolvedEmployee();
|
||
|
||
for (const [employeeName, rows] of grouped.entries()) {
|
||
if (employeeName === null) continue;
|
||
const group = document.createElement('div');
|
||
group.className = 'preview-employee-group';
|
||
const h3 = document.createElement('h3');
|
||
h3.textContent = employeeName;
|
||
group.appendChild(h3);
|
||
|
||
const table = document.createElement('table');
|
||
table.className = 'preview-table';
|
||
const thead = document.createElement('thead');
|
||
const headRow = document.createElement('tr');
|
||
for (const headText of ['Datum', 'Wochentag', 'Slot', 'Anteil', 'Aktion']) {
|
||
const th = document.createElement('th');
|
||
th.textContent = headText;
|
||
headRow.appendChild(th);
|
||
}
|
||
thead.appendChild(headRow);
|
||
table.appendChild(thead);
|
||
|
||
const tbody = document.createElement('tbody');
|
||
for (const r of rows) {
|
||
const tr = document.createElement('tr');
|
||
tr.className = 'preview-row';
|
||
const m = r.entry.date.getMonth() + 1;
|
||
const y = r.entry.date.getFullYear();
|
||
const outside = (m !== this.session.targetMonth || y !== this.session.targetYear);
|
||
if (outside) tr.classList.add('outside-month');
|
||
|
||
const tdDate = document.createElement('td');
|
||
tdDate.textContent = r.entry.dateStr + (outside ? ' (ausserhalb Monat)' : '');
|
||
tr.appendChild(tdDate);
|
||
|
||
const tdWeekday = document.createElement('td');
|
||
tdWeekday.textContent = weekdayFmt.format(r.entry.date);
|
||
tr.appendChild(tdWeekday);
|
||
|
||
const tdSlot = document.createElement('td');
|
||
const slot = this.classify(r.entry.date);
|
||
const slotBadge = document.createElement('span');
|
||
slotBadge.className = `slot-badge slot-${slot}`;
|
||
slotBadge.textContent = slot;
|
||
tdSlot.appendChild(slotBadge);
|
||
tr.appendChild(tdSlot);
|
||
|
||
const tdShare = document.createElement('td');
|
||
tdShare.textContent = r.entry.share.toFixed(1);
|
||
tr.appendChild(tdShare);
|
||
|
||
const tdAction = document.createElement('td');
|
||
const removeBtn = document.createElement('button');
|
||
removeBtn.className = 'row-remove-btn';
|
||
removeBtn.title = 'Entfernen';
|
||
removeBtn.textContent = 'Entfernen';
|
||
removeBtn.addEventListener('click', () => this.onRemoveEntry(r.index));
|
||
tdAction.appendChild(removeBtn);
|
||
tr.appendChild(tdAction);
|
||
|
||
tbody.appendChild(tr);
|
||
}
|
||
table.appendChild(tbody);
|
||
group.appendChild(table);
|
||
tableHost.appendChild(group);
|
||
}
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Build Map<resolvedEmployeeName|null, [{ entry, index }]> based on session.entries
|
||
* and unknowns choices.
|
||
*/
|
||
groupEntriesByResolvedEmployee() {
|
||
const choiceByCandidate = new Map();
|
||
for (const u of this.session.unknowns) {
|
||
choiceByCandidate.set(u.candidate, u.choice);
|
||
}
|
||
|
||
const grouped = new Map();
|
||
for (let i = 0; i < this.session.entries.length; i++) {
|
||
const e = this.session.entries[i];
|
||
let resolved;
|
||
if (this.session.resolvedNames.has(e.name)) {
|
||
resolved = this.session.resolvedNames.get(e.name);
|
||
} else {
|
||
const choice = choiceByCandidate.get(e.name) || 'new';
|
||
if (choice === 'ignore') resolved = null;
|
||
else if (choice === 'new') resolved = e.name;
|
||
else if (choice.startsWith('assign:')) resolved = choice.slice('assign:'.length);
|
||
else resolved = e.name;
|
||
}
|
||
if (!grouped.has(resolved)) grouped.set(resolved, []);
|
||
grouped.get(resolved).push({ entry: e, index: i });
|
||
}
|
||
return grouped;
|
||
}
|
||
|
||
onUnknownChoiceChange(candidate, choice) {
|
||
const unk = this.session.unknowns.find(u => u.candidate === candidate);
|
||
if (unk) unk.choice = choice;
|
||
this.renderPreview();
|
||
}
|
||
|
||
onRemoveEntry(index) {
|
||
this.session.entries.splice(index, 1);
|
||
this.renderPreview();
|
||
}
|
||
|
||
/**
|
||
* Stage 1 to Stage 2 to (Stage 3 | back-to-1 on error).
|
||
* Compress, call API, parse, dedupe, match names, then showStage(3).
|
||
*/
|
||
async runRecognition() {
|
||
if (!this.session || !this.session.file) {
|
||
if (this.app) this.app.showToast('Kein Bild ausgewaehlt', 'error');
|
||
return;
|
||
}
|
||
|
||
this.showStage(2);
|
||
this.abortController = new AbortController();
|
||
|
||
try {
|
||
const compressed = await this.compressImage(this.session.file);
|
||
this.session.dataUrl = compressed.dataUrl;
|
||
|
||
const apiKey = this.storage.getApiKey();
|
||
const modelId = this.storage.getApiModel();
|
||
const rawContent = await this.callVisionAPI(
|
||
compressed.dataUrl,
|
||
apiKey,
|
||
modelId,
|
||
this.abortController.signal
|
||
);
|
||
|
||
const parsed = this.parseResponse(rawContent);
|
||
if (parsed.entries.length === 0) {
|
||
if (this.app) this.app.showToast('Keine Dienste erkannt', 'info');
|
||
this.showStage(1);
|
||
return;
|
||
}
|
||
|
||
// Dedupe (name, date) — keep higher share on conflict
|
||
const dedup = new Map();
|
||
const dupeNotes = [];
|
||
for (const e of parsed.entries) {
|
||
const key = `${e.name}|${e.date}`;
|
||
const prev = dedup.get(key);
|
||
if (prev) {
|
||
if (e.share > prev.share) {
|
||
dedup.set(key, e);
|
||
dupeNotes.push(`Doppelter Eintrag fuer ${e.name} am ${e.date} - hoeherer Anteil verwendet`);
|
||
}
|
||
} else {
|
||
dedup.set(key, e);
|
||
}
|
||
}
|
||
const dedupedEntries = Array.from(dedup.values());
|
||
|
||
const employees = this.storage.getEmployees();
|
||
const matchResult = this.matchNames(dedupedEntries, employees);
|
||
|
||
this.session.detectedMonth = parsed.month;
|
||
this.session.detectedYear = parsed.year;
|
||
this.session.notes = [...(parsed.notes || []), ...dupeNotes];
|
||
this.session.entries = dedupedEntries.map(e => ({
|
||
name: e.name,
|
||
date: new Date(e.date + 'T12:00:00'),
|
||
dateStr: e.date,
|
||
share: e.share
|
||
}));
|
||
this.session.unknowns = matchResult.unknowns.map(u => ({
|
||
candidate: u.candidate,
|
||
suggested: u.suggested,
|
||
choice: u.suggested ? `assign:${u.suggested}` : 'new'
|
||
}));
|
||
this.session.resolvedNames = new Map();
|
||
matchResult.matched.forEach(m => {
|
||
this.session.resolvedNames.set(m.entry.name, m.resolvedName);
|
||
});
|
||
|
||
this.renderPreview();
|
||
this.showStage(3);
|
||
} catch (err) {
|
||
this.handleRecognitionError(err);
|
||
} finally {
|
||
this.abortController = null;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Toast appropriate message and return to Stage 1 (or close on AbortError).
|
||
* @param {Error} err
|
||
*/
|
||
handleRecognitionError(err) {
|
||
if (err && err.name === 'AbortError') {
|
||
this.close();
|
||
return;
|
||
}
|
||
let msg;
|
||
if (err && err.name === 'OpenRouterError') {
|
||
switch (err.status) {
|
||
case 401: msg = 'API-Key ungueltig'; break;
|
||
case 402:
|
||
case 429: msg = 'Limit erreicht oder Guthaben aufgebraucht'; break;
|
||
default:
|
||
if (err.status >= 500) msg = `Server-Fehler, spaeter nochmal (HTTP ${err.status})`;
|
||
else msg = `Anfrage abgelehnt (HTTP ${err.status})`;
|
||
}
|
||
} else if (err instanceof TypeError) {
|
||
msg = 'Keine Verbindung zu OpenRouter - Internet pruefen';
|
||
} else if (err instanceof SyntaxError || (err && /JSON|Schema|entries/i.test(err.message))) {
|
||
msg = 'Erkennung fehlgeschlagen - anderes Modell probieren oder Bild pruefen';
|
||
} else {
|
||
msg = 'Erkennung fehlgeschlagen';
|
||
}
|
||
if (this.app) this.app.showToast(msg, 'error');
|
||
this.showStage(1);
|
||
}
|
||
|
||
/**
|
||
* Pure: turn session state into a commit plan.
|
||
* @param {object} session
|
||
* @returns {{ newEmployees: string[], commits: Array<{employeeName:string,year:number,month:number,date:Date,dateStr:string,share:number}>, skippedOutsideMonth: number }}
|
||
*/
|
||
resolveImports(session) {
|
||
const choiceByCandidate = new Map();
|
||
for (const u of session.unknowns) {
|
||
choiceByCandidate.set(u.candidate, u.choice);
|
||
}
|
||
|
||
const newEmployees = new Set();
|
||
const commits = [];
|
||
let skippedOutsideMonth = 0;
|
||
|
||
for (const e of session.entries) {
|
||
let resolved;
|
||
if (session.resolvedNames.has(e.name)) {
|
||
resolved = session.resolvedNames.get(e.name);
|
||
} else {
|
||
const choice = choiceByCandidate.get(e.name) || 'new';
|
||
if (choice === 'ignore') continue;
|
||
if (choice === 'new') {
|
||
resolved = e.name;
|
||
newEmployees.add(e.name);
|
||
} else if (choice.startsWith('assign:')) {
|
||
resolved = choice.slice('assign:'.length);
|
||
} else {
|
||
resolved = e.name;
|
||
}
|
||
}
|
||
|
||
const y = e.date.getFullYear();
|
||
const m = e.date.getMonth() + 1;
|
||
if (y !== session.targetYear || m !== session.targetMonth) {
|
||
skippedOutsideMonth++;
|
||
continue;
|
||
}
|
||
|
||
commits.push({
|
||
employeeName: resolved,
|
||
year: session.targetYear,
|
||
month: session.targetMonth,
|
||
date: e.date,
|
||
dateStr: e.dateStr,
|
||
share: e.share
|
||
});
|
||
}
|
||
|
||
return { newEmployees: Array.from(newEmployees), commits, skippedOutsideMonth };
|
||
}
|
||
|
||
/**
|
||
* Stage 3 to Stage 4. Resolve plan, persist via DataStorage, refresh UI.
|
||
*/
|
||
async commitImport() {
|
||
if (!this.session) return;
|
||
const plan = this.resolveImports(this.session);
|
||
|
||
for (const name of plan.newEmployees) {
|
||
this.storage.addEmployee(name);
|
||
}
|
||
|
||
let okCount = 0;
|
||
let errCount = 0;
|
||
const affectedEmployees = new Set();
|
||
for (const c of plan.commits) {
|
||
try {
|
||
this.storage.addDuty(c.employeeName, c.year, c.month, c.date, c.share);
|
||
affectedEmployees.add(c.employeeName);
|
||
okCount++;
|
||
} catch (e) {
|
||
console.error('commitImport: addDuty failed', e);
|
||
errCount++;
|
||
break; // per spec 13.4
|
||
}
|
||
}
|
||
|
||
if (this.app) {
|
||
if (plan.newEmployees.length > 0) {
|
||
this.app.loadEmployeeSelects();
|
||
this.app.loadEmployeeList();
|
||
}
|
||
this.app.loadDutiesForSelectedEmployee();
|
||
|
||
if (errCount > 0) {
|
||
this.app.showToast(`Speicherfehler - Import unvollstaendig (${okCount} von ${plan.commits.length} erfolgreich)`, 'error');
|
||
} else {
|
||
const msg = `${okCount} Dienste fuer ${affectedEmployees.size} Mitarbeiter importiert`;
|
||
this.app.showToast(msg, 'success');
|
||
if (plan.skippedOutsideMonth > 0) {
|
||
setTimeout(() => {
|
||
this.app.showToast(`${plan.skippedOutsideMonth} Eintraege ausserhalb des gewaehlten Monats uebersprungen`, 'info');
|
||
}, 1600);
|
||
}
|
||
}
|
||
}
|
||
|
||
const doneSummary = document.getElementById('image-import-done-summary');
|
||
if (doneSummary) {
|
||
doneSummary.textContent = `${okCount} Dienste fuer ${affectedEmployees.size} Mitarbeiter importiert.`;
|
||
}
|
||
this.showStage(4);
|
||
setTimeout(() => this.close(), 1500);
|
||
}
|
||
|
||
/**
|
||
* Validate file (type + size), set into session, render thumbnail, enable Erkennen.
|
||
* @param {File} file
|
||
*/
|
||
onFileSelected(file) {
|
||
if (!file.type || !file.type.startsWith('image/')) {
|
||
if (this.app) this.app.showToast('Nur Bildformate werden unterstuetzt', 'error');
|
||
return;
|
||
}
|
||
const MAX = 20 * 1024 * 1024;
|
||
if (file.size > MAX) {
|
||
if (this.app) this.app.showToast('Bild zu gross (max. 20 MB)', 'error');
|
||
return;
|
||
}
|
||
|
||
if (this.session.thumbnailUrl) URL.revokeObjectURL(this.session.thumbnailUrl);
|
||
this.session.file = file;
|
||
this.session.thumbnailUrl = URL.createObjectURL(file);
|
||
|
||
const wrap = document.getElementById('image-import-thumb-wrap');
|
||
const img = document.getElementById('image-import-thumb');
|
||
const nameEl = document.getElementById('image-import-thumb-name');
|
||
const sizeEl = document.getElementById('image-import-thumb-size');
|
||
if (img) img.src = this.session.thumbnailUrl;
|
||
if (nameEl) nameEl.textContent = file.name;
|
||
if (sizeEl) sizeEl.textContent = `${Math.round(file.size / 1024)} KB`;
|
||
if (wrap) wrap.hidden = false;
|
||
|
||
const recognizeBtn = document.getElementById('image-import-recognize-btn');
|
||
if (recognizeBtn) recognizeBtn.disabled = false;
|
||
}
|
||
}
|
||
|
||
// Verbatim system prompt — German with Umlaute (per spec §7.3).
|
||
ImageImporter.SYSTEM_PROMPT = `Du extrahierst Dienstpläne aus Tabellenbildern für eine deutsche Klinik.
|
||
|
||
Regeln:
|
||
- Die Tabelle listet pro Datum die diensthabenden Ärzte.
|
||
- Es gibt Assistenzärzte und Oberärzte. Extrahiere NUR Assistenzärzte. Oberärzte werden ignoriert.
|
||
- Wenn du nicht sicher bist, ob ein Name zu einem Assistenzarzt oder Oberarzt gehört, vermerke dies in \`notes\`.
|
||
- Wenn in einer Zelle NUR EIN Name steht: share = 1.0 für diesen Arzt.
|
||
- Wenn in einer Zelle ZWEI Namen stehen: share = 0.5 für jeden der beiden.
|
||
- Datum stets im ISO-Format YYYY-MM-DD.
|
||
- Wenn das Bild einen Monatstitel zeigt (z.B. „November 2025"), gib \`month\` (1–12) und \`year\` (vierstellig) in der Antwort an. Sonst null.
|
||
- Wenn ein Name unklar zu lesen ist, übernimm deinen besten Ratevorschlag und vermerke es in \`notes\`.
|
||
|
||
Antworte STRIKT in diesem JSON-Schema und sonst nichts:
|
||
{
|
||
"month": number | null,
|
||
"year": number | null,
|
||
"entries": [
|
||
{ "name": "string", "date": "YYYY-MM-DD", "share": 1.0 | 0.5 }
|
||
],
|
||
"notes": ["string", ...]
|
||
}`;
|
||
|
||
// Make available globally
|
||
window.ImageImporter = ImageImporter;
|
||
|
||
// Auto-instantiate when DOM + app are ready
|
||
if (typeof document !== 'undefined') {
|
||
document.addEventListener('DOMContentLoaded', () => {
|
||
if (window.app) {
|
||
window.imageImporter = new ImageImporter(window.app);
|
||
}
|
||
});
|
||
}
|