/** * Monitor-Check – Google Apps Script Backend * ------------------------------------------ * Stellt eine JSON-API fuer das Web-Tool bereit. * Deployment: Bereitstellen > Neue Bereitstellung > Web-App * Ausfuehren als: Ich * Zugriff: Jeder * * WICHTIG: API_TOKEN unten aendern und identisch im Frontend eintragen. */ var CONFIG = { SHEET_NAME: 'Monitore', LOG_SHEET_NAME: 'Protokoll', API_TOKEN: 'BITTE-AENDERN-langes-zufaelliges-token', ID_PREFIX: 'MON', ID_PAD: 3 }; var HEADERS = [ 'ID', 'Bezeichnung', 'Standort', 'Bild-URL', 'Audio', 'Bild', 'Sprache', 'Status', 'Notiz', 'Erfasst am', 'Geprueft am', 'Geprueft von' ]; var COL = { ID: 0, BEZEICHNUNG: 1, STANDORT: 2, BILD_URL: 3, AUDIO: 4, BILD: 5, SPRACHE: 6, STATUS: 7, NOTIZ: 8, ERFASST: 9, GEPRUEFT: 10, PRUEFER: 11 }; /* ------------------------------------------------------------------ */ /* Status-Logik */ /* ------------------------------------------------------------------ */ var STATUS = { OK: 'Funktionsfähig', TEIL: 'Teilweise funktionsfähig', FEHLER: 'Fehlerhaft', AUS: 'Deaktiviert', OFFEN: 'Nicht geprüft' }; /** * Regeln (audio/bild/sprache jeweils 'JA' | 'NEIN' | 'n.A.' | '') * - Felder mit 'n.A.' werden aus der Bewertung ausgeschlossen. * - Alle drei 'n.A.' -> Deaktiviert * - Kein Feld gesetzt -> Nicht geprueft * - Alle bewerteten Felder JA -> Funktionsfaehig * - Alle bewerteten Felder NEIN -> Fehlerhaft * - Gemischt -> Teilweise funktionsfaehig */ function berechneStatus(audio, bild, sprache) { var werte = [norm_(audio), norm_(bild), norm_(sprache)]; var gesetzt = werte.filter(function (w) { return w !== ''; }); if (gesetzt.length === 0) return STATUS.OFFEN; var na = werte.filter(function (w) { return w === 'n.A.'; }).length; if (na === 3) return STATUS.AUS; var bewertet = werte.filter(function (w) { return w === 'JA' || w === 'NEIN'; }); if (bewertet.length === 0) return STATUS.AUS; var ja = bewertet.filter(function (w) { return w === 'JA'; }).length; var nein = bewertet.filter(function (w) { return w === 'NEIN'; }).length; if (nein === 0) return STATUS.OK; if (ja === 0) return STATUS.FEHLER; return STATUS.TEIL; } /** * Vereinheitlicht Statustexte. Aeltere Zeilen wurden ohne Umlaute geschrieben * ("Funktionsfaehig"); beide Schreibweisen ergeben denselben Status. */ function statusKanon_(v) { var k = String(v == null ? '' : v).toLowerCase().trim() .replace(/ä/g, 'ae').replace(/ö/g, 'oe').replace(/ü/g, 'ue').replace(/ß/g, 'ss'); if (k === 'funktionsfaehig') return STATUS.OK; if (k === 'teilweise funktionsfaehig') return STATUS.TEIL; if (k === 'fehlerhaft') return STATUS.FEHLER; if (k === 'deaktiviert') return STATUS.AUS; return STATUS.OFFEN; } function norm_(v) { if (v === null || v === undefined) return ''; var s = String(v).trim().toUpperCase(); if (s === '') return ''; if (s === 'JA' || s === 'J' || s === 'YES' || s === 'TRUE') return 'JA'; if (s === 'NEIN' || s === 'N' || s === 'NO' || s === 'FALSE') return 'NEIN'; if (s === 'N.A.' || s === 'NA' || s === 'N/A' || s === 'N.A') return 'n.A.'; return ''; } /* ------------------------------------------------------------------ */ /* HTTP-Einstieg */ /* ------------------------------------------------------------------ */ function doGet(e) { return handle_(e, (e && e.parameter) ? e.parameter : {}); } function doPost(e) { var payload = {}; try { if (e && e.postData && e.postData.contents) { payload = JSON.parse(e.postData.contents); } } catch (err) { return json_({ ok: false, error: 'Ungueltiges JSON' }); } return handle_(e, payload); } function handle_(e, p) { try { if (String(p.token || '') !== CONFIG.API_TOKEN) { return json_({ ok: false, error: 'Nicht autorisiert' }); } switch (String(p.action || '')) { case 'ping': return json_({ ok: true, version: 1, time: new Date().toISOString() }); case 'list': return json_({ ok: true, items: listeEintraege_() }); case 'create': return json_(erstelleEintrag_(p)); case 'status': return json_(setzeStatus_(p)); case 'update': return json_(aktualisiereEintrag_(p)); case 'archive': return json_(archiviereEintrag_(p)); case 'delete': return json_(loescheEintrag_(p)); default: return json_({ ok: false, error: 'Unbekannte Aktion' }); } } catch (err) { return json_({ ok: false, error: String(err && err.message ? err.message : err) }); } } function json_(obj) { return ContentService .createTextOutput(JSON.stringify(obj)) .setMimeType(ContentService.MimeType.JSON); } /* ------------------------------------------------------------------ */ /* Sheet-Zugriff */ /* ------------------------------------------------------------------ */ function sheet_() { var ss = SpreadsheetApp.getActiveSpreadsheet(); var sh = ss.getSheetByName(CONFIG.SHEET_NAME); if (!sh) { sh = ss.insertSheet(CONFIG.SHEET_NAME); } if (sh.getLastRow() === 0 || String(sh.getRange(1, 1).getValue()).trim() === '') { sh.getRange(1, 1, 1, HEADERS.length).setValues([HEADERS]); sh.getRange(1, 1, 1, HEADERS.length).setFontWeight('bold'); sh.setFrozenRows(1); } return sh; } function daten_() { var sh = sheet_(); var last = sh.getLastRow(); if (last < 2) return []; return sh.getRange(2, 1, last - 1, HEADERS.length).getValues(); } function zeileFuerId_(id) { var rows = daten_(); for (var i = 0; i < rows.length; i++) { if (String(rows[i][COL.ID]).trim() === String(id).trim()) { return { row: i + 2, values: rows[i] }; } } return null; } function listeEintraege_() { return daten_() .filter(function (r) { return String(r[COL.ID]).trim() !== ''; }) .map(function (r) { return { id: String(r[COL.ID]), bezeichnung: String(r[COL.BEZEICHNUNG] || ''), standort: String(r[COL.STANDORT] || ''), bildUrl: String(r[COL.BILD_URL] || ''), audio: norm_(r[COL.AUDIO]), bild: norm_(r[COL.BILD]), sprache: norm_(r[COL.SPRACHE]), status: statusKanon_(r[COL.STATUS]), notiz: String(r[COL.NOTIZ] || ''), erfasstAm: iso_(r[COL.ERFASST]), geprueftAm: iso_(r[COL.GEPRUEFT]), pruefer: String(r[COL.PRUEFER] || '') }; }); } function iso_(v) { if (!v) return ''; if (v instanceof Date) return v.toISOString(); return String(v); } /* ------------------------------------------------------------------ */ /* Aktionen */ /* ------------------------------------------------------------------ */ function erstelleEintrag_(p) { var bez = String(p.bezeichnung || '').trim(); if (!bez) return { ok: false, error: 'Bezeichnung fehlt' }; var lock = LockService.getScriptLock(); lock.waitLock(20000); try { var sh = sheet_(); var id = naechsteId_(sh); var jetzt = new Date(); var audio = norm_(p.audio); var bild = norm_(p.bild); var sprache = norm_(p.sprache); var status = berechneStatus(audio, bild, sprache); var geprueft = status === STATUS.OFFEN ? '' : jetzt; var zeile = []; zeile[COL.ID] = id; zeile[COL.BEZEICHNUNG] = bez; zeile[COL.STANDORT] = String(p.standort || '').trim(); zeile[COL.BILD_URL] = String(p.bildUrl || '').trim(); zeile[COL.AUDIO] = audio; zeile[COL.BILD] = bild; zeile[COL.SPRACHE] = sprache; zeile[COL.STATUS] = status; zeile[COL.NOTIZ] = String(p.notiz || '').trim(); zeile[COL.ERFASST] = jetzt; zeile[COL.GEPRUEFT] = geprueft; zeile[COL.PRUEFER] = String(p.pruefer || '').trim(); sh.appendRow(zeile); faerbeZeile_(sh, sh.getLastRow(), status); protokolliere_(id, 'angelegt', status, p.pruefer); return { ok: true, id: id, status: status }; } finally { lock.releaseLock(); } } /** * Naechste freie ID. Der hoechste je vergebene Zaehler wird zusaetzlich in den * Skript-Eigenschaften gemerkt, damit IDs geloeschter Eintraege nie erneut * vergeben werden. */ function naechsteId_(sh) { var props = PropertiesService.getScriptProperties(); var max = parseInt(props.getProperty('lastId') || '0', 10); if (isNaN(max)) max = 0; var rows = daten_(); var re = new RegExp('^' + CONFIG.ID_PREFIX + '-(\\d+)$'); for (var i = 0; i < rows.length; i++) { var m = re.exec(String(rows[i][COL.ID]).trim()); if (m) { var n = parseInt(m[1], 10); if (n > max) max = n; } } var nummer = max + 1; props.setProperty('lastId', String(nummer)); var next = String(nummer); while (next.length < CONFIG.ID_PAD) next = '0' + next; return CONFIG.ID_PREFIX + '-' + next; } function setzeStatus_(p) { var treffer = zeileFuerId_(p.id); if (!treffer) return { ok: false, error: 'ID nicht gefunden: ' + p.id }; var sh = sheet_(); var audio = norm_(p.audio); var bild = norm_(p.bild); var sprache = norm_(p.sprache); var status = berechneStatus(audio, bild, sprache); var jetzt = new Date(); sh.getRange(treffer.row, COL.AUDIO + 1, 1, 4) .setValues([[audio, bild, sprache, status]]); if (p.notiz !== undefined) { sh.getRange(treffer.row, COL.NOTIZ + 1).setValue(String(p.notiz || '')); } sh.getRange(treffer.row, COL.GEPRUEFT + 1, 1, 2) .setValues([[jetzt, String(p.pruefer || '')]]); faerbeZeile_(sh, treffer.row, status); protokolliere_(p.id, 'Status gesetzt', status, p.pruefer); return { ok: true, id: String(p.id), status: status, geprueftAm: jetzt.toISOString() }; } function aktualisiereEintrag_(p) { var treffer = zeileFuerId_(p.id); if (!treffer) return { ok: false, error: 'ID nicht gefunden: ' + p.id }; var sh = sheet_(); if (p.bezeichnung !== undefined) sh.getRange(treffer.row, COL.BEZEICHNUNG + 1).setValue(String(p.bezeichnung)); if (p.standort !== undefined) sh.getRange(treffer.row, COL.STANDORT + 1).setValue(String(p.standort)); if (p.bildUrl !== undefined) sh.getRange(treffer.row, COL.BILD_URL + 1).setValue(String(p.bildUrl)); if (p.notiz !== undefined) sh.getRange(treffer.row, COL.NOTIZ + 1).setValue(String(p.notiz)); protokolliere_(p.id, 'bearbeitet', '', p.pruefer); return { ok: true, id: String(p.id) }; } function archiviereEintrag_(p) { var treffer = zeileFuerId_(p.id); if (!treffer) return { ok: false, error: 'ID nicht gefunden: ' + p.id }; var sh = sheet_(); sh.getRange(treffer.row, COL.AUDIO + 1, 1, 4).setValues([['n.A.', 'n.A.', 'n.A.', STATUS.AUS]]); faerbeZeile_(sh, treffer.row, STATUS.AUS); protokolliere_(p.id, 'deaktiviert', STATUS.AUS, p.pruefer); return { ok: true, id: String(p.id), status: STATUS.AUS }; } function loescheEintrag_(p) { var treffer = zeileFuerId_(p.id); if (!treffer) return { ok: false, error: 'ID nicht gefunden: ' + p.id }; var lock = LockService.getScriptLock(); lock.waitLock(20000); try { // Zeile koennte sich zwischenzeitlich verschoben haben – erneut suchen. treffer = zeileFuerId_(p.id); if (!treffer) return { ok: false, error: 'ID nicht gefunden: ' + p.id }; var bez = String(treffer.values[COL.BEZEICHNUNG] || ''); var bild = String(treffer.values[COL.BILD_URL] || ''); sheet_().deleteRow(treffer.row); protokolliere_(p.id, 'geloescht (' + bez + ')', bild, p.pruefer); return { ok: true, id: String(p.id) }; } finally { lock.releaseLock(); } } /* ------------------------------------------------------------------ */ /* Darstellung + Protokoll */ /* ------------------------------------------------------------------ */ function faerbeZeile_(sh, row, status) { var farben = {}; farben[STATUS.OK] = '#e6f4ea'; farben[STATUS.TEIL] = '#fef7e0'; farben[STATUS.FEHLER] = '#fce8e6'; farben[STATUS.AUS] = '#f1f3f4'; farben[STATUS.OFFEN] = '#ffffff'; sh.getRange(row, 1, 1, HEADERS.length).setBackground(farben[status] || '#ffffff'); } function protokolliere_(id, aktion, status, pruefer) { try { var ss = SpreadsheetApp.getActiveSpreadsheet(); var log = ss.getSheetByName(CONFIG.LOG_SHEET_NAME); if (!log) { log = ss.insertSheet(CONFIG.LOG_SHEET_NAME); log.appendRow(['Zeitpunkt', 'ID', 'Aktion', 'Status', 'Person']); log.getRange(1, 1, 1, 5).setFontWeight('bold'); log.setFrozenRows(1); } log.appendRow([new Date(), String(id || ''), String(aktion || ''), String(status || ''), String(pruefer || '')]); } catch (err) { // Protokoll ist optional – Fehler hier duerfen die Aktion nicht abbrechen. } } /* ------------------------------------------------------------------ */ /* Einmalige Einrichtung / Menue */ /* ------------------------------------------------------------------ */ function onOpen() { SpreadsheetApp.getUi() .createMenu('Monitor-Check') .addItem('Tabelle einrichten', 'einrichten') .addItem('Alle Status neu berechnen', 'statusNeuBerechnen') .addToUi(); } function einrichten() { var sh = sheet_(); sh.setColumnWidth(COL.ID + 1, 90); sh.setColumnWidth(COL.BEZEICHNUNG + 1, 200); sh.setColumnWidth(COL.STANDORT + 1, 160); sh.setColumnWidth(COL.BILD_URL + 1, 260); sh.setColumnWidth(COL.STATUS + 1, 190); sh.setColumnWidth(COL.NOTIZ + 1, 260); var jn = SpreadsheetApp.newDataValidation() .requireValueInList(['JA', 'NEIN', 'n.A.'], true) .setAllowInvalid(false) .build(); sh.getRange(2, COL.AUDIO + 1, 1000, 3).setDataValidation(jn); protokolliere_('', 'Tabelle eingerichtet', '', Session.getActiveUser().getEmail()); SpreadsheetApp.getUi().alert('Tabelle "' + CONFIG.SHEET_NAME + '" ist eingerichtet.'); } function statusNeuBerechnen() { var sh = sheet_(); var rows = daten_(); for (var i = 0; i < rows.length; i++) { if (String(rows[i][COL.ID]).trim() === '') continue; var s = berechneStatus(rows[i][COL.AUDIO], rows[i][COL.BILD], rows[i][COL.SPRACHE]); sh.getRange(i + 2, COL.STATUS + 1).setValue(s); faerbeZeile_(sh, i + 2, s); } } /* ------------------------------------------------------------------ */ /* Selbsttest (im Editor ausfuehrbar) */ /* ------------------------------------------------------------------ */ function testStatusLogik() { var faelle = [ [['JA', 'JA', 'JA'], STATUS.OK], [['NEIN', 'NEIN', 'NEIN'], STATUS.FEHLER], [['JA', 'NEIN', 'JA'], STATUS.TEIL], [['JA', 'NEIN', 'NEIN'], STATUS.TEIL], [['n.A.', 'n.A.', 'n.A.'], STATUS.AUS], [['n.A.', 'JA', 'JA'], STATUS.OK], [['n.A.', 'NEIN', 'NEIN'], STATUS.FEHLER], [['n.A.', 'JA', 'NEIN'], STATUS.TEIL], [['', '', ''], STATUS.OFFEN] ]; var fehler = []; faelle.forEach(function (f) { var ist = berechneStatus(f[0][0], f[0][1], f[0][2]); if (ist !== f[1]) fehler.push(f[0].join('/') + ' -> ' + ist + ' (erwartet ' + f[1] + ')'); }); if (fehler.length) throw new Error('Fehlgeschlagen:\n' + fehler.join('\n')); Logger.log('Alle ' + faelle.length + ' Testfaelle bestanden.'); }