brain/docs/superpowers/plans/2026-04-12-o365-calendar-in...

34 KiB

O365 Calendar Integration — Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Enable meeting note creation from O365 calendar events with automatic participant matching, series recognition, and agenda pre-population — via Templater and via Claude.

Architecture: Two independent creation paths (Templater User Script + MCP Server) share the same note format and matching logic. Node.js scripts handle Graph API access and person matching for the Templater path. Claude applies the same rules via MCP tools and CLAUDE.md instructions.

Tech Stack: Node.js (v25), @azure/msal-node, @microsoft/microsoft-graph-client, Templater plugin, Softeria graph-mcp-server (or alternative)

Vault: /home/chk/Dokumente/brain (no git repo — no commits, verify manually)

Spec: docs/superpowers/specs/2026-04-12-o365-calendar-integration-design.md


File Structure

scripts/                          # Templater user scripts folder
  package.json                    # Dependencies
  .env                            # OAuth credentials (NOT synced)
  .env.example                    # Template for credentials
  o365-calendar.js                # Graph API client: auth, fetch events (ES module)
  person-matcher.js               # Read vault persons, match by email/name, auto-create (ES module)
  meeting-builder.js              # Orchestrator: combines calendar + matching + agenda → note data (ES module)
  create-meeting-note.js          # Templater user script entry point (CommonJS wrapper)
  test/
    o365-calendar.test.js         # Unit tests for calendar fetching
    person-matcher.test.js        # Unit tests for matching logic
    meeting-builder.test.js       # Integration test for full flow

Templates/                        # Templater template folder (new)
  Meeting Note.md                 # Thin Templater template calling user script

03 Bereiche/Meetings/             # New folder for meeting notes

03 Bereiche/IT-Management/
  O365 Kalender Integration.md    # User documentation

CLAUDE.md                         # Updated with matching rules for Claude path

Task 1: Vault Structure & Frontmatter Updates

Files:

  • Create: 03 Bereiche/Meetings/.gitkeep

  • Modify: 00 Kontext/Personen/Christopher Klein.md (add email)

  • Modify: 00 Kontext/Personen/Eray Kara.md (add email)

  • Modify: 00 Kontext/Personen/Florian Fiala.md (add email)

  • Modify: 00 Kontext/Personen/Josiah Stieve.md (add email)

  • Modify: 00 Kontext/Personen/Philip Losch.md (add email)

  • Modify: 00 Kontext/Personen/Steffen Ackerschott.md (add email)

  • Modify: 00 Kontext/Personen/Steffen Brauer.md (add email)

  • Modify: 00 Kontext/Firmen/KRAH.md (add domain)

  • Step 1: Create Meetings folder

mkdir -p "03 Bereiche/Meetings"

Note: No .gitkeep needed — vault has no git repo.

  • Step 2: Add domain to KRAH company note

In 00 Kontext/Firmen/KRAH.md, add domain: krah.de to frontmatter:

---
tags: [firma]
name: KRAH Elektrotechnische Fabrik GmbH & Co. KG
kurzname: KRAH
branche: Elektrotechnik
ort: Drolshagen, NRW
domain: krah.de
---
  • Step 3: Add email to all KRAH person notes

For each person note in 00 Kontext/Personen/, add an email field to the frontmatter. The user must provide the actual email addresses. Use this pattern:

Christopher Klein.md:

---
tags: [person, mitarbeiter]
vorname: Christopher
nachname: Klein
email: TODO@krah.de
kategorie: Mitarbeiter
# ... rest unchanged
---

Repeat for: Eray Kara.md, Florian Fiala.md, Josiah Stieve.md, Philip Losch.md, Steffen Ackerschott.md, Steffen Brauer.md.

Important: Ask the user for the actual email addresses before filling them in. Do not guess.

  • Step 4: Verify all person notes have email field

Read each person note and confirm the email field is present in frontmatter.


Task 2: Node.js Project Setup

Files:

  • Create: scripts/package.json

  • Create: scripts/.env

  • Create: scripts/.env.example

  • Step 1: Create scripts/package.json

{
  "name": "obsidian-o365-scripts",
  "version": "1.0.0",
  "private": true,
  "type": "module",
  "description": "O365 calendar integration scripts for Obsidian Templater",
  "scripts": {
    "test": "node --test test/*.test.js"
  },
  "dependencies": {
    "@azure/msal-node": "^2.16.0",
    "@microsoft/microsoft-graph-client": "^3.0.7"
  }
}
  • Step 2: Create scripts/.env.example
# Azure AD App Registration
AZURE_TENANT_ID=your-tenant-id
AZURE_CLIENT_ID=your-client-id
AZURE_CLIENT_SECRET=your-client-secret
# User whose calendar to read (for application flow)
AZURE_USER_EMAIL=christian.kauer@krah.de
  • Step 3: Create scripts/.env with actual credentials

Copy .env.example to .env and fill in the actual values from the user's Azure App Registration. Ask user for:

  • Tenant ID

  • Client ID

  • Client Secret

  • User email for calendar access

  • Step 4: Install dependencies

cd scripts && npm install
  • Step 5: Verify .env is excluded from sync

Check that Remotely Save or any sync mechanism ignores scripts/.env. If the vault uses .gitignore or similar, add:

scripts/.env
scripts/node_modules/

Task 3: O365 Calendar Client

Files:

  • Create: scripts/o365-calendar.js

  • Create: scripts/test/o365-calendar.test.js

  • Step 1: Write tests for calendar client

Create scripts/test/o365-calendar.test.js:

import { describe, it, mock } from 'node:test';
import assert from 'node:assert/strict';
import { parseEventToMeeting, formatEventChoice, isRecurring } from '../o365-calendar.js';

describe('parseEventToMeeting', () => {
  it('extracts meeting data from Graph API event', () => {
    const event = {
      id: 'AAMkAG123',
      subject: 'IT Team Weekly',
      start: { dateTime: '2026-04-14T09:00:00', timeZone: 'Europe/Berlin' },
      end: { dateTime: '2026-04-14T10:00:00', timeZone: 'Europe/Berlin' },
      body: { content: '<p>Agenda: Status updates</p>', contentType: 'html' },
      attendees: [
        {
          emailAddress: { name: 'Christopher Klein', address: 'c.klein@krah.de' },
          type: 'required'
        },
        {
          emailAddress: { name: 'Philip Losch', address: 'p.losch@krah.de' },
          type: 'required'
        }
      ],
      seriesMasterId: 'AAMkSeries456'
    };

    const result = parseEventToMeeting(event);

    assert.equal(result.id, 'AAMkAG123');
    assert.equal(result.title, 'IT Team Weekly');
    assert.equal(result.date, '2026-04-14');
    assert.equal(result.start, '09:00');
    assert.equal(result.end, '10:00');
    assert.equal(result.bodyText, 'Agenda: Status updates');
    assert.equal(result.attendees.length, 2);
    assert.equal(result.attendees[0].email, 'c.klein@krah.de');
    assert.equal(result.attendees[0].name, 'Christopher Klein');
    assert.equal(result.isRecurring, true);
  });

  it('handles event without attendees', () => {
    const event = {
      id: 'AAMkAG789',
      subject: 'Focus Time',
      start: { dateTime: '2026-04-14T14:00:00', timeZone: 'Europe/Berlin' },
      end: { dateTime: '2026-04-14T15:00:00', timeZone: 'Europe/Berlin' },
      body: { content: '', contentType: 'text' },
      attendees: []
    };

    const result = parseEventToMeeting(event);

    assert.equal(result.attendees.length, 0);
    assert.equal(result.isRecurring, false);
    assert.equal(result.bodyText, '');
  });
});

describe('formatEventChoice', () => {
  it('formats event for selection list', () => {
    const meeting = {
      title: 'IT Team Weekly',
      date: '2026-04-14',
      start: '09:00',
      end: '10:00'
    };

    const result = formatEventChoice(meeting);

    assert.equal(result, '📅 09:00-10:00 IT Team Weekly');
  });
});

describe('isRecurring', () => {
  it('returns true when seriesMasterId exists', () => {
    assert.equal(isRecurring({ seriesMasterId: 'ABC' }), true);
  });

  it('returns false when no seriesMasterId', () => {
    assert.equal(isRecurring({}), false);
    assert.equal(isRecurring({ seriesMasterId: null }), false);
  });
});
  • Step 2: Run tests — expect failure
cd scripts && node --test test/o365-calendar.test.js

Expected: fail — module ../o365-calendar.js not found.

  • Step 3: Implement scripts/o365-calendar.js
import { ConfidentialClientApplication } from '@azure/msal-node';
import { Client } from '@microsoft/microsoft-graph-client';
import { readFileSync } from 'node:fs';
import { resolve, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';

const __dirname = dirname(fileURLToPath(import.meta.url));

function loadEnv() {
  const envPath = resolve(__dirname, '.env');
  const content = readFileSync(envPath, 'utf-8');
  const vars = {};
  for (const line of content.split('\n')) {
    const trimmed = line.trim();
    if (!trimmed || trimmed.startsWith('#')) continue;
    const [key, ...rest] = trimmed.split('=');
    vars[key.trim()] = rest.join('=').trim();
  }
  return vars;
}

function stripHtml(html) {
  return html
    .replace(/<br\s*\/?>/gi, '\n')
    .replace(/<\/p>/gi, '\n')
    .replace(/<[^>]+>/g, '')
    .replace(/&nbsp;/g, ' ')
    .replace(/&amp;/g, '&')
    .replace(/&lt;/g, '<')
    .replace(/&gt;/g, '>')
    .replace(/\n{3,}/g, '\n\n')
    .trim();
}

export function isRecurring(event) {
  return Boolean(event.seriesMasterId);
}

export function parseEventToMeeting(event) {
  const startDate = event.start.dateTime.split('T');
  const endDate = event.end.dateTime.split('T');

  return {
    id: event.id,
    title: event.subject,
    date: startDate[0],
    start: startDate[1].substring(0, 5),
    end: endDate[1].substring(0, 5),
    bodyText: event.body?.contentType === 'html'
      ? stripHtml(event.body.content)
      : (event.body?.content || '').trim(),
    attendees: (event.attendees || []).map(a => ({
      name: a.emailAddress.name,
      email: a.emailAddress.address.toLowerCase()
    })),
    isRecurring: isRecurring(event)
  };
}

export function formatEventChoice(meeting) {
  return `📅 ${meeting.start}-${meeting.end} ${meeting.title}`;
}

export async function getCalendarEvents(daysAhead = 7) {
  const env = loadEnv();

  const cca = new ConfidentialClientApplication({
    auth: {
      clientId: env.AZURE_CLIENT_ID,
      clientSecret: env.AZURE_CLIENT_SECRET,
      authority: `https://login.microsoftonline.com/${env.AZURE_TENANT_ID}`
    }
  });

  const tokenResponse = await cca.acquireTokenByClientCredential({
    scopes: ['https://graph.microsoft.com/.default']
  });

  const client = Client.init({
    authProvider: (done) => done(null, tokenResponse.accessToken)
  });

  const now = new Date();
  const future = new Date(now);
  future.setDate(future.getDate() + daysAhead);

  const response = await client
    .api(`/users/${env.AZURE_USER_EMAIL}/calendarView`)
    .query({
      startDateTime: now.toISOString(),
      endDateTime: future.toISOString()
    })
    .select('id,subject,start,end,body,attendees,seriesMasterId')
    .orderby('start/dateTime')
    .top(50)
    .get();

  return (response.value || []).map(parseEventToMeeting);
}
  • Step 4: Run tests — expect pass
cd scripts && node --test test/o365-calendar.test.js

Expected: all tests pass.


Task 4: Person Matcher

Files:

  • Create: scripts/person-matcher.js

  • Create: scripts/test/person-matcher.test.js

  • Step 1: Write tests for person matcher

Create scripts/test/person-matcher.test.js:

import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import {
  parseFrontmatter,
  matchAttendeeToPersons,
  buildNewPersonNote,
  resolveCompanyFromDomain
} from '../person-matcher.js';

describe('parseFrontmatter', () => {
  it('extracts YAML frontmatter from markdown', () => {
    const content = `---
tags: [person, mitarbeiter]
vorname: Christopher
nachname: Klein
email: c.klein@krah.de
kategorie: Mitarbeiter
---

# Christopher Klein
`;
    const fm = parseFrontmatter(content);

    assert.equal(fm.vorname, 'Christopher');
    assert.equal(fm.nachname, 'Klein');
    assert.equal(fm.email, 'c.klein@krah.de');
  });

  it('returns empty object for no frontmatter', () => {
    const fm = parseFrontmatter('# Just a heading');
    assert.deepEqual(fm, {});
  });
});

describe('matchAttendeeToPersons', () => {
  const persons = [
    { file: 'Christopher Klein.md', fm: { vorname: 'Christopher', nachname: 'Klein', email: 'c.klein@krah.de' } },
    { file: 'Philip Losch.md', fm: { vorname: 'Philip', nachname: 'Losch', email: 'p.losch@krah.de' } }
  ];

  it('matches by email (priority)', () => {
    const result = matchAttendeeToPersons(
      { name: 'Chris K.', email: 'c.klein@krah.de' },
      persons
    );
    assert.equal(result.matched, true);
    assert.equal(result.file, 'Christopher Klein.md');
    assert.equal(result.matchType, 'email');
  });

  it('falls back to name match', () => {
    const result = matchAttendeeToPersons(
      { name: 'Philip Losch', email: 'philip@private.de' },
      persons
    );
    assert.equal(result.matched, true);
    assert.equal(result.file, 'Philip Losch.md');
    assert.equal(result.matchType, 'name');
  });

  it('returns unmatched for unknown attendee', () => {
    const result = matchAttendeeToPersons(
      { name: 'Max Müller', email: 'max@landata.de' },
      persons
    );
    assert.equal(result.matched, false);
  });
});

describe('resolveCompanyFromDomain', () => {
  const companies = [
    { file: 'KRAH.md', fm: { domain: 'krah.de', kurzname: 'KRAH' } }
  ];

  it('resolves known domain to wikilink', () => {
    const result = resolveCompanyFromDomain('c.klein@krah.de', companies);
    assert.equal(result, '[[00 Kontext/Firmen/KRAH]]');
  });

  it('returns plain domain for unknown company', () => {
    const result = resolveCompanyFromDomain('max@landata.de', companies);
    assert.equal(result, 'landata.de');
  });
});

describe('buildNewPersonNote', () => {
  it('generates markdown for new person', () => {
    const note = buildNewPersonNote({
      name: 'Max Müller',
      email: 'max.mueller@landata.de',
      firma: 'landata.de'
    });

    assert.ok(note.includes('vorname: Max'));
    assert.ok(note.includes('nachname: Müller'));
    assert.ok(note.includes('email: max.mueller@landata.de'));
    assert.ok(note.includes('kategorie: Extern'));
    assert.ok(note.includes('status: ungeprüft'));
    assert.ok(note.includes('# Max Müller'));
  });

  it('handles single-word display names', () => {
    const note = buildNewPersonNote({
      name: 'Siri',
      email: 'siri@example.com',
      firma: 'example.com'
    });

    assert.ok(note.includes('vorname: Siri'));
    assert.ok(note.includes('nachname: ""'));
  });
});
  • Step 2: Run tests — expect failure
cd scripts && node --test test/person-matcher.test.js

Expected: fail — module not found.

  • Step 3: Implement scripts/person-matcher.js
import { readFileSync, writeFileSync, readdirSync, existsSync } from 'node:fs';
import { resolve, dirname, basename } from 'node:path';
import { fileURLToPath } from 'node:url';

const __dirname = dirname(fileURLToPath(import.meta.url));
const VAULT_ROOT = resolve(__dirname, '..');
const PERSONS_DIR = resolve(VAULT_ROOT, '00 Kontext/Personen');
const COMPANIES_DIR = resolve(VAULT_ROOT, '00 Kontext/Firmen');

export function parseFrontmatter(content) {
  const match = content.match(/^---\n([\s\S]*?)\n---/);
  if (!match) return {};

  const fm = {};
  for (const line of match[1].split('\n')) {
    const colonIdx = line.indexOf(':');
    if (colonIdx === -1) continue;
    const key = line.substring(0, colonIdx).trim();
    let value = line.substring(colonIdx + 1).trim();
    // Remove quotes
    if ((value.startsWith('"') && value.endsWith('"')) ||
        (value.startsWith("'") && value.endsWith("'"))) {
      value = value.slice(1, -1);
    }
    // Skip arrays and complex values for simple extraction
    if (value.startsWith('[') || value.startsWith('{')) continue;
    fm[key] = value;
  }
  return fm;
}

export function loadPersons() {
  if (!existsSync(PERSONS_DIR)) return [];

  return readdirSync(PERSONS_DIR)
    .filter(f => f.endsWith('.md'))
    .map(file => {
      const content = readFileSync(resolve(PERSONS_DIR, file), 'utf-8');
      return { file, fm: parseFrontmatter(content) };
    });
}

export function loadCompanies() {
  if (!existsSync(COMPANIES_DIR)) return [];

  return readdirSync(COMPANIES_DIR)
    .filter(f => f.endsWith('.md'))
    .map(file => {
      const content = readFileSync(resolve(COMPANIES_DIR, file), 'utf-8');
      return { file, fm: parseFrontmatter(content) };
    });
}

export function matchAttendeeToPersons(attendee, persons) {
  // Priority 1: email match
  const emailMatch = persons.find(p =>
    p.fm.email && p.fm.email.toLowerCase() === attendee.email.toLowerCase()
  );
  if (emailMatch) {
    return { matched: true, file: emailMatch.file, matchType: 'email' };
  }

  // Priority 2: name match (vorname + nachname)
  const nameMatch = persons.find(p => {
    const fullName = `${p.fm.vorname || ''} ${p.fm.nachname || ''}`.trim().toLowerCase();
    return fullName && fullName === attendee.name.toLowerCase();
  });
  if (nameMatch) {
    return { matched: true, file: nameMatch.file, matchType: 'name' };
  }

  return { matched: false };
}

export function resolveCompanyFromDomain(email, companies) {
  const domain = email.split('@')[1]?.toLowerCase();
  if (!domain) return '';

  const match = companies.find(c =>
    c.fm.domain && c.fm.domain.toLowerCase() === domain
  );
  if (match) {
    const name = match.file.replace('.md', '');
    return `[[00 Kontext/Firmen/${name}]]`;
  }

  return domain;
}

function splitName(displayName) {
  const parts = displayName.trim().split(/\s+/);
  if (parts.length === 1) {
    return { vorname: parts[0], nachname: '' };
  }
  const nachname = parts.pop();
  return { vorname: parts.join(' '), nachname };
}

export function buildNewPersonNote({ name, email, firma }) {
  const { vorname, nachname } = splitName(name);

  return `---
tags: [person]
vorname: ${vorname}
nachname: ${nachname ? nachname : '""'}
email: ${email}
kategorie: Extern
firma: "${firma}"
status: ungeprüft
---

# ${name}

## Zur Person

- **E-Mail:** ${email}
- **Firma:** ${firma}

## Notizen

`;
}

export function createPersonNote({ name, email }, companies) {
  const firma = resolveCompanyFromDomain(email, companies);
  const content = buildNewPersonNote({ name, email, firma });
  const { vorname, nachname } = splitName(name);
  const fileName = nachname ? `${vorname} ${nachname}.md` : `${vorname}.md`;
  const filePath = resolve(PERSONS_DIR, fileName);

  if (!existsSync(filePath)) {
    writeFileSync(filePath, content, 'utf-8');
  }

  return fileName;
}

export function resolveAttendees(attendees) {
  const persons = loadPersons();
  const companies = loadCompanies();

  return attendees.map(attendee => {
    const match = matchAttendeeToPersons(attendee, persons);

    if (match.matched) {
      const personName = match.file.replace('.md', '');
      return { wikilink: `[[${personName}]]`, name: attendee.name, isNew: false };
    }

    // Auto-create new person
    const fileName = createPersonNote(attendee, companies);
    const personName = fileName.replace('.md', '');
    return { wikilink: `[[${personName}]]`, name: attendee.name, isNew: true };
  });
}
  • Step 4: Run tests — expect pass
cd scripts && node --test test/person-matcher.test.js

Expected: all tests pass.


Task 5: Meeting Builder (Orchestrator)

Files:

  • Create: scripts/meeting-builder.js

  • Create: scripts/test/meeting-builder.test.js

  • Step 1: Write tests for meeting builder

Create scripts/test/meeting-builder.test.js:

import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { detectSeries, loadJourFixeAgenda, buildMeetingNote, buildFileName } from '../meeting-builder.js';

describe('detectSeries', () => {
  const jourFixeFolders = ['IT Team', 'Christian Hermann', 'SAP Team'];

  it('matches event title to Jour Fixe folder', () => {
    assert.equal(detectSeries('IT Team Weekly', jourFixeFolders), 'IT Team');
  });

  it('matches partial title containing folder name', () => {
    assert.equal(detectSeries('Jour Fixe Christian Hermann', jourFixeFolders), 'Christian Hermann');
  });

  it('returns empty string for no match', () => {
    assert.equal(detectSeries('Call mit LANdata', jourFixeFolders), '');
  });
});

describe('buildFileName', () => {
  it('builds date-prefixed filename', () => {
    assert.equal(buildFileName('2026-04-14', 'IT Team Weekly'), '2026-04-14 IT Team Weekly.md');
  });

  it('sanitizes invalid filename characters', () => {
    assert.equal(
      buildFileName('2026-04-14', 'Review: Q2/Q3 Plan'),
      '2026-04-14 Review - Q2-Q3 Plan.md'
    );
  });
});

describe('buildMeetingNote', () => {
  it('generates complete meeting note markdown', () => {
    const data = {
      title: 'IT Team Weekly',
      date: '2026-04-14',
      start: '09:00',
      end: '10:00',
      serie: 'IT Team',
      o365_id: 'AAMkAG123',
      teilnehmer: [
        { wikilink: '[[Christopher Klein]]', name: 'Christopher Klein', isNew: false },
        { wikilink: '[[Philip Losch]]', name: 'Philip Losch', isNew: false }
      ],
      agenda: '- Allgemeiner Status-Update KIT'
    };

    const note = buildMeetingNote(data);

    assert.ok(note.includes('tags: [meeting]'));
    assert.ok(note.includes('date: 2026-04-14'));
    assert.ok(note.includes('serie: "IT Team"'));
    assert.ok(note.includes('- "[[Christopher Klein]]"'));
    assert.ok(note.includes('# IT Team Weekly'));
    assert.ok(note.includes('- Allgemeiner Status-Update KIT'));
  });
});
  • Step 2: Run tests — expect failure
cd scripts && node --test test/meeting-builder.test.js
  • Step 3: Implement scripts/meeting-builder.js
import { readFileSync, readdirSync, existsSync } from 'node:fs';
import { resolve, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';

const __dirname = dirname(fileURLToPath(import.meta.url));
const VAULT_ROOT = resolve(__dirname, '..');
const JOUR_FIXE_DIR = resolve(VAULT_ROOT, '03 Bereiche/Jour Fixe');

export function getJourFixeFolders() {
  if (!existsSync(JOUR_FIXE_DIR)) return [];
  return readdirSync(JOUR_FIXE_DIR, { withFileTypes: true })
    .filter(d => d.isDirectory())
    .map(d => d.name);
}

export function detectSeries(eventTitle, jourFixeFolders) {
  const titleLower = eventTitle.toLowerCase();

  // Exact match first
  const exact = jourFixeFolders.find(f => f.toLowerCase() === titleLower);
  if (exact) return exact;

  // Folder name contained in title
  const contained = jourFixeFolders.find(f => titleLower.includes(f.toLowerCase()));
  if (contained) return contained;

  return '';
}

export function loadJourFixeAgenda(serie) {
  const agendaPath = resolve(JOUR_FIXE_DIR, serie, 'Agenda.md');
  if (!existsSync(agendaPath)) return '';

  const content = readFileSync(agendaPath, 'utf-8');

  // Extract 🔴 Offen section
  const openMatch = content.match(/## 🔴 Offen\n([\s\S]*?)(?=\n## |$)/);
  if (!openMatch) return '';

  const items = openMatch[1].trim();
  return items || '';
}

export function buildFileName(date, title) {
  const sanitized = title
    .replace(/:/g, ' -')
    .replace(/[\/\\]/g, '-')
    .replace(/[<>"|?*]/g, '')
    .trim();
  return `${date} ${sanitized}.md`;
}

export function buildMeetingNote(data) {
  const teilnehmerYaml = data.teilnehmer
    .map(t => `  - "${t.wikilink}"`)
    .join('\n');

  const teilnehmerList = data.teilnehmer
    .map(t => `- ${t.wikilink}${t.isNew ? ' *(neu angelegt)*' : ''}`)
    .join('\n');

  const agenda = data.agenda || '';

  return `---
tags: [meeting]
date: ${data.date}
start: "${data.start}"
end: "${data.end}"
serie: "${data.serie}"
projekt: 
teilnehmer:
${teilnehmerYaml}
status: offen
o365_id: "${data.o365_id}"
---

# ${data.title}

## Teilnehmer
${teilnehmerList}

## Agenda
${agenda}

## Notizen


## Entscheidungen


## Aufgaben

`;
}

export function buildMeetingFromEvent(meeting, resolvedAttendees) {
  const jourFixeFolders = getJourFixeFolders();
  const serie = meeting.isRecurring ? detectSeries(meeting.title, jourFixeFolders) : '';

  let agenda = '';
  if (serie) {
    agenda = loadJourFixeAgenda(serie);
  }
  if (!agenda && meeting.bodyText) {
    agenda = meeting.bodyText;
  }

  return {
    title: meeting.title,
    date: meeting.date,
    start: meeting.start,
    end: meeting.end,
    serie,
    o365_id: meeting.id,
    teilnehmer: resolvedAttendees,
    agenda
  };
}
  • Step 4: Run tests — expect pass
cd scripts && node --test test/meeting-builder.test.js

Expected: all tests pass.


Task 6: Templater Template

Files:

  • Create: Templates/Meeting Note.md

  • Step 1: Create Templates folder

mkdir -p Templates
  • Step 2: Create Templater user script wrapper

Templater user scripts must export a function callable via tp.user.scriptName(tp). Create scripts/create-meeting-note.js as the Templater entry point that wraps the ES module scripts:

// scripts/create-meeting-note.js
// Templater user script — called via tp.user.create_meeting_note(tp)
// Uses dynamic import() to load ES module scripts

async function create_meeting_note(tp) {
  const scriptsDir = app.vault.adapter.basePath + '/scripts';

  const { getCalendarEvents, formatEventChoice } = await import(scriptsDir + '/o365-calendar.js');
  const { resolveAttendees } = await import(scriptsDir + '/person-matcher.js');
  const { buildMeetingFromEvent, buildMeetingNote, buildFileName } = await import(scriptsDir + '/meeting-builder.js');

  // Fetch events
  const meetings = await getCalendarEvents(7);

  if (meetings.length === 0) {
    new Notice('No calendar events found in the next 7 days.');
    return '';
  }

  // Build selection list
  const choices = meetings.map(m => formatEventChoice(m));
  const selected = await tp.system.suggester(choices, meetings);

  if (!selected) return '';

  // Resolve attendees against vault persons
  const resolvedAttendees = resolveAttendees(selected.attendees);

  // Build meeting data with series detection + agenda
  const meetingData = buildMeetingFromEvent(selected, resolvedAttendees);

  // Build note content
  const noteContent = buildMeetingNote(meetingData);

  // Create the note file
  const fileName = buildFileName(meetingData.date, meetingData.title);
  const filePath = '03 Bereiche/Meetings/' + fileName;
  await app.vault.create(filePath, noteContent);
  await app.workspace.openLinkText(filePath, '');

  return ''; // Template output is empty — note was created as separate file
}

module.exports = create_meeting_note;
  • Step 3: Create Templates/Meeting Note.md

The template is a thin wrapper that calls the user script:

<%* await tp.user.create_meeting_note(tp); await app.vault.delete(tp.config.target_file); %>
  • Step 3: Configure Templater plugin

Open Obsidian Settings → Templater:

  • Template folder location: Templates
  • User script functions folder: scripts
  • Enable Trigger Templater on new file creation: OFF (we trigger manually)

Verify the template appears in the Templater command palette (Ctrl+P → "Templater: Create new note from template").

  • Step 4: Manual smoke test
  1. Open Obsidian
  2. Ctrl+P → "Templater: Create new note from template" → Select "Meeting Note"
  3. Verify: calendar events appear in the selection list
  4. Select an event → verify the meeting note is created in 03 Bereiche/Meetings/
  5. Verify: participants are matched/linked correctly
  6. Check 00 Kontext/Personen/ for any auto-created person notes

Task 7: MCP Server Setup

Files:

  • Modify: ~/.claude/settings.json or equivalent MCP config

  • Step 1: Evaluate Softeria graph-mcp-server

npx -y @softeria/graph-mcp-server --help

Check if the server:

  • Supports delegated or application auth
  • Exposes calendar event listing with attendees and body
  • Is compatible with Claude Code's MCP protocol

If Softeria doesn't meet requirements, check alternatives:

  • nicola-2021/mcp-server-office365

  • kenzaxtazi/outlook-mcp

  • Step 2: Configure MCP server for Claude Code

Add to Claude Code's MCP configuration (exact path depends on evaluation in Step 1):

{
  "mcpServers": {
    "microsoft-graph": {
      "command": "npx",
      "args": ["-y", "@softeria/graph-mcp-server"],
      "env": {
        "AZURE_TENANT_ID": "<from .env>",
        "AZURE_CLIENT_ID": "<from .env>",
        "AZURE_CLIENT_SECRET": "<from .env>",
        "AZURE_USER_EMAIL": "christian.kauer@krah.de"
      }
    }
  }
}
  • Step 3: Verify MCP server access

In a Claude Code session, test:

  • List calendar events for today
  • Get details of a specific event (attendees, body)
  • Confirm data matches what the Templater path returns

Task 8: CLAUDE.md Updates

Files:

  • Modify: CLAUDE.md

  • Step 1: Add Meeting Notes section to CLAUDE.md

Append the following section to the vault's CLAUDE.md:

## Meeting-Notizen

### Anlegen einer Meeting-Notiz

Wenn der Nutzer eine Meeting-Notiz anlegen möchte:

1. Kalender-Events per MCP abrufen (nächste 7 Tage)
2. Event auswählen lassen oder aus dem Kontext ableiten
3. Teilnehmer-Matching durchführen (siehe unten)
4. Notiz erstellen in `03 Bereiche/Meetings/YYYY-MM-DD Titel.md`

### Teilnehmer-Matching (Priorität)

1. **E-Mail-Match:** Suche in `00 Kontext/Personen/` nach `email` im Frontmatter
2. **Name-Fallback:** Vergleiche Display Name mit `vorname` + `nachname`
3. **Neu anlegen:** Kein Match → neue Personen-Notiz mit `status: ungeprüft`

### Firmen-Zuordnung

- Domain aus E-Mail extrahieren (z.B. `landata.de` aus `max@landata.de`)
- Suche in `00 Kontext/Firmen/` nach `domain` im Frontmatter
- Match → Wikilink, kein Match → Domain als Plain Text

### Serie-Erkennung

- Wiederkehrendes Meeting → Titel gegen `03 Bereiche/Jour Fixe/*/` matchen
- Serie erkannt → offene Punkte aus `Agenda.md` (🔴 Offen) als Agenda übernehmen
- Keine Serie → O365-Termin-Body als Agenda
- Beides leer → leere Agenda-Sektion

### Meeting-Notiz Format

Dateiname: `YYYY-MM-DD Titel.md`
Ort: `03 Bereiche/Meetings/`
Frontmatter: tags, date, start, end, serie, projekt, teilnehmer, status, o365_id
Sektionen: Teilnehmer, Agenda, Notizen, Entscheidungen, Aufgaben
  • Step 2: Verify by asking Claude to create a test meeting note

Test the Claude path end-to-end:

  1. "Zeig mir meine Kalender-Events für heute"
  2. "Leg eine Meeting-Notiz an für [Event]"
  3. Verify note format, participant matching, series recognition

Task 9: User Documentation

Files:

  • Create: 03 Bereiche/IT-Management/O365 Kalender Integration.md

  • Step 1: Create user documentation

Create 03 Bereiche/IT-Management/O365 Kalender Integration.md:

---
tags: [it-management, integration]
status: aktiv
date: 2026-04-12
---

# O365 Kalender Integration

## Was ist das?

Diese Integration verbindet den Office 365 Kalender mit dem Obsidian Vault. Du kannst Meeting-Notizen aus Kalender-Events erstellen — entweder manuell über Templater oder über Claude.

## Wie funktioniert es?

Zwei Wege zum selben Ergebnis:

### Weg 1: Manuell (Templater)

1. **Ctrl+P** → "Templater: Create new note from template"
2. Wähle **"Meeting Note"**
3. Eine Liste deiner nächsten 7 Tage Kalender-Events erscheint
4. Wähle das Meeting → Notiz wird automatisch angelegt

### Weg 2: Über Claude

Sag einfach:
- "Leg eine Meeting-Notiz an für das IT Team Meeting heute"
- "Was steht heute im Kalender?"
- "Erstelle Meeting-Notiz für den nächsten Termin"

## Was passiert automatisch?

### Teilnehmer-Erkennung
- Teilnehmer werden automatisch mit Personen-Notizen verknüpft
- **Match über E-Mail-Adresse** (bevorzugt) oder Name
- Unbekannte Teilnehmer werden als neue Personen-Notiz angelegt mit `status: ungeprüft`

### Firmen-Zuordnung
- Die E-Mail-Domain wird gegen bekannte Firmen-Notizen abgeglichen
- `max@landata.de` → Suche nach Firma mit `domain: landata.de`
- Unbekannte Domains werden als Text gespeichert

### Serien-Erkennung
- Wiederkehrende Meetings werden automatisch der Jour-Fixe-Serie zugeordnet
- Die offenen Agenda-Punkte aus der Jour-Fixe-Agenda werden übernommen
- Für Ad-hoc-Meetings wird der Termin-Text aus O365 als Agenda genutzt

## Wo landen die Notizen?

- **Meeting-Notizen:** `03 Bereiche/Meetings/YYYY-MM-DD Titel.md`
- **Neue Personen:** `00 Kontext/Personen/Vorname Nachname.md`

## Aufräumen

- Prüfe regelmäßig `00 Kontext/Personen/` auf Einträge mit `status: ungeprüft`
- Entscheide: Behalten und vervollständigen oder löschen
- Meeting-Notizen mit `status: offen` nach dem Meeting auf `abgeschlossen` setzen

## Technische Details

- **Azure App Registration:** Authentifizierung über OAuth2 (Application Credentials)
- **Microsoft Graph API:** Lese-Zugriff auf Kalender-Events
- **Node.js Scripts:** Liegen in `scripts/``o365-calendar.js`, `person-matcher.js`, `meeting-builder.js`
- **MCP Server:** Claude nutzt einen separaten MCP-Server für den Graph API Zugriff
- **Kein Rückschreiben:** Die Integration liest nur — es wird nichts in O365 geändert

## Bekannte Einschränkungen

- Nur Kalender (E-Mail-Integration ist für Phase 2 geplant)
- Kein automatischer Sync — Meeting-Notizen werden immer bewusst erstellt
- Firmen werden nicht automatisch angelegt, nur die Domain gespeichert
- Teilnehmer-Match funktioniert nur wenn die E-Mail-Adresse in der Personen-Notiz hinterlegt ist

## Siehe auch

- [[docs/superpowers/specs/2026-04-12-o365-calendar-integration-design]] — Design Spec

Task Order & Dependencies

Task 1 (Vault Structure)     — no dependencies
Task 2 (Node.js Setup)       — no dependencies
Task 3 (Calendar Client)     — depends on Task 2
Task 4 (Person Matcher)      — depends on Task 2
Task 5 (Meeting Builder)     — depends on Task 3 + 4
Task 6 (Templater Template)  — depends on Task 5
Task 7 (MCP Server)          — depends on Task 2 (for .env)
Task 8 (CLAUDE.md)           — depends on Task 7
Task 9 (Documentation)       — no dependencies (can run anytime)

Tasks 1, 2, 9 can run in parallel. Tasks 3, 4 can run in parallel after Task 2.