diff --git a/.changeset/famous-buses-hang.md b/.changeset/famous-buses-hang.md new file mode 100644 index 0000000..9817726 --- /dev/null +++ b/.changeset/famous-buses-hang.md @@ -0,0 +1,5 @@ +--- +"@syncdown/cli": minor +--- + +Add Apple Notes sync support with CLI and TUI setup on macOS. diff --git a/README.md b/README.md index 2456ca9..947ae8b 100644 --- a/README.md +++ b/README.md @@ -54,11 +54,12 @@ Treat the Markdown files and connector folders under that output tree as `syncdo ### 3. Connect a source -Open **Connectors** and set up one or both of the supported sources: +Open **Connectors** and set up the sources you want: - **Notion** for pages and databases you've allowed the Notion connection to access - **Gmail** for `Primary` inbox sync through Google OAuth - **Google Calendar** for selected calendars through the shared Google OAuth account +- **Apple Notes** for local notes on macOS ### 4. Run your first sync @@ -68,6 +69,7 @@ From the TUI home screen, open **Sync** and run: - **Run Notion** - **Run Gmail** - **Run Google Calendar** +- **Run Apple Notes** You can also do a minimal headless setup from the CLI: @@ -165,12 +167,14 @@ syncdown reset --yes - **Notion** with token or OAuth auth - **Gmail** with Google OAuth and incremental inbox sync - **Google Calendar** with shared Google OAuth and selected-calendar incremental sync +- **Apple Notes** with local macOS access Connector-specific setup details, supported behavior, and current limits are documented separately: -- [Notion connector](https://syncdown.dev/docs/content/docs/connectors/notion.mdx) -- [Gmail connector](https://syncdown.dev/docs/connectors/gmail.mdx) -- [Google Calendar connector](https://syncdown.dev/docs/connectors/google-calendar.mdx) +- [Notion connector](https://syncdown.dev/docs/connectors/notion) +- [Gmail connector](https://syncdown.dev/docs/connectors/gmail) +- [Google Calendar connector](https://syncdown.dev/docs/connectors/google-calendar) +- [Apple Notes connector](https://syncdown.dev/docs/connectors/apple-notes) ## Docs diff --git a/apps/cli/package.json b/apps/cli/package.json index 0c35b7a..fb5208d 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -19,6 +19,7 @@ "src" ], "devDependencies": { + "@syncdown/connector-apple-notes": "workspace:*", "@syncdown/connector-google-calendar": "workspace:*", "@syncdown/connector-gmail": "workspace:*", "@syncdown/connector-notion": "workspace:*", diff --git a/apps/cli/src/program.test.ts b/apps/cli/src/program.test.ts index 8a1cee6..034af9f 100644 --- a/apps/cli/src/program.test.ts +++ b/apps/cli/src/program.test.ts @@ -460,6 +460,26 @@ test("status prints google calendar selection count", async () => { ); }); +test("status hides apple notes when the connector is unavailable", async () => { + const { io, writes } = createIoCapture(); + const { app } = createAppStub({ + inspect: async () => createSnapshot(), + }); + + await withTty(false, () => + runTestCli(["syncdown", "syncdown", "status"], { + app, + io, + secrets: createSecretsStub(), + }), + ); + + expect(writes.some((line) => line.startsWith("apple-notes:"))).toBe(false); + expect(writes.some((line) => line.includes("appleNotes.enabled"))).toBe( + false, + ); +}); + test("status prints gmail performance settings", async () => { const { io, writes } = createIoCapture(); const baseSnapshot = createSnapshot(); diff --git a/apps/cli/src/program.ts b/apps/cli/src/program.ts index 212bbbc..67c2ef9 100644 --- a/apps/cli/src/program.ts +++ b/apps/cli/src/program.ts @@ -1,9 +1,11 @@ +import { createAppleNotesConnector } from "@syncdown/connector-apple-notes"; import { createGmailConnector } from "@syncdown/connector-gmail"; import { createGoogleCalendarConnector } from "@syncdown/connector-google-calendar"; import { createNotionConnector } from "@syncdown/connector-notion"; import type { AppIo, ApplyUpdateResult, + ConnectorId, GmailSyncFilter, RunOptions, SelfUpdater, @@ -15,6 +17,7 @@ import type { import { createStdIo, createSyncdownApp, + DEFAULT_APPLE_NOTES_CONNECTION_ID, DEFAULT_NOTION_OAUTH_APP_ID, DEFAULT_NOTION_OAUTH_CONNECTION_ID, DEFAULT_NOTION_TOKEN_CONNECTION_ID, @@ -26,6 +29,7 @@ import { getNotionOAuthConnectionSecretNames, hasGoogleCredentials, hasNotionOAuthConnectionCredentials, + isAppleNotesIntegration, isCalendarIntegration, isGmailIntegration, readConfig, @@ -41,7 +45,60 @@ import { launchConfigTui } from "@syncdown/tui"; import { createCliSelfUpdater } from "./updater.js"; +function supportsAppleNotes( + platform: NodeJS.Platform = process.platform, +): boolean { + return platform === "darwin"; +} + +function getSupportedRunConnectorIds( + platform: NodeJS.Platform = process.platform, +): ConnectorId[] { + return supportsAppleNotes(platform) + ? ["notion", "gmail", "google-calendar", "apple-notes"] + : ["notion", "gmail", "google-calendar"]; +} + +function isSupportedRunConnectorId( + value: string, + platform: NodeJS.Platform = process.platform, +): value is ConnectorId { + return getSupportedRunConnectorIds(platform).includes(value as ConnectorId); +} + +function getConfigSetKeys( + platform: NodeJS.Platform = process.platform, +): string[] { + const keys = [ + "outputDir", + "notion.enabled", + "notion.interval", + "notion.authMethod", + "notion.token", + "notion.oauth.clientId", + "notion.oauth.clientSecret", + "notion.oauth.refreshToken", + "gmail.enabled", + "gmail.interval", + "gmail.fetchConcurrency", + "gmail.syncFilter", + "googleCalendar.enabled", + "googleCalendar.interval", + "googleCalendar.selectedCalendarIds", + "google.clientId", + "google.clientSecret", + "google.refreshToken", + ]; + + if (supportsAppleNotes(platform)) { + keys.splice(15, 0, "appleNotes.enabled", "appleNotes.interval"); + } + + return keys; +} + function getHelpLines(): string[] { + const connectorUsage = getSupportedRunConnectorIds().join("|"); return [ "syncdown", "", @@ -52,9 +109,9 @@ function getHelpLines(): string[] { " syncdown config set --stdin", " syncdown config unset ", " syncdown run", - " syncdown run --connector ", + ` syncdown run --connector <${connectorUsage}>`, " syncdown run --integration ", - " syncdown run --reset [--connector |--integration ]", + ` syncdown run --reset [--connector <${connectorUsage}>|--integration ]`, " syncdown run --watch [--interval <5m|15m|1h|6h|24h>]", " syncdown reset --yes", " syncdown connectors", @@ -70,24 +127,7 @@ function getHelpLines(): string[] { " syncdown config unset ... Remove config values non-interactively.", "", "Config keys:", - " outputDir", - " notion.enabled", - " notion.interval", - " notion.authMethod", - " notion.token", - " notion.oauth.clientId", - " notion.oauth.clientSecret", - " notion.oauth.refreshToken", - " gmail.enabled", - " gmail.interval", - " gmail.fetchConcurrency", - " gmail.syncFilter", - " googleCalendar.enabled", - " googleCalendar.interval", - " googleCalendar.selectedCalendarIds", - " google.clientId", - " google.clientSecret", - " google.refreshToken", + ...getConfigSetKeys().map((key) => ` ${key}`), "", "Run exit codes:", ` ${EXIT_CODES.OK} success`, @@ -172,7 +212,7 @@ async function loadConfig(): Promise<{ function printConfigSetUsage(io: AppIo): void { io.error( - "Usage: syncdown config set ", + `Usage: syncdown config set <${getConfigSetKeys().join("|")}> `, ); } @@ -184,7 +224,7 @@ function printConfigUnsetUsage(io: AppIo): void { function printRunUsage(io: AppIo): void { io.error( - "Usage: syncdown run [--connector |--integration ] [--reset] [--watch] [--interval <5m|15m|1h|6h|24h>]", + `Usage: syncdown run [--connector <${getSupportedRunConnectorIds().join("|")}>|--integration ] [--reset] [--watch] [--interval <5m|15m|1h|6h|24h>]`, ); } @@ -242,14 +282,11 @@ function parseRunOptions(args: string[], io: AppIo): RunOptions | null { if (arg === "--connector") { const value = args[index + 1]; - if ( - value !== "notion" && - value !== "gmail" && - value !== "google-calendar" - ) { + const validConnectors = getSupportedRunConnectorIds(); + if (!value || !isSupportedRunConnectorId(value)) { io.error( value - ? "--connector must be one of: notion, gmail, google-calendar" + ? `--connector must be one of: ${validConnectors.join(", ")}` : "--connector requires a value.", ); printRunUsage(io); @@ -330,11 +367,15 @@ function parseRunOptions(args: string[], io: AppIo): RunOptions | null { } async function getCredentialStatus( - connectorId: "notion" | "gmail" | "google-calendar", + connectorId: "notion" | "gmail" | "google-calendar" | "apple-notes", config: SyncdownConfig, paths: ReturnType, secrets: ReturnType, -): Promise<"complete" | "missing"> { +): Promise<"complete" | "missing" | "local"> { + if (connectorId === "apple-notes") { + return supportsAppleNotes() ? "local" : "missing"; + } + if (connectorId === "notion") { const notionIntegration = getNotionIntegrationConfig(config); if (notionIntegration.connectionId === DEFAULT_NOTION_OAUTH_CONNECTION_ID) { @@ -388,6 +429,14 @@ function getGoogleCalendarIntegrationConfig(config: SyncdownConfig) { return integration; } +function getAppleNotesIntegrationConfig(config: SyncdownConfig) { + const integration = getDefaultIntegration(config, "apple-notes"); + if (!isAppleNotesIntegration(integration)) { + throw new Error("Expected default Apple Notes integration"); + } + return integration; +} + function parseCommaSeparatedIds(value: string): string[] { return [ ...new Set( @@ -603,6 +652,34 @@ async function handleConfigSet( ); return EXIT_CODES.OK; } + case "appleNotes.enabled": { + const parsed = parseBoolean(value.trim()); + if (parsed === null) { + io.error("appleNotes.enabled must be `true` or `false`."); + return EXIT_CODES.CONFIG_ERROR; + } + getAppleNotesIntegrationConfig(config).enabled = parsed; + await writeConfig(paths, config); + io.write( + `Set appleNotes.enabled=${getAppleNotesIntegrationConfig(config).enabled}`, + ); + return EXIT_CODES.OK; + } + case "appleNotes.interval": { + const nextValue = value.trim(); + if (!isSyncIntervalPreset(nextValue)) { + io.error( + `appleNotes.interval must be one of: ${INTERVAL_PRESETS.join(", ")}`, + ); + return EXIT_CODES.CONFIG_ERROR; + } + getAppleNotesIntegrationConfig(config).interval = nextValue; + await writeConfig(paths, config); + io.write( + `Set appleNotes.interval=${getAppleNotesIntegrationConfig(config).interval}`, + ); + return EXIT_CODES.OK; + } case GOOGLE_SECRET_NAMES.clientId: case GOOGLE_SECRET_NAMES.clientSecret: case GOOGLE_SECRET_NAMES.refreshToken: { @@ -699,6 +776,12 @@ async function printOverview( const googleCalendarConfig = getGoogleCalendarIntegrationConfig( snapshot.config, ); + const appleNotesSupported = snapshot.connectors.some( + (connector) => connector.id === "apple-notes", + ); + const appleNotesConfig = appleNotesSupported + ? getAppleNotesIntegrationConfig(snapshot.config) + : null; const notion = snapshot.integrations.find( (integration) => integration.connectorId === "notion", ); @@ -708,6 +791,11 @@ async function printOverview( const googleCalendar = snapshot.integrations.find( (integration) => integration.connectorId === "google-calendar", ); + const appleNotes = appleNotesSupported + ? snapshot.integrations.find( + (integration) => integration.connectorId === "apple-notes", + ) + : undefined; const [ notionCredentials, gmailCredentials, @@ -724,6 +812,14 @@ async function printOverview( ), hasGoogleCredentials(secrets, snapshot.paths), ]); + const appleNotesCredentials = appleNotesSupported + ? await getCredentialStatus( + "apple-notes", + snapshot.config, + snapshot.paths, + secrets, + ) + : "missing"; const hasEnabledConfiguredConnector = ((notion?.enabled ?? notionConfig.enabled) && notionCredentials === "complete") || @@ -731,7 +827,10 @@ async function printOverview( gmailCredentials === "complete") || ((googleCalendar?.enabled ?? googleCalendarConfig.enabled) && googleCalendarCredentials === "complete" && - googleCalendarConfig.config.selectedCalendarIds.length > 0); + googleCalendarConfig.config.selectedCalendarIds.length > 0) || + (appleNotesSupported && + (appleNotes?.enabled ?? appleNotesConfig?.enabled ?? false) && + appleNotesCredentials === "local"); io.write("syncdown"); io.write(""); @@ -747,6 +846,11 @@ async function printOverview( io.write( `google-calendar: ${(googleCalendar?.enabled ?? googleCalendarConfig.enabled) ? "enabled" : "disabled"} | interval=${googleCalendar?.interval ?? googleCalendarConfig.interval} | selected calendars=${googleCalendarConfig.config.selectedCalendarIds.length} | credentials=${googleCalendarCredentials} | last sync=${googleCalendar?.lastSyncAt ?? "never"}`, ); + if (appleNotesSupported && appleNotesConfig) { + io.write( + `apple-notes: ${(appleNotes?.enabled ?? appleNotesConfig.enabled) ? "enabled" : "disabled"} | interval=${appleNotes?.interval ?? appleNotesConfig.interval} | access=${appleNotesCredentials} | connection=${DEFAULT_APPLE_NOTES_CONNECTION_ID} | last sync=${appleNotes?.lastSyncAt ?? "never"}`, + ); + } io.write(""); io.write("Next:"); @@ -788,6 +892,10 @@ async function printOverview( io.write( "- Example: `printf '%s' \"$GOOGLE_REFRESH_TOKEN\" | syncdown config set google.refreshToken --stdin`", ); + if (appleNotesSupported) { + io.write("- Example: `syncdown config set appleNotes.enabled true`"); + io.write("- Example: `syncdown config set appleNotes.interval 1h`"); + } } } else { io.write("- Run `syncdown run` to start a sync."); @@ -928,6 +1036,7 @@ export async function runCli( createNotionConnector(), createGmailConnector(), createGoogleCalendarConnector(), + ...(supportsAppleNotes() ? [createAppleNotesConnector()] : []), ], renderer: createMarkdownRenderer(), sink: createFileSystemSink(), diff --git a/apps/docs/content/docs/cli.ja.mdx b/apps/docs/content/docs/cli.ja.mdx index 72bdb11..6867365 100644 --- a/apps/docs/content/docs/cli.ja.mdx +++ b/apps/docs/content/docs/cli.ja.mdx @@ -18,9 +18,9 @@ syncdown config set syncdown config set --stdin syncdown config unset syncdown run -syncdown run --connector +syncdown run --connector syncdown run --integration -syncdown run --reset [--connector |--integration ] +syncdown run --reset [--connector |--integration ] syncdown run --watch [--interval <5m|15m|1h|6h|24h>] syncdown reset --yes syncdown connectors @@ -29,6 +29,8 @@ syncdown update syncdown update --check ``` +macOS では `syncdown run --connector` と `syncdown run --reset --connector` で `apple-notes` も利用できます。 + ## `syncdown` 対話型ターミナルでは `syncdown` が TUI を起動します。 @@ -46,6 +48,7 @@ TTY がない場合はヘルプを表示して、設定エラーで終了しま - Notion の状態、認証方式、interval、認証情報、前回同期 - Gmail の状態、interval、受信トレイフィルタ、初回同期上限、並列度、認証情報、前回同期 - Google Calendar の状態、interval、選択済みカレンダー数、認証情報、前回同期 +- Apple Notes の状態、interval、ローカルアクセス、connection ID、前回同期 設定が完了しているかどうかに応じて、次に取るべきガイダンスも表示します。 @@ -62,6 +65,8 @@ syncdown config set notion.authMethod token syncdown config set gmail.syncFilter primary syncdown config set gmail.fetchConcurrency 20 syncdown config set googleCalendar.selectedCalendarIds primary,work@example.com +syncdown config set appleNotes.enabled true +syncdown config set appleNotes.interval 1h ``` secret 値の例: @@ -94,6 +99,7 @@ syncdown run ```sh syncdown run --connector notion syncdown run --connector google-calendar +syncdown run --connector apple-notes syncdown run --integration notion-default ``` diff --git a/apps/docs/content/docs/cli.ko.mdx b/apps/docs/content/docs/cli.ko.mdx index 9dfaad3..0a6d0e6 100644 --- a/apps/docs/content/docs/cli.ko.mdx +++ b/apps/docs/content/docs/cli.ko.mdx @@ -18,9 +18,9 @@ syncdown config set syncdown config set --stdin syncdown config unset syncdown run -syncdown run --connector +syncdown run --connector syncdown run --integration -syncdown run --reset [--connector |--integration ] +syncdown run --reset [--connector |--integration ] syncdown run --watch [--interval <5m|15m|1h|6h|24h>] syncdown reset --yes syncdown connectors @@ -29,6 +29,8 @@ syncdown update syncdown update --check ``` +macOS에서는 `syncdown run --connector`와 `syncdown run --reset --connector`에 `apple-notes`도 사용할 수 있습니다. + ## `syncdown` 인터랙티브 터미널에서는 `syncdown`이 TUI를 실행합니다. @@ -46,6 +48,7 @@ TTY가 없으면 도움말을 출력하고 설정 오류와 함께 종료합니 - Notion 상태, 인증 방식, interval, 자격 증명, 마지막 동기화 - Gmail 상태, interval, 받은편지함 필터, 초기 동기화 제한, 동시성, 자격 증명, 마지막 동기화 - Google Calendar 상태, interval, 선택된 캘린더 수, 자격 증명, 마지막 동기화 +- Apple Notes 상태, interval, 로컬 접근, connection ID, 마지막 동기화 또한 설정 완료 여부에 따라 다음 단계 안내도 출력합니다. @@ -62,6 +65,8 @@ syncdown config set notion.authMethod token syncdown config set gmail.syncFilter primary syncdown config set gmail.fetchConcurrency 20 syncdown config set googleCalendar.selectedCalendarIds primary,work@example.com +syncdown config set appleNotes.enabled true +syncdown config set appleNotes.interval 1h ``` secret 값 예시: @@ -94,6 +99,7 @@ syncdown run ```sh syncdown run --connector notion syncdown run --connector google-calendar +syncdown run --connector apple-notes syncdown run --integration notion-default ``` diff --git a/apps/docs/content/docs/cli.mdx b/apps/docs/content/docs/cli.mdx index bfede02..6e7b079 100644 --- a/apps/docs/content/docs/cli.mdx +++ b/apps/docs/content/docs/cli.mdx @@ -18,9 +18,9 @@ syncdown config set syncdown config set --stdin syncdown config unset syncdown run -syncdown run --connector +syncdown run --connector syncdown run --integration -syncdown run --reset [--connector |--integration ] +syncdown run --reset [--connector |--integration ] syncdown run --watch [--interval <5m|15m|1h|6h|24h>] syncdown reset --yes syncdown connectors @@ -29,6 +29,8 @@ syncdown update syncdown update --check ``` +On macOS, `apple-notes` is also available for `syncdown run --connector` and `syncdown run --reset --connector`. + ## `syncdown` In an interactive terminal, `syncdown` launches the TUI. @@ -46,6 +48,7 @@ Shows a high-level summary of: - Notion status, auth method, interval, credentials, and last sync - Gmail status, interval, inbox filter, initial sync limit, concurrency, credentials, and last sync - Google Calendar status, interval, selected calendar count, credentials, and last sync +- Apple Notes status, interval, local access, connection ID, and last sync It also prints next-step guidance based on whether setup is complete. @@ -62,6 +65,8 @@ syncdown config set notion.authMethod token syncdown config set gmail.syncFilter primary syncdown config set gmail.fetchConcurrency 20 syncdown config set googleCalendar.selectedCalendarIds primary,work@example.com +syncdown config set appleNotes.enabled true +syncdown config set appleNotes.interval 1h ``` For secret values: @@ -94,6 +99,7 @@ Run one connector or one integration once: ```sh syncdown run --connector notion syncdown run --connector google-calendar +syncdown run --connector apple-notes syncdown run --integration notion-default ``` diff --git a/apps/docs/content/docs/cli.zh-CN.mdx b/apps/docs/content/docs/cli.zh-CN.mdx index 220c9a2..000c1e7 100644 --- a/apps/docs/content/docs/cli.zh-CN.mdx +++ b/apps/docs/content/docs/cli.zh-CN.mdx @@ -18,9 +18,9 @@ syncdown config set syncdown config set --stdin syncdown config unset syncdown run -syncdown run --connector +syncdown run --connector syncdown run --integration -syncdown run --reset [--connector |--integration ] +syncdown run --reset [--connector |--integration ] syncdown run --watch [--interval <5m|15m|1h|6h|24h>] syncdown reset --yes syncdown connectors @@ -29,6 +29,8 @@ syncdown update syncdown update --check ``` +在 macOS 上,`syncdown run --connector` 和 `syncdown run --reset --connector` 还支持 `apple-notes`。 + ## `syncdown` 在交互式终端中,`syncdown` 会启动 TUI。 @@ -46,6 +48,7 @@ syncdown update --check - Notion 状态、认证方式、interval、凭证和最近一次同步 - Gmail 状态、interval、收件箱过滤器、初始同步限制、并发度、凭证和最近一次同步 - Google Calendar 状态、interval、已选日历数量、凭证和最近一次同步 +- Apple Notes 状态、interval、本地访问、connection ID 和最近一次同步 它还会根据配置是否完成,输出下一步建议。 @@ -62,6 +65,8 @@ syncdown config set notion.authMethod token syncdown config set gmail.syncFilter primary syncdown config set gmail.fetchConcurrency 20 syncdown config set googleCalendar.selectedCalendarIds primary,work@example.com +syncdown config set appleNotes.enabled true +syncdown config set appleNotes.interval 1h ``` secret 值示例: @@ -94,6 +99,7 @@ syncdown run ```sh syncdown run --connector notion syncdown run --connector google-calendar +syncdown run --connector apple-notes syncdown run --integration notion-default ``` diff --git a/apps/docs/content/docs/configuration.ja.mdx b/apps/docs/content/docs/configuration.ja.mdx index 8c446be..aa06592 100644 --- a/apps/docs/content/docs/configuration.ja.mdx +++ b/apps/docs/content/docs/configuration.ja.mdx @@ -28,6 +28,8 @@ syncdown run Gmail、Google Calendar、または Notion OAuth を使う場合は、以下のコネクタ別シークレットキーを使用してください。 +Apple Notes は別です。macOS ではシークレットを保存せずに有効化できます。 + ## 設定モデル 永続化される設定には次が含まれます。 @@ -57,6 +59,9 @@ syncdown config set gmail.fetchConcurrency 10 syncdown config set googleCalendar.enabled true syncdown config set googleCalendar.interval 1h syncdown config set googleCalendar.selectedCalendarIds primary,work@example.com + +syncdown config set appleNotes.enabled true +syncdown config set appleNotes.interval 1h ``` 標準入力からシークレット値を読む: @@ -119,6 +124,8 @@ syncdown config unset google.refreshToken - `googleCalendar.enabled` - `googleCalendar.interval` - `googleCalendar.selectedCalendarIds` +- macOS 上の `appleNotes.enabled` +- macOS 上の `appleNotes.interval` secret-backed な値: @@ -137,6 +144,7 @@ secret-backed な値: - `notion.interval` - `gmail.interval` - `googleCalendar.interval` +- macOS 上の `appleNotes.interval` これらの interval は、TUI の同期ダッシュボードが **integration ごとの watch** モードを開始するときに使われます。 diff --git a/apps/docs/content/docs/configuration.ko.mdx b/apps/docs/content/docs/configuration.ko.mdx index b683e30..bc8a078 100644 --- a/apps/docs/content/docs/configuration.ko.mdx +++ b/apps/docs/content/docs/configuration.ko.mdx @@ -28,6 +28,8 @@ syncdown run Gmail, Google Calendar 또는 Notion OAuth를 사용할 때는 아래 커넥터별 secret key를 사용하세요. +Apple Notes는 다릅니다. macOS에서는 secret 없이 활성화할 수 있습니다. + ## 설정 모델 영속 설정에는 다음이 포함됩니다. @@ -57,6 +59,9 @@ syncdown config set gmail.fetchConcurrency 10 syncdown config set googleCalendar.enabled true syncdown config set googleCalendar.interval 1h syncdown config set googleCalendar.selectedCalendarIds primary,work@example.com + +syncdown config set appleNotes.enabled true +syncdown config set appleNotes.interval 1h ``` 표준 입력에서 secret 값 읽기: @@ -119,6 +124,8 @@ syncdown config unset google.refreshToken - `googleCalendar.enabled` - `googleCalendar.interval` - `googleCalendar.selectedCalendarIds` +- macOS의 `appleNotes.enabled` +- macOS의 `appleNotes.interval` secret-backed 값: @@ -137,6 +144,7 @@ secret-backed 값: - `notion.interval` - `gmail.interval` - `googleCalendar.interval` +- macOS의 `appleNotes.interval` 이 interval은 TUI 동기화 대시보드에서 **integration별 watch** 모드를 시작할 때 사용됩니다. diff --git a/apps/docs/content/docs/configuration.mdx b/apps/docs/content/docs/configuration.mdx index 4e3604b..22cbe2d 100644 --- a/apps/docs/content/docs/configuration.mdx +++ b/apps/docs/content/docs/configuration.mdx @@ -28,6 +28,8 @@ syncdown run For Gmail, Google Calendar, or Notion OAuth, use the connector-specific secret keys shown below. +Apple Notes is different: on macOS you can enable it without storing secrets. + ## Config model The persisted config contains: @@ -57,6 +59,9 @@ syncdown config set gmail.fetchConcurrency 10 syncdown config set googleCalendar.enabled true syncdown config set googleCalendar.interval 1h syncdown config set googleCalendar.selectedCalendarIds primary,work@example.com + +syncdown config set appleNotes.enabled true +syncdown config set appleNotes.interval 1h ``` Read secret values from standard input: @@ -119,6 +124,8 @@ Plain config values: - `googleCalendar.enabled` - `googleCalendar.interval` - `googleCalendar.selectedCalendarIds` +- `appleNotes.enabled` on macOS +- `appleNotes.interval` on macOS Secret-backed values: @@ -137,6 +144,7 @@ Connector intervals are stored per integration: - `notion.interval` - `gmail.interval` - `googleCalendar.interval` +- `appleNotes.interval` on macOS These intervals are used by the TUI sync dashboard when it starts **per-integration watch** mode. diff --git a/apps/docs/content/docs/configuration.zh-CN.mdx b/apps/docs/content/docs/configuration.zh-CN.mdx index 028026a..a2b40ed 100644 --- a/apps/docs/content/docs/configuration.zh-CN.mdx +++ b/apps/docs/content/docs/configuration.zh-CN.mdx @@ -28,6 +28,8 @@ syncdown run 对于 Gmail、Google Calendar 或 Notion OAuth,请使用下面列出的各 connector secret key。 +Apple Notes 不同。在 macOS 上,你可以在不保存 secret 的情况下启用它。 + ## 配置模型 持久化配置包含: @@ -57,6 +59,9 @@ syncdown config set gmail.fetchConcurrency 10 syncdown config set googleCalendar.enabled true syncdown config set googleCalendar.interval 1h syncdown config set googleCalendar.selectedCalendarIds primary,work@example.com + +syncdown config set appleNotes.enabled true +syncdown config set appleNotes.interval 1h ``` 从标准输入读取 secret 值: @@ -119,6 +124,8 @@ syncdown config unset google.refreshToken - `googleCalendar.enabled` - `googleCalendar.interval` - `googleCalendar.selectedCalendarIds` +- macOS 上的 `appleNotes.enabled` +- macOS 上的 `appleNotes.interval` secret-backed 值: @@ -137,6 +144,7 @@ connector interval 按 integration 保存: - `notion.interval` - `gmail.interval` - `googleCalendar.interval` +- macOS 上的 `appleNotes.interval` 这些 interval 会在 TUI 同步面板启动 **按 integration 的 watch** 模式时使用。 diff --git a/apps/docs/content/docs/connectors/apple-notes.ja.mdx b/apps/docs/content/docs/connectors/apple-notes.ja.mdx new file mode 100644 index 0000000..4ef8067 --- /dev/null +++ b/apps/docs/content/docs/connectors/apple-notes.ja.mdx @@ -0,0 +1,55 @@ +--- +title: Apple Notes +description: ローカル Notes アプリから何が同期されるかと Apple Notes コネクタの設定方法を説明します。 +--- + +# Apple Notes + +Apple Notes コネクタは、ローカル Notes アプリで読めるノートを Markdown に同期します。 + +## 対応プラットフォーム + +現在 Apple Notes 同期は macOS でのみ対応しています。 + +`syncdown` は、実行中の同じ Mac 上にある Notes アプリへローカルアクセスできる必要があります。macOS がアクセスをブロックした場合は、コネクタを再実行して権限ダイアログを承認するか、現在のターミナルまたはアプリにアクセス権を付与してください。 + +## ヘッドレス設定 + +```sh +syncdown config set appleNotes.enabled true +syncdown config set appleNotes.interval 1h +``` + +その後、次を実行します。 + +```sh +syncdown run --connector apple-notes +``` + +## 何が同期されるか + +現在このコネクタは次を同期します。 + +- ノートタイトル +- Markdown テキストとしてレンダリングされた本文 +- 作成・更新タイムスタンプ +- アカウント名 +- フォルダパス + +現時点では添付ファイルの抽出やコピーは行いません。 + +## 出力パス + +Markdown ファイルはアカウントとフォルダのパス配下に書き込まれます。 + +```text +apple-notes///note-title-.md +``` + +ネストしたフォルダも出力パスにそのまま保持されます。 + +## 現在の制限 + +- macOS 専用 +- ローカル Notes へのアクセス権が必要 +- ロックされたノートや読み取れないノートは warning 付きでスキップされる diff --git a/apps/docs/content/docs/connectors/apple-notes.ko.mdx b/apps/docs/content/docs/connectors/apple-notes.ko.mdx new file mode 100644 index 0000000..6d54598 --- /dev/null +++ b/apps/docs/content/docs/connectors/apple-notes.ko.mdx @@ -0,0 +1,55 @@ +--- +title: Apple Notes +description: 로컬 Notes 앱에서 무엇을 동기화하는지와 Apple Notes 커넥터 설정 방법을 설명합니다. +--- + +# Apple Notes + +Apple Notes 커넥터는 로컬 Notes 앱에서 읽을 수 있는 노트를 Markdown으로 동기화합니다. + +## 플랫폼 지원 + +현재 Apple Notes 동기화는 macOS에서만 지원합니다. + +`syncdown`은 실행 중인 같은 Mac의 Notes 앱에 로컬로 접근할 수 있어야 합니다. macOS가 접근을 막으면 커넥터를 다시 실행한 뒤 권한 프롬프트를 승인하거나 현재 터미널/앱에 접근 권한을 부여하세요. + +## Headless 설정 + +```sh +syncdown config set appleNotes.enabled true +syncdown config set appleNotes.interval 1h +``` + +그다음 다음 명령을 실행합니다. + +```sh +syncdown run --connector apple-notes +``` + +## 무엇이 동기화되나요 + +현재 커넥터는 다음을 동기화합니다. + +- 노트 제목 +- Markdown 텍스트로 렌더링한 본문 +- 생성/수정 시각 +- 계정 이름 +- 폴더 경로 + +현재는 첨부파일을 추출하거나 복사하지 않습니다. + +## 출력 경로 + +Markdown 파일은 계정과 폴더 경로 아래에 기록됩니다. + +```text +apple-notes///note-title-.md +``` + +중첩 폴더 구조도 출력 경로에 그대로 반영합니다. + +## 현재 제한 사항 + +- macOS 전용 +- 로컬 Notes 접근 권한 필요 +- 잠긴 노트나 읽을 수 없는 노트는 warning과 함께 건너뜀 diff --git a/apps/docs/content/docs/connectors/apple-notes.mdx b/apps/docs/content/docs/connectors/apple-notes.mdx new file mode 100644 index 0000000..3006eeb --- /dev/null +++ b/apps/docs/content/docs/connectors/apple-notes.mdx @@ -0,0 +1,55 @@ +--- +title: Apple Notes +description: Set up the Apple Notes connector and understand what syncs from the local Notes app. +--- + +# Apple Notes + +The Apple Notes connector syncs readable notes from the local Notes app into Markdown. + +## Platform support + +Apple Notes sync is currently supported only on macOS. + +`syncdown` needs local access to the Notes app on the same Mac where you run it. If macOS blocks access, run the connector again and approve the permission prompt or grant access to the current terminal or app. + +## Headless setup + +```sh +syncdown config set appleNotes.enabled true +syncdown config set appleNotes.interval 1h +``` + +Then run: + +```sh +syncdown run --connector apple-notes +``` + +## What gets synced + +The connector currently syncs: + +- note title +- text body rendered as Markdown text +- created and updated timestamps +- account name +- folder path + +The connector does not currently extract or copy attachments. + +## Output layout + +Markdown files are written under account and folder paths: + +```text +apple-notes///note-title-.md +``` + +Nested folders are preserved in the output path. + +## Current limits + +- macOS only +- local Notes access is required +- locked or unreadable notes are skipped with warnings diff --git a/apps/docs/content/docs/connectors/apple-notes.zh-CN.mdx b/apps/docs/content/docs/connectors/apple-notes.zh-CN.mdx new file mode 100644 index 0000000..1c4d353 --- /dev/null +++ b/apps/docs/content/docs/connectors/apple-notes.zh-CN.mdx @@ -0,0 +1,55 @@ +--- +title: Apple Notes +description: 说明如何设置 Apple Notes connector,以及本地 Notes 应用中哪些内容会被同步。 +--- + +# Apple Notes + +Apple Notes connector 会把本地 Notes 应用中可读取的笔记同步为 Markdown。 + +## 平台支持 + +当前 Apple Notes 同步仅支持 macOS。 + +`syncdown` 需要能够在你运行它的同一台 Mac 上本地访问 Notes 应用。如果 macOS 阻止访问,请重新运行该 connector 并批准权限提示,或者为当前终端或应用授予访问权限。 + +## 无界面设置 + +```sh +syncdown config set appleNotes.enabled true +syncdown config set appleNotes.interval 1h +``` + +然后运行: + +```sh +syncdown run --connector apple-notes +``` + +## 会同步什么 + +当前该 connector 会同步: + +- 笔记标题 +- 渲染为 Markdown 文本的正文 +- 创建和更新时间戳 +- 账号名称 +- 文件夹路径 + +当前不会提取或复制附件。 + +## 输出布局 + +Markdown 文件会写入账号和文件夹路径下: + +```text +apple-notes///note-title-.md +``` + +嵌套文件夹结构会保留在输出路径中。 + +## 当前限制 + +- 仅支持 macOS +- 需要本地 Notes 访问权限 +- 已加锁或无法读取的笔记会带 warning 跳过 diff --git a/apps/docs/content/docs/connectors/index.ja.mdx b/apps/docs/content/docs/connectors/index.ja.mdx index f01c403..197c7ed 100644 --- a/apps/docs/content/docs/connectors/index.ja.mdx +++ b/apps/docs/content/docs/connectors/index.ja.mdx @@ -1,15 +1,16 @@ --- title: コネクタ -description: Notion、Gmail、Google Calendar ごとの設定と挙動を説明します。 +description: Notion、Gmail、Google Calendar、Apple Notes ごとの設定と挙動を説明します。 --- # コネクタ -現在の `syncdown` には 3 つのコネクタがあります。 +現在の `syncdown` には 4 つのコネクタがあります。 - [Notion](/docs/connectors/notion) - [Gmail](/docs/connectors/gmail) - [Google Calendar](/docs/connectors/google-calendar) +- [Apple Notes](/docs/connectors/apple-notes) いずれも同じレンダラーとファイルシステム sink を通して Markdown を書き込みますが、認証フロー、検出モデル、同期セマンティクスは異なります。 @@ -27,3 +28,4 @@ description: Notion、Gmail、Google Calendar ごとの設定と挙動を説明 - [Notion](/docs/connectors/notion) ではトークンと OAuth の設定、ページ検出、データソース動作を扱います。 - [Gmail](/docs/connectors/gmail) では Google OAuth、Primary 範囲の同期フィルタ、Gmail history ベースの増分挙動を扱います。 - [Google Calendar](/docs/connectors/google-calendar) では共有 Google OAuth、カレンダー選択、イベントの増分同期を扱います。 +- [Apple Notes](/docs/connectors/apple-notes) では macOS 専用のローカルアクセス、アカウント/フォルダ出力パス、現在の制限事項を扱います。 diff --git a/apps/docs/content/docs/connectors/index.ko.mdx b/apps/docs/content/docs/connectors/index.ko.mdx index cb99a2d..8d9df19 100644 --- a/apps/docs/content/docs/connectors/index.ko.mdx +++ b/apps/docs/content/docs/connectors/index.ko.mdx @@ -1,17 +1,18 @@ --- title: 커넥터 -description: Notion, Gmail, Google Calendar별 설정과 동작을 설명합니다. +description: Notion, Gmail, Google Calendar, Apple Notes별 설정과 동작을 설명합니다. --- # 커넥터 -현재 `syncdown`에는 세 가지 커넥터가 있습니다. +현재 `syncdown`에는 네 가지 커넥터가 있습니다. - [Notion](/docs/connectors/notion) - [Gmail](/docs/connectors/gmail) - [Google Calendar](/docs/connectors/google-calendar) +- [Apple Notes](/docs/connectors/apple-notes) -세 커넥터 모두 같은 렌더러와 파일시스템 sink를 통해 Markdown을 쓰지만, 인증 흐름, 데이터 발견 방식, 동기화 의미론은 서로 다릅니다. +모든 커넥터는 같은 렌더러와 파일시스템 sink를 통해 Markdown을 쓰지만, 접근 방식, 데이터 발견 방식, 동기화 의미론은 서로 다릅니다. ## 공통 동작 @@ -27,3 +28,4 @@ description: Notion, Gmail, Google Calendar별 설정과 동작을 설명합니 - [Notion](/docs/connectors/notion)은 토큰과 OAuth 설정, 페이지 탐색, 데이터 소스 동작을 다룹니다. - [Gmail](/docs/connectors/gmail)은 Google OAuth, Primary 범위 동기화 필터, Gmail history 기반 증분 동작을 다룹니다. - [Google Calendar](/docs/connectors/google-calendar)는 공유 Google OAuth, 캘린더 선택, 이벤트 증분 동기화를 다룹니다. +- [Apple Notes](/docs/connectors/apple-notes)는 macOS 전용 로컬 접근, 계정/폴더 출력 경로, 현재 제한 사항을 다룹니다. diff --git a/apps/docs/content/docs/connectors/index.mdx b/apps/docs/content/docs/connectors/index.mdx index 4e2edba..b7501b2 100644 --- a/apps/docs/content/docs/connectors/index.mdx +++ b/apps/docs/content/docs/connectors/index.mdx @@ -1,17 +1,18 @@ --- title: Connectors -description: Connector-specific setup and behavior for Notion, Gmail, and Google Calendar. +description: Connector-specific setup and behavior for Notion, Gmail, Google Calendar, and Apple Notes. --- # Connectors -`syncdown` currently ships with three connectors: +`syncdown` currently ships with four connectors: - [Notion](/docs/connectors/notion) - [Gmail](/docs/connectors/gmail) - [Google Calendar](/docs/connectors/google-calendar) +- [Apple Notes](/docs/connectors/apple-notes) -Both connectors write Markdown through the same renderer and filesystem sink, but they differ in auth flow, discovery model, and sync semantics. +All connectors write Markdown through the same renderer and filesystem sink, but they differ in access model, discovery model, and sync semantics. ## Shared behavior @@ -27,3 +28,4 @@ For every connector: - Use [Notion](/docs/connectors/notion) for token vs OAuth setup, page discovery, and data source behavior. - Use [Gmail](/docs/connectors/gmail) for Google OAuth, Primary-scoped sync filters, and incremental history behavior. - Use [Google Calendar](/docs/connectors/google-calendar) for shared Google OAuth, calendar selection, and incremental event sync. +- Use [Apple Notes](/docs/connectors/apple-notes) for macOS-only local access, account/folder output paths, and current limits. diff --git a/apps/docs/content/docs/connectors/index.zh-CN.mdx b/apps/docs/content/docs/connectors/index.zh-CN.mdx index 420ac26..52fdb98 100644 --- a/apps/docs/content/docs/connectors/index.zh-CN.mdx +++ b/apps/docs/content/docs/connectors/index.zh-CN.mdx @@ -1,15 +1,16 @@ --- title: 连接器 -description: 说明 Notion、Gmail 和 Google Calendar 的专属设置与行为。 +description: 说明 Notion、Gmail、Google Calendar 和 Apple Notes 的专属设置与行为。 --- # 连接器 -`syncdown` 当前内置三个 connector: +`syncdown` 当前内置四个 connector: - [Notion](/docs/connectors/notion) - [Gmail](/docs/connectors/gmail) - [Google Calendar](/docs/connectors/google-calendar) +- [Apple Notes](/docs/connectors/apple-notes) 它们都会通过同一个 renderer 和 filesystem sink 输出 Markdown,但在认证流程、发现模型和同步语义上各不相同。 @@ -27,3 +28,4 @@ description: 说明 Notion、Gmail 和 Google Calendar 的专属设置与行为 - [Notion](/docs/connectors/notion) 介绍 token 与 OAuth 配置、页面发现以及数据源行为。 - [Gmail](/docs/connectors/gmail) 介绍 Google OAuth、Primary 范围同步过滤器以及基于 history 的增量行为。 - [Google Calendar](/docs/connectors/google-calendar) 介绍共享 Google OAuth、日历选择以及事件增量同步。 +- [Apple Notes](/docs/connectors/apple-notes) 介绍 macOS 专用本地访问、账号/文件夹输出路径以及当前限制。 diff --git a/apps/docs/content/docs/getting-started.ja.mdx b/apps/docs/content/docs/getting-started.ja.mdx index 3980a42..2cc61b1 100644 --- a/apps/docs/content/docs/getting-started.ja.mdx +++ b/apps/docs/content/docs/getting-started.ja.mdx @@ -79,6 +79,12 @@ TUI は Google Desktop app client ID と client secret を求め、ブラウザ 共有 Google アカウントがまだ接続されていない場合は **Connect Google account** を使い、その後 **Select calendars** を開いて同期したいカレンダーを選びます。 +### Apple Notes + +**Connectors → Apple Notes** を開きます。 + +macOS では Apple Notes 同期を有効にします。初回実行時には、現在のターミナルまたはアプリに Notes へのアクセスを許可するよう macOS が求めることがあります。 + ## 5. 最初の同期を実行 TUI ホーム画面で **Sync** を開き、次のいずれかを選びます。 @@ -87,6 +93,7 @@ TUI ホーム画面で **Sync** を開き、次のいずれかを選びます。 - **Run Notion** - **Run Gmail** - **Run Google Calendar** +- **Run Apple Notes** 最近のコネクタログと実行状態は、同じダッシュボード上で確認できます。 diff --git a/apps/docs/content/docs/getting-started.ko.mdx b/apps/docs/content/docs/getting-started.ko.mdx index b7e1d8e..6d231e3 100644 --- a/apps/docs/content/docs/getting-started.ko.mdx +++ b/apps/docs/content/docs/getting-started.ko.mdx @@ -79,6 +79,12 @@ TUI는 Google Desktop app client ID와 client secret을 요청하고, 브라우 공유 Google 계정이 아직 연결되지 않았다면 **Connect Google account**를 사용하고, 그다음 **Select calendars**를 열어 동기화할 캘린더를 선택합니다. +### Apple Notes + +**Connectors → Apple Notes**를 엽니다. + +macOS에서는 Apple Notes 동기화를 활성화하세요. 첫 실행 시 현재 터미널이나 앱에 Notes 접근 권한을 허용하라는 macOS 프롬프트가 나타날 수 있습니다. + ## 5. 첫 동기화 실행 TUI 홈 화면에서 **Sync**를 열고 다음 중 하나를 선택합니다. @@ -87,6 +93,7 @@ TUI 홈 화면에서 **Sync**를 열고 다음 중 하나를 선택합니다. - **Run Notion** - **Run Gmail** - **Run Google Calendar** +- **Run Apple Notes** 최근 커넥터 로그와 실행 상태는 같은 대시보드에서 계속 확인할 수 있습니다. diff --git a/apps/docs/content/docs/getting-started.mdx b/apps/docs/content/docs/getting-started.mdx index 9765ed9..9962af6 100644 --- a/apps/docs/content/docs/getting-started.mdx +++ b/apps/docs/content/docs/getting-started.mdx @@ -79,6 +79,12 @@ Open **Connectors → Google Calendar**. Use **Connect Google account** if you do not already have a shared Google account connected, then open **Select calendars** and choose the calendars you want to sync. +### Apple Notes + +Open **Connectors → Apple Notes**. + +If you are on macOS, enable Apple Notes sync. On the first run, macOS may ask you to grant Notes access to your current terminal or app. + ## 5. Run your first sync From the TUI home screen, open **Sync** and choose: @@ -87,6 +93,7 @@ From the TUI home screen, open **Sync** and choose: - **Run Notion** - **Run Gmail** - **Run Google Calendar** +- **Run Apple Notes** Recent connector logs and run status stay visible in the same dashboard. diff --git a/apps/docs/content/docs/getting-started.zh-CN.mdx b/apps/docs/content/docs/getting-started.zh-CN.mdx index 10ce6ce..081bcc0 100644 --- a/apps/docs/content/docs/getting-started.zh-CN.mdx +++ b/apps/docs/content/docs/getting-started.zh-CN.mdx @@ -79,6 +79,12 @@ TUI 会要求输入 Google Desktop app client ID 和 client secret,打开浏 如果你还没有连接共享 Google 账号,请先使用 **Connect Google account**,然后打开 **Select calendars** 选择要同步的日历。 +### Apple Notes + +打开 **Connectors → Apple Notes**。 + +在 macOS 上启用 Apple Notes 同步。首次运行时,macOS 可能会要求你为当前终端或应用授予 Notes 访问权限。 + ## 5. 运行第一次同步 在 TUI 首页打开 **Sync**,选择以下任一操作: @@ -87,6 +93,7 @@ TUI 会要求输入 Google Desktop app client ID 和 client secret,打开浏 - **Run Notion** - **Run Gmail** - **Run Google Calendar** +- **Run Apple Notes** 最近的 connector 日志和运行状态会持续显示在同一个面板中。 diff --git a/apps/docs/content/docs/index.ja.mdx b/apps/docs/content/docs/index.ja.mdx index 990f797..17bdc93 100644 --- a/apps/docs/content/docs/index.ja.mdx +++ b/apps/docs/content/docs/index.ja.mdx @@ -18,13 +18,14 @@ description: syncdown が何をするのか、何を同期できるのか、ど - 接続した Notion 連携にアクセスを許可したページとデータベース向けの Notion コネクタ - `Primary` 受信トレイメッセージのための Gmail コネクタ - 選択したカレンダーとイベント/繰り返しシリーズ書き出しのための Google Calendar コネクタ +- macOS 上のローカルノートのための Apple Notes コネクタ - コネクタ単位のファイルへ出力するローカル Markdown レンダリング ## できること 大まかには、`syncdown` は次を助けます。 -1. Notion、Gmail、Google Calendar などの対応サービスに接続する。 +1. Notion、Gmail、Google Calendar、Apple Notes などの対応サービスに接続する。 2. ローカルの出力ディレクトリを選ぶ。 3. それらのサービスからコンテンツを取得する。 4. 予測しやすいフォルダ構造に、ソースごとの Markdown ファイルを書き出す。 @@ -54,6 +55,13 @@ description: syncdown が何をするのか、何を同期できるのか、ど - 選択したカレンダーの同期 - カレンダーごとの sync token を使った増分更新 +### Apple Notes + +- 読み取り可能なローカルノートごとに 1 つの Markdown ファイル +- macOS 上のローカル Notes アクセス +- アカウントとフォルダ単位の出力パス +- ローカル Notes 内容を保つ再実行同期 + ## 出力の形 レンダリングされたファイルは、確認しやすいようにコネクタごとに整理されます。 @@ -65,6 +73,7 @@ notion/pages/project-plan-.md notion/databases/tasks/task-item-.md gmail/account-example-com/2026/03/weekly-update-.md google-calendar/primary/2026/03/team-sync-.md +apple-notes/personal-icloud/scratchpad/daily-notes-note-id-123.md ``` 各ドキュメントは、コネクタのメタデータとソース固有の項目を保持する YAML frontmatter で始まります。正確なフィールドはコネクタによって異なります。 diff --git a/apps/docs/content/docs/index.ko.mdx b/apps/docs/content/docs/index.ko.mdx index 80e4335..b3b67ad 100644 --- a/apps/docs/content/docs/index.ko.mdx +++ b/apps/docs/content/docs/index.ko.mdx @@ -18,13 +18,14 @@ description: syncdown이 무엇을 하는지, 무엇을 동기화하는지, 어 - 사용자가 Notion 연결에 접근을 허용한 페이지와 데이터베이스를 위한 Notion 커넥터 - `Primary` 받은편지함 메시지를 위한 Gmail 커넥터 - 선택한 캘린더와 이벤트/반복 시리즈 내보내기를 위한 Google Calendar 커넥터 +- macOS 로컬 노트를 위한 Apple Notes 커넥터 - 커넥터별 파일로 저장되는 로컬 Markdown 렌더링 ## 하는 일 큰 흐름에서 `syncdown`은 다음을 도와줍니다. -1. Notion, Gmail, Google Calendar 같은 지원 서비스에 연결합니다. +1. Notion, Gmail, Google Calendar, Apple Notes 같은 지원 서비스에 연결합니다. 2. 로컬 출력 디렉터리를 선택합니다. 3. 서비스에서 콘텐츠를 가져옵니다. 4. 예측 가능한 폴더 구조에 소스별 Markdown 파일을 씁니다. @@ -54,6 +55,13 @@ description: syncdown이 무엇을 하는지, 무엇을 동기화하는지, 어 - 선택한 캘린더 동기화 - 캘린더별 sync token을 이용한 증분 업데이트 +### Apple Notes + +- 읽을 수 있는 로컬 노트당 하나의 Markdown 파일 +- macOS의 로컬 Notes 접근 +- 계정과 폴더 기준 출력 경로 +- 로컬 Notes 콘텐츠를 맞추는 재실행 동기화 + ## 출력 형태 렌더링된 파일은 커넥터별로 묶여 있어서 확인하기 쉽습니다. @@ -65,6 +73,7 @@ notion/pages/project-plan-.md notion/databases/tasks/task-item-.md gmail/account-example-com/2026/03/weekly-update-.md google-calendar/primary/2026/03/team-sync-.md +apple-notes/personal-icloud/scratchpad/daily-notes-note-id-123.md ``` 각 문서는 커넥터 메타데이터와 소스별 필드를 보존하는 YAML frontmatter로 시작합니다. 정확한 필드는 커넥터마다 다릅니다. diff --git a/apps/docs/content/docs/index.mdx b/apps/docs/content/docs/index.mdx index 1e78d64..bd29a7e 100644 --- a/apps/docs/content/docs/index.mdx +++ b/apps/docs/content/docs/index.mdx @@ -18,13 +18,14 @@ Today, `syncdown` includes: - a Notion connector for pages and databases you've allowed the Notion connection to access - a Gmail connector for `Primary` inbox messages - a Google Calendar connector for selected calendars and event/series exports +- an Apple Notes connector for local notes on macOS - local Markdown rendering into connector-scoped files ## What it does At a high level, `syncdown` helps you: -1. Connect supported services such as Notion, Gmail, and Google Calendar. +1. Connect supported services such as Notion, Gmail, Google Calendar, and Apple Notes. 2. Choose a local output directory. 3. Fetch content from those services. 4. Write one Markdown file per source into a predictable folder structure. @@ -54,6 +55,13 @@ That makes it useful for local knowledge bases, archives, search indexing, backu - Selected-calendar sync - Incremental updates using per-calendar sync tokens +### Apple Notes + +- One Markdown file per readable local note +- Local macOS Notes access +- Account and folder-based output paths +- Re-runs that keep local Notes content in sync + ## What the output looks like Rendered files are grouped by connector so they stay easy to inspect. @@ -65,6 +73,7 @@ notion/pages/project-plan-.md notion/databases/tasks/task-item-.md gmail/account-example-com/2026/03/weekly-update-.md google-calendar/primary/2026/03/team-sync-.md +apple-notes/personal-icloud/scratchpad/daily-notes-note-id-123.md ``` Each document starts with YAML frontmatter that preserves connector metadata and source-specific fields. The exact fields depend on the connector. diff --git a/apps/docs/content/docs/index.zh-CN.mdx b/apps/docs/content/docs/index.zh-CN.mdx index f53e2b7..a669552 100644 --- a/apps/docs/content/docs/index.zh-CN.mdx +++ b/apps/docs/content/docs/index.zh-CN.mdx @@ -18,13 +18,14 @@ description: 了解 syncdown 做什么、能同步什么,以及应该从哪种 - 用于同步你已授权 Notion 连接访问的页面和数据库的 Notion connector - 用于 `Primary` 收件箱消息的 Gmail connector - 用于选定日历以及事件/重复系列导出的 Google Calendar connector +- 用于 macOS 本地笔记的 Apple Notes connector - 按 connector 分组写入本地文件的 Markdown 渲染 ## 它能做什么 从整体流程看,`syncdown` 可以帮助你: -1. 连接 Notion、Gmail、Google Calendar 等受支持的服务。 +1. 连接 Notion、Gmail、Google Calendar、Apple Notes 等受支持的服务。 2. 选择本地输出目录。 3. 从这些服务中抓取内容。 4. 按可预测的目录结构写出每个来源对应的 Markdown 文件。 @@ -54,6 +55,13 @@ description: 了解 syncdown 做什么、能同步什么,以及应该从哪种 - 选择性日历同步 - 基于每个日历 sync token 的增量更新 +### Apple Notes + +- 每条可读取的本地笔记对应一个 Markdown 文件 +- macOS 本地 Notes 访问 +- 按账号和文件夹组织输出路径 +- 通过重复运行保持本地 Notes 内容同步 + ## 输出长什么样 渲染后的文件会按 connector 分组,便于检查和管理。 @@ -65,6 +73,7 @@ notion/pages/project-plan-.md notion/databases/tasks/task-item-.md gmail/account-example-com/2026/03/weekly-update-.md google-calendar/primary/2026/03/team-sync-.md +apple-notes/personal-icloud/scratchpad/daily-notes-note-id-123.md ``` 每个文档都会以 YAML frontmatter 开头,用来保留 connector 元数据和数据源特有字段。具体字段取决于 connector。 diff --git a/apps/docs/content/docs/storage.ja.mdx b/apps/docs/content/docs/storage.ja.mdx index ace3e66..5cfa2a2 100644 --- a/apps/docs/content/docs/storage.ja.mdx +++ b/apps/docs/content/docs/storage.ja.mdx @@ -33,6 +33,12 @@ Google Calendar イベント: /google-calendar////-.md ``` +Apple Notes: + +```text +/apple-notes///-.md +``` + ## 管理対象の出力 出力ツリーは `syncdown` が管理する出力として扱うのが無難です。 @@ -50,22 +56,20 @@ Google Calendar イベント: ```yaml --- title: "Weekly Update" -source_url: "https://mail.google.com/mail/u/0/#inbox/18c9..." -created_at: 2026-03-17T01:23:45.000Z -updated_at: 2026-03-17T04:56:00.000Z +source: "https://mail.google.com/mail/u/0/#inbox/18c9..." +created: "2026-03-17T01:23:45.000Z" +updated: "2026-03-17T04:56:00.000Z" --- ``` 追加の Notion 固有フィールドには次が含まれる場合があります。 -- `source_url` -- `created_at` -- `updated_at` - `database` -- `properties` +- `status`、`due_date`、`assignee_name` のような正規化済み top-level プロパティキー 追加の Gmail 固有フィールドには次が含まれる場合があります。 +- `account` - `from` - `to` - `cc` @@ -73,15 +77,18 @@ updated_at: 2026-03-17T04:56:00.000Z 追加の Google Calendar 固有フィールドには次が含まれる場合があります。 -- `calendar_id` -- `calendar_name` -- `event_status` -- `start_at` -- `end_at` +- `calendar` +- `status` +- `start` +- `end` - `all_day` - `location` - `organizer` - `attendees` - `recurrence` +追加の Apple Notes 固有フィールドには次が含まれる場合があります。 + +- `folder` + フロントマターの後に、`syncdown` は Markdown の `# Title` 見出しとレンダリング済み本文を書き込みます。 diff --git a/apps/docs/content/docs/storage.ko.mdx b/apps/docs/content/docs/storage.ko.mdx index d202683..a650d84 100644 --- a/apps/docs/content/docs/storage.ko.mdx +++ b/apps/docs/content/docs/storage.ko.mdx @@ -33,6 +33,12 @@ Google Calendar 이벤트: /google-calendar////-.md ``` +Apple Notes: + +```text +/apple-notes///-.md +``` + ## 관리되는 출력물 출력 트리는 `syncdown`이 관리하는 출력물로 보는 편이 좋습니다. @@ -81,4 +87,8 @@ updated: "2026-03-17T04:56:00.000Z" - `attendees` - `recurrence` +추가적인 Apple Notes 전용 필드에는 다음이 포함될 수 있습니다. + +- `folder` + 프론트매터 뒤에는 `syncdown`이 Markdown `# Title` 제목과 렌더링된 본문을 기록합니다. diff --git a/apps/docs/content/docs/storage.mdx b/apps/docs/content/docs/storage.mdx index 652ef6d..e21c892 100644 --- a/apps/docs/content/docs/storage.mdx +++ b/apps/docs/content/docs/storage.mdx @@ -33,6 +33,12 @@ Google Calendar events: /google-calendar////-.md ``` +Apple Notes: + +```text +/apple-notes///-.md +``` + ## Managed output Treat the output tree as `syncdown`-managed output. @@ -81,4 +87,8 @@ Additional Google Calendar-specific fields may include: - `attendees` - `recurrence` +Additional Apple Notes-specific fields may include: + +- `folder` + After the frontmatter, `syncdown` writes a Markdown `# Title` heading followed by the rendered body. diff --git a/apps/docs/content/docs/storage.zh-CN.mdx b/apps/docs/content/docs/storage.zh-CN.mdx index d4961e7..e2bfbef 100644 --- a/apps/docs/content/docs/storage.zh-CN.mdx +++ b/apps/docs/content/docs/storage.zh-CN.mdx @@ -33,6 +33,12 @@ Google Calendar 事件: /google-calendar////-.md ``` +Apple Notes: + +```text +/apple-notes///-.md +``` + ## 受管理的输出 建议将输出树视为由 `syncdown` 管理的输出。 @@ -50,22 +56,20 @@ Google Calendar 事件: ```yaml --- title: "Weekly Update" -source_url: "https://mail.google.com/mail/u/0/#inbox/18c9..." -created_at: 2026-03-17T01:23:45.000Z -updated_at: 2026-03-17T04:56:00.000Z +source: "https://mail.google.com/mail/u/0/#inbox/18c9..." +created: "2026-03-17T01:23:45.000Z" +updated: "2026-03-17T04:56:00.000Z" --- ``` 额外的 Notion 专属字段可能包括: -- `source_url` -- `created_at` -- `updated_at` - `database` -- `properties` +- 经过规范化的顶层属性键,例如 `status`、`due_date` 或 `assignee_name` 额外的 Gmail 专属字段可能包括: +- `account` - `from` - `to` - `cc` @@ -73,15 +77,18 @@ updated_at: 2026-03-17T04:56:00.000Z 额外的 Google Calendar 专属字段可能包括: -- `calendar_id` -- `calendar_name` -- `event_status` -- `start_at` -- `end_at` +- `calendar` +- `status` +- `start` +- `end` - `all_day` - `location` - `organizer` - `attendees` - `recurrence` +额外的 Apple Notes 专属字段可能包括: + +- `folder` + 在 frontmatter 之后,`syncdown` 会写入 Markdown `# Title` 标题以及渲染后的正文。 diff --git a/apps/docs/content/docs/sync.ja.mdx b/apps/docs/content/docs/sync.ja.mdx index bdaaedb..500b5f1 100644 --- a/apps/docs/content/docs/sync.ja.mdx +++ b/apps/docs/content/docs/sync.ja.mdx @@ -18,6 +18,7 @@ syncdown run ```sh syncdown run --connector notion syncdown run --connector google-calendar +syncdown run --connector apple-notes syncdown run --integration notion-default ``` @@ -51,14 +52,17 @@ interval を指定しない場合の既定値は `1h` です。 TUI は専用の同期ダッシュボードで次の操作を提供します。 - 有効なすべてのコネクタを即時実行 -- 全体、Notion、Gmail、Google Calendar のフル再同期を実行 +- 全体、Notion、Gmail、Google Calendar、Apple Notes のフル再同期を実行 - Notion を即時実行 - Gmail を即時実行 - Google Calendar を即時実行 +- Apple Notes を即時実行 - watch モードを開始 - watch モードを停止 - 画面上のダッシュボードログを消去 +Apple Notes の同期アクションは macOS でのみ利用できます。 + `syncdown run --watch` と異なり、TUI ダッシュボードは **integration ごとの watch** モードを開始します。つまり、有効な各コネクタがそれぞれ設定済み interval を使います。 ## 実行時の挙動 diff --git a/apps/docs/content/docs/sync.ko.mdx b/apps/docs/content/docs/sync.ko.mdx index 91fa23e..594b780 100644 --- a/apps/docs/content/docs/sync.ko.mdx +++ b/apps/docs/content/docs/sync.ko.mdx @@ -18,6 +18,7 @@ syncdown run ```sh syncdown run --connector notion syncdown run --connector google-calendar +syncdown run --connector apple-notes syncdown run --integration notion-default ``` @@ -51,14 +52,17 @@ interval을 지정하지 않으면 기본값은 `1h`입니다. TUI는 별도의 동기화 대시보드에서 다음 작업을 제공합니다. - 모든 활성 커넥터 즉시 실행 -- 전체, Notion, Gmail, Google Calendar 대상 전체 재동기화 실행 +- 전체, Notion, Gmail, Google Calendar, Apple Notes 대상 전체 재동기화 실행 - Notion 즉시 실행 - Gmail 즉시 실행 - Google Calendar 즉시 실행 +- Apple Notes 즉시 실행 - watch 모드 시작 - watch 모드 중지 - 화면에 보이는 대시보드 로그 지우기 +Apple Notes 동기화 액션은 macOS에서만 표시됩니다. + `syncdown run --watch`와 달리 TUI 대시보드는 **integration별 watch** 모드를 시작합니다. 즉, 각 활성 커넥터가 자신의 설정된 interval을 사용합니다. ## 런타임 동작 diff --git a/apps/docs/content/docs/sync.mdx b/apps/docs/content/docs/sync.mdx index 0433287..d48dcba 100644 --- a/apps/docs/content/docs/sync.mdx +++ b/apps/docs/content/docs/sync.mdx @@ -20,6 +20,7 @@ Target a single connector or integration: ```sh syncdown run --connector notion syncdown run --connector google-calendar +syncdown run --connector apple-notes syncdown run --integration notion-default ``` @@ -53,15 +54,17 @@ If no interval is provided, the default is `1h`. The TUI exposes a separate sync dashboard with these actions: - run all enabled connectors immediately -- run full resyncs for all, Notion, or Gmail -- run full resyncs for all, Notion, Gmail, or Google Calendar +- run full resyncs for all, Notion, Gmail, Google Calendar, or Apple Notes - run Notion immediately - run Gmail immediately - run Google Calendar immediately +- run Apple Notes immediately - start watch mode - stop watch mode - clear the visible dashboard log +Apple Notes sync actions are available on macOS. + Unlike `syncdown run --watch`, the TUI dashboard starts **per-integration watch** mode. That means each enabled connector uses its own configured interval. ## Runtime behavior diff --git a/apps/docs/content/docs/sync.zh-CN.mdx b/apps/docs/content/docs/sync.zh-CN.mdx index 9e138df..3701057 100644 --- a/apps/docs/content/docs/sync.zh-CN.mdx +++ b/apps/docs/content/docs/sync.zh-CN.mdx @@ -18,6 +18,7 @@ syncdown run ```sh syncdown run --connector notion syncdown run --connector google-calendar +syncdown run --connector apple-notes syncdown run --integration notion-default ``` @@ -51,14 +52,17 @@ syncdown run --watch --interval 5m TUI 提供一个独立的同步面板,包含以下操作: - 立即运行所有已启用的 connector -- 对全部、Notion、Gmail、Google Calendar 执行完整重同步 +- 对全部、Notion、Gmail、Google Calendar、Apple Notes 执行完整重同步 - 立即运行 Notion - 立即运行 Gmail - 立即运行 Google Calendar +- 立即运行 Apple Notes - 启动 watch 模式 - 停止 watch 模式 - 清除当前可见的面板日志 +Apple Notes 同步操作仅在 macOS 上可用。 + 与 `syncdown run --watch` 不同,TUI 面板启动的是 **按 integration 的 watch** 模式。也就是说,每个已启用的 connector 都会使用自己配置的 interval。 ## 运行时行为 diff --git a/bun.lock b/bun.lock index 5551ef0..b592efe 100644 --- a/bun.lock +++ b/bun.lock @@ -16,11 +16,12 @@ }, "apps/cli": { "name": "@syncdown/cli", - "version": "0.1.0", + "version": "0.1.2", "bin": { "syncdown": "./src/bin.ts", }, "devDependencies": { + "@syncdown/connector-apple-notes": "workspace:*", "@syncdown/connector-gmail": "workspace:*", "@syncdown/connector-google-calendar": "workspace:*", "@syncdown/connector-notion": "workspace:*", @@ -60,6 +61,13 @@ "wrangler": "^4.71.1", }, }, + "packages/connector-apple-notes": { + "name": "@syncdown/connector-apple-notes", + "version": "0.1.0", + "dependencies": { + "@syncdown/core": "workspace:*", + }, + }, "packages/connector-gmail": { "name": "@syncdown/connector-gmail", "version": "0.1.0", @@ -617,6 +625,8 @@ "@syncdown/cli": ["@syncdown/cli@workspace:apps/cli"], + "@syncdown/connector-apple-notes": ["@syncdown/connector-apple-notes@workspace:packages/connector-apple-notes"], + "@syncdown/connector-gmail": ["@syncdown/connector-gmail@workspace:packages/connector-gmail"], "@syncdown/connector-google-calendar": ["@syncdown/connector-google-calendar@workspace:packages/connector-google-calendar"], diff --git a/docs/readme/README.ja.md b/docs/readme/README.ja.md index 3d4e69b..3f8880d 100644 --- a/docs/readme/README.ja.md +++ b/docs/readme/README.ja.md @@ -57,11 +57,12 @@ TUI で **Output** を開き、Markdown の出力先を選択します。 ### 3. ソースを接続 -**Connectors** を開き、対応しているソースを 1 つ以上設定します。 +**Connectors** を開き、使いたいソースを設定します。 - **Notion**: 接続した Notion 連携にアクセスを許可したページとデータベースを同期 - **Gmail**: Google OAuth を使って `Primary` 受信トレイを同期 - **Google Calendar**: 共有 Google OAuth アカウント経由で選択したカレンダーを同期 +- **Apple Notes**: macOS 上のローカルノートを同期 ### 4. 最初の同期を実行 @@ -71,6 +72,7 @@ TUI のホーム画面で **Sync** を開き、次のいずれかを実行しま - **Run Notion** - **Run Gmail** - **Run Google Calendar** +- **Run Apple Notes** 最小限のヘッドレス設定は CLI からも行えます。 @@ -117,6 +119,8 @@ syncdown doctor notion/pages/project-plan-.md notion/databases/tasks/task-item-.md gmail/account-example-com/2026/03/weekly-update-.md +google-calendar/primary/2026/03/team-sync-.md +apple-notes/personal-icloud/scratchpad/daily-notes-note-id-123.md ``` レンダリングされた Markdown には、コネクタのメタデータとソース固有の項目が YAML frontmatter として含まれるため、同期したデータを本文と構造化メタデータの両方として扱えます。 @@ -166,21 +170,25 @@ syncdown reset --yes - **Notion**: token または OAuth 認証 - **Gmail**: Google OAuth と増分 inbox 同期 +- **Google Calendar**: 共有 Google OAuth と選択カレンダーの増分同期 +- **Apple Notes**: macOS 上のローカルアクセス コネクタごとの設定方法、対応動作、現在の制限事項は別ドキュメントにあります。 -- [Notion connector](./apps/docs/content/docs/connectors/notion.mdx) -- [Gmail connector](./apps/docs/content/docs/connectors/gmail.mdx) +- [Notion connector](https://syncdown.dev/docs/connectors/notion) +- [Gmail connector](https://syncdown.dev/docs/connectors/gmail) +- [Google Calendar connector](https://syncdown.dev/docs/connectors/google-calendar) +- [Apple Notes connector](https://syncdown.dev/docs/connectors/apple-notes) ## Docs より詳しいガイドは次を参照してください。 -- [Getting Started](./apps/docs/content/docs/getting-started.mdx) -- [Configuration](./apps/docs/content/docs/configuration.mdx) -- [CLI reference](./apps/docs/content/docs/cli.mdx) +- [Getting Started](https://syncdown.dev/docs/getting-started) +- [Configuration](https://syncdown.dev/docs/configuration) +- [CLI reference](https://syncdown.dev/docs/cli) -詳細ドキュメントの本文は現時点では英語が基準です。日本語の docs UI ルートは利用できますが、翻訳済みページがない箇所は英語コンテンツにフォールバックします。 +詳細ドキュメントの本文は現時点では英語が基準です。英語、韓国語、日本語、簡体字中国語のロケールルートは利用でき、未翻訳ページは英語コンテンツにフォールバックします。 ## Developing syncdown diff --git a/docs/readme/README.ko.md b/docs/readme/README.ko.md index 1b59c32..c2c924c 100644 --- a/docs/readme/README.ko.md +++ b/docs/readme/README.ko.md @@ -56,11 +56,12 @@ TUI에서 **Output**을 열고 Markdown 출력 위치를 선택합니다. ### 3. 소스 연결 -**Connectors**를 열고 지원되는 소스를 하나 이상 설정합니다. +**Connectors**를 열고 원하는 소스를 설정합니다. - **Notion**: 사용자가 Notion 연결에 접근을 허용한 페이지와 데이터베이스 동기화 - **Gmail**: Google OAuth 기반 `Primary` 받은편지함 동기화 - **Google Calendar**: 공유 Google OAuth 계정으로 선택한 캘린더 동기화 +- **Apple Notes**: macOS의 로컬 노트 동기화 ### 4. 첫 동기화 실행 @@ -70,6 +71,7 @@ TUI 홈 화면에서 **Sync**를 열고 다음 중 하나를 실행합니다. - **Run Notion** - **Run Gmail** - **Run Google Calendar** +- **Run Apple Notes** 최소한의 헤드리스 설정은 CLI로도 가능합니다. @@ -116,6 +118,8 @@ syncdown doctor notion/pages/project-plan-.md notion/databases/tasks/task-item-.md gmail/account-example-com/2026/03/weekly-update-.md +google-calendar/primary/2026/03/team-sync-.md +apple-notes/personal-icloud/scratchpad/daily-notes-note-id-123.md ``` 렌더링된 Markdown에는 커넥터 메타데이터와 소스별 필드가 YAML frontmatter로 함께 들어가므로, 동기화된 데이터는 읽기용 본문과 구조화된 메타데이터 둘 다로 활용할 수 있습니다. @@ -165,21 +169,25 @@ syncdown reset --yes - **Notion**: token 또는 OAuth 인증 - **Gmail**: Google OAuth 및 증분 inbox 동기화 +- **Google Calendar**: 공유 Google OAuth 및 선택한 캘린더 증분 동기화 +- **Apple Notes**: macOS 로컬 접근 커넥터별 설정 방법, 지원 동작, 현재 제약은 별도 문서에 정리되어 있습니다. -- [Notion connector](./apps/docs/content/docs/connectors/notion.mdx) -- [Gmail connector](./apps/docs/content/docs/connectors/gmail.mdx) +- [Notion connector](https://syncdown.dev/docs/connectors/notion) +- [Gmail connector](https://syncdown.dev/docs/connectors/gmail) +- [Google Calendar connector](https://syncdown.dev/docs/connectors/google-calendar) +- [Apple Notes connector](https://syncdown.dev/docs/connectors/apple-notes) ## Docs 더 자세한 가이드는 다음 문서를 참고하세요. -- [Getting Started](./apps/docs/content/docs/getting-started.mdx) -- [Configuration](./apps/docs/content/docs/configuration.mdx) -- [CLI reference](./apps/docs/content/docs/cli.mdx) +- [Getting Started](https://syncdown.dev/docs/getting-started) +- [Configuration](https://syncdown.dev/docs/configuration) +- [CLI reference](https://syncdown.dev/docs/cli) -문서 앱은 현재 영어 콘텐츠를 기본으로 사용합니다. 한국어 로케일 라우트와 UI는 이미 지원되며, 일부 상세 페이지 콘텐츠는 번역본이 추가되기 전까지 영어 원문을 그대로 사용할 수 있습니다. +문서 앱은 현재 영어 콘텐츠를 기본으로 사용합니다. 영어, 한국어, 일본어, 중국어 간 로케일 라우트가 준비되어 있으며, 아직 번역되지 않은 페이지는 영어 원문으로 폴백될 수 있습니다. ## Developing syncdown diff --git a/docs/readme/README.zh-CN.md b/docs/readme/README.zh-CN.md index da13555..e5a7849 100644 --- a/docs/readme/README.zh-CN.md +++ b/docs/readme/README.zh-CN.md @@ -57,11 +57,12 @@ syncdown ### 3. 连接数据源 -打开 **Connectors**,配置一个或多个支持的数据源: +打开 **Connectors**,配置你需要的数据源: - **Notion**:同步你已授权 Notion 连接访问的页面和数据库 - **Gmail**:通过 Google OAuth 同步 `Primary` 收件箱 - **Google Calendar**:通过共享 Google OAuth 账号同步所选日历 +- **Apple Notes**:同步 macOS 上的本地笔记 ### 4. 执行第一次同步 @@ -71,6 +72,7 @@ syncdown - **Run Notion** - **Run Gmail** - **Run Google Calendar** +- **Run Apple Notes** 你也可以通过 CLI 完成最小化的无界面配置: @@ -117,6 +119,8 @@ syncdown doctor notion/pages/project-plan-.md notion/databases/tasks/task-item-.md gmail/account-example-com/2026/03/weekly-update-.md +google-calendar/primary/2026/03/team-sync-.md +apple-notes/personal-icloud/scratchpad/daily-notes-note-id-123.md ``` 渲染后的 Markdown 会包含 YAML frontmatter,其中包括 connector 元数据和各数据源特有的字段,因此同步后的数据既可作为可读正文使用,也可作为结构化元数据使用。 @@ -166,21 +170,25 @@ syncdown reset --yes - **Notion**:token 或 OAuth 认证 - **Gmail**:Google OAuth 和增量 inbox 同步 +- **Google Calendar**:共享 Google OAuth 和所选日历的增量同步 +- **Apple Notes**:macOS 本地访问 各 connector 的配置方式、支持行为和当前限制见单独文档: -- [Notion connector](./apps/docs/content/docs/connectors/notion.mdx) -- [Gmail connector](./apps/docs/content/docs/connectors/gmail.mdx) +- [Notion connector](https://syncdown.dev/docs/connectors/notion) +- [Gmail connector](https://syncdown.dev/docs/connectors/gmail) +- [Google Calendar connector](https://syncdown.dev/docs/connectors/google-calendar) +- [Apple Notes connector](https://syncdown.dev/docs/connectors/apple-notes) ## Docs 更详细的指南请参考: -- [Getting Started](./apps/docs/content/docs/getting-started.mdx) -- [Configuration](./apps/docs/content/docs/configuration.mdx) -- [CLI reference](./apps/docs/content/docs/cli.mdx) +- [Getting Started](https://syncdown.dev/docs/getting-started) +- [Configuration](https://syncdown.dev/docs/configuration) +- [CLI reference](https://syncdown.dev/docs/cli) -详细文档内容目前仍以英文为主。简体中文 docs UI 路由已经可用,在尚未提供翻译页面的地方,会回退到英文内容。 +详细文档内容目前仍以英文为主。英语、韩语、日语和简体中文的 docs 路由已经可用,尚未提供翻译的页面会回退到英文内容。 ## Developing syncdown diff --git a/packages/connector-apple-notes/package.json b/packages/connector-apple-notes/package.json new file mode 100644 index 0000000..fd97f72 --- /dev/null +++ b/packages/connector-apple-notes/package.json @@ -0,0 +1,18 @@ +{ + "name": "@syncdown/connector-apple-notes", + "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-apple-notes/src/index.test.ts b/packages/connector-apple-notes/src/index.test.ts new file mode 100644 index 0000000..6d4c477 --- /dev/null +++ b/packages/connector-apple-notes/src/index.test.ts @@ -0,0 +1,322 @@ +import { expect, test } from "bun:test"; + +import type { + ConnectorSyncRequest, + SourceRecord, + StoredSourceSnapshot, +} from "@syncdown/core"; +import { MemoryStateStore } from "../../core/src/test-support.js"; +import { + APPLE_NOTES_ACCESS_ERROR, + createAppleNotesConnector, +} from "./index.js"; + +function createRequest( + options: { + existingSourceIds?: string[]; + }, + overrides: Partial = {}, +): ConnectorSyncRequest { + const state = new MemoryStateStore(); + for (const sourceId of options.existingSourceIds ?? []) { + void state.upsertSourceRecord({ + integrationId: "apple-notes-integration", + connectorId: "apple-notes", + sourceId, + entityType: "note", + relativePath: `apple-notes/account/folder/${sourceId}.md`, + sourceHash: `hash-${sourceId}`, + renderVersion: "test", + snapshotHash: `snapshot-${sourceId}`, + lastRenderedAt: "2026-03-17T00:00:00.000Z", + } satisfies SourceRecord); + void state.upsertSourceSnapshot({ + integrationId: "apple-notes-integration", + connectorId: "apple-notes", + sourceId, + snapshotHash: `snapshot-${sourceId}`, + snapshotSchemaVersion: "1", + payload: { + integrationId: "apple-notes-integration", + connectorId: "apple-notes", + sourceId, + entityType: "note", + title: sourceId, + slug: sourceId, + pathHint: { + kind: "note", + appleNotesAccount: "Personal", + appleNotesFolder: "Projects", + appleNotesFolderPath: ["Projects"], + }, + metadata: {}, + bodyMd: "", + sourceHash: `hash-${sourceId}`, + snapshotSchemaVersion: "1", + }, + } satisfies StoredSourceSnapshot); + } + + return { + config: { + oauthApps: [], + connections: [ + { + id: "apple-notes-local-default", + kind: "apple-notes-local", + label: "Default Apple Notes Connection", + }, + ], + integrations: [ + { + id: "apple-notes-integration", + connectorId: "apple-notes", + connectionId: "apple-notes-local-default", + label: "Apple Notes", + enabled: true, + interval: "1h", + config: {}, + }, + ], + }, + integration: { + id: "apple-notes-integration", + connectorId: "apple-notes", + connectionId: "apple-notes-local-default", + label: "Apple Notes", + enabled: true, + interval: "1h", + config: {}, + }, + connection: { + id: "apple-notes-local-default", + kind: "apple-notes-local", + label: "Default Apple Notes Connection", + }, + 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: null, + renderVersion: "test", + secrets: { + async hasSecret() { + return false; + }, + async getSecret() { + return null; + }, + async setSecret() {}, + async deleteSecret() {}, + describe() { + return "memory"; + }, + }, + state, + resolvedAuth: null, + throwIfCancelled() {}, + async persistSource() {}, + async deleteSource() {}, + async resetIntegrationState() {}, + setProgress() {}, + ...overrides, + }; +} + +test("validate returns an error on non-macOS", async () => { + const connector = createAppleNotesConnector({ + platform: "linux", + adapter: { + async validateAccess() {}, + async listNotes() { + return { notes: [], warnings: [] }; + }, + }, + }); + + const result = await connector.validate(createRequest({})); + expect(result.status).toBe("error"); + expect(result.message).toContain("macOS"); +}); + +test("validate returns permission guidance when local access is unavailable", async () => { + const connector = createAppleNotesConnector({ + platform: "darwin", + adapter: { + async validateAccess() { + throw new Error(APPLE_NOTES_ACCESS_ERROR); + }, + async listNotes() { + return { notes: [], warnings: [] }; + }, + }, + }); + + const result = await connector.validate(createRequest({})); + expect(result.status).toBe("error"); + expect(result.message).toBe(APPLE_NOTES_ACCESS_ERROR); +}); + +test("sync persists notes and removes missing records", async () => { + const connector = createAppleNotesConnector({ + platform: "darwin", + adapter: { + async validateAccess() {}, + async listNotes() { + return { + notes: [ + { + id: "raw-note-1", + title: "Roadmap", + body: "Body", + account: "iCloud", + folderPath: ["Projects", "Planning"], + createdAt: "2026-03-16T00:00:00.000Z", + updatedAt: "2026-03-17T00:00:00.000Z", + }, + ], + warnings: [], + }; + }, + }, + }); + const persisted: Array<{ + sourceId: string; + account?: string; + folder?: string; + folderPath?: string[]; + hasPinnedMetadata?: boolean; + hasLockedMetadata?: boolean; + }> = []; + const deleted: string[] = []; + const request = createRequest( + { + existingSourceIds: ["obsolete-note"], + }, + { + async persistSource(source) { + persisted.push({ + sourceId: source.sourceId, + account: source.pathHint.appleNotesAccount, + folder: source.metadata.appleNotesFolder as string | undefined, + folderPath: source.pathHint.appleNotesFolderPath, + hasPinnedMetadata: "appleNotesPinned" in source.metadata, + hasLockedMetadata: "appleNotesLocked" in source.metadata, + }); + }, + async deleteSource(sourceId) { + deleted.push(sourceId); + }, + }, + ); + + const result = await connector.sync(request); + expect(result.nextCursor).toBeNull(); + expect(persisted).toHaveLength(1); + expect(persisted[0]?.account).toBe("iCloud"); + expect(persisted[0]?.folder).toBe("Planning"); + expect(persisted[0]?.folderPath).toEqual(["Projects", "Planning"]); + expect(persisted[0]?.hasPinnedMetadata).toBe(false); + expect(persisted[0]?.hasLockedMetadata).toBe(false); + expect(deleted).toEqual(["obsolete-note"]); +}); + +test("sync skips locked notes and surfaces warnings without failing", async () => { + const writes: string[] = []; + const connector = createAppleNotesConnector({ + platform: "darwin", + adapter: { + async validateAccess() {}, + async listNotes() { + return { + notes: [ + { + id: "locked-note", + title: "Locked", + body: "", + account: "iCloud", + folderPath: ["Secure"], + locked: true, + }, + ], + warnings: [{ noteId: "failed-note", message: "permission denied" }], + }; + }, + }, + }); + const persisted: string[] = []; + + await connector.sync( + createRequest( + {}, + { + io: { + write(line) { + writes.push(line); + }, + error(line) { + writes.push(`ERR:${line}`); + }, + }, + async persistSource(source) { + persisted.push(source.sourceId); + }, + }, + ), + ); + + expect(persisted).toEqual([]); + expect(writes.join("\n")).toContain("failed-note"); + expect(writes.join("\n")).toContain("locked-note"); +}); + +test("sync uses fallback account and folder buckets when missing", async () => { + const connector = createAppleNotesConnector({ + platform: "darwin", + adapter: { + async validateAccess() {}, + async listNotes() { + return { + notes: [ + { + id: "raw-note-2", + title: "", + body: "

Hello

", + account: "", + folderPath: [], + }, + ], + warnings: [], + }; + }, + }, + }); + let persistedAccount: string | undefined; + let persistedFolder: string | undefined; + let persistedFolderPath: string[] | undefined; + + await connector.sync( + createRequest( + {}, + { + async persistSource(source) { + persistedAccount = source.pathHint.appleNotesAccount; + persistedFolder = source.metadata.appleNotesFolder as + | string + | undefined; + persistedFolderPath = source.pathHint.appleNotesFolderPath; + }, + }, + ), + ); + + expect(persistedAccount).toBe("unknown-account"); + expect(persistedFolder).toBe("root"); + expect(persistedFolderPath).toEqual(["root"]); +}); diff --git a/packages/connector-apple-notes/src/index.ts b/packages/connector-apple-notes/src/index.ts new file mode 100644 index 0000000..bfa8604 --- /dev/null +++ b/packages/connector-apple-notes/src/index.ts @@ -0,0 +1,473 @@ +import { spawn } from "node:child_process"; + +import type { + Connector, + ConnectorSyncRequest, + ConnectorSyncResult, + HealthCheck, + SourceSnapshot, +} from "@syncdown/core"; + +export interface AppleNotesNote { + id: string; + title: string; + body: string; + account: string; + folderPath: string[]; + createdAt?: string; + updatedAt?: string; + locked?: boolean; +} + +export interface AppleNotesWarning { + noteId?: string; + message: string; +} + +export interface AppleNotesScanResult { + notes: AppleNotesNote[]; + warnings: AppleNotesWarning[]; +} + +export interface AppleNotesAdapter { + validateAccess(): Promise; + listNotes(): Promise; +} + +export interface AppleNotesScriptRunner { + run(script: string): Promise; +} + +export interface CreateAppleNotesConnectorOptions { + adapter?: AppleNotesAdapter; + platform?: NodeJS.Platform; +} + +interface AppleNotesScriptPayload { + notes?: Array<{ + id?: string; + title?: string; + body?: string; + account?: string; + folderPath?: string[]; + createdAt?: string | null; + updatedAt?: string | null; + locked?: boolean; + }>; + warnings?: Array<{ + noteId?: string; + message?: string; + }>; +} + +const APPLE_NOTES_SNAPSHOT_SCHEMA_VERSION = "1"; +const APPLE_NOTES_ACCESS_ERROR = + "Apple Notes access is unavailable. On macOS, allow syncdown or the current terminal to control Notes and try again."; + +class AppleNotesAdapterError extends Error { + constructor(message: string) { + super(message); + this.name = "AppleNotesAdapterError"; + } +} + +class OsaScriptRunner implements AppleNotesScriptRunner { + async run(script: string): Promise { + return new Promise((resolve, reject) => { + const child = spawn("osascript", ["-l", "JavaScript", "-e", script], { + stdio: ["ignore", "pipe", "pipe"], + }); + let stdout = ""; + let stderr = ""; + + child.stdout.on("data", (chunk) => { + stdout += chunk.toString(); + }); + child.stderr.on("data", (chunk) => { + stderr += chunk.toString(); + }); + child.on("error", (error) => { + reject(new AppleNotesAdapterError(error.message)); + }); + child.on("close", (code) => { + if (code === 0) { + resolve(stdout.trim()); + return; + } + + reject( + new AppleNotesAdapterError( + stderr.trim() || `osascript exited with code ${code ?? 1}`, + ), + ); + }); + }); + } +} + +function buildAccessProbeScript(): string { + return ` +const Notes = Application("Notes"); +Notes.includeStandardAdditions = true; +JSON.stringify({ accountCount: Notes.accounts().length }); +`.trim(); +} + +function buildListNotesScript(): string { + return ` +const Notes = Application("Notes"); +Notes.includeStandardAdditions = true; + +function toIso(value) { + try { + if (!value) { + return null; + } + return new Date(value).toISOString(); + } catch (error) { + return null; + } +} + +function getBody(note) { + try { + const plain = note.plaintext(); + if (typeof plain === "string" && plain.length > 0) { + return plain; + } + } catch (error) {} + + try { + const body = note.body(); + return typeof body === "string" ? body : ""; + } catch (error) { + return ""; + } +} + +function visitFolder(accountName, folder, parentPath, notes, warnings) { + const folderName = folder.name(); + const folderPath = parentPath.concat([folderName]); + + for (const note of folder.notes()) { + try { + notes.push({ + id: String(note.id()), + title: String(note.name() || "Untitled"), + body: getBody(note), + account: String(accountName || "Unknown Account"), + folderPath, + createdAt: toIso(note.creationDate()), + updatedAt: toIso(note.modificationDate()), + locked: Boolean(note.passwordProtected ? note.passwordProtected() : false), + }); + } catch (error) { + warnings.push({ + noteId: (() => { + try { + return String(note.id()); + } catch (_error) { + return undefined; + } + })(), + message: error instanceof Error ? error.message : String(error), + }); + } + } + + for (const childFolder of folder.folders()) { + visitFolder(accountName, childFolder, folderPath, notes, warnings); + } +} + +const notes = []; +const warnings = []; + +for (const account of Notes.accounts()) { + const accountName = String(account.name() || "Unknown Account"); + for (const folder of account.folders()) { + visitFolder(accountName, folder, [], notes, warnings); + } +} + +JSON.stringify({ notes, warnings }); +`.trim(); +} + +function normalizeAppleNotesBody(value: string): string { + const withoutHtml = value + .replace(//gi, "\n") + .replace(/<\/p>/gi, "\n\n") + .replace(/<[^>]+>/g, "") + .replace(/ /g, " ") + .replace(/&/g, "&") + .replace(/</g, "<") + .replace(/>/g, ">") + .replace(/\r\n/g, "\n") + .replace(/\r/g, "\n"); + + return withoutHtml + .split("\n") + .map((line) => line.trimEnd()) + .join("\n") + .replace(/\n{3,}/g, "\n\n") + .trim(); +} + +function normalizeSegment(value: string | undefined, fallback: string): string { + const trimmed = value?.trim(); + return trimmed && trimmed.length > 0 ? trimmed : fallback; +} + +function toSourceId(rawId: string): string { + return new Bun.CryptoHasher("sha256") + .update(rawId) + .digest("hex") + .slice(0, 16); +} + +function toSourceHash(note: AppleNotesNote): string { + return new Bun.CryptoHasher("sha256") + .update( + JSON.stringify({ + title: note.title, + body: note.body, + account: note.account, + folderPath: note.folderPath, + createdAt: note.createdAt, + updatedAt: note.updatedAt, + locked: note.locked, + }), + ) + .digest("hex"); +} + +function toSourceSnapshot( + request: ConnectorSyncRequest, + note: AppleNotesNote, +): SourceSnapshot { + const account = normalizeSegment(note.account, "unknown-account"); + const folderPath = + note.folderPath.length > 0 + ? note.folderPath.map((segment) => normalizeSegment(segment, "root")) + : ["root"]; + const folder = folderPath.at(-1) ?? "root"; + const title = normalizeSegment(note.title, "Untitled"); + const sourceId = toSourceId(note.id); + + return { + integrationId: request.integration.id, + connectorId: "apple-notes", + sourceId, + entityType: "note", + title, + slug: "", + pathHint: { + kind: "note", + appleNotesAccount: account, + appleNotesFolder: folder, + appleNotesFolderPath: folderPath, + }, + metadata: { + createdAt: note.createdAt, + updatedAt: note.updatedAt, + appleNotesNoteId: note.id, + appleNotesFolder: folder, + appleNotesFolderPath: folderPath, + }, + bodyMd: normalizeAppleNotesBody(note.body), + sourceHash: toSourceHash(note), + snapshotSchemaVersion: APPLE_NOTES_SNAPSHOT_SCHEMA_VERSION, + }; +} + +function parsePayload(raw: string): AppleNotesScanResult { + const parsed = JSON.parse(raw) as AppleNotesScriptPayload; + + return { + notes: + parsed.notes?.flatMap((note): AppleNotesNote[] => { + if (!note?.id) { + return []; + } + + return [ + { + id: note.id, + title: note.title?.trim() || "Untitled", + body: typeof note.body === "string" ? note.body : "", + account: note.account?.trim() || "unknown-account", + folderPath: Array.isArray(note.folderPath) + ? note.folderPath.filter( + (value): value is string => + typeof value === "string" && value.trim().length > 0, + ) + : [], + createdAt: note.createdAt ?? undefined, + updatedAt: note.updatedAt ?? undefined, + locked: note.locked ?? undefined, + }, + ]; + }) ?? [], + warnings: + parsed.warnings?.flatMap((warning): AppleNotesWarning[] => { + if (!warning?.message) { + return []; + } + + return [ + { + noteId: warning.noteId, + message: warning.message, + }, + ]; + }) ?? [], + }; +} + +class MacAppleNotesAdapter implements AppleNotesAdapter { + constructor( + private readonly runner: AppleNotesScriptRunner = new OsaScriptRunner(), + ) {} + + private mapError(error: unknown): AppleNotesAdapterError { + const message = error instanceof Error ? error.message : String(error); + if ( + message.includes("Not authorized") || + message.includes("authorization") || + message.includes("Application isn’t running") + ) { + return new AppleNotesAdapterError(APPLE_NOTES_ACCESS_ERROR); + } + + return new AppleNotesAdapterError(message); + } + + async validateAccess(): Promise { + try { + await this.runner.run(buildAccessProbeScript()); + } catch (error) { + throw this.mapError(error); + } + } + + async listNotes(): Promise { + try { + return parsePayload(await this.runner.run(buildListNotesScript())); + } catch (error) { + throw this.mapError(error); + } + } +} + +class AppleNotesConnector implements Connector { + readonly id = "apple-notes"; + readonly label = "Apple Notes"; + readonly setupMethods = [] as const; + + constructor( + private readonly adapter: AppleNotesAdapter, + private readonly platform: NodeJS.Platform, + ) {} + + async validate(request: ConnectorSyncRequest): Promise { + if (!request.integration.enabled) { + return { + status: "warn", + message: "integration disabled", + }; + } + + if (this.platform !== "darwin") { + return { + status: "error", + message: "Apple Notes sync is only supported on macOS", + }; + } + + try { + await this.adapter.validateAccess(); + return { + status: "ok", + message: "local Apple Notes access available", + }; + } catch (error) { + return { + status: "error", + message: + error instanceof Error ? error.message : APPLE_NOTES_ACCESS_ERROR, + }; + } + } + + async sync(request: ConnectorSyncRequest): Promise { + if (this.platform !== "darwin") { + throw new Error("Apple Notes sync is only supported on macOS"); + } + + const { notes, warnings } = await this.adapter.listNotes(); + const seenSourceIds = new Set(); + request.setProgress({ + mode: "determinate", + phase: "syncing", + detail: "Scanning Apple Notes", + completed: 0, + total: notes.length, + unit: "items", + }); + + for (const warning of warnings) { + request.io.write( + warning.noteId + ? `Skipped Apple Notes item ${warning.noteId}: ${warning.message}` + : `Skipped Apple Notes item: ${warning.message}`, + ); + } + + for (const [index, note] of notes.entries()) { + request.throwIfCancelled(); + if (note.locked) { + request.io.write(`Skipped locked Apple Note ${note.id}`); + continue; + } + + const snapshot = toSourceSnapshot(request, note); + seenSourceIds.add(snapshot.sourceId); + await request.persistSource(snapshot); + request.setProgress({ + mode: "determinate", + phase: "syncing", + detail: snapshot.title, + completed: index + 1, + total: notes.length, + unit: "items", + }); + } + + for (const record of await request.state.listSourceRecords( + request.integration.id, + )) { + if (!seenSourceIds.has(record.sourceId)) { + await request.deleteSource(record.sourceId); + } + } + + request.setProgress(null); + return { nextCursor: null }; + } +} + +export function createAppleNotesAdapter(): AppleNotesAdapter { + return new MacAppleNotesAdapter(); +} + +export function createAppleNotesConnector( + options: CreateAppleNotesConnectorOptions = {}, +): Connector { + return new AppleNotesConnector( + options.adapter ?? createAppleNotesAdapter(), + options.platform ?? process.platform, + ); +} + +export { APPLE_NOTES_ACCESS_ERROR, APPLE_NOTES_SNAPSHOT_SCHEMA_VERSION }; diff --git a/packages/connector-apple-notes/tsconfig.json b/packages/connector-apple-notes/tsconfig.json new file mode 100644 index 0000000..7870973 --- /dev/null +++ b/packages/connector-apple-notes/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "noEmit": true + }, + "include": ["src/**/*.ts"] +} diff --git a/packages/core/src/config-model.ts b/packages/core/src/config-model.ts index 4bf891d..1229d8d 100644 --- a/packages/core/src/config-model.ts +++ b/packages/core/src/config-model.ts @@ -1,6 +1,7 @@ import { randomUUID } from "node:crypto"; import type { + AppleNotesIntegrationConfig, CalendarIntegrationConfig, ConnectionConfig, ConnectionSummary, @@ -22,6 +23,7 @@ export const DEFAULT_GOOGLE_CONNECTION_ID = "google-account-default"; export const DEFAULT_NOTION_OAUTH_APP_ID = "notion-oauth-app-default"; export const DEFAULT_NOTION_TOKEN_CONNECTION_ID = "notion-token-default"; export const DEFAULT_NOTION_OAUTH_CONNECTION_ID = "notion-oauth-default"; +export const DEFAULT_APPLE_NOTES_CONNECTION_ID = "apple-notes-local-default"; export function createDefaultOAuthApps(): OAuthAppConfig[] { return [ @@ -57,6 +59,11 @@ export function createDefaultConnections(): ConnectionConfig[] { label: "Default Notion OAuth Connection", oauthAppId: DEFAULT_NOTION_OAUTH_APP_ID, }, + { + id: DEFAULT_APPLE_NOTES_CONNECTION_ID, + kind: "apple-notes-local", + label: "Default Apple Notes Connection", + }, ]; } @@ -94,6 +101,15 @@ export function createDefaultIntegrations(): IntegrationConfig[] { selectedCalendarIds: [], }, }, + { + id: randomUUID(), + connectorId: "apple-notes", + connectionId: DEFAULT_APPLE_NOTES_CONNECTION_ID, + label: "Apple Notes", + enabled: false, + interval: "1h", + config: {}, + }, ]; } @@ -155,7 +171,9 @@ export function getDefaultIntegration( export function getDefaultConnectionId(connectorId: ConnectorId): string { return connectorId === "gmail" || connectorId === "google-calendar" ? DEFAULT_GOOGLE_CONNECTION_ID - : DEFAULT_NOTION_TOKEN_CONNECTION_ID; + : connectorId === "apple-notes" + ? DEFAULT_APPLE_NOTES_CONNECTION_ID + : DEFAULT_NOTION_TOKEN_CONNECTION_ID; } export function getDefaultConnection( @@ -202,6 +220,12 @@ export function isCalendarIntegration( return integration.connectorId === "google-calendar"; } +export function isAppleNotesIntegration( + integration: IntegrationConfig, +): integration is AppleNotesIntegrationConfig { + return integration.connectorId === "apple-notes"; +} + export function toConnectorDefinitions( connectors: readonly Connector[], ): ConnectorDefinitionSummary[] { @@ -323,6 +347,16 @@ export function normalizeConfig( ]; } + if (candidate.kind === "apple-notes-local") { + return [ + { + id: candidate.id, + kind: "apple-notes-local", + label: candidate.label, + }, + ]; + } + const notionOauthCandidate = candidate as Partial; if ( @@ -453,6 +487,20 @@ export function normalizeConfig( ]; } + if (candidate.connectorId === "apple-notes") { + return [ + { + id: candidate.id, + connectorId: "apple-notes", + connectionId: candidate.connectionId, + label: candidate.label, + enabled: candidate.enabled, + interval: candidate.interval, + config: {}, + }, + ]; + } + return []; }) : defaults.integrations; diff --git a/packages/core/src/execution.ts b/packages/core/src/execution.ts index db15df3..104a2cb 100644 --- a/packages/core/src/execution.ts +++ b/packages/core/src/execution.ts @@ -143,6 +143,19 @@ async function resolveConnectionAuth( }; } + if (integration.connectorId === "apple-notes") { + if (connection.kind !== "apple-notes-local") { + throw new Error( + `Integration ${integration.id} requires an apple-notes-local connection`, + ); + } + + return { + connection, + resolvedAuth: null, + }; + } + if (connection.kind === "notion-token") { const token = await services.secrets.getSecret( getNotionConnectionSecretName(connection.id), @@ -273,6 +286,10 @@ export async function hasIntegrationStoredCredentials( }); } + if (integration.connectorId === "apple-notes") { + return process.platform === "darwin"; + } + if (connection.kind === "notion-token") { return services.secrets.hasSecret( getNotionConnectionSecretName(connection.id), diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 6fc22e0..440e2f3 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -9,6 +9,7 @@ export { } from "./config.js"; export { createDefaultConfig, + DEFAULT_APPLE_NOTES_CONNECTION_ID, DEFAULT_GOOGLE_CONNECTION_ID, DEFAULT_GOOGLE_OAUTH_APP_ID, DEFAULT_NOTION_OAUTH_APP_ID, @@ -20,6 +21,7 @@ export { getDefaultConnection, getDefaultConnectionId, getDefaultIntegration, + isAppleNotesIntegration, isCalendarIntegration, isGmailIntegration, isGoogleAccountConnection, @@ -72,6 +74,9 @@ export { } from "./notion-auth.js"; export type { AppIo, + AppleNotesIntegrationConfig, + AppleNotesIntegrationSettings, + AppleNotesLocalConnectionConfig, ApplyUpdateResult, AppPaths, AppSnapshot, diff --git a/packages/core/src/test-support.ts b/packages/core/src/test-support.ts index 3d2ab27..e15c235 100644 --- a/packages/core/src/test-support.ts +++ b/packages/core/src/test-support.ts @@ -141,6 +141,8 @@ export class TestRenderer implements MarkdownRenderer { return "test-renderer-gmail-v1"; case "google-calendar": return "test-renderer-google-calendar-v1"; + case "apple-notes": + return "test-renderer-apple-notes-v1"; default: throw new Error(`Unsupported connector: ${connectorId}`); } @@ -152,10 +154,12 @@ export class TestRenderer implements MarkdownRenderer { ? `${document.connectorId}/primary/${document.sourceId}.md` : document.pathHint.kind === "calendar-event" ? `${document.connectorId}/${document.pathHint.calendarName ?? "calendar"}/${document.sourceId}.md` - : document.pathHint.kind === "database" && - document.pathHint.databaseName - ? `${document.connectorId}/databases/${document.pathHint.databaseName}/${document.sourceId}.md` - : `${document.connectorId}/pages/${document.sourceId}.md`; + : document.pathHint.kind === "note" + ? `${document.connectorId}/${document.pathHint.appleNotesAccount ?? "unknown-account"}/${(document.pathHint.appleNotesFolderPath ?? [document.pathHint.appleNotesFolder ?? "root"]).join("/")}/${document.sourceId}.md` + : document.pathHint.kind === "database" && + document.pathHint.databaseName + ? `${document.connectorId}/databases/${document.pathHint.databaseName}/${document.sourceId}.md` + : `${document.connectorId}/pages/${document.sourceId}.md`; return { sourceId: document.sourceId, @@ -218,13 +222,19 @@ export function createSource( } export function createConnector( - id: "notion" | "gmail" | "google-calendar", + id: "notion" | "gmail" | "google-calendar" | "apple-notes", syncImpl?: (request: ConnectorSyncRequest) => Promise, ): Connector { return { id, label: - id === "notion" ? "Notion" : id === "gmail" ? "Gmail" : "Google Calendar", + id === "notion" + ? "Notion" + : id === "gmail" + ? "Gmail" + : id === "google-calendar" + ? "Google Calendar" + : "Apple Notes", setupMethods: id === "gmail" ? [ @@ -246,16 +256,18 @@ export function createConnector( ], }, ] - : [ - { - kind: "token", - }, - { - kind: "provider-oauth", - providerId: "notion", - requiredScopes: [], - }, - ], + : id === "apple-notes" + ? [] + : [ + { + kind: "token", + }, + { + kind: "provider-oauth", + providerId: "notion", + requiredScopes: [], + }, + ], async validate(): Promise<{ status: "ok"; message: string }> { return { status: "ok", message: "credentials valid" }; }, diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index a2c91cc..b3c0c75 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -11,11 +11,16 @@ export type ExitCode = (typeof EXIT_CODES)[keyof typeof EXIT_CODES]; export type SyncIntervalPreset = "5m" | "15m" | "1h" | "6h" | "24h"; export type ProviderId = "google" | "notion"; -export type ConnectorId = "notion" | "gmail" | "google-calendar"; +export type ConnectorId = + | "notion" + | "gmail" + | "google-calendar" + | "apple-notes"; export type ConnectionKind = | "google-account" | "notion-token" - | "notion-oauth-account"; + | "notion-oauth-account" + | "apple-notes-local"; export interface ProviderOAuthSetupMethodDescriptor { kind: "provider-oauth"; @@ -64,10 +69,15 @@ export interface NotionOAuthConnectionConfig extends BaseConnectionConfig { ownerUserName?: string; } +export interface AppleNotesLocalConnectionConfig extends BaseConnectionConfig { + kind: "apple-notes-local"; +} + export type ConnectionConfig = | GoogleAccountConnectionConfig | NotionTokenConnectionConfig - | NotionOAuthConnectionConfig; + | NotionOAuthConnectionConfig + | AppleNotesLocalConnectionConfig; export type GmailSyncFilter = "primary" | "primary-important"; @@ -81,6 +91,7 @@ export interface CalendarIntegrationSettings { } export type NotionIntegrationSettings = Record; +export type AppleNotesIntegrationSettings = Record; export interface BaseIntegrationConfig { id: string; @@ -104,10 +115,15 @@ export type CalendarIntegrationConfig = BaseIntegrationConfig< "google-calendar", CalendarIntegrationSettings >; +export type AppleNotesIntegrationConfig = BaseIntegrationConfig< + "apple-notes", + AppleNotesIntegrationSettings +>; export type IntegrationConfig = | NotionIntegrationConfig | GmailIntegrationConfig - | CalendarIntegrationConfig; + | CalendarIntegrationConfig + | AppleNotesIntegrationConfig; export interface SyncdownConfig { outputDir?: string; @@ -137,10 +153,13 @@ export interface HealthCheck { } export interface DocumentPathHint { - kind: "page" | "database" | "message" | "calendar-event"; + kind: "page" | "database" | "message" | "calendar-event" | "note"; databaseName?: string; gmailAccountEmail?: string; calendarName?: string; + appleNotesAccount?: string; + appleNotesFolder?: string; + appleNotesFolderPath?: string[]; } export interface SourceMetadata extends Record { @@ -169,6 +188,9 @@ export interface SourceMetadata extends Record { calendarOrganizer?: string; calendarAttendees?: string[]; calendarRecurrence?: string[]; + appleNotesNoteId?: string; + appleNotesFolder?: string; + appleNotesFolderPath?: string[]; } export interface SourceSnapshot { diff --git a/packages/renderer-md/src/frontmatter.ts b/packages/renderer-md/src/frontmatter.ts index 943dc06..759bd4b 100644 --- a/packages/renderer-md/src/frontmatter.ts +++ b/packages/renderer-md/src/frontmatter.ts @@ -3,6 +3,15 @@ import type { SourceSnapshot } from "@syncdown/core"; import { normalizeFrontmatterKey } from "./strings.js"; import { appendYamlValue } from "./yaml.js"; +function getAppleNotesFolderLabel(document: SourceSnapshot): string | null { + const folderPath = document.metadata.appleNotesFolderPath; + if (Array.isArray(folderPath) && folderPath.length > 0) { + return folderPath.join("/"); + } + + return document.metadata.appleNotesFolder ?? null; +} + export function buildFrontmatterFields( document: SourceSnapshot, ): Map { @@ -95,6 +104,11 @@ export function buildFrontmatterFields( fields.set("recurrence", document.metadata.calendarRecurrence); } + const appleNotesFolderLabel = getAppleNotesFolderLabel(document); + if (appleNotesFolderLabel) { + fields.set("folder", appleNotesFolderLabel); + } + return fields; } diff --git a/packages/renderer-md/src/index.test.ts b/packages/renderer-md/src/index.test.ts index 2967156..71ef8f7 100644 --- a/packages/renderer-md/src/index.test.ts +++ b/packages/renderer-md/src/index.test.ts @@ -7,6 +7,7 @@ import { createMarkdownRenderer } from "./index.js"; const NOTION_INTEGRATION_ID = "11111111-1111-4111-8111-111111111111"; const GMAIL_INTEGRATION_ID = "22222222-2222-4222-8222-222222222222"; const CALENDAR_INTEGRATION_ID = "33333333-3333-4333-8333-333333333333"; +const APPLE_NOTES_INTEGRATION_ID = "44444444-4444-4444-8444-444444444444"; function createGmailSnapshot(): SourceSnapshot { return { @@ -95,6 +96,33 @@ function createCalendarSnapshot(): SourceSnapshot { }; } +function createAppleNotesSnapshot(): SourceSnapshot { + return { + integrationId: APPLE_NOTES_INTEGRATION_ID, + connectorId: "apple-notes", + sourceId: "apple-note-123", + entityType: "note", + title: "Daily Notes", + slug: "", + pathHint: { + kind: "note", + appleNotesAccount: "Personal iCloud", + appleNotesFolder: "Scratchpad", + appleNotesFolderPath: ["Work", "Scratchpad"], + }, + metadata: { + createdAt: "2026-03-18T01:02:03.000Z", + updatedAt: "2026-03-18T04:05:06.000Z", + appleNotesNoteId: "x-coredata://note/id/123", + appleNotesFolder: "Scratchpad", + appleNotesFolderPath: ["Work", "Scratchpad"], + }, + bodyMd: "Captured locally", + sourceHash: "hash-apple-note-123", + snapshotSchemaVersion: "1", + }; +} + test("gmail message paths render under account, year, and month folders", () => { const renderer = createMarkdownRenderer(); const document = renderer.render(createGmailSnapshot()); @@ -110,6 +138,7 @@ test("renderer exposes connector-specific versions", () => { expect(renderer.getVersion("notion")).toBe("1"); expect(renderer.getVersion("gmail")).toBe("1"); expect(renderer.getVersion("google-calendar")).toBe("1"); + expect(renderer.getVersion("apple-notes")).toBe("1"); }); test("calendar paths use event ids for filenames when available", () => { @@ -130,6 +159,15 @@ test("notion database item paths omit integration ids", () => { ); }); +test("apple notes paths include account and folder", () => { + const renderer = createMarkdownRenderer(); + const document = renderer.render(createAppleNotesSnapshot()); + + expect(document.relativePath).toBe( + "apple-notes/personal-icloud/work/scratchpad/daily-notes-note-id-123.md", + ); +}); + test("gmail frontmatter includes gmail metadata fields", () => { const renderer = createMarkdownRenderer(); const document = renderer.render(createGmailSnapshot()); @@ -190,6 +228,15 @@ test("notion frontmatter excludes archived while keeping notion metadata fields" expect(document.contents).toMatch(/^status: "Done"$/m); }); +test("apple notes frontmatter stays user-facing", () => { + const renderer = createMarkdownRenderer(); + const document = renderer.render(createAppleNotesSnapshot()); + + expect(document.contents).toMatch(/^folder: "Work\/Scratchpad"$/m); + expect(document.contents).not.toMatch(/^folder_path:/m); + expect(document.contents).not.toMatch(/^apple_notes_note_id:/m); +}); + test("notion frontmatter flattens normalized property keys", () => { const renderer = createMarkdownRenderer(); const document = renderer.render({ diff --git a/packages/renderer-md/src/path-builder.ts b/packages/renderer-md/src/path-builder.ts index 13a5f2c..ed38caa 100644 --- a/packages/renderer-md/src/path-builder.ts +++ b/packages/renderer-md/src/path-builder.ts @@ -4,6 +4,11 @@ import type { SourceSnapshot } from "@syncdown/core"; import { slugifySegment } from "./strings.js"; +function getAppleNotesFileIdentifier(noteId: string): string { + const withoutScheme = noteId.replace(/^[a-z][a-z0-9+.-]*:\/\//i, ""); + return slugifySegment(withoutScheme); +} + function getCalendarBucketDate(document: SourceSnapshot): Date | null { const value = document.metadata.calendarStartAt ?? document.metadata.createdAt; @@ -23,6 +28,23 @@ function getGmailAccountSegment(document: SourceSnapshot): string { ); } +function getAppleNotesFolderSegments(document: SourceSnapshot): string[] { + const rawPath = + document.pathHint.appleNotesFolderPath ?? + document.metadata.appleNotesFolderPath; + if (Array.isArray(rawPath) && rawPath.length > 0) { + return rawPath.map((segment) => slugifySegment(String(segment))); + } + + return [ + slugifySegment( + document.pathHint.appleNotesFolder ?? + document.metadata.appleNotesFolder ?? + "root", + ), + ]; +} + function getFileIdentifier(document: SourceSnapshot): string { if (document.pathHint.kind === "calendar-event") { const eventId = document.metadata.calendarEventId; @@ -31,6 +53,13 @@ function getFileIdentifier(document: SourceSnapshot): string { } } + if (document.pathHint.kind === "note") { + const noteId = document.metadata.appleNotesNoteId; + if (typeof noteId === "string" && noteId.trim().length > 0) { + return getAppleNotesFileIdentifier(noteId); + } + } + return document.sourceId; } @@ -73,6 +102,18 @@ export function buildRelativePath(document: SourceSnapshot): string { return path.join(document.connectorId, calendarName, year, month, fileName); } + if (document.pathHint.kind === "note") { + const accountName = slugifySegment( + document.pathHint.appleNotesAccount ?? "unknown-account", + ); + return path.join( + document.connectorId, + accountName, + ...getAppleNotesFolderSegments(document), + fileName, + ); + } + if (document.pathHint.kind === "database" && document.pathHint.databaseName) { return path.join( document.connectorId, diff --git a/packages/renderer-md/src/versions.ts b/packages/renderer-md/src/versions.ts index 198a543..ce5990a 100644 --- a/packages/renderer-md/src/versions.ts +++ b/packages/renderer-md/src/versions.ts @@ -4,4 +4,5 @@ export const MARKDOWN_RENDERER_VERSIONS: Record = { notion: "1", gmail: "1", "google-calendar": "1", + "apple-notes": "1", }; diff --git a/packages/tui/src/app.ts b/packages/tui/src/app.ts index 1eab054..dfcf938 100644 --- a/packages/tui/src/app.ts +++ b/packages/tui/src/app.ts @@ -712,7 +712,8 @@ export class ConfigTuiApp { if ( selection === "notion" || selection === "gmail" || - selection === "google-calendar" + selection === "google-calendar" || + selection === "apple-notes" ) { pushRoute(this.ui, createConnectorDetailsRoute(selection)); } @@ -764,6 +765,17 @@ export class ConfigTuiApp { text: "Gmail enabled.", }); } + } else if (selection === "enable" && route.connector === "apple-notes") { + const saved = await this.persistDraftMutation( + (draft) => setConnectorEnabled(draft, "apple-notes", true), + "Failed to enable Apple Notes.", + ); + if (saved) { + setNotice(this.ui, { + kind: "success", + text: "Apple Notes enabled.", + }); + } } else if ( selection === "enable" && route.connector === "google-calendar" @@ -848,7 +860,9 @@ export class ConfigTuiApp { ? "Notion" : connector === "gmail" ? "Gmail" - : "Google Calendar"; + : connector === "google-calendar" + ? "Google Calendar" + : "Apple Notes"; const saved = await this.persistDraftMutation((draft) => { if (route.mode === "provider") { stageProviderDisconnect(draft, route.provider ?? "google"); @@ -875,7 +889,9 @@ export class ConfigTuiApp { ? "Notion disconnected." : connector === "gmail" ? "Gmail disabled." - : "Google Calendar disabled.", + : connector === "google-calendar" + ? "Google Calendar disabled." + : "Apple Notes disabled.", }); this.refreshView(); } @@ -912,7 +928,8 @@ export class ConfigTuiApp { if ( selection === "notion" || selection === "gmail" || - selection === "google-calendar" + selection === "google-calendar" || + selection === "apple-notes" ) { pushRoute(this.ui, createIntervalRoute(selection)); } @@ -929,13 +946,13 @@ export class ConfigTuiApp { const connector = route.connector; const saved = await this.persistDraftMutation( (draft) => setSyncInterval(draft, connector, interval), - `Failed to save the ${connector === "notion" ? "Notion" : connector === "gmail" ? "Gmail" : "Google Calendar"} interval.`, + `Failed to save the ${connector === "notion" ? "Notion" : connector === "gmail" ? "Gmail" : connector === "google-calendar" ? "Google Calendar" : "Apple Notes"} interval.`, ); if (saved) { popRoute(this.ui); setNotice(this.ui, { kind: "success", - text: `${connector === "notion" ? "Notion" : connector === "gmail" ? "Gmail" : "Google Calendar"} interval saved.`, + text: `${connector === "notion" ? "Notion" : connector === "gmail" ? "Gmail" : connector === "google-calendar" ? "Google Calendar" : "Apple Notes"} interval saved.`, }); this.refreshView(); } @@ -1268,6 +1285,21 @@ export class ConfigTuiApp { { resetState: true }, ); this.setSyncRunNotice("Google Calendar full resync completed."); + } else if (selection === "runAppleNotes") { + await this.request.session.runNow({ + kind: "integration", + integrationId: getDraftIntegration(this.draft, "apple-notes").id, + }); + this.setSyncRunNotice("Apple Notes run completed."); + } else if (selection === "runAppleNotesReset") { + await this.request.session.runNow( + { + kind: "integration", + integrationId: getDraftIntegration(this.draft, "apple-notes").id, + }, + { resetState: true }, + ); + this.setSyncRunNotice("Apple Notes full resync completed."); } } catch (error) { setNotice(this.ui, { diff --git a/packages/tui/src/state.ts b/packages/tui/src/state.ts index e798293..5e3560a 100644 --- a/packages/tui/src/state.ts +++ b/packages/tui/src/state.ts @@ -1,6 +1,7 @@ import path from "node:path"; import type { AppIo, + AppleNotesIntegrationConfig, AppPaths, CalendarIntegrationConfig, ConnectionConfig, @@ -35,7 +36,11 @@ export type OutputPresetAction = | "downloads" | "home" | "custom"; -export type ConnectorTarget = "notion" | "gmail" | "google-calendar"; +export type ConnectorTarget = + | "notion" + | "gmail" + | "google-calendar" + | "apple-notes"; export type ProviderTarget = "google" | "notion"; export type SecretTarget = | "notionToken" @@ -99,6 +104,12 @@ export interface ConnectorStatus { description: string; } +export function isAppleNotesSupportedPlatform( + platform: NodeJS.Platform = process.platform, +): boolean { + return platform === "darwin"; +} + export const INTERVAL_OPTIONS: SyncIntervalPreset[] = [ "5m", "15m", @@ -121,6 +132,7 @@ export function getDraftIntegration( draft: DraftState, connector: ConnectorTarget, ): + | AppleNotesIntegrationConfig | GmailIntegrationConfig | CalendarIntegrationConfig | ReturnType { @@ -258,6 +270,10 @@ export function hasAnyStoredCredentials( draft: DraftState, connector: ConnectorTarget, ): boolean { + if (connector === "apple-notes") { + return false; + } + if (connector === "notion") { return [ draft.notionToken, @@ -285,6 +301,27 @@ export function getConnectorStatus( draft: DraftState, connector: ConnectorTarget, ): ConnectorStatus { + if (connector === "apple-notes") { + if (!isDraftConnectorEnabled(draft, connector)) { + return { + label: "disconnected", + description: "Local Apple Notes sync is disabled.", + }; + } + + if (!isAppleNotesSupportedPlatform()) { + return { + label: "needs setup", + description: "Apple Notes sync requires macOS.", + }; + } + + return { + label: "connected", + description: "Ready to sync from the local Notes app.", + }; + } + const enabled = isDraftConnectorEnabled(draft, connector); const completeCredentials = connector === "notion" @@ -410,6 +447,9 @@ export function stageStoredCredentialDisconnect( connector: ConnectorTarget, ): void { setConnectorEnabled(draft, connector, false); + if (connector === "apple-notes") { + return; + } if (connector === "notion") { if (getDraftNotionAuthMethod(draft) === "oauth") { applySecretAction(draft, "notionOauthClientId", "delete"); @@ -571,14 +611,23 @@ export function buildOverview(paths: AppPaths, draft: DraftState): string { const notionStatus = getConnectorStatus(draft, "notion"); const gmailStatus = getConnectorStatus(draft, "gmail"); const googleCalendarStatus = getConnectorStatus(draft, "google-calendar"); - return [ + const lines = [ `config: ${paths.configPath}`, `secrets: ${paths.secretsPath}`, `output: ${draft.config.outputDir ?? ""}`, `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"}`, - ].join("\n"); + ]; + + if (isAppleNotesSupportedPlatform()) { + const appleNotesStatus = getConnectorStatus(draft, "apple-notes"); + lines.push( + `apple-notes: ${appleNotesStatus.label} | interval=${getDraftInterval(draft, "apple-notes")} | enabled=${isDraftConnectorEnabled(draft, "apple-notes") ? "yes" : "no"}`, + ); + } + + return lines.join("\n"); } function padTwo(value: number): string { diff --git a/packages/tui/src/view-state.ts b/packages/tui/src/view-state.ts index 6d07dfb..bbd3439 100644 --- a/packages/tui/src/view-state.ts +++ b/packages/tui/src/view-state.ts @@ -21,6 +21,7 @@ import { getDraftSelectedGoogleCalendarIds, hasAnyStoredCredentials, INTERVAL_OPTIONS, + isAppleNotesSupportedPlatform, isDraftConnectorEnabled, OUTPUT_PRESET_LABELS, type ProviderTarget, @@ -621,7 +622,9 @@ export function getRouteTitle(route: ConfigRoute): string { ? "Notion" : route.connector === "gmail" ? "Gmail" - : "Google Calendar"; + : route.connector === "google-calendar" + ? "Google Calendar" + : "Apple Notes"; case "connectorAuth": return route.authMethod === "notion-token" ? "Notion Token" @@ -634,7 +637,11 @@ export function getRouteTitle(route: ConfigRoute): string { } return route.connector === "notion" ? "Disconnect Notion" - : "Disable Gmail"; + : route.connector === "gmail" + ? "Disable Gmail" + : route.connector === "google-calendar" + ? "Disable Google Calendar" + : "Disable Apple Notes"; case "confirmReset": return "Reset App Data"; case "output": @@ -648,7 +655,9 @@ export function getRouteTitle(route: ConfigRoute): string { ? "Notion Interval" : route.connector === "gmail" ? "Gmail Interval" - : "Google Calendar Interval"; + : route.connector === "google-calendar" + ? "Google Calendar Interval" + : "Apple Notes Interval"; case "gmailFilter": return "Gmail Inbox Filter"; case "googleCalendarSelection": @@ -690,11 +699,31 @@ function shouldShowDisconnectAction( } function getConnectedSyncTargets(draft: DraftState): ConnectorTarget[] { - return (["notion", "gmail", "google-calendar"] as const).filter( + return getVisibleConnectorTargets().filter( (connector) => getConnectorStatus(draft, connector).label === "connected", ); } +function getVisibleConnectorTargets(): ConnectorTarget[] { + return isAppleNotesSupportedPlatform() + ? ["notion", "gmail", "google-calendar", "apple-notes"] + : ["notion", "gmail", "google-calendar"]; +} + +function getVisibleConnectorSummaryLines(draft: DraftState): string[] { + return getVisibleConnectorTargets().map((connector) => { + const label = + connector === "notion" + ? "Notion" + : connector === "gmail" + ? "Gmail" + : connector === "google-calendar" + ? "Google Calendar" + : "Apple Notes"; + return `${label}: ${getConnectorSummaryLine(draft, connector)}`; + }); +} + function formatVersion(version: string): string { return version.startsWith("v") ? version : `v${version}`; } @@ -756,9 +785,7 @@ export function getRouteBody( return [ ...getHomeUpdateBannerLines(route), `Output: ${draft.config.outputDir ?? ""}`, - `Notion: ${getConnectorSummaryLine(draft, "notion")}`, - `Gmail: ${getConnectorSummaryLine(draft, "gmail")}`, - `Google Calendar: ${getConnectorSummaryLine(draft, "google-calendar")}`, + ...getVisibleConnectorSummaryLines(draft), ].join("\n"); case "syncDashboard": return [ @@ -783,9 +810,11 @@ export function getRouteBody( ? hasAnyStoredCredentials(draft, route.connector) ? "stored Google account" : "missing Google account" - : hasAnyStoredCredentials(draft, route.connector) - ? `stored ${getDraftNotionAuthMethod(draft) === "oauth" ? "OAuth credentials" : "token"}` - : "missing"; + : route.connector === "apple-notes" + ? "local macOS access" + : hasAnyStoredCredentials(draft, route.connector) + ? `stored ${getDraftNotionAuthMethod(draft) === "oauth" ? "OAuth credentials" : "token"}` + : "missing"; return [ `Status: ${status.label}`, status.description, @@ -801,7 +830,9 @@ export function getRouteBody( ? [ `Selected calendars: ${getDraftSelectedGoogleCalendarIds(draft).length}`, ] - : []), + : route.connector === "apple-notes" + ? ["Source: local Notes app on macOS"] + : []), ].join("\n"); } case "connectorAuth": @@ -814,7 +845,11 @@ export function getRouteBody( } return route.connector === "notion" ? "This will disable the connector and remove its stored credentials immediately." - : "This will disable Gmail but keep the stored Google account."; + : route.connector === "gmail" + ? "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."; case "confirmReset": return [ "This will remove all local syncdown app data immediately.", @@ -1010,7 +1045,9 @@ export function getRouteOptions( }, { name: "Connectors", - description: `${getConnectorStatus(draft, "notion").label} / ${getConnectorStatus(draft, "gmail").label}`, + description: getVisibleConnectorTargets() + .map((connector) => getConnectorStatus(draft, connector).label) + .join(" / "), value: "connectors", }, { @@ -1021,7 +1058,19 @@ export function getRouteOptions( }, { name: "Schedule", - description: `Notion ${getDraftInterval(draft, "notion")} | Gmail ${getDraftInterval(draft, "gmail")}`, + description: getVisibleConnectorTargets() + .map((connector) => { + const label = + connector === "notion" + ? "Notion" + : connector === "gmail" + ? "Gmail" + : connector === "google-calendar" + ? "Google Calendar" + : "Apple Notes"; + return `${label} ${getDraftInterval(draft, connector)}`; + }) + .join(" | "), value: "schedule", }, { @@ -1094,6 +1143,15 @@ export function getRouteOptions( }, ] : []), + ...(connectedTargets.includes("apple-notes") + ? [ + { + name: "Run Apple Notes", + description: "Run only Apple Notes now", + value: "runAppleNotes", + }, + ] + : []), ...(hasConnectedTargets ? [ { @@ -1132,6 +1190,16 @@ export function getRouteOptions( }, ] : []), + ...(connectedTargets.includes("apple-notes") + ? [ + { + name: "Run Apple Notes (full resync)", + description: + "Reset Apple Notes state and rerun it from scratch", + value: "runAppleNotesReset", + }, + ] + : []), { name: "Clear log", description: "Hide existing log lines in this dashboard view", @@ -1163,8 +1231,35 @@ export function getRouteOptions( description: getConnectorSummaryLine(draft, "google-calendar"), value: "google-calendar", }, + ...(isAppleNotesSupportedPlatform() + ? [ + { + name: "Apple Notes", + description: getConnectorSummaryLine(draft, "apple-notes"), + value: "apple-notes", + } satisfies UiSelectOption, + ] + : []), ]; case "connectorDetails": + if (route.connector === "apple-notes") { + const connectorEnabled = isDraftConnectorEnabled( + draft, + route.connector, + ); + return [ + { + name: connectorEnabled + ? "Disable Apple Notes" + : "Enable Apple Notes", + description: connectorEnabled + ? "Stop syncing Apple Notes" + : "Enable local Apple Notes sync on macOS", + value: connectorEnabled ? "disable" : "enable", + }, + ]; + } + if ( route.connector === "gmail" || route.connector === "google-calendar" @@ -1304,7 +1399,11 @@ export function getRouteOptions( ? "Disconnect account" : route.connector === "notion" ? "Disconnect now" - : "Disable Gmail", + : route.connector === "gmail" + ? "Disable Gmail" + : route.connector === "google-calendar" + ? "Disable Google Calendar" + : "Disable Apple Notes", description: route.mode === "provider" ? route.provider === "notion" @@ -1380,6 +1479,15 @@ export function getRouteOptions( description: "Open the interval picker", value: "google-calendar", }, + ...(isAppleNotesSupportedPlatform() + ? [ + { + name: `Apple Notes interval: ${getDraftInterval(draft, "apple-notes")}`, + description: "Open the interval picker", + value: "apple-notes", + } satisfies UiSelectOption, + ] + : []), ]; case "interval": return INTERVAL_OPTIONS.map((interval) => ({