Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 12 additions & 25 deletions apps/desktop/src/main/calendar/provider/adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,33 +7,20 @@ import type {
import type { CalendarSyncSourceType } from '../types'

/**
* How a provider tells us "what changed since last time".
* The capability model lives in contracts because the renderer needs it too —
* a provider that cannot write must not be offered a "push events" toggle.
* Re-exported here under the main-process names so adapter code reads locally.
*
* - `sync-token` — Google: opaque token returned with each page
* - `delta-link` — Microsoft Graph: `@odata.deltaLink`
* - `sync-collection` — CalDAV RFC 6578
* - `ctag-etag` — CalDAV without RFC 6578: collection ctag, then per-item etags
* - `conditional-get` — plain HTTP `ETag` / `Last-Modified` on a whole feed (ICS)
* - `full` — no incremental support; every pass re-reads everything
* `incrementalMode` is how a provider tells us "what changed since last time":
* `sync-token` (Google), `delta-link` (Microsoft Graph), `sync-collection`
* (CalDAV RFC 6578), `ctag-etag` (CalDAV without it), `conditional-get`
* (plain HTTP ETag on a whole ICS feed), `full` (no incremental support).
*/
export type ProviderIncrementalMode =
'sync-token' | 'delta-link' | 'sync-collection' | 'ctag-etag' | 'conditional-get' | 'full'

/** Which connect flow the UI shell has to render for this provider. */
export type ProviderAuthFlow = 'oauth2' | 'basic' | 'url' | 'none'

export interface ProviderCapabilities {
/** False for read-only providers (ICS). The engine — not the adapter — refuses writes. */
supportsWrite: boolean
/** Can we provision our own "memrynote" calendar on the remote? */
supportsCreateCalendar: boolean
/** Real-time change notifications. False means the runner polls. */
supportsPush: boolean
/** More than one connected account per provider. */
supportsMultiAccount: boolean
incrementalMode: ProviderIncrementalMode
authFlow: ProviderAuthFlow
}
export type {
CalendarProviderAuthFlow as ProviderAuthFlow,
CalendarProviderCapabilities as ProviderCapabilities,
CalendarProviderIncrementalMode as ProviderIncrementalMode
} from '@memry/contracts/calendar-api'

export interface RemoteCalendarDescriptor {
id: string
Expand Down
23 changes: 23 additions & 0 deletions apps/desktop/src/main/calendar/provider/builtin.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { googleProviderDefinition } from '../providers/google/provider-definition'
import { registerProvider } from './registry'

/**
* Registers the providers this build ships with.
*
* Kept out of `registry.ts` so the registry never imports a provider and the
* providers never import the registry's contents — the definitions only need
* its types. Idempotent: handler registration runs more than once across a
* window teardown/rebuild, and the tests call it directly.
*/
let registered = false

export function ensureBuiltInCalendarProviders(): void {
if (registered) return
registered = true
registerProvider(googleProviderDefinition)
}

// Register on import as well, so a module that only reads the registry (an
// agent tool, a sync effect) cannot observe an empty one just because it
// loaded before the IPC handlers did.
ensureBuiltInCalendarProviders()
63 changes: 63 additions & 0 deletions apps/desktop/src/main/calendar/provider/registry.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import { describe, expect, it } from 'vitest'
import { ensureBuiltInCalendarProviders } from './builtin'
import {
getProvider,
getProviderCapabilities,
listProviders,
registerProvider,
unsupportedProviderMessage,
type CalendarProviderDefinition
} from './registry'

describe('calendar provider registry', () => {
it('resolves google from the built-in registration', () => {
ensureBuiltInCalendarProviders()

const google = getProvider('google')

expect(google?.id).toBe('google')
expect(google?.capabilities).toEqual({
supportsWrite: true,
supportsCreateCalendar: true,
supportsPush: true,
supportsMultiAccount: true,
incrementalMode: 'sync-token',
authFlow: 'oauth2'
})
})

it('returns null for a provider this build does not ship', () => {
ensureBuiltInCalendarProviders()

expect(getProvider('caldav')).toBeNull()
expect(getProviderCapabilities('caldav')).toBeNull()
})

it('keeps the unsupported-provider message byte-identical to the old guards', () => {
expect(unsupportedProviderMessage('caldav')).toBe('Unsupported calendar provider: caldav')
expect(unsupportedProviderMessage('ics')).toBe('Unsupported calendar provider: ics')
})

it('registering a second provider does not disturb the first', () => {
ensureBuiltInCalendarProviders()
const readOnly = {
id: 'test-read-only',
capabilities: {
supportsWrite: false,
supportsCreateCalendar: false,
supportsPush: false,
supportsMultiAccount: false,
incrementalMode: 'conditional-get',
authFlow: 'url'
}
} as CalendarProviderDefinition

registerProvider(readOnly)

expect(getProvider('test-read-only')?.capabilities.supportsWrite).toBe(false)
expect(getProvider('google')?.capabilities.supportsWrite).toBe(true)
expect(listProviders().map((definition) => definition.id)).toEqual(
expect.arrayContaining(['google', 'test-read-only'])
)
})
})
101 changes: 101 additions & 0 deletions apps/desktop/src/main/calendar/provider/registry.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
import type {
CalendarProviderCapabilities,
ListProviderCalendarsResponse,
SetDefaultProviderCalendarResponse
} from '@memry/contracts/calendar-api'
import type { DataDb } from '../../database/types'
import type { CalendarProviderAdapter } from './adapter'

/**
* What a provider hands back once the user has finished its connect flow.
* The caller turns this into the `calendar_sources` rows — id conventions and
* sync bookkeeping are the engine's business, not the provider's.
*/
export interface ProviderConnectResult {
accountId: string
account: {
remoteId: string
title: string
timezone: string | null
email: string
}
primaryCalendar: {
remoteId: string
title: string
timezone: string | null
color: string | null
isPrimary: boolean
}
}

export interface CalendarProviderDefinition {
readonly id: string
readonly capabilities: CalendarProviderCapabilities

/** An adapter bound to one connected account. */
createAdapter(accountId: string): CalendarProviderAdapter

/** Run the provider's own connect flow (OAuth window, URL prompt, …). */
connect(input: { accountId?: string }): Promise<ProviderConnectResult>

/** Forget one account's stored credentials. Must tolerate an unknown id. */
disconnect(accountId: string): Promise<void>

/** Accounts this provider currently has rows for. */
listAccountIds(db: DataDb): string[]

/** The account a provider-wide request (list calendars, push) routes to. */
resolveDefaultAccountId(db: DataDb): string | null

/** The provider's calendars, plus which one is the current default target. */
listCalendars(db: DataDb): Promise<ListProviderCalendarsResponse>

/** Persist the onboarding choice of default target calendar. */
setDefaultCalendar(
db: DataDb,
input: { calendarId: string | null; markOnboardingComplete: boolean }
): SetDefaultProviderCalendarResponse

/** Does any / this one account still hold usable local credentials? */
hasLocalAuth(db: DataDb): Promise<boolean>
hasAccountLocalAuth(accountId: string): Promise<boolean>

/** Refresh the calendar list for one account into `calendar_sources`. */
discoverSources(db: DataDb, accountId: string): Promise<void>

/** Pull everything this provider has, now. */
syncNow(db: DataDb): Promise<void>

/** Re-run one `calendar_sources` row. */
syncSource(db: DataDb, sourceId: string): Promise<void>

startSyncRunner(): Promise<void>
stopSyncRunner(): void
}

const providers = new Map<string, CalendarProviderDefinition>()

export function registerProvider(definition: CalendarProviderDefinition): void {
providers.set(definition.id, definition)
}

export function getProvider(id: string): CalendarProviderDefinition | null {
return providers.get(id) ?? null
}

export function listProviders(): CalendarProviderDefinition[] {
return [...providers.values()]
}

export function getProviderCapabilities(id: string): CalendarProviderCapabilities | null {
return providers.get(id)?.capabilities ?? null
}

/**
* The message an unregistered provider gets. Kept as one function so every
* call site says exactly the same thing — it is asserted verbatim by the IPC
* tests, and the renderer surfaces it as-is.
*/
export function unsupportedProviderMessage(id: string): string {
return `Unsupported calendar provider: ${id}`
}
105 changes: 105 additions & 0 deletions apps/desktop/src/main/calendar/providers/google/provider-definition.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
import type {
CalendarProviderCapabilities,
ListProviderCalendarsResponse,
SetDefaultProviderCalendarResponse
} from '@memry/contracts/calendar-api'
import type { DataDb } from '../../../database/types'
import type { CalendarProviderDefinition, ProviderConnectResult } from '../../provider/registry'
import { createGoogleCalendarClient } from './client'
import { listGoogleCalendars, setDefaultGoogleCalendar } from './onboarding'
import {
connectGoogleCalendar,
disconnectGoogleCalendar,
hasAnyGoogleCalendarLocalAuth,
hasGoogleCalendarLocalAuth,
listGoogleAccountIds,
resolveDefaultGoogleAccountId
} from './oauth'
import {
discoverGoogleCalendarSources,
startGoogleCalendarSyncRunner,
stopGoogleCalendarSyncRunner,
syncGoogleCalendarNow,
syncGoogleCalendarSource
} from './sync-service'

export const GOOGLE_PROVIDER_ID = 'google'

export const GOOGLE_CAPABILITIES: CalendarProviderCapabilities = {
supportsWrite: true,
supportsCreateCalendar: true,
supportsPush: true,
supportsMultiAccount: true,
incrementalMode: 'sync-token',
authFlow: 'oauth2'
}

export const googleProviderDefinition: CalendarProviderDefinition = {
id: GOOGLE_PROVIDER_ID,
capabilities: GOOGLE_CAPABILITIES,

createAdapter(accountId: string) {
return createGoogleCalendarClient({ accountId })
},

async connect(): Promise<ProviderConnectResult> {
return await connectGoogleCalendar()
},

async disconnect(accountId: string): Promise<void> {
await disconnectGoogleCalendar(accountId)
},

listAccountIds(db: DataDb): string[] {
return listGoogleAccountIds(db)
},

resolveDefaultAccountId(db: DataDb): string | null {
return resolveDefaultGoogleAccountId(db)
},

async listCalendars(db: DataDb): Promise<ListProviderCalendarsResponse> {
const accountId = resolveDefaultGoogleAccountId(db)
if (!accountId) {
return { calendars: [], primary: null, currentDefaultId: null }
}
return await listGoogleCalendars(db, createGoogleCalendarClient({ accountId }))
},

setDefaultCalendar(
db: DataDb,
input: { calendarId: string | null; markOnboardingComplete: boolean }
): SetDefaultProviderCalendarResponse {
// Still writes the `calendar.google` settings group verbatim; the
// per-provider settings namespace lands in #1394.
return setDefaultGoogleCalendar(db, input)
},

async hasLocalAuth(db: DataDb): Promise<boolean> {
return await hasAnyGoogleCalendarLocalAuth(db)
},

async hasAccountLocalAuth(accountId: string): Promise<boolean> {
return await hasGoogleCalendarLocalAuth(accountId)
},

async discoverSources(db: DataDb, accountId: string): Promise<void> {
await discoverGoogleCalendarSources(db, createGoogleCalendarClient({ accountId }), accountId)
},

async syncNow(db: DataDb): Promise<void> {
await syncGoogleCalendarNow(db)
},

async syncSource(db: DataDb, sourceId: string): Promise<void> {
await syncGoogleCalendarSource(db, sourceId)
},

async startSyncRunner(): Promise<void> {
await startGoogleCalendarSyncRunner()
},

stopSyncRunner(): void {
stopGoogleCalendarSyncRunner()
}
}
Loading
Loading