diff --git a/.changeset/feat-google-contacts.md b/.changeset/feat-google-contacts.md new file mode 100644 index 0000000..412275e --- /dev/null +++ b/.changeset/feat-google-contacts.md @@ -0,0 +1,9 @@ +--- +"@syncdown/connector-google-contacts": minor +"@syncdown/connectors": minor +"@syncdown/core": minor +"@syncdown/renderer-md": minor +"@syncdown/cli": minor +--- + +Add Google Contacts connector backed by the People API. Syncs name, emails, phones, organizations, addresses, URLs, contact groups, birthdays, biographies, and custom fields into one markdown file per contact under `/google-contacts//`. Uses People API `syncToken` for incremental syncs and `contactGroups.list` to resolve human-readable group labels. Requires the `contacts.readonly` OAuth scope — existing users will need to re-authorize the shared Google connection to pick up the new scope. diff --git a/bun.lock b/bun.lock index ee733b9..2a38eb7 100644 --- a/bun.lock +++ b/bun.lock @@ -84,6 +84,13 @@ "@syncdown/core": "workspace:*", }, }, + "packages/connector-google-contacts": { + "name": "@syncdown/connector-google-contacts", + "version": "0.1.0", + "dependencies": { + "@syncdown/core": "workspace:*", + }, + }, "packages/connector-notion": { "name": "@syncdown/connector-notion", "version": "0.1.0", @@ -99,6 +106,7 @@ "@syncdown/connector-apple-notes": "workspace:*", "@syncdown/connector-gmail": "workspace:*", "@syncdown/connector-google-calendar": "workspace:*", + "@syncdown/connector-google-contacts": "workspace:*", "@syncdown/connector-notion": "workspace:*", "@syncdown/core": "workspace:*", }, @@ -644,6 +652,8 @@ "@syncdown/connector-google-calendar": ["@syncdown/connector-google-calendar@workspace:packages/connector-google-calendar"], + "@syncdown/connector-google-contacts": ["@syncdown/connector-google-contacts@workspace:packages/connector-google-contacts"], + "@syncdown/connector-notion": ["@syncdown/connector-notion@workspace:packages/connector-notion"], "@syncdown/connectors": ["@syncdown/connectors@workspace:packages/connectors"], diff --git a/packages/connector-apple-notes/src/index.ts b/packages/connector-apple-notes/src/index.ts index 802a6a2..26bcee2 100644 --- a/packages/connector-apple-notes/src/index.ts +++ b/packages/connector-apple-notes/src/index.ts @@ -13,6 +13,7 @@ import type { import { DEFAULT_APPLE_NOTES_CONNECTION_ID, defineConnectorPlugin, + stableStringify, } from "@syncdown/core"; export interface AppleNotesNote { @@ -236,7 +237,7 @@ function toSourceId(rawId: string): string { function toSourceHash(note: AppleNotesNote): string { return new Bun.CryptoHasher("sha256") .update( - JSON.stringify({ + stableStringify({ title: note.title, body: note.body, account: note.account, diff --git a/packages/connector-gmail/src/index.ts b/packages/connector-gmail/src/index.ts index 6363340..1de3e50 100644 --- a/packages/connector-gmail/src/index.ts +++ b/packages/connector-gmail/src/index.ts @@ -21,6 +21,7 @@ import { GOOGLE_SECRET_NAMES, getGoogleConnectionSecretNames, getGoogleOAuthAppSecretNames, + stableStringify, } from "@syncdown/core"; const HISTORY_ID_INVALID_REASON = "invalid_history_id"; @@ -138,7 +139,15 @@ async function parseJsonResponse(response: Response): Promise { return null; } - return JSON.parse(text) as T; + try { + return JSON.parse(text) as T; + } catch (error) { + const reason = error instanceof Error ? error.message : String(error); + throw new GmailApiError( + response.status, + `Gmail API response was not valid JSON: ${reason}`, + ); + } } class OfficialGmailAdapter implements GmailAdapter { @@ -433,7 +442,7 @@ function computeSourceHash( ): string { return new Bun.CryptoHasher("sha256") .update( - JSON.stringify({ + stableStringify({ connectorId: snapshot.connectorId, sourceId: snapshot.sourceId, title: snapshot.title, diff --git a/packages/connector-google-calendar/src/index.ts b/packages/connector-google-calendar/src/index.ts index 345ee1a..63e9d8c 100644 --- a/packages/connector-google-calendar/src/index.ts +++ b/packages/connector-google-calendar/src/index.ts @@ -17,6 +17,7 @@ import { DEFAULT_GOOGLE_CONNECTION_ID, DEFAULT_GOOGLE_OAUTH_APP_ID, defineConnectorPlugin, + stableStringify, } from "@syncdown/core"; const GOOGLE_CALENDAR_API_BASE_URL = "https://www.googleapis.com/calendar/v3/"; @@ -125,7 +126,15 @@ async function parseJsonResponse(response: Response): Promise { return null; } - return JSON.parse(text) as T; + try { + return JSON.parse(text) as T; + } catch (error) { + const reason = error instanceof Error ? error.message : String(error); + throw new GoogleCalendarApiError( + response.status, + `Google Calendar API response was not valid JSON: ${reason}`, + ); + } } class OfficialGoogleCalendarAdapter implements GoogleCalendarAdapter { @@ -309,7 +318,7 @@ function computeSourceHash( ): string { return new Bun.CryptoHasher("sha256") .update( - JSON.stringify({ + stableStringify({ connectorId: snapshot.connectorId, sourceId: snapshot.sourceId, title: snapshot.title, diff --git a/packages/connector-google-contacts/package.json b/packages/connector-google-contacts/package.json new file mode 100644 index 0000000..6745485 --- /dev/null +++ b/packages/connector-google-contacts/package.json @@ -0,0 +1,18 @@ +{ + "name": "@syncdown/connector-google-contacts", + "private": true, + "version": "0.1.0", + "type": "module", + "main": "./src/index.ts", + "types": "./src/index.ts", + "exports": { + ".": "./src/index.ts" + }, + "scripts": { + "typecheck": "tsgo -p ./tsconfig.json --noEmit --pretty false", + "test": "bun test ./src/index.test.ts" + }, + "dependencies": { + "@syncdown/core": "workspace:*" + } +} diff --git a/packages/connector-google-contacts/src/index.test.ts b/packages/connector-google-contacts/src/index.test.ts new file mode 100644 index 0000000..3925052 --- /dev/null +++ b/packages/connector-google-contacts/src/index.test.ts @@ -0,0 +1,349 @@ +import { expect, test } from "bun:test"; + +import type { + ConnectorSyncRequest, + GoogleResolvedAuth, + SourceRecord, + SourceSnapshot, + StoredSourceSnapshot, +} from "@syncdown/core"; +import { MemoryStateStore } from "../../core/src/test-support.js"; +import { + createGoogleContactsConnector, + type GoogleContactGroup, + type GoogleContactsAdapter, + type GooglePerson, +} from "./index.js"; + +function createRequest( + options: { + since?: string | null; + resolvedAuth?: GoogleResolvedAuth | null; + existingSourceIds?: string[]; + }, + overrides: Partial = {}, +): ConnectorSyncRequest { + const state = new MemoryStateStore(); + for (const sourceId of options.existingSourceIds ?? []) { + void state.upsertSourceRecord({ + integrationId: "google-contacts-integration", + connectorId: "google-contacts", + sourceId, + entityType: "contact", + relativePath: `google-contacts/default/${sourceId}.md`, + sourceHash: `hash-${sourceId}`, + renderVersion: "test", + snapshotHash: `snapshot-${sourceId}`, + lastRenderedAt: "2026-03-17T00:00:00.000Z", + } satisfies SourceRecord); + void state.upsertSourceSnapshot({ + integrationId: "google-contacts-integration", + connectorId: "google-contacts", + sourceId, + snapshotHash: `snapshot-${sourceId}`, + snapshotSchemaVersion: "1", + payload: { + integrationId: "google-contacts-integration", + connectorId: "google-contacts", + sourceId, + entityType: "contact", + title: sourceId, + slug: sourceId, + pathHint: { kind: "contact" }, + metadata: {}, + bodyMd: "", + sourceHash: `hash-${sourceId}`, + snapshotSchemaVersion: "1", + }, + } satisfies StoredSourceSnapshot); + } + + return { + config: { + oauthApps: [], + connections: [], + integrations: [ + { + id: "google-contacts-integration", + connectorId: "google-contacts", + connectionId: "google-account-default", + label: "Google Contacts", + enabled: true, + interval: "1h", + config: {}, + }, + ], + }, + integration: { + id: "google-contacts-integration", + connectorId: "google-contacts", + connectionId: "google-account-default", + label: "Google Contacts", + enabled: true, + interval: "1h", + config: {}, + }, + connection: { + id: "google-account-default", + kind: "google-account", + label: "Default Google Account", + oauthAppId: "google-default", + }, + io: { + write() {}, + error() {}, + }, + paths: { + configDir: "/tmp/config", + dataDir: "/tmp/data", + configPath: "/tmp/config/config.json", + statePath: "/tmp/data/state.db", + secretsPath: "/tmp/data/secrets.enc", + masterKeyPath: "/tmp/data/master.key", + lockPath: "/tmp/data/sync.lock", + }, + since: options.since ?? null, + renderVersion: "test", + secrets: { + async hasSecret() { + return true; + }, + async getSecret() { + return "secret"; + }, + async setSecret() {}, + async deleteSecret() {}, + describe() { + return "memory"; + }, + }, + state, + resolvedAuth: + options.resolvedAuth ?? + ({ + kind: "google-oauth", + clientId: "client-id", + clientSecret: "client-secret", + refreshToken: "refresh-token", + requiredScopes: ["https://www.googleapis.com/auth/contacts.readonly"], + } satisfies GoogleResolvedAuth), + throwIfCancelled() {}, + async persistSource() {}, + async deleteSource() {}, + async resetIntegrationState() {}, + setProgress() {}, + ...overrides, + }; +} + +function makeAdapter(options: { + pages: Array<{ + connections: GooglePerson[]; + nextPageToken?: string; + nextSyncToken?: string; + invalidSyncToken?: boolean; + }>; + groups?: GoogleContactGroup[]; + ownerEmail?: string | null; + observed?: { + pageTokens: Array; + syncTokens: Array; + }; +}): GoogleContactsAdapter { + const queue = [...options.pages]; + return { + async listConnections(_credentials, opts) { + options.observed?.pageTokens.push(opts.pageToken); + options.observed?.syncTokens.push(opts.syncToken); + const page = queue.shift(); + if (!page) { + return { connections: [] }; + } + return page; + }, + async listContactGroups() { + return options.groups ?? []; + }, + async getOwnerEmail() { + return options.ownerEmail ?? "owner@example.com"; + }, + }; +} + +test("google contacts initial sync persists contacts and stores syncToken", async () => { + const persisted: SourceSnapshot[] = []; + const adapter = makeAdapter({ + pages: [ + { + connections: [ + { + resourceName: "people/c111", + names: [{ displayName: "Alice Adams" }], + emailAddresses: [{ value: "alice@example.com" }], + phoneNumbers: [{ value: "+1 555 0100" }], + organizations: [{ name: "Acme", title: "Engineer" }], + memberships: [ + { + contactGroupMembership: { + contactGroupResourceName: "contactGroups/family", + }, + }, + ], + }, + ], + nextSyncToken: "sync-v1", + }, + ], + groups: [ + { + resourceName: "contactGroups/family", + name: "Family", + groupType: "SYSTEM_CONTACT_GROUP", + }, + ], + }); + const connector = createGoogleContactsConnector({ adapter }); + const request = createRequest( + {}, + { + async persistSource(snapshot) { + persisted.push(snapshot); + }, + }, + ); + + const result = await connector.sync(request); + expect(persisted).toHaveLength(1); + const [snapshot] = persisted; + expect(snapshot.sourceId).toBe("people/c111"); + expect(snapshot.title).toBe("Alice Adams"); + expect(snapshot.pathHint.kind).toBe("contact"); + expect(snapshot.pathHint.contactAccountEmail).toBe("owner@example.com"); + expect(snapshot.metadata.contactEmails).toEqual(["alice@example.com"]); + expect(snapshot.metadata.contactPhones).toEqual(["+1 555 0100"]); + expect(snapshot.metadata.contactOrganizations).toEqual(["Acme"]); + expect(snapshot.metadata.contactTitles).toEqual(["Engineer"]); + expect(snapshot.metadata.contactGroups).toEqual(["Family"]); + expect(snapshot.metadata.contactSource).toBe("person"); + expect(snapshot.bodyMd).toContain("## Emails"); + expect(snapshot.bodyMd).toContain("alice@example.com"); + + expect(result.nextCursor).toBe( + JSON.stringify({ + version: 1, + syncToken: "sync-v1", + accountEmail: "owner@example.com", + }), + ); +}); + +test("google contacts incremental sync deletes contacts flagged deleted", async () => { + const persisted: string[] = []; + const deleted: string[] = []; + const adapter = makeAdapter({ + pages: [ + { + connections: [ + { + resourceName: "people/c222", + metadata: { deleted: true }, + }, + { + resourceName: "people/c333", + names: [{ displayName: "Bob" }], + }, + ], + nextSyncToken: "sync-v2", + }, + ], + }); + const connector = createGoogleContactsConnector({ adapter }); + const request = createRequest( + { + since: JSON.stringify({ + version: 1, + syncToken: "sync-v1", + accountEmail: "owner@example.com", + }), + }, + { + async deleteSource(sourceId) { + deleted.push(sourceId); + }, + async persistSource(snapshot) { + persisted.push(snapshot.sourceId); + }, + }, + ); + + const result = await connector.sync(request); + expect(deleted).toEqual(["people/c222"]); + expect(persisted).toEqual(["people/c333"]); + expect(result.nextCursor).toContain('"syncToken":"sync-v2"'); +}); + +test("google contacts handles expired sync token by rebuilding from scratch", async () => { + const persisted: string[] = []; + const adapter = makeAdapter({ + pages: [ + { connections: [], invalidSyncToken: true }, + { + connections: [ + { + resourceName: "people/c444", + names: [{ displayName: "Carol" }], + }, + ], + nextSyncToken: "sync-v3", + }, + ], + }); + const connector = createGoogleContactsConnector({ adapter }); + const request = createRequest( + { + since: JSON.stringify({ + version: 1, + syncToken: "stale", + accountEmail: "owner@example.com", + }), + }, + { + async persistSource(snapshot) { + persisted.push(snapshot.sourceId); + }, + }, + ); + + const result = await connector.sync(request); + expect(persisted).toEqual(["people/c444"]); + expect(result.nextCursor).toContain('"syncToken":"sync-v3"'); +}); + +test("google contacts picks fallback title from email when name missing", async () => { + const persisted: SourceSnapshot[] = []; + const adapter = makeAdapter({ + pages: [ + { + connections: [ + { + resourceName: "people/c555", + emailAddresses: [{ value: "no-name@example.com" }], + }, + ], + nextSyncToken: "sync-v1", + }, + ], + }); + const connector = createGoogleContactsConnector({ adapter }); + const request = createRequest( + {}, + { + async persistSource(snapshot) { + persisted.push(snapshot); + }, + }, + ); + + await connector.sync(request); + expect(persisted[0].title).toBe("no-name@example.com"); +}); diff --git a/packages/connector-google-contacts/src/index.ts b/packages/connector-google-contacts/src/index.ts new file mode 100644 index 0000000..13d8733 --- /dev/null +++ b/packages/connector-google-contacts/src/index.ts @@ -0,0 +1,1131 @@ +import { randomUUID } from "node:crypto"; + +import type { + Connector, + ConnectorPlugin, + ConnectorSyncRequest, + ConnectorSyncResult, + ContactsIntegrationConfig, + GoogleAccessTokenProvider, + GoogleOAuthCredentials, + HealthCheck, + IntegrationConfig, + SourceSnapshot, +} from "@syncdown/core"; +import { + assertGoogleGrantedScopes, + createGoogleAccessTokenProvider, + DEFAULT_GOOGLE_CONNECTION_ID, + DEFAULT_GOOGLE_OAUTH_APP_ID, + defineConnectorPlugin, + stableStringify, +} from "@syncdown/core"; + +const GOOGLE_PEOPLE_API_BASE_URL = "https://people.googleapis.com/v1/"; + +export const GOOGLE_CONTACTS_REQUIRED_SCOPES = [ + "https://www.googleapis.com/auth/contacts.readonly", +] as const; + +const PERSON_FIELDS = [ + "names", + "nicknames", + "emailAddresses", + "phoneNumbers", + "addresses", + "organizations", + "birthdays", + "urls", + "memberships", + "biographies", + "userDefined", + "events", + "imClients", + "relations", + "metadata", +].join(","); + +const CURSOR_VERSION = 1; + +type ContactsCredentials = GoogleOAuthCredentials; + +interface GooglePersonName { + displayName?: string; + displayNameLastFirst?: string; + unstructuredName?: string; + familyName?: string; + givenName?: string; + middleName?: string; + honorificPrefix?: string; + honorificSuffix?: string; +} + +interface GooglePersonField { + metadata?: { primary?: boolean; source?: { type?: string; id?: string } }; + type?: string; + formattedType?: string; + value?: T; +} + +interface GoogleEmailAddress extends GooglePersonField { + displayName?: string; +} + +interface GooglePhoneNumber extends GooglePersonField { + canonicalForm?: string; +} + +interface GoogleAddress { + metadata?: { primary?: boolean }; + type?: string; + formattedValue?: string; + streetAddress?: string; + extendedAddress?: string; + city?: string; + region?: string; + postalCode?: string; + country?: string; + countryCode?: string; +} + +interface GoogleOrganization { + metadata?: { primary?: boolean }; + type?: string; + name?: string; + title?: string; + department?: string; + startDate?: { year?: number; month?: number; day?: number }; + endDate?: { year?: number; month?: number; day?: number }; + current?: boolean; +} + +interface GoogleBirthday { + date?: { year?: number; month?: number; day?: number }; + text?: string; +} + +interface GoogleUrl extends GooglePersonField {} + +interface GoogleNickname { + value?: string; + type?: string; +} + +interface GoogleMembership { + contactGroupMembership?: { + contactGroupId?: string; + contactGroupResourceName?: string; + }; +} + +interface GoogleBiography { + value?: string; + contentType?: "TEXT_PLAIN" | "TEXT_HTML"; +} + +interface GoogleUserDefined { + key?: string; + value?: string; +} + +interface GoogleEvent { + date?: { year?: number; month?: number; day?: number }; + type?: string; + formattedType?: string; +} + +interface GoogleImClient extends GooglePersonField { + protocol?: string; + formattedProtocol?: string; +} + +interface GoogleRelation { + person?: string; + type?: string; + formattedType?: string; +} + +interface GooglePersonMetadata { + deleted?: boolean; + sources?: Array<{ + type?: string; + id?: string; + etag?: string; + updateTime?: string; + }>; +} + +export interface GooglePerson { + resourceName?: string; + etag?: string; + metadata?: GooglePersonMetadata; + names?: GooglePersonName[]; + nicknames?: GoogleNickname[]; + emailAddresses?: GoogleEmailAddress[]; + phoneNumbers?: GooglePhoneNumber[]; + addresses?: GoogleAddress[]; + organizations?: GoogleOrganization[]; + birthdays?: GoogleBirthday[]; + urls?: GoogleUrl[]; + memberships?: GoogleMembership[]; + biographies?: GoogleBiography[]; + userDefined?: GoogleUserDefined[]; + events?: GoogleEvent[]; + imClients?: GoogleImClient[]; + relations?: GoogleRelation[]; +} + +export interface GoogleContactsPage { + connections: GooglePerson[]; + nextPageToken?: string | null; + nextSyncToken?: string | null; + invalidSyncToken?: boolean; +} + +export interface GoogleContactGroup { + resourceName: string; + name: string; + groupType?: string; + formattedName?: string; +} + +export interface GoogleContactsAdapter { + listConnections( + credentials: ContactsCredentials, + options: { pageToken?: string; syncToken?: string }, + ): Promise; + listContactGroups( + credentials: ContactsCredentials, + ): Promise; + getOwnerEmail(credentials: ContactsCredentials): Promise; +} + +interface PeopleApiConnectionsResponse { + connections?: GooglePerson[]; + nextPageToken?: string | null; + nextSyncToken?: string | null; + totalPeople?: number; + error?: { + code?: number; + message?: string; + errors?: Array<{ reason?: string; message?: string }>; + }; +} + +interface PeopleApiContactGroupsResponse { + contactGroups?: Array<{ + resourceName?: string; + name?: string; + formattedName?: string; + groupType?: string; + }>; +} + +interface PeopleApiSelfResponse { + emailAddresses?: Array<{ + value?: string; + metadata?: { primary?: boolean; source?: { type?: string } }; + }>; +} + +class GoogleContactsApiError extends Error { + constructor( + readonly status: number, + message: string, + readonly reasons: string[] = [], + ) { + super(message); + this.name = "GoogleContactsApiError"; + } +} + +async function parseJsonResponse(response: Response): Promise { + const text = await response.text(); + if (!text) { + return null; + } + try { + return JSON.parse(text) as T; + } catch (error) { + const reason = error instanceof Error ? error.message : String(error); + throw new GoogleContactsApiError( + response.status, + `Google People API response was not valid JSON: ${reason}`, + ); + } +} + +class OfficialGoogleContactsAdapter implements GoogleContactsAdapter { + constructor( + private readonly accessTokenProvider: GoogleAccessTokenProvider = createGoogleAccessTokenProvider(), + ) {} + + private async getAccessToken( + credentials: ContactsCredentials, + ): Promise { + return this.accessTokenProvider.getAccessToken(credentials); + } + + private async request( + credentials: ContactsCredentials, + path: string, + params: Record = {}, + ): Promise { + const url = new URL(path, GOOGLE_PEOPLE_API_BASE_URL); + for (const [key, value] of Object.entries(params)) { + if (value === undefined) { + continue; + } + url.searchParams.set(key, String(value)); + } + + const response = await fetch(url, { + headers: { + authorization: `Bearer ${await this.getAccessToken(credentials)}`, + accept: "application/json", + }, + }); + const payload = await parseJsonResponse( + response, + ); + if (!response.ok) { + const errorPayload = payload as PeopleApiConnectionsResponse | null; + const reasons = + errorPayload?.error?.errors + ?.map((entry) => entry.reason) + .filter((value): value is string => Boolean(value)) ?? []; + throw new GoogleContactsApiError( + response.status, + errorPayload?.error?.message ?? + `Google People API request failed: HTTP ${response.status}`, + reasons, + ); + } + + return (payload ?? {}) as T; + } + + async listConnections( + credentials: ContactsCredentials, + options: { pageToken?: string; syncToken?: string }, + ): Promise { + try { + const response = await this.request( + credentials, + "people/me/connections", + { + personFields: PERSON_FIELDS, + pageSize: 1000, + pageToken: options.pageToken, + syncToken: options.syncToken, + requestSyncToken: true, + }, + ); + + return { + connections: response.connections ?? [], + nextPageToken: response.nextPageToken ?? undefined, + nextSyncToken: response.nextSyncToken ?? undefined, + }; + } catch (error) { + if ( + error instanceof GoogleContactsApiError && + (error.status === 410 || error.reasons.includes("EXPIRED_SYNC_TOKEN")) + ) { + return { connections: [], invalidSyncToken: true }; + } + throw error; + } + } + + async listContactGroups( + credentials: ContactsCredentials, + ): Promise { + const response = await this.request( + credentials, + "contactGroups", + { pageSize: 200 }, + ); + const groups: GoogleContactGroup[] = []; + for (const entry of response.contactGroups ?? []) { + if (!entry.resourceName || !entry.name) { + continue; + } + groups.push({ + resourceName: entry.resourceName, + name: entry.formattedName ?? entry.name, + groupType: entry.groupType, + }); + } + return groups; + } + + async getOwnerEmail( + credentials: ContactsCredentials, + ): Promise { + const response = await this.request( + credentials, + "people/me", + { personFields: "emailAddresses" }, + ); + const emails = response.emailAddresses ?? []; + const primary = emails.find( + (entry) => + entry.metadata?.primary && + entry.metadata?.source?.type === "ACCOUNT" && + typeof entry.value === "string", + ); + const fallback = emails.find((entry) => typeof entry.value === "string"); + return primary?.value ?? fallback?.value ?? null; + } +} + +export function createGoogleContactsAdapter(): GoogleContactsAdapter { + return new OfficialGoogleContactsAdapter(); +} + +function slugifySegment(input: string): string { + return ( + input + .toLowerCase() + .trim() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, "") || "untitled" + ); +} + +function computeSourceHash( + snapshot: Omit, +): string { + return new Bun.CryptoHasher("sha256") + .update( + stableStringify({ + connectorId: snapshot.connectorId, + sourceId: snapshot.sourceId, + title: snapshot.title, + entityType: snapshot.entityType, + pathHint: snapshot.pathHint, + metadata: snapshot.metadata, + bodyMd: snapshot.bodyMd, + }), + ) + .digest("hex"); +} + +function formatDateParts(date: { + year?: number; + month?: number; + day?: number; +}): string | null { + const { year, month, day } = date; + if (year && month && day) { + return `${year.toString().padStart(4, "0")}-${month.toString().padStart(2, "0")}-${day.toString().padStart(2, "0")}`; + } + if (month && day) { + return `--${month.toString().padStart(2, "0")}-${day.toString().padStart(2, "0")}`; + } + if (year) { + return String(year); + } + return null; +} + +function pickDisplayName(person: GooglePerson): string { + const name = person.names?.[0]; + const candidate = + name?.displayName?.trim() || + name?.unstructuredName?.trim() || + [name?.givenName, name?.familyName].filter(Boolean).join(" ").trim() || + person.emailAddresses?.find((entry) => entry.value)?.value?.trim() || + person.phoneNumbers?.find((entry) => entry.value)?.value?.trim() || + person.organizations?.find((entry) => entry.name)?.name?.trim(); + return candidate || "(unnamed contact)"; +} + +function collectEmails(person: GooglePerson): string[] { + return (person.emailAddresses ?? []) + .map((entry) => entry.value?.trim()) + .filter((value): value is string => Boolean(value)); +} + +function collectPhones(person: GooglePerson): string[] { + return (person.phoneNumbers ?? []) + .map((entry) => entry.canonicalForm?.trim() || entry.value?.trim()) + .filter((value): value is string => Boolean(value)); +} + +function collectOrganizations(person: GooglePerson): string[] { + return (person.organizations ?? []) + .map((entry) => entry.name?.trim()) + .filter((value): value is string => Boolean(value)); +} + +function collectTitles(person: GooglePerson): string[] { + return (person.organizations ?? []) + .map((entry) => entry.title?.trim()) + .filter((value): value is string => Boolean(value)); +} + +function collectAddresses(person: GooglePerson): string[] { + return (person.addresses ?? []) + .map( + (entry) => + entry.formattedValue?.trim() || + [ + entry.streetAddress, + entry.city, + entry.region, + entry.postalCode, + entry.country, + ] + .filter(Boolean) + .join(", "), + ) + .filter((value): value is string => Boolean(value)); +} + +function collectUrls(person: GooglePerson): string[] { + return (person.urls ?? []) + .map((entry) => entry.value?.trim()) + .filter((value): value is string => Boolean(value)); +} + +function collectGroups( + person: GooglePerson, + groupMap: Map, +): string[] { + const labels = new Set(); + for (const membership of person.memberships ?? []) { + const resourceName = + membership.contactGroupMembership?.contactGroupResourceName; + if (!resourceName) { + continue; + } + labels.add(groupMap.get(resourceName) ?? resourceName); + } + return [...labels]; +} + +function pickBirthday(person: GooglePerson): string | undefined { + for (const entry of person.birthdays ?? []) { + if (entry.date) { + const formatted = formatDateParts(entry.date); + if (formatted) { + return formatted; + } + } + if (entry.text) { + return entry.text; + } + } + return undefined; +} + +function pickUpdatedAt(person: GooglePerson): string | undefined { + for (const source of person.metadata?.sources ?? []) { + if (source.updateTime) { + return source.updateTime; + } + } + return undefined; +} + +function buildContactBody(person: GooglePerson, groups: string[]): string { + const sections: string[] = []; + + const emails = collectEmails(person); + if (emails.length > 0) { + sections.push( + ["## Emails", ...emails.map((entry) => `- ${entry}`)].join("\n"), + ); + } + + const phones = collectPhones(person); + if (phones.length > 0) { + sections.push( + ["## Phones", ...phones.map((entry) => `- ${entry}`)].join("\n"), + ); + } + + const orgs = person.organizations ?? []; + if (orgs.length > 0) { + const lines = orgs + .map((entry) => + [entry.title, entry.name, entry.department].filter(Boolean).join(" — "), + ) + .filter(Boolean); + if (lines.length > 0) { + sections.push( + ["## Organizations", ...lines.map((entry) => `- ${entry}`)].join("\n"), + ); + } + } + + const addresses = collectAddresses(person); + if (addresses.length > 0) { + sections.push( + ["## Addresses", ...addresses.map((entry) => `- ${entry}`)].join("\n"), + ); + } + + const urls = collectUrls(person); + if (urls.length > 0) { + sections.push(["## URLs", ...urls.map((entry) => `- ${entry}`)].join("\n")); + } + + if (groups.length > 0) { + sections.push( + ["## Groups", ...groups.map((entry) => `- ${entry}`)].join("\n"), + ); + } + + const events = person.events ?? []; + if (events.length > 0) { + const lines = events + .map((entry) => { + const date = entry.date ? formatDateParts(entry.date) : null; + const label = entry.formattedType ?? entry.type ?? "event"; + return date ? `${label}: ${date}` : label; + }) + .filter(Boolean); + if (lines.length > 0) { + sections.push( + ["## Events", ...lines.map((entry) => `- ${entry}`)].join("\n"), + ); + } + } + + const userDefined = person.userDefined ?? []; + if (userDefined.length > 0) { + const lines = userDefined + .map((entry) => + entry.key && entry.value ? `${entry.key}: ${entry.value}` : null, + ) + .filter((value): value is string => Boolean(value)); + if (lines.length > 0) { + sections.push( + ["## Custom", ...lines.map((entry) => `- ${entry}`)].join("\n"), + ); + } + } + + const notes = (person.biographies ?? []) + .map((entry) => entry.value?.trim()) + .filter((value): value is string => Boolean(value)); + if (notes.length > 0) { + sections.push(["## Notes", ...notes].join("\n\n")); + } + + return sections.join("\n\n"); +} + +function toSourceSnapshot( + integrationId: string, + person: GooglePerson, + accountEmail: string | null, + groupMap: Map, +): SourceSnapshot { + const resourceName = person.resourceName; + if (!resourceName) { + throw new Error("Google People API contact missing resourceName"); + } + + const title = pickDisplayName(person); + const emails = collectEmails(person); + const phones = collectPhones(person); + const organizations = collectOrganizations(person); + const titles = collectTitles(person); + const addresses = collectAddresses(person); + const urls = collectUrls(person); + const groups = collectGroups(person, groupMap); + const birthday = pickBirthday(person); + const updatedAt = pickUpdatedAt(person); + + const snapshotBase: Omit = { + integrationId, + connectorId: "google-contacts", + sourceId: resourceName, + entityType: "contact", + title, + slug: slugifySegment(title), + pathHint: { + kind: "contact", + contactAccountEmail: accountEmail ?? undefined, + }, + metadata: { + updatedAt, + contactResourceName: resourceName, + contactAccountEmail: accountEmail ?? undefined, + contactEmails: emails.length > 0 ? emails : undefined, + contactPhones: phones.length > 0 ? phones : undefined, + contactOrganizations: + organizations.length > 0 ? organizations : undefined, + contactTitles: titles.length > 0 ? titles : undefined, + contactGroups: groups.length > 0 ? groups : undefined, + contactBirthday: birthday, + contactAddresses: addresses.length > 0 ? addresses : undefined, + contactUrls: urls.length > 0 ? urls : undefined, + contactSource: "person", + }, + bodyMd: buildContactBody(person, groups), + snapshotSchemaVersion: "1", + }; + + return { + ...snapshotBase, + sourceHash: computeSourceHash(snapshotBase), + }; +} + +interface StoredGoogleContactsCursor { + version: 1; + syncToken: string | null; + accountEmail: string | null; +} + +function decodeCursor(value: string | null): StoredGoogleContactsCursor { + if (!value) { + return { version: CURSOR_VERSION, syncToken: null, accountEmail: null }; + } + try { + const parsed = JSON.parse(value) as Partial; + if (parsed.version !== CURSOR_VERSION) { + throw new Error("legacy"); + } + return { + version: CURSOR_VERSION, + syncToken: + typeof parsed.syncToken === "string" && parsed.syncToken.length > 0 + ? parsed.syncToken + : null, + accountEmail: + typeof parsed.accountEmail === "string" && + parsed.accountEmail.length > 0 + ? parsed.accountEmail + : null, + }; + } catch { + return { version: CURSOR_VERSION, syncToken: null, accountEmail: null }; + } +} + +function encodeCursor(cursor: StoredGoogleContactsCursor): string { + return JSON.stringify({ + version: CURSOR_VERSION, + syncToken: cursor.syncToken, + accountEmail: cursor.accountEmail, + } satisfies StoredGoogleContactsCursor); +} + +async function getCredentials( + request: ConnectorSyncRequest, +): Promise { + if (request.resolvedAuth?.kind !== "google-oauth") { + throw new Error("Missing Google credentials in encrypted store"); + } + return { + clientId: request.resolvedAuth.clientId, + clientSecret: request.resolvedAuth.clientSecret, + refreshToken: request.resolvedAuth.refreshToken, + }; +} + +function isPersonDeleted(person: GooglePerson): boolean { + return person.metadata?.deleted === true; +} + +async function fullSync( + request: ConnectorSyncRequest, + adapter: GoogleContactsAdapter, + credentials: ContactsCredentials, + accountEmail: string | null, + groupMap: Map, +): Promise { + let pageToken: string | undefined; + let nextSyncToken: string | null = null; + const seenSourceIds = new Set(); + + do { + request.throwIfCancelled(); + const page = await adapter.listConnections(credentials, { pageToken }); + for (const person of page.connections) { + request.throwIfCancelled(); + if (!person.resourceName) { + continue; + } + seenSourceIds.add(person.resourceName); + if (isPersonDeleted(person)) { + await request.deleteSource(person.resourceName); + continue; + } + await request.persistSource( + toSourceSnapshot( + request.integration.id, + person, + accountEmail, + groupMap, + ), + ); + } + pageToken = page.nextPageToken ?? undefined; + nextSyncToken = page.nextSyncToken ?? nextSyncToken; + } while (pageToken); + + const existing = await request.state.listSourceRecords( + request.integration.id, + ); + for (const record of existing) { + if (!seenSourceIds.has(record.sourceId)) { + await request.deleteSource(record.sourceId); + } + } + + return nextSyncToken; +} + +async function incrementalSync( + request: ConnectorSyncRequest, + adapter: GoogleContactsAdapter, + credentials: ContactsCredentials, + accountEmail: string | null, + groupMap: Map, + syncToken: string, +): Promise<{ nextSyncToken: string | null; invalidSyncToken: boolean }> { + let pageToken: string | undefined; + let nextSyncToken: string | null = null; + + do { + request.throwIfCancelled(); + const page = await adapter.listConnections(credentials, { + pageToken, + syncToken, + }); + + if (page.invalidSyncToken) { + return { nextSyncToken: null, invalidSyncToken: true }; + } + + for (const person of page.connections) { + request.throwIfCancelled(); + if (!person.resourceName) { + continue; + } + if (isPersonDeleted(person)) { + await request.deleteSource(person.resourceName); + continue; + } + await request.persistSource( + toSourceSnapshot( + request.integration.id, + person, + accountEmail, + groupMap, + ), + ); + } + + pageToken = page.nextPageToken ?? undefined; + nextSyncToken = page.nextSyncToken ?? nextSyncToken; + } while (pageToken); + + return { nextSyncToken, invalidSyncToken: false }; +} + +export interface CreateGoogleContactsConnectorOptions { + adapter?: GoogleContactsAdapter; +} + +class GoogleContactsConnector implements Connector { + readonly id = "google-contacts"; + readonly label = "Google Contacts"; + readonly setupMethods = [ + { + kind: "provider-oauth", + providerId: "google", + requiredScopes: GOOGLE_CONTACTS_REQUIRED_SCOPES, + connectionId: DEFAULT_GOOGLE_CONNECTION_ID, + connectionKind: "google-account", + label: "Google OAuth", + }, + ] as const; + + constructor(private readonly adapter: GoogleContactsAdapter) {} + + async validate(request: ConnectorSyncRequest): Promise { + if (!request.integration.enabled) { + return { status: "warn", message: "integration disabled" }; + } + if (request.resolvedAuth?.kind !== "google-oauth") { + return { + status: "error", + message: "credentials missing in encrypted store", + }; + } + try { + const credentials = await getCredentials(request); + await assertGoogleGrantedScopes( + fetch, + credentials, + GOOGLE_CONTACTS_REQUIRED_SCOPES, + ); + await this.adapter.getOwnerEmail(credentials); + return { status: "ok", message: "credentials valid" }; + } catch (error) { + return { + status: "error", + message: + error instanceof Error ? error.message : "unknown validation error", + }; + } + } + + async sync(request: ConnectorSyncRequest): Promise { + if (request.integration.connectorId !== "google-contacts") { + throw new Error( + `Invalid integration for Google Contacts connector: ${request.integration.connectorId}`, + ); + } + + const credentials = await getCredentials(request); + const previousCursor = decodeCursor(request.since); + + const accountEmail = + (await this.adapter.getOwnerEmail(credentials).catch(() => null)) ?? + previousCursor.accountEmail; + + const groups = await this.adapter + .listContactGroups(credentials) + .catch(() => [] as GoogleContactGroup[]); + const groupMap = new Map( + groups.map((group) => [group.resourceName, group.name]), + ); + + request.setProgress({ + mode: "indeterminate", + phase: "Syncing Google Contacts", + detail: accountEmail ?? null, + completed: null, + total: null, + unit: "items", + }); + + let nextSyncToken: string | null = null; + if (previousCursor.syncToken) { + const delta = await incrementalSync( + request, + this.adapter, + credentials, + accountEmail, + groupMap, + previousCursor.syncToken, + ); + if (delta.invalidSyncToken) { + request.io.write( + "Google Contacts sync token expired. Rebuilding from scratch.", + ); + nextSyncToken = await fullSync( + request, + this.adapter, + credentials, + accountEmail, + groupMap, + ); + } else { + nextSyncToken = delta.nextSyncToken; + } + } else { + nextSyncToken = await fullSync( + request, + this.adapter, + credentials, + accountEmail, + groupMap, + ); + } + + request.setProgress(null); + + return { + nextCursor: encodeCursor({ + version: CURSOR_VERSION, + syncToken: nextSyncToken, + accountEmail, + }), + }; + } +} + +function normalizeGoogleContactsConnection( + entry: Partial<{ + id: string; + kind: string; + label: string; + oauthAppId?: string; + accountEmail?: string; + }>, +) { + if ( + entry.kind !== "google-account" || + typeof entry.id !== "string" || + typeof entry.label !== "string" || + typeof entry.oauthAppId !== "string" + ) { + return []; + } + return [ + { + id: entry.id, + kind: "google-account" as const, + label: entry.label, + oauthAppId: entry.oauthAppId, + accountEmail: + typeof entry.accountEmail === "string" ? entry.accountEmail : undefined, + }, + ]; +} + +function normalizeGoogleContactsIntegration(entry: Partial) { + if ( + entry.connectorId !== "google-contacts" || + typeof entry.id !== "string" || + typeof entry.connectionId !== "string" || + typeof entry.label !== "string" || + typeof entry.enabled !== "boolean" || + (entry.interval !== "5m" && + entry.interval !== "15m" && + entry.interval !== "1h" && + entry.interval !== "6h" && + entry.interval !== "24h") + ) { + return []; + } + return [ + { + id: entry.id, + connectorId: "google-contacts" as const, + connectionId: entry.connectionId, + label: entry.label, + enabled: entry.enabled, + interval: entry.interval, + config: {} as ContactsIntegrationConfig["config"], + }, + ]; +} + +export function createGoogleContactsConnectorPlugin( + options: CreateGoogleContactsConnectorOptions = {}, +): ConnectorPlugin { + const runtime = new GoogleContactsConnector( + options.adapter ?? createGoogleContactsAdapter(), + ); + const setupMethods = [ + { + kind: "provider-oauth" as const, + providerId: "google" as const, + requiredScopes: [...GOOGLE_CONTACTS_REQUIRED_SCOPES], + connectionId: DEFAULT_GOOGLE_CONNECTION_ID, + connectionKind: "google-account", + label: "Google OAuth", + }, + ]; + + return defineConnectorPlugin({ + id: runtime.id, + label: runtime.label, + setupMethods, + validate: runtime.validate.bind(runtime), + sync: runtime.sync.bind(runtime), + manifest: { + id: runtime.id, + label: runtime.label, + setupMethods, + cliAliases: [ + { + key: "googleContacts.enabled", + async setValue(context, rawValue) { + if (rawValue !== "true" && rawValue !== "false") { + throw new Error( + "googleContacts.enabled must be `true` or `false`.", + ); + } + const integration = context.config.integrations.find( + (candidate) => candidate.connectorId === "google-contacts", + ); + if (!integration) { + throw new Error("Missing default Google Contacts integration."); + } + integration.enabled = rawValue === "true"; + return `Set googleContacts.enabled=${integration.enabled}`; + }, + }, + { + key: "googleContacts.interval", + async setValue(context, rawValue) { + if ( + rawValue !== "5m" && + rawValue !== "15m" && + rawValue !== "1h" && + rawValue !== "6h" && + rawValue !== "24h" + ) { + throw new Error( + "googleContacts.interval must be one of: 5m, 15m, 1h, 6h, 24h", + ); + } + const integration = context.config.integrations.find( + (candidate) => candidate.connectorId === "google-contacts", + ); + if (!integration) { + throw new Error("Missing default Google Contacts integration."); + } + integration.interval = rawValue; + return `Set googleContacts.interval=${integration.interval}`; + }, + }, + ], + }, + render: { + version: "1", + }, + seedOAuthApps() { + return [ + { + id: DEFAULT_GOOGLE_OAUTH_APP_ID, + providerId: "google", + label: "Default Google OAuth App", + }, + ]; + }, + seedConnections() { + return [ + { + id: DEFAULT_GOOGLE_CONNECTION_ID, + kind: "google-account", + label: "Default Google Account", + oauthAppId: DEFAULT_GOOGLE_OAUTH_APP_ID, + }, + ]; + }, + seedIntegrations() { + return [ + { + id: randomUUID(), + connectorId: "google-contacts", + connectionId: DEFAULT_GOOGLE_CONNECTION_ID, + label: "Google Contacts", + enabled: false, + interval: "1h", + config: {}, + }, + ]; + }, + normalizeConnection: normalizeGoogleContactsConnection, + normalizeIntegration: normalizeGoogleContactsIntegration, + }); +} + +export function createGoogleContactsConnector( + options: CreateGoogleContactsConnectorOptions = {}, +): Connector { + return createGoogleContactsConnectorPlugin(options); +} diff --git a/packages/connector-google-contacts/tsconfig.json b/packages/connector-google-contacts/tsconfig.json new file mode 100644 index 0000000..7870973 --- /dev/null +++ b/packages/connector-google-contacts/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "noEmit": true + }, + "include": ["src/**/*.ts"] +} diff --git a/packages/connector-notion/src/notion-source.ts b/packages/connector-notion/src/notion-source.ts index 08a6dc2..fb4aa6c 100644 --- a/packages/connector-notion/src/notion-source.ts +++ b/packages/connector-notion/src/notion-source.ts @@ -1,4 +1,5 @@ import type { SourceSnapshot } from "@syncdown/core"; +import { stableStringify } from "@syncdown/core"; import type { NotionDataSource, @@ -181,7 +182,7 @@ function sanitizeMarkdown(markdown: string): string { function hashDocumentPayload(value: unknown): string { return new Bun.CryptoHasher("sha256") - .update(JSON.stringify(value)) + .update(stableStringify(value)) .digest("hex"); } diff --git a/packages/connectors/package.json b/packages/connectors/package.json index 81c20bf..1f12810 100644 --- a/packages/connectors/package.json +++ b/packages/connectors/package.json @@ -16,6 +16,7 @@ "@syncdown/connector-apple-notes": "workspace:*", "@syncdown/connector-gmail": "workspace:*", "@syncdown/connector-google-calendar": "workspace:*", + "@syncdown/connector-google-contacts": "workspace:*", "@syncdown/connector-notion": "workspace:*", "@syncdown/core": "workspace:*" } diff --git a/packages/connectors/src/index.test.ts b/packages/connectors/src/index.test.ts index 5dfcd46..c01428a 100644 --- a/packages/connectors/src/index.test.ts +++ b/packages/connectors/src/index.test.ts @@ -8,10 +8,16 @@ import { test("createBuiltinConnectorPlugins respects platform support", () => { expect( createBuiltinConnectorPlugins("darwin").map((plugin) => plugin.id), - ).toEqual(["notion", "gmail", "google-calendar", "apple-notes"]); + ).toEqual([ + "notion", + "gmail", + "google-calendar", + "google-contacts", + "apple-notes", + ]); expect( createBuiltinConnectorPlugins("linux").map((plugin) => plugin.id), - ).toEqual(["notion", "gmail", "google-calendar"]); + ).toEqual(["notion", "gmail", "google-calendar", "google-contacts"]); }); test("createConnectorAliasMap exposes built-in config aliases", () => { @@ -24,5 +30,8 @@ test("createConnectorAliasMap exposes built-in config aliases", () => { expect(aliases.get("googleCalendar.selectedCalendarIds")?.key).toBe( "googleCalendar.selectedCalendarIds", ); + expect(aliases.get("googleContacts.enabled")?.key).toBe( + "googleContacts.enabled", + ); expect(aliases.get("appleNotes.interval")?.key).toBe("appleNotes.interval"); }); diff --git a/packages/connectors/src/index.ts b/packages/connectors/src/index.ts index e4a29ba..662cdde 100644 --- a/packages/connectors/src/index.ts +++ b/packages/connectors/src/index.ts @@ -1,6 +1,7 @@ import { createAppleNotesConnectorPlugin } from "@syncdown/connector-apple-notes"; import { createGmailConnectorPlugin } from "@syncdown/connector-gmail"; import { createGoogleCalendarConnectorPlugin } from "@syncdown/connector-google-calendar"; +import { createGoogleContactsConnectorPlugin } from "@syncdown/connector-google-contacts"; import { createNotionConnectorPlugin } from "@syncdown/connector-notion"; import type { ConnectorCliAlias, ConnectorPlugin } from "@syncdown/core"; @@ -11,6 +12,7 @@ export function createBuiltinConnectorPlugins( createNotionConnectorPlugin(), createGmailConnectorPlugin(), createGoogleCalendarConnectorPlugin(), + createGoogleContactsConnectorPlugin(), ...(platform === "darwin" ? [createAppleNotesConnectorPlugin()] : []), ]; } diff --git a/packages/core/src/config-model.ts b/packages/core/src/config-model.ts index 4f191d9..c5fe549 100644 --- a/packages/core/src/config-model.ts +++ b/packages/core/src/config-model.ts @@ -8,6 +8,7 @@ import type { ConnectorDefinitionSummary, ConnectorId, ConnectorPlugin, + ContactsIntegrationConfig, GmailIntegrationConfig, GoogleAccountConnectionConfig, IntegrationConfig, @@ -110,6 +111,15 @@ function getFallbackIntegrations(): IntegrationConfig[] { interval: "1h", config: {}, }, + { + id: randomUUID(), + connectorId: "google-contacts", + connectionId: DEFAULT_GOOGLE_CONNECTION_ID, + label: "Google Contacts", + enabled: false, + interval: "1h", + config: {}, + }, ]; } @@ -227,7 +237,9 @@ export function getDefaultIntegration( } export function getDefaultConnectionId(connectorId: ConnectorId): string { - return connectorId === "gmail" || connectorId === "google-calendar" + return connectorId === "gmail" || + connectorId === "google-calendar" || + connectorId === "google-contacts" ? DEFAULT_GOOGLE_CONNECTION_ID : connectorId === "apple-notes" ? DEFAULT_APPLE_NOTES_CONNECTION_ID @@ -284,6 +296,12 @@ export function isAppleNotesIntegration( return integration.connectorId === "apple-notes"; } +export function isContactsIntegration( + integration: IntegrationConfig, +): integration is ContactsIntegrationConfig { + return integration.connectorId === "google-contacts"; +} + export function toConnectorDefinitions( plugins: readonly ConnectorPlugin[], ): ConnectorDefinitionSummary[] { @@ -592,6 +610,20 @@ function normalizeLegacyIntegration( ]; } + if (candidate.connectorId === "google-contacts") { + return [ + { + id: candidate.id ?? randomUUID(), + connectorId: "google-contacts", + connectionId: candidate.connectionId ?? DEFAULT_GOOGLE_CONNECTION_ID, + label: candidate.label ?? "Google Contacts", + enabled: candidate.enabled ?? false, + interval: candidate.interval ?? "1h", + config: {}, + }, + ]; + } + return []; } diff --git a/packages/core/src/hashing.test.ts b/packages/core/src/hashing.test.ts new file mode 100644 index 0000000..df999e5 --- /dev/null +++ b/packages/core/src/hashing.test.ts @@ -0,0 +1,19 @@ +import { describe, expect, test } from "bun:test"; + +import { stableStringify } from "./hashing.js"; + +describe("stableStringify", () => { + test("produces identical output regardless of key insertion order", () => { + const a = { b: 2, a: 1, c: { y: 2, x: 1 } }; + const b = { c: { x: 1, y: 2 }, a: 1, b: 2 }; + expect(stableStringify(a)).toBe(stableStringify(b)); + }); + + test("preserves array element order", () => { + expect(stableStringify([3, 1, 2])).toBe("[3,1,2]"); + }); + + test("sorts nested object keys inside arrays", () => { + expect(stableStringify([{ b: 2, a: 1 }])).toBe('[{"a":1,"b":2}]'); + }); +}); diff --git a/packages/core/src/hashing.ts b/packages/core/src/hashing.ts new file mode 100644 index 0000000..55d7a34 --- /dev/null +++ b/packages/core/src/hashing.ts @@ -0,0 +1,12 @@ +export function stableStringify(value: unknown): string { + return JSON.stringify(value, (_key, v) => { + if (v && typeof v === "object" && !Array.isArray(v)) { + const sorted: Record = {}; + for (const k of Object.keys(v as Record).sort()) { + sorted[k] = (v as Record)[k]; + } + return sorted; + } + return v; + }); +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 64bf747..734ec81 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -24,6 +24,7 @@ export { getDefaultIntegration, isAppleNotesIntegration, isCalendarIntegration, + isContactsIntegration, isGmailIntegration, isGoogleAccountConnection, isNotionIntegration, @@ -73,6 +74,7 @@ export { readNotionOAuthConnectionCredentials, refreshNotionAccessToken, } from "./notion-auth.js"; +export { stableStringify } from "./hashing.js"; export { defineConnectorPlugin } from "./plugin.js"; export type { AppIo, @@ -102,6 +104,8 @@ export type { ConnectorRenderHooks, ConnectorSyncRequest, ConnectorSyncResult, + ContactsIntegrationConfig, + ContactsIntegrationSettings, DocumentSink, GenericConnectionConfig, GenericIntegrationConfig, diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index e4d7734..bdafab8 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -137,6 +137,8 @@ export interface CalendarIntegrationSettings { selectedCalendarIds: string[]; } +export type ContactsIntegrationSettings = Record; + export type NotionIntegrationSettings = Record; export type AppleNotesIntegrationSettings = Record; @@ -166,6 +168,10 @@ export type AppleNotesIntegrationConfig = BaseIntegrationConfig< "apple-notes", AppleNotesIntegrationSettings >; +export type ContactsIntegrationConfig = BaseIntegrationConfig< + "google-contacts", + ContactsIntegrationSettings +>; export interface GenericIntegrationConfig extends BaseIntegrationConfig> {} @@ -173,7 +179,8 @@ export type IntegrationConfig = | NotionIntegrationConfig | GmailIntegrationConfig | CalendarIntegrationConfig - | AppleNotesIntegrationConfig; + | AppleNotesIntegrationConfig + | ContactsIntegrationConfig; export interface SyncdownConfig { outputDir?: string; @@ -203,13 +210,14 @@ export interface HealthCheck { } export interface DocumentPathHint { - kind: "page" | "database" | "message" | "calendar-event" | "note"; + kind: "page" | "database" | "message" | "calendar-event" | "note" | "contact"; databaseName?: string; gmailAccountEmail?: string; calendarName?: string; appleNotesAccount?: string; appleNotesFolder?: string; appleNotesFolderPath?: string[]; + contactAccountEmail?: string; } export interface SourceMetadata extends Record { @@ -241,6 +249,17 @@ export interface SourceMetadata extends Record { appleNotesNoteId?: string; appleNotesFolder?: string; appleNotesFolderPath?: string[]; + contactResourceName?: string; + contactAccountEmail?: string; + contactEmails?: string[]; + contactPhones?: string[]; + contactOrganizations?: string[]; + contactTitles?: string[]; + contactGroups?: string[]; + contactBirthday?: string; + contactAddresses?: string[]; + contactUrls?: string[]; + contactSource?: "person" | "other-contact"; } export interface SourceSnapshot { diff --git a/packages/renderer-md/src/frontmatter.ts b/packages/renderer-md/src/frontmatter.ts index 2b499f0..f0f40a7 100644 --- a/packages/renderer-md/src/frontmatter.ts +++ b/packages/renderer-md/src/frontmatter.ts @@ -109,6 +109,50 @@ export function buildFrontmatterFields( fields.set("folder", appleNotesFolderLabel); } + if (document.metadata.contactAccountEmail) { + fields.set("account", document.metadata.contactAccountEmail); + } + + if (document.metadata.contactResourceName) { + fields.set("resource_name", document.metadata.contactResourceName); + } + + if (document.metadata.contactEmails) { + fields.set("emails", document.metadata.contactEmails); + } + + if (document.metadata.contactPhones) { + fields.set("phones", document.metadata.contactPhones); + } + + if (document.metadata.contactOrganizations) { + fields.set("organizations", document.metadata.contactOrganizations); + } + + if (document.metadata.contactTitles) { + fields.set("titles", document.metadata.contactTitles); + } + + if (document.metadata.contactGroups) { + fields.set("groups", document.metadata.contactGroups); + } + + if (document.metadata.contactBirthday) { + fields.set("birthday", document.metadata.contactBirthday); + } + + if (document.metadata.contactAddresses) { + fields.set("addresses", document.metadata.contactAddresses); + } + + if (document.metadata.contactUrls) { + fields.set("urls", document.metadata.contactUrls); + } + + if (document.metadata.contactSource) { + fields.set("contact_source", document.metadata.contactSource); + } + return fields; } diff --git a/packages/renderer-md/src/path-builder.ts b/packages/renderer-md/src/path-builder.ts index b9c3e58..870a2df 100644 --- a/packages/renderer-md/src/path-builder.ts +++ b/packages/renderer-md/src/path-builder.ts @@ -45,6 +45,10 @@ function getAppleNotesFolderSegments(document: SourceSnapshot): string[] { ]; } +function getContactFileIdentifier(resourceName: string): string { + return slugifySegment(resourceName.replace(/^people\//, "")); +} + function getFileIdentifier(document: SourceSnapshot): string { if (document.pathHint.kind === "calendar-event") { const eventId = document.metadata.calendarEventId; @@ -60,6 +64,13 @@ function getFileIdentifier(document: SourceSnapshot): string { } } + if (document.pathHint.kind === "contact") { + const resourceName = document.metadata.contactResourceName; + if (typeof resourceName === "string" && resourceName.trim().length > 0) { + return getContactFileIdentifier(resourceName); + } + } + return document.sourceId; } @@ -68,14 +79,20 @@ const MAX_FILENAME_LENGTH = 255; function truncateToBytes(str: string, maxBytes: number): string { if (maxBytes <= 0) return ""; if (Buffer.byteLength(str) <= maxBytes) return str; - return Buffer.from(str, "utf8").subarray(0, maxBytes).toString("utf8").replace(/�+$/, ""); + return Buffer.from(str, "utf8") + .subarray(0, maxBytes) + .toString("utf8") + .replace(/�+$/, ""); } function buildFileName(document: SourceSnapshot): string { const identifier = getFileIdentifier(document); const suffix = `-${identifier}.md`; const rawSlug = document.slug || slugifySegment(document.title); - const maxSlugBytes = Math.max(0, MAX_FILENAME_LENGTH - Buffer.byteLength(suffix)); + const maxSlugBytes = Math.max( + 0, + MAX_FILENAME_LENGTH - Buffer.byteLength(suffix), + ); const slug = truncateToBytes(rawSlug, maxSlugBytes).replace(/-+$/, ""); return `${slug}${suffix}`; } @@ -140,5 +157,14 @@ export function buildRelativePath(document: SourceSnapshot): string { ); } + if (document.pathHint.kind === "contact") { + const accountSegment = slugifySegment( + document.pathHint.contactAccountEmail ?? + document.metadata.contactAccountEmail ?? + "default", + ); + return path.join(document.connectorId, accountSegment, fileName); + } + return path.join(document.connectorId, "pages", fileName); } diff --git a/packages/tui/src/config-auth-controller.ts b/packages/tui/src/config-auth-controller.ts index aeb9579..802d20c 100644 --- a/packages/tui/src/config-auth-controller.ts +++ b/packages/tui/src/config-auth-controller.ts @@ -189,7 +189,7 @@ export function createConfigAuthController(deps: ConfigAuthControllerDeps) { }, async ensureGoogleScopesForConnector( - connector: "gmail" | "google-calendar", + connector: "gmail" | "google-calendar" | "google-contacts", ): Promise { const credentials = await getCurrentGoogleCredentials(); const requiredScopes = await getRequiredGoogleScopes(connector); @@ -432,7 +432,7 @@ export function createConfigAuthController(deps: ConfigAuthControllerDeps) { } async function getRequiredGoogleScopes( - connectorId: "gmail" | "google-calendar", + connectorId: "gmail" | "google-calendar" | "google-contacts", ): Promise { const snapshot = await deps.inspectApp(); const integrations = snapshot.integrations.map((integration) => ({ @@ -444,7 +444,9 @@ export function createConfigAuthController(deps: ConfigAuthControllerDeps) { ? isDraftConnectorEnabled(deps.draft, "gmail") : integration.connectorId === "google-calendar" ? isDraftConnectorEnabled(deps.draft, "google-calendar") - : integration.enabled, + : integration.connectorId === "google-contacts" + ? isDraftConnectorEnabled(deps.draft, "google-contacts") + : integration.enabled, })); return collectGoogleProviderScopes(integrations, { includeIds: [ diff --git a/packages/tui/src/config-route-actions.ts b/packages/tui/src/config-route-actions.ts index 0a6ecdd..5687cab 100644 --- a/packages/tui/src/config-route-actions.ts +++ b/packages/tui/src/config-route-actions.ts @@ -56,7 +56,7 @@ interface ConfigRouteActionsDeps { getSyncSnapshot(): SyncRuntimeSnapshot; refreshView(): void; ensureGoogleScopesForConnector( - connector: "gmail" | "google-calendar", + connector: "gmail" | "google-calendar" | "google-contacts", ): Promise; persistDraftMutation( mutate: (draft: DraftState) => void, @@ -133,6 +133,21 @@ export function createConfigRouteActions(deps: ConfigRouteActionsDeps) { return; } + if (connector === "google-contacts") { + const hasScopes = + await deps.ensureGoogleScopesForConnector("google-contacts"); + if (!hasScopes) { + return; + } + + await enableConnector( + connector, + `Failed to enable ${getConnectorLabel(connector)}.`, + `${getConnectorLabel(connector)} enabled.`, + ); + return; + } + if (connector === "apple-notes") { await enableConnector( connector, @@ -447,6 +462,7 @@ function isConnectorTarget(selection: unknown): selection is ConnectorTarget { selection === "notion" || selection === "gmail" || selection === "google-calendar" || + selection === "google-contacts" || selection === "apple-notes" ); } @@ -459,6 +475,8 @@ function getConnectorLabel(connector: ConnectorTarget): string { return "Gmail"; case "google-calendar": return "Google Calendar"; + case "google-contacts": + return "Google Contacts"; case "apple-notes": return "Apple Notes"; default: { diff --git a/packages/tui/src/config-runtime-controller.ts b/packages/tui/src/config-runtime-controller.ts index ebae048..b4fdd5a 100644 --- a/packages/tui/src/config-runtime-controller.ts +++ b/packages/tui/src/config-runtime-controller.ts @@ -306,6 +306,23 @@ export function createConfigRuntimeController( { resetState: true }, ); setSyncRunNotice("Google Calendar full resync completed."); + } else if (selection === "runGoogleContacts") { + await deps.request.session.runNow({ + kind: "integration", + integrationId: getDraftIntegration(deps.draft, "google-contacts") + .id, + }); + setSyncRunNotice("Google Contacts run completed."); + } else if (selection === "runGoogleContactsReset") { + await deps.request.session.runNow( + { + kind: "integration", + integrationId: getDraftIntegration(deps.draft, "google-contacts") + .id, + }, + { resetState: true }, + ); + setSyncRunNotice("Google Contacts full resync completed."); } else if (selection === "runAppleNotes") { await deps.request.session.runNow({ kind: "integration", diff --git a/packages/tui/src/state.ts b/packages/tui/src/state.ts index 5e3560a..407b5a8 100644 --- a/packages/tui/src/state.ts +++ b/packages/tui/src/state.ts @@ -40,6 +40,7 @@ export type ConnectorTarget = | "notion" | "gmail" | "google-calendar" + | "google-contacts" | "apple-notes"; export type ProviderTarget = "google" | "notion"; export type SecretTarget = @@ -427,7 +428,10 @@ export function stageGoogleConnection( clientId: string, clientSecret: string, refreshToken: string, - connector: Extract = "gmail", + connector: Extract< + ConnectorTarget, + "gmail" | "google-calendar" | "google-contacts" + > = "gmail", ): void { applySecretAction(draft, "googleClientId", "set", clientId); applySecretAction(draft, "googleClientSecret", "set", clientSecret); @@ -611,6 +615,7 @@ export function buildOverview(paths: AppPaths, draft: DraftState): string { const notionStatus = getConnectorStatus(draft, "notion"); const gmailStatus = getConnectorStatus(draft, "gmail"); const googleCalendarStatus = getConnectorStatus(draft, "google-calendar"); + const googleContactsStatus = getConnectorStatus(draft, "google-contacts"); const lines = [ `config: ${paths.configPath}`, `secrets: ${paths.secretsPath}`, @@ -618,6 +623,7 @@ export function buildOverview(paths: AppPaths, draft: DraftState): string { `notion: ${notionStatus.label} | method=${getDraftNotionAuthMethod(draft)} | interval=${getDraftInterval(draft, "notion")} | enabled=${isDraftConnectorEnabled(draft, "notion") ? "yes" : "no"}`, `gmail: ${gmailStatus.label} | interval=${getDraftInterval(draft, "gmail")} | enabled=${isDraftConnectorEnabled(draft, "gmail") ? "yes" : "no"}`, `google-calendar: ${googleCalendarStatus.label} | interval=${getDraftInterval(draft, "google-calendar")} | selected=${getDraftSelectedGoogleCalendarIds(draft).length} | enabled=${isDraftConnectorEnabled(draft, "google-calendar") ? "yes" : "no"}`, + `google-contacts: ${googleContactsStatus.label} | interval=${getDraftInterval(draft, "google-contacts")} | enabled=${isDraftConnectorEnabled(draft, "google-contacts") ? "yes" : "no"}`, ]; if (isAppleNotesSupportedPlatform()) { diff --git a/packages/tui/src/view-state.test.ts b/packages/tui/src/view-state.test.ts index 5c2b471..8515dba 100644 --- a/packages/tui/src/view-state.test.ts +++ b/packages/tui/src/view-state.test.ts @@ -478,6 +478,26 @@ test("connectors and schedule expose google calendar", () => { ).toContain("Google Calendar interval: 1h"); }); +test("connectors and schedule expose google contacts", () => { + const draft = createDraftState(createConfig(), { + notionTokenStored: true, + googleClientIdStored: true, + googleClientSecretStored: true, + googleRefreshTokenStored: true, + }); + + expect( + getRouteOptions({ id: "connectors", selectedIndex: 0 }, draft).map( + (option) => option.name, + ), + ).toContain("Google Contacts"); + expect( + getRouteOptions({ id: "schedule", selectedIndex: 0 }, draft).map( + (option) => option.name, + ), + ).toContain("Google Contacts interval: 1h"); +}); + test("sync dashboard route renders status summary and actions", () => { const draft = createDraftState(createConfig(), { notionTokenStored: true, diff --git a/packages/tui/src/view-state.ts b/packages/tui/src/view-state.ts index bbd3439..caf82f7 100644 --- a/packages/tui/src/view-state.ts +++ b/packages/tui/src/view-state.ts @@ -251,7 +251,9 @@ export function getConnectorAuthDocsUrl( ? getDocsPath("connectors/notion") : route.connector === "gmail" ? getDocsPath("connectors/gmail") - : getDocsPath("connectors/google-calendar"); + : route.connector === "google-contacts" + ? getDocsPath("connectors/google-contacts") + : getDocsPath("connectors/google-calendar"); return new URL(docsPath, normalizedBaseUrl).toString(); } @@ -624,7 +626,9 @@ export function getRouteTitle(route: ConfigRoute): string { ? "Gmail" : route.connector === "google-calendar" ? "Google Calendar" - : "Apple Notes"; + : route.connector === "google-contacts" + ? "Google Contacts" + : "Apple Notes"; case "connectorAuth": return route.authMethod === "notion-token" ? "Notion Token" @@ -641,7 +645,9 @@ export function getRouteTitle(route: ConfigRoute): string { ? "Disable Gmail" : route.connector === "google-calendar" ? "Disable Google Calendar" - : "Disable Apple Notes"; + : route.connector === "google-contacts" + ? "Disable Google Contacts" + : "Disable Apple Notes"; case "confirmReset": return "Reset App Data"; case "output": @@ -657,7 +663,9 @@ export function getRouteTitle(route: ConfigRoute): string { ? "Gmail Interval" : route.connector === "google-calendar" ? "Google Calendar Interval" - : "Apple Notes Interval"; + : route.connector === "google-contacts" + ? "Google Contacts Interval" + : "Apple Notes Interval"; case "gmailFilter": return "Gmail Inbox Filter"; case "googleCalendarSelection": @@ -706,24 +714,36 @@ function getConnectedSyncTargets(draft: DraftState): ConnectorTarget[] { function getVisibleConnectorTargets(): ConnectorTarget[] { return isAppleNotesSupportedPlatform() - ? ["notion", "gmail", "google-calendar", "apple-notes"] - : ["notion", "gmail", "google-calendar"]; + ? ["notion", "gmail", "google-calendar", "google-contacts", "apple-notes"] + : ["notion", "gmail", "google-calendar", "google-contacts"]; } function getVisibleConnectorSummaryLines(draft: DraftState): string[] { return getVisibleConnectorTargets().map((connector) => { - const label = - connector === "notion" - ? "Notion" - : connector === "gmail" - ? "Gmail" - : connector === "google-calendar" - ? "Google Calendar" - : "Apple Notes"; + const label = getConnectorLabel(connector); return `${label}: ${getConnectorSummaryLine(draft, connector)}`; }); } +function getConnectorLabel(connector: ConnectorTarget): string { + switch (connector) { + case "notion": + return "Notion"; + case "gmail": + return "Gmail"; + case "google-calendar": + return "Google Calendar"; + case "google-contacts": + return "Google Contacts"; + case "apple-notes": + return "Apple Notes"; + default: { + const exhaustive: never = connector; + return exhaustive; + } + } +} + function formatVersion(version: string): string { return version.startsWith("v") ? version : `v${version}`; } @@ -806,7 +826,9 @@ export function getRouteBody( case "connectorDetails": { const status = getConnectorStatus(draft, route.connector); const credentialLabel = - route.connector === "gmail" + route.connector === "gmail" || + route.connector === "google-calendar" || + route.connector === "google-contacts" ? hasAnyStoredCredentials(draft, route.connector) ? "stored Google account" : "missing Google account" @@ -849,7 +871,9 @@ export function getRouteBody( ? "This will disable Gmail but keep the stored Google account." : route.connector === "google-calendar" ? "This will disable Google Calendar but keep the stored Google account." - : "This will disable Apple Notes immediately."; + : route.connector === "google-contacts" + ? "This will disable Google Contacts but keep the stored Google account." + : "This will disable Apple Notes immediately."; case "confirmReset": return [ "This will remove all local syncdown app data immediately.", @@ -1059,17 +1083,10 @@ export function getRouteOptions( { name: "Schedule", description: getVisibleConnectorTargets() - .map((connector) => { - const label = - connector === "notion" - ? "Notion" - : connector === "gmail" - ? "Gmail" - : connector === "google-calendar" - ? "Google Calendar" - : "Apple Notes"; - return `${label} ${getDraftInterval(draft, connector)}`; - }) + .map( + (connector) => + `${getConnectorLabel(connector)} ${getDraftInterval(draft, connector)}`, + ) .join(" | "), value: "schedule", }, @@ -1143,6 +1160,15 @@ export function getRouteOptions( }, ] : []), + ...(connectedTargets.includes("google-contacts") + ? [ + { + name: "Run Google Contacts", + description: "Run only Google Contacts now", + value: "runGoogleContacts", + }, + ] + : []), ...(connectedTargets.includes("apple-notes") ? [ { @@ -1190,6 +1216,16 @@ export function getRouteOptions( }, ] : []), + ...(connectedTargets.includes("google-contacts") + ? [ + { + name: "Run Google Contacts (full resync)", + description: + "Reset Google Contacts state and rerun it from scratch", + value: "runGoogleContactsReset", + }, + ] + : []), ...(connectedTargets.includes("apple-notes") ? [ { @@ -1231,6 +1267,11 @@ export function getRouteOptions( description: getConnectorSummaryLine(draft, "google-calendar"), value: "google-calendar", }, + { + name: "Google Contacts", + description: getConnectorSummaryLine(draft, "google-contacts"), + value: "google-contacts", + }, ...(isAppleNotesSupportedPlatform() ? [ { @@ -1262,13 +1303,15 @@ export function getRouteOptions( if ( route.connector === "gmail" || - route.connector === "google-calendar" + route.connector === "google-calendar" || + route.connector === "google-contacts" ) { const hasGoogleAccount = hasAnyStoredCredentials(draft, "gmail"); const connectorEnabled = isDraftConnectorEnabled( draft, route.connector, ); + const connectorLabel = getConnectorLabel(route.connector); return [ ...(!hasGoogleAccount ? [ @@ -1289,10 +1332,7 @@ export function getRouteOptions( ] : [ { - name: - route.connector === "gmail" - ? "Enable Gmail" - : "Enable Google Calendar", + name: `Enable ${connectorLabel}`, description: "Enable with the stored Google account and verify permissions if needed", value: "enable", @@ -1315,24 +1355,20 @@ export function getRouteOptions( value: "gmailFilter", } satisfies UiSelectOption, ] - : [ - { - name: "Select calendars", - description: `Current: ${getDraftSelectedGoogleCalendarIds(draft).length} selected`, - value: "googleCalendarSelection", - } satisfies UiSelectOption, - ]), + : route.connector === "google-calendar" + ? [ + { + name: "Select calendars", + description: `Current: ${getDraftSelectedGoogleCalendarIds(draft).length} selected`, + value: "googleCalendarSelection", + } satisfies UiSelectOption, + ] + : []), ...(connectorEnabled ? [ { - name: - route.connector === "gmail" - ? "Disable Gmail" - : "Disable Google Calendar", - description: - route.connector === "gmail" - ? "Stop syncing Gmail but keep the stored Google account" - : "Stop syncing Google Calendar but keep the stored Google account", + name: `Disable ${connectorLabel}`, + description: `Stop syncing ${connectorLabel} but keep the stored Google account`, value: "disable", } satisfies UiSelectOption, ] @@ -1479,6 +1515,11 @@ export function getRouteOptions( description: "Open the interval picker", value: "google-calendar", }, + { + name: `Google Contacts interval: ${getDraftInterval(draft, "google-contacts")}`, + description: "Open the interval picker", + value: "google-contacts", + }, ...(isAppleNotesSupportedPlatform() ? [ { diff --git a/tsconfig.base.json b/tsconfig.base.json index 89050dc..9c06250 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -13,6 +13,9 @@ "@syncdown/connector-google-calendar": [ "./packages/connector-google-calendar/src/index.ts" ], + "@syncdown/connector-google-contacts": [ + "./packages/connector-google-contacts/src/index.ts" + ], "@syncdown/connector-notion": [ "./packages/connector-notion/src/index.ts" ],