Magie des Kalendermanagements

Datum

Scripting

Jedes Service-Angebot verkauft Zeiten, und diese organisiert man am einfachsten in Kalendern, auf die man von überall zugreifen kann. Sobald Dienstleistungen jedoch komplexer werden und in Buchungsvorgänge überführt werden sollen, greifen viele zu kommerziellen Programmen mit regelmäßigen Abonnementkosten.

Für Newcomer, Studierende, Start-ups oder kleinere Serviceangebote – etwa auf malamadita.de – ist das oft keine Option.

Dieser Artikel zeigt, wie sich mit Open-Source-Werkzeugen und Google Apps Script nicht-öffentliche Google-Kalender automatisch in einem öffentlichen Kalender als belegt sichtbar machen lassen.

Mehrere private Kalender (z. B. Cal1, Cal2, Cal3) übertragen ihre belegten Zeiten in einen öffentlichen Kalender, der per iframe auf der Website eingebunden ist. So lassen sich freie Zeiten transparent verwalten und anfragen.

Open-Source-Lösungen versprechen einen robusten und datensicheren Umgang. Sie sind oft genauso intuitiv einrichtbar wie kommerzielle Systeme. Der Komfort komplexer Gesamtlösungen ist jedoch nicht immer dauerhaft: Einzelne Komponenten werden unabhängig voneinander gepflegt, was langfristig zu Inkompatibilitäten führen kann.

Der praktikable Weg liegt häufig in halbautomatisierten Systemen: Nicht alles wird vollständig verknüpft, einige Stellschrauben werden bewusst manuell bedient. Die Alternative – alle Bibliotheken zu einer eigenen Monolith-Lösung zu verschmelzen – ist für Einzelentwickler selten sinnvoll.

Hier wird ein Teil einer solchen Lösung vorgestellt: eine semiautomatisierte Infrastruktur für Tätigkeitsdokumentation, Zeit- und Wegeerfassung, Rechnungsstellung sowie Buchungs- und Auftragsmanagement auf Open-Source-Basis – stabil und vergleichsweise datensicher.

Zum Einsatz kommt Thunderbird Lightning als lokale Open-Source-Kalendersoftware, synchronisiert über die Google Kalender API.

Datenschutz ist dabei kein absolutes Versprechen, sondern eine Risikoabwägung: Ziel ist es nicht, jegliche Datennutzung auszuschließen, sondern unnötige Einsicht durch Dritte zu minimieren – einschließlich kommerzieller Anbieter und Behörden.

Ein robustes Code.gs Apps Script , das du sofort einsetzen kannst: es synchronisiert nur Frei/Belegt-Zeiten und vermeidet Duplikate, indem es Quell-Event-IDs in der Beschreibung speichert. Später lässt sich das Script problemlos auf (z. B.) 12 Quellkalender skalieren. 1. Melde dich mit dem Google-Account an, der Zugriff auf beide Kalender hat (idealerweise Besitzer oder mit Schreibrechten für malamadita und Leserechte für johann franki ). 2. Öffne Google Kalender → Einstellungen → bei jedem Kalender → Einstellungen und Freigabe : Cal-x : Unter „Zugriffsberechtigungen“: "Nur Frei/Belegt (keine Details) für Öffentlichkeit anzeigen" ist okay — das betrifft das Anzeigen über die Website. Aber wichtig: der Account, mit dem du das Script ausführst, muss auf malamadita schreibend zugreifen dürfen (also Besitzer oder Editor). Cal1 : stelle sicher, dass der Script-Account Leserechte hat (in deinem Fall ist es derselbe Account, also in der Regel fine). 3. Kalender-IDs merken: Kalender → Einstellungen -> „Kalender integrieren“ → dort steht die Kalender-ID (z.B. ...@group.calendar.google.com oder eine gmail- Adresse). Du brauchst: SOURCE_CALENDAR_ID (Cal1) und TARGET_CALENDAR_ID (Cal-x). 2) Neues Google Apps Script anlegen 1. Gehe zu https://script.google.com Neues Projekt . 2. Nenne es z. B. cal-x-busy-sync . 4 / 25 javascript /** * Sync private calendars to a public "Busy-only" calendar. * - Stores original sourceEventId inside the created target event description * - Updates existing target events when times change, creates new ones when needed * - Skips all-day events (optional) — change if you want to include them * * Set SOURCE_CALENDARS = [ 'Cal1_calendar_id' ] * Set TARGET_CALENDAR_ID = 'Cal-x_calendar_id' */ const SOURCE_CALENDARS = [ 'Cal1_calendar_id_here' // replace with actual calendar ID ]; const TARGET_CALENDAR_ID = 'Cal-x_calendar_id_here' ; // replace with actual calendar ID /** * Helper: Parse a stored marker from description to find source event * Format used: "SYNC_FROM:sourceCalendarId|sourceEventId" */ function makeMarker ( sourceCalId, sourceEventId ) { return 'SYNC_FROM:' + sourceCalId + '|' + sourceEventId; } function parseMarker ( desc ) { if (!desc) return null ; const m = desc. match ( /SYNC_FROM:([^|]+)\|(.+?)(\s|$)/ ); if (!m) return null ; return { sourceCalendarId : m[ 1 ], sourceEventId : m[ 2 ] }; } /** * Main sync function. */ function syncBusyTimes ( ) { const now = new Date (); const future = new Date (); future. setMonth (future. getMonth () + 3 ); // Sync 3 Monate vorwärts (anpassbar) const targetCal = CalendarApp . getCalendarById ( TARGET_CALENDAR_ID ); if (!targetCal) { Logger . log ( 'TARGET calendar not found: ' + TARGET_CALENDAR_ID ); return ; } SOURCE_CALENDARS . forEach ( sourceCalId => { const sourceCal = CalendarApp . getCalendarById (sourceCalId); . filter ( e => e. getId () === parsed. sourceEventId ); if (matches. length === 0 ) { // source event missing — delete target to keep things clean try { te. deleteEvent (); } catch (e) { Logger . log ( 'Could not delete orphan target event: ' + e); } } } }); }); Logger . log ( 'Sync finished at ' + new Date ()); } function createBusyEvent ( targetCal, start, end, sourceCalId, sourceEventId ) { // Create with minimal title and marker in description. const ev = targetCal. createEvent ( 'Busy' , start, end, { description : makeMarker (sourceCalId, sourceEventId)}); // Set visibility (PRIVATE) — final visibility to public is controlled by calendar sharing settings. try { ev. setVisibility ( CalendarApp . Visibility . PRIVATE ); } catch (e) { /* ignore */ } } Wichtig: Ersetze Cal1_calendar_id_here und Cal-x_calendar_id_here durch die echten Kalender-IDs (siehe Schritt 1). 4) Authorisierung & Testlauf 1. Speichere das Script (Strg+S). 2. Wähle syncBusyTimes im Dropdown oben und klicke auf Run. 3. Beim ersten Lauf fordert Google die Autorisierung an (Berechtigungen für Calendar). Erlaube sie mit dem Account, der Zugriff hat. 4. Prüfe im Zielkalender Cal-x , ob neue Events mit Titel "Busy" angelegt wurden (sie haben in der Beschreibung den Marker SYNC_FROM:... ). 5. Teste durch Erstellen eines Termins in Ca1 und führe das Script erneut aus — der Termin sollte im Ziel erscheinen. Ändere die Zeit und führe wieder aus — das Ziel-Event wird aktualisiert (via Lösch+Neu). 5) Trigger einrichten (Automatisierung) 1. In Apps Script links → Triggers (Auslöser) Trigger hinzufügen . 8 / 25 syncBusyTimes Logger.log(...) SOURCE_CALENDARS 9) Optionale Verbesserungen (wenn du willst) Ignoriere bestimmte Termine (z. B. mit Label "privat-sync-exclude") — das Script könnte Titel/Description prüfen. Synchronisiere auch ganztägige Einträge (aktuell übersprungen). Schreibe ein kleines Log-Sheet in Google Sheets, das Sync-Stats speichert. Wenn du möchtest, mache ich dir jetzt 3 Dinge direkt (sofort, in dieser Antwort): 1. Ersetze die Platzhalter-IDs im Script, wenn du mir die beiden Kalender-IDs hier gibst. 2. Passe das Script an, damit ganztägige Events mit synchronisiert werden. 3. Baue eine kleine Cleanup-Routine ein, die älter als 6 Monate liegende Busy-Events entfernt. Sag mir kurz, welche der 3 du jetzt willst — oder kopiere die beiden Kalender-IDs hier (oder setze sie selbst ins Script) und du kannst sofort loslegen. Ich bin bei 4.3 Es kommt folgende Meldung: Google hasn’t verified this app The app is requesting access to sensitive info in your Google Account. Until the developer (dein Konto) verifies this app with Google, you shouldn't use it. Perfekt, du bist genau an der richtigen Stelle – und diese Meldung ist ganz normal , sobald du ein eigenes Google Apps Script zum ersten Mal ausführst. Ich erkläre dir präzise, was das bedeutet, warum das sicher ist und wie du trotzdem fortfahren kannst (ohne Risiko, solange das Script dein eigenes ist). 🧩 Warum diese Warnung erscheint Google zeigt das immer an, wenn ein Script: auf sensible Daten zugreift (z. B. Kalender, Gmail, Drive), nicht über die Google-OAuth-Verification freigegeben wurde, und nicht aus dem Google Workspace Marketplace stammt. In deinem Fall hast du selbst das Script erstellt – es läuft unter deinem eigenen Konto ( Dein_Konto ). Google kennt es also nicht als „verifizierte App“, aber du bist gleichzeitig der Entwickler und der Nutzer . Damit ist das völlig unbedenklich. 10 / 25 So gehst du jetzt vor (sicher und richtig) 1. In dem Fenster, in dem steht: “Google hasn’t verified this app” klicke auf „Advanced“ (links unten, klein geschrieben). 2. Danach erscheint ein neuer Textabschnitt mit dem Hinweis: “Go to malamadita-busy-sync (unsafe)” oder ähnlich (mit deinem Projektnamen). 👉 Klicke genau darauf. 3. Danach wirst du gefragt, welche Berechtigungen du erteilen willst. Wähle dein Konto aus. 4. Google zeigt dir, dass das Script Kalenderdaten lesen/schreiben darf – das ist gewollt. Klicke auf „Allow“ / „Zulassen“ . 5. Danach wird das Script normal ausgeführt. Beim ersten Lauf kann es ein paar Sekunden dauern, bevor du in Apps Script wieder Kontrolle hast. 🔒 Wichtiger Hinweis zur Sicherheit Da du das Script selbst in deinem eigenen Google-Konto erstellt hast: läuft es nur unter deinem Login , greift nur auf deine eigenen Kalender zu, und keine Daten verlassen dein Konto oder Google-Server. Das ist also keine externe App , sondern deine persönliche, lokale Automatisierung. Erst wenn du dieses Script irgendwann für andere Accounts freigibst (z. B. Teammitglieder mit anderen Google-Logins), wäre eine Verifizierung sinnvoll – aber für dich allein ist das unnötig. 🧭 Nach dem Bestätigen Wenn du die Autorisierung erlaubt hast: das Script läuft und legt ggf. die ersten “Busy”-Einträge im malamadita -Kalender an, du kannst im Menü oben in Apps Script → „View“ → „Executions“ oder „Logs“ die Ausführung kontrollieren. 11 / 25 Ich passe das Script so an, dass ganztägige Termine (All-day Events) aus johann franki ebenfalls als „Busy“-Blöcke im öffentlichen Kalender malamadita erscheinen. Dabei bleibt das Verhalten für normale Termine gleich. 🔧 Was sich ändert Im ursprünglichen Script gab es diese Zeile: javascript if (src. isAllDayEvent ()) return ; Das hat die Synchronisation für ganztägige Events übersprungen. Wir entfernen diese Zeile und sorgen dafür, dass sie korrekt im Zielkalender angelegt werden. Aktualisierte Version Hier ist das vollständige (bereinigte) Script mit All-Day-Support — du kannst den bisherigen Code komplett ersetzen: 12 / 25 javascript /** * Sync private calendars to a public "Busy-only" calendar. * - Supports timed and all-day events * - Avoids duplicates by storing a unique marker in description * - Updates events if start/end times change * * Replace IDs below with your real calendar IDs. */ const SOURCE_CALENDARS = [ 'johann_franki_calendar_id_here' ]; const TARGET_CALENDAR_ID = 'malamadita_calendar_id_here' ; function makeMarker ( sourceCalId, sourceEventId ) { return 'SYNC_FROM:' + sourceCalId + '|' + sourceEventId; } function parseMarker ( desc ) { if (!desc) return null ; const m = desc. match ( /SYNC_FROM:([^|]+)\|(.+?)(\s|$)/ ); if (!m) return null ; return { sourceCalendarId : m[ 1 ], sourceEventId : m[ 2 ] }; } function syncBusyTimes ( ) { const now = new Date (); const future = new Date (); future. setMonth (future. getMonth () + 3 ); // 3 Monate im Voraus synchronisieren const targetCal = CalendarApp . getCalendarById ( TARGET_CALENDAR_ID ); if (!targetCal) { Logger . log ( 'TARGET calendar not found: ' + TARGET_CALENDAR_ID ); return ; } SOURCE_CALENDARS . forEach ( sourceCalId => { const sourceCal = CalendarApp . getCalendarById (sourceCalId); if (!sourceCal) { Logger . log ( 'SOURCE calendar not found: ' + sourceCalId); return ; } const sourceEvents = sourceCal. getEvents (now, future); const targetEvents = targetCal. getEvents (now, future); sourceEvents. forEach ( src => { const srcId = src. getId (); const srcStart = src. getStartTime (); 13 / 25 const srcEnd = src. getEndTime (); const srcAllDay = src. isAllDayEvent (); // Find matching target event let matched = null ; for ( let te of targetEvents) { const parsed = parseMarker (te. getDescription ()); if (parsed && parsed. sourceCalendarId === sourceCalId && parsed. sourceEventId === srcId) { matched = te; break ; } } if (matched) { const tStart = matched. getStartTime (); const tEnd = matched. getEndTime (); const tAllDay = matched. isAllDayEvent (); if (tStart. getTime () !== srcStart. getTime () || tEnd. getTime () !== srcEnd. getTime () || tAllDay !== srcAllDay) { try { matched. deleteEvent (); } catch (e) { Logger . log ( 'Could not delete matched event: ' + e); } createBusyEvent (targetCal, srcStart, srcEnd, srcAllDay, sourceCalId, srcId); } } else { createBusyEvent (targetCal, srcStart, srcEnd, srcAllDay, sourceCalId, srcId); } }); // Cleanup orphaned target events targetEvents. forEach ( te => { const parsed = parseMarker (te. getDescription ()); if (parsed && parsed. sourceCalendarId === sourceCalId) { const matches = sourceCal. getEvents (te. getStartTime (), te. getEndTime ()) . filter ( e => e. getId () === parsed. sourceEventId ); if (matches. length === 0 ) { try { te. deleteEvent (); } catch (e) { Logger . log ( 'Could not delete orphan target event: ' + e); } } } }); }); Logger . log ( 'Sync finished at ' + new Date ()); } function createBusyEvent ( targetCal, start, end, allDay, sourceCalId, sourceEventId ) { let ev; if (allDay) { ev = targetCal. createAllDayEvent ( 'Busy' , start, { description : makeMarker (sourceCalId, sourceEventId) }); } else { ev = targetCal. createEvent ( 'Busy' , start, end, { description : 14 / 25 makeMarker (sourceCalId, sourceEventId) }); } try { ev. setVisibility ( CalendarApp . Visibility . PRIVATE ); } catch (e) { /* ignore */ } } 🧭 Was du jetzt tust 1. Ersetze im Code deine Kalender-IDs: 2. Speichern → erneut ausführen ( syncBusyTimes ) 3. Falls erneut eine Auth-Warnung kommt → wie vorhin „Advanced → Go to ... (unsafe) → Allow“. 15 / 25 🔹 javascript /** * Sync multiple private calendars to public "Busy-only" calendar (malamadita) * - Supports timed and all-day events * - Keeps only current year's events (Jan 1 - Dec 31) * - Deletes last year's Busy events after May 31 of following year * * To scale: just add up to 12 calendar IDs in SOURCE_CALENDARS */ const SOURCE_CALENDARS = [ 'johann_franki_calendar_id_here' , 'calendar2_id_here' , 'calendar3_id_here' , // ... 'calendar12_id_here' ]; const TARGET_CALENDAR_ID = 'malamadita_calendar_id_here' ; function makeMarker ( sourceCalId, sourceEventId ) { return 'SYNC_FROM:' + sourceCalId + '|' + sourceEventId; } function parseMarker ( desc ) { if (!desc) return null ; const m = desc. match ( /SYNC_FROM:([^|]+)\|(.+?)(\s|$)/ ); if (!m) return null ; return { sourceCalendarId : m[ 1 ], sourceEventId : m[ 2 ] }; } function syncBusyTimes ( ) { const today = new Date (); const currentYear = today. getFullYear (); const startOfYear = new Date (currentYear, 0 , 1 ); const endOfYear = new Date (currentYear, 11 , 31 , 23 , 59 , 59 ); const targetCal = CalendarApp . getCalendarById ( TARGET_CALENDAR_ID ); if (!targetCal) return ; SOURCE_CALENDARS . forEach ( sourceCalId => { const sourceCal = CalendarApp . getCalendarById (sourceCalId); if (!sourceCal) return ; const sourceEvents = sourceCal. getEvents (startOfYear, endOfYear); const targetEvents = targetCal. getEvents (startOfYear, endOfYear); sourceEvents. forEach ( src => { const srcId = src. getId (); const srcStart = src. getStartTime (); const srcEnd = src. getEndTime (); const srcAllDay = src. isAllDayEvent (); // check for existing target event let matched = null ; for ( let te of targetEvents) { const parsed = parseMarker (te. getDescription ()); if (parsed && parsed. sourceCalendarId === sourceCalId && parsed. sourceEventId === srcId) { matched = te; break ; } } if (matched) { const tStart = matched. getStartTime (); const tEnd = matched. getEndTime (); const tAllDay = matched. isAllDayEvent (); if (tStart. getTime () !== srcStart. getTime () || tEnd. getTime () !== srcEnd. getTime () || tAllDay !== srcAllDay) { try { matched. deleteEvent (); } catch (e) {} createBusyEvent (targetCal, srcStart, srcEnd, srcAllDay, sourceCalId, srcId); } } else { createBusyEvent (targetCal, srcStart, srcEnd, srcAllDay, sourceCalId, srcId); } }); // cleanup orphaned target events for this source targetEvents. forEach ( te => { const parsed = parseMarker (te. getDescription ()); if (parsed && parsed. sourceCalendarId === sourceCalId) { const matches = sourceCal. getEvents (te. getStartTime (), te. getEndTime ()) . filter ( e => e. getId () === parsed. sourceEventId ); if (matches. length === 0 ) { try { te. deleteEvent (); } catch (e) {} } } }); }); // cleanup previous year after May 31 const cutoffMonth = 4 ; // May const cutoffDay = 31 ; if (today. getMonth () >= cutoffMonth && today. getDate () >= cutoffDay) { const lastYear = currentYear - 1 ; const lastStart = new Date (lastYear, 0 , 1 ); const lastEnd = new Date (lastYear, 11 , 31 , 23 , 59 , 59 ); const oldEvents = targetCal. getEvents (lastStart, lastEnd); oldEvents. forEach ( ev => { try { ev. deleteEvent (); } catch (e) {} }); } } function createBusyEvent ( targetCal, start, end, allDay, sourceCalId, sourceEventId ) { if (allDay) { const ev = targetCal. createAllDayEvent ( 'Busy' , start, { description : makeMarker (sourceCalId, sourceEventId) }); try { ev. setVisibility ( CalendarApp . Visibility . PRIVATE ); } catch (e) {} } else { const ev = targetCal. createEvent ( 'Busy' , start, end, { description : makeMarker (sourceCalId, sourceEventId) }); try { ev. setVisibility ( CalendarApp . Visibility . PRIVATE ); } catch (e) {} } } SOURCE_CALENDARS 󾠮 󾠯 getEvents 󾠰 󾠱




weitere Artikel zum Thema
 

zurück

 

weiter