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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/famous-buses-hang.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@syncdown/cli": minor
---

Add Apple Notes sync support with CLI and TUI setup on macOS.
12 changes: 8 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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:

Expand Down Expand Up @@ -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

Expand Down
1 change: 1 addition & 0 deletions apps/cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
"src"
],
"devDependencies": {
"@syncdown/connector-apple-notes": "workspace:*",
"@syncdown/connector-google-calendar": "workspace:*",
"@syncdown/connector-gmail": "workspace:*",
"@syncdown/connector-notion": "workspace:*",
Expand Down
20 changes: 20 additions & 0 deletions apps/cli/src/program.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
171 changes: 140 additions & 31 deletions apps/cli/src/program.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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,
Expand All @@ -26,6 +29,7 @@ import {
getNotionOAuthConnectionSecretNames,
hasGoogleCredentials,
hasNotionOAuthConnectionCredentials,
isAppleNotesIntegration,
isCalendarIntegration,
isGmailIntegration,
readConfig,
Expand All @@ -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",
"",
Expand All @@ -52,9 +109,9 @@ function getHelpLines(): string[] {
" syncdown config set <key> --stdin",
" syncdown config unset <key>",
" syncdown run",
" syncdown run --connector <notion|gmail|google-calendar>",
` syncdown run --connector <${connectorUsage}>`,
" syncdown run --integration <integration-id>",
" syncdown run --reset [--connector <notion|gmail|google-calendar>|--integration <integration-id>]",
` syncdown run --reset [--connector <${connectorUsage}>|--integration <integration-id>]`,
" syncdown run --watch [--interval <5m|15m|1h|6h|24h>]",
" syncdown reset --yes",
" syncdown connectors",
Expand All @@ -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`,
Expand Down Expand Up @@ -172,7 +212,7 @@ async function loadConfig(): Promise<{

function printConfigSetUsage(io: AppIo): void {
io.error(
"Usage: syncdown config set <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> <value|--stdin>",
`Usage: syncdown config set <${getConfigSetKeys().join("|")}> <value|--stdin>`,
);
}

Expand All @@ -184,7 +224,7 @@ function printConfigUnsetUsage(io: AppIo): void {

function printRunUsage(io: AppIo): void {
io.error(
"Usage: syncdown run [--connector <notion|gmail|google-calendar>|--integration <integration-id>] [--reset] [--watch] [--interval <5m|15m|1h|6h|24h>]",
`Usage: syncdown run [--connector <${getSupportedRunConnectorIds().join("|")}>|--integration <integration-id>] [--reset] [--watch] [--interval <5m|15m|1h|6h|24h>]`,
);
}

Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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<typeof resolveAppPaths>,
secrets: ReturnType<typeof createSecretsStore>,
): 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) {
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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: {
Expand Down Expand Up @@ -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",
);
Expand All @@ -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,
Expand All @@ -724,14 +812,25 @@ 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") ||
((gmail?.enabled ?? gmailConfig.enabled) &&
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("");
Expand All @@ -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:");

Expand Down Expand Up @@ -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.");
Expand Down Expand Up @@ -928,6 +1036,7 @@ export async function runCli(
createNotionConnector(),
createGmailConnector(),
createGoogleCalendarConnector(),
...(supportsAppleNotes() ? [createAppleNotesConnector()] : []),
],
renderer: createMarkdownRenderer(),
sink: createFileSystemSink(),
Expand Down
Loading
Loading