From 04fa53ea3b392353a00700a503e593b6736c4c7f Mon Sep 17 00:00:00 2001 From: gleber Date: Tue, 19 May 2026 05:13:31 +0000 Subject: [PATCH 1/3] feat(connectors): add Google Contacts connector backed by the People API Adds a new @syncdown/connector-google-contacts package that syncs Google Contacts via the People API. Reuses the shared Google OAuth connection used by Gmail and Google Calendar and requests the contacts.readonly scope. Existing users will need to re-authorize the shared Google connection to grant the new scope. Each contact renders to one markdown file at /google-contacts//-.md with frontmatter for emails, phones, organizations, titles, groups, addresses, urls, birthday, and a structured body section. Uses People API syncToken for incremental syncs and contactGroups.list to resolve group labels. Threaded through core via: - new 'contact' DocumentPathHint kind + contact* SourceMetadata fields - new ContactsIntegrationConfig and ContactsIntegrationSettings types - getDefaultConnectionId / fallback integration / normalize branch / isContactsIntegration guard in config-model - flat per-account routing in renderer-md path-builder - contact frontmatter mapping in renderer-md frontmatter - registration in @syncdown/connectors Co-Authored-By: Claude Opus 4.7 --- .changeset/feat-google-contacts.md | 9 + bun.lock | 10 + .../connector-google-contacts/package.json | 18 + .../src/index.test.ts | 349 +++++ .../connector-google-contacts/src/index.ts | 1122 +++++++++++++++++ .../connector-google-contacts/tsconfig.json | 7 + packages/connectors/package.json | 1 + packages/connectors/src/index.test.ts | 13 +- packages/connectors/src/index.ts | 2 + packages/core/src/config-model.ts | 34 +- packages/core/src/index.ts | 3 + packages/core/src/types.ts | 23 +- packages/renderer-md/src/frontmatter.ts | 44 + packages/renderer-md/src/path-builder.ts | 30 +- tsconfig.base.json | 3 + 15 files changed, 1661 insertions(+), 7 deletions(-) create mode 100644 .changeset/feat-google-contacts.md create mode 100644 packages/connector-google-contacts/package.json create mode 100644 packages/connector-google-contacts/src/index.test.ts create mode 100644 packages/connector-google-contacts/src/index.ts create mode 100644 packages/connector-google-contacts/tsconfig.json 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-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..4967887 --- /dev/null +++ b/packages/connector-google-contacts/src/index.ts @@ -0,0 +1,1122 @@ +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, +} 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; + } + return JSON.parse(text) as T; +} + +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( + JSON.stringify({ + 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/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/index.ts b/packages/core/src/index.ts index 64bf747..3941859 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, @@ -102,6 +103,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/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" ], From 3478225d44402c9abe217694ff9bcf83f42fe44a Mon Sep 17 00:00:00 2001 From: gleber Date: Tue, 19 May 2026 22:01:24 +0000 Subject: [PATCH 2/3] feat(tui): surface Google Contacts connector in TUI + sync dashboard Extends ConnectorTarget union with google-contacts and threads it through every TUI display path that previously hardcoded only notion/gmail/google-calendar/apple-notes: - state.ts: union, stageGoogleConnection extract, buildOverview line - view-state.ts: connectorDetails title and body, confirmDisconnect title and body, interval title, getVisibleConnectorTargets list, getVisibleConnectorSummaryLines via new getConnectorLabel helper, connectors-route option list, schedule-route option list, sync dashboard run/reset options, connectorDetails action list (Google-shared branch widened to include contacts) - config-route-actions.ts: handleEnableConnectorSelection branch, isConnectorTarget predicate, getConnectorLabel switch - config-auth-controller.ts: getRequiredGoogleScopes + ensureGoogleScopesForConnector signature widened to accept google-contacts - config-runtime-controller.ts: runGoogleContacts and runGoogleContactsReset selection handlers Adds a TUI test asserting the connector and schedule routes both expose Google Contacts. Existing 90 tests stay green. --- packages/tui/src/config-auth-controller.ts | 8 +- packages/tui/src/config-route-actions.ts | 20 ++- packages/tui/src/config-runtime-controller.ts | 17 +++ packages/tui/src/state.ts | 8 +- packages/tui/src/view-state.test.ts | 20 +++ packages/tui/src/view-state.ts | 135 ++++++++++++------ 6 files changed, 156 insertions(+), 52 deletions(-) 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() ? [ { From 129de76e471f88702892b32b271ac2934dee7210 Mon Sep 17 00:00:00 2001 From: Gleb Peregud Date: Wed, 20 May 2026 22:06:53 +0000 Subject: [PATCH 3/3] refactor(connectors): stable hashing + safer JSON parsing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address review feedback on PR #23 by applying the suggested fixes across all connectors that share the same patterns. * Add shared `stableStringify` helper in `@syncdown/core` that sorts object keys before serializing, and use it in every `computeSourceHash` / `toSourceHash` / `hashDocumentPayload` (gmail, google-calendar, google-contacts, apple-notes, notion). Previously each used plain `JSON.stringify`, which is only deterministic when callers never reorder object literal keys — a fragile invariant. Changing the hash triggers a one-time re-render of every document on next sync. * Wrap `JSON.parse` in `parseJsonResponse` with a try/catch in the three Google connectors (gmail, google-calendar, google-contacts) so a non-JSON HTTP response (proxy/load-balancer HTML, etc.) throws a descriptive `*ApiError` instead of an opaque `SyntaxError`. Co-Authored-By: Claude Opus 4.7 --- packages/connector-apple-notes/src/index.ts | 3 ++- packages/connector-gmail/src/index.ts | 13 +++++++++++-- .../connector-google-calendar/src/index.ts | 13 +++++++++++-- .../connector-google-contacts/src/index.ts | 13 +++++++++++-- .../connector-notion/src/notion-source.ts | 3 ++- packages/core/src/hashing.test.ts | 19 +++++++++++++++++++ packages/core/src/hashing.ts | 12 ++++++++++++ packages/core/src/index.ts | 1 + 8 files changed, 69 insertions(+), 8 deletions(-) create mode 100644 packages/core/src/hashing.test.ts create mode 100644 packages/core/src/hashing.ts 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/src/index.ts b/packages/connector-google-contacts/src/index.ts index 4967887..13d8733 100644 --- a/packages/connector-google-contacts/src/index.ts +++ b/packages/connector-google-contacts/src/index.ts @@ -18,6 +18,7 @@ import { 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/"; @@ -243,7 +244,15 @@ async function parseJsonResponse(response: Response): Promise { if (!text) { 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 GoogleContactsApiError( + response.status, + `Google People API response was not valid JSON: ${reason}`, + ); + } } class OfficialGoogleContactsAdapter implements GoogleContactsAdapter { @@ -390,7 +399,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-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/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 3941859..734ec81 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -74,6 +74,7 @@ export { readNotionOAuthConnectionCredentials, refreshNotionAccessToken, } from "./notion-auth.js"; +export { stableStringify } from "./hashing.js"; export { defineConnectorPlugin } from "./plugin.js"; export type { AppIo,