@portablecore/notes-sync 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,58 @@
1
+ /**
2
+ * Apple Notes Access
3
+ *
4
+ * Reads notes via AppleScript (osascript) since the SQLite database
5
+ * is sandboxed and uses a complex protobuf format.
6
+ * Writes via AppleScript or notes:// URL scheme.
7
+ *
8
+ * macOS only. Requires the Notes app to be present.
9
+ */
10
+ export interface AppleNote {
11
+ id: string;
12
+ title: string;
13
+ content: string;
14
+ folder: string;
15
+ createdAt: string;
16
+ modifiedAt: string;
17
+ }
18
+ export interface AppleNoteCompact {
19
+ id: string;
20
+ title: string;
21
+ folder: string;
22
+ modifiedAt: string;
23
+ createdAt: string;
24
+ contentLength: number;
25
+ preview: string;
26
+ }
27
+ export declare function isAppleNotesAvailable(): boolean;
28
+ /**
29
+ * Get all notes with metadata (no body, for listing).
30
+ */
31
+ export declare function getAllNotesCompact(): AppleNoteCompact[];
32
+ /**
33
+ * Get full content of a single note by ID or title.
34
+ */
35
+ export declare function getNote(identifier: string): AppleNote | null;
36
+ /**
37
+ * Search notes by keyword (title and body).
38
+ */
39
+ export declare function searchNotes(query: string, limit?: number): AppleNoteCompact[];
40
+ /**
41
+ * Get recently modified notes.
42
+ */
43
+ export declare function getRecentNotes(limit?: number): AppleNoteCompact[];
44
+ /**
45
+ * List all folders.
46
+ */
47
+ export declare function listFolders(): Array<{
48
+ name: string;
49
+ noteCount: number;
50
+ }>;
51
+ /**
52
+ * Get notes in a specific folder.
53
+ */
54
+ export declare function getNotesByFolder(folder: string, limit?: number): AppleNoteCompact[];
55
+ /**
56
+ * Create a new note in Apple Notes.
57
+ */
58
+ export declare function createNote(title: string, content: string, folder?: string): Promise<string>;
@@ -0,0 +1,195 @@
1
+ /**
2
+ * Apple Notes Access
3
+ *
4
+ * Reads notes via AppleScript (osascript) since the SQLite database
5
+ * is sandboxed and uses a complex protobuf format.
6
+ * Writes via AppleScript or notes:// URL scheme.
7
+ *
8
+ * macOS only. Requires the Notes app to be present.
9
+ */
10
+ import { execFileSync, execFile } from "node:child_process";
11
+ import { existsSync } from "node:fs";
12
+ import { homedir } from "node:os";
13
+ import { join } from "node:path";
14
+ // ---------------------------------------------------------------------------
15
+ // Availability
16
+ // ---------------------------------------------------------------------------
17
+ const NOTES_DB_PATH = join(homedir(), "Library/Group Containers/group.com.apple.notes/NoteStore.sqlite");
18
+ export function isAppleNotesAvailable() {
19
+ if (process.platform !== "darwin")
20
+ return false;
21
+ return existsSync(NOTES_DB_PATH);
22
+ }
23
+ // ---------------------------------------------------------------------------
24
+ // AppleScript helpers
25
+ // ---------------------------------------------------------------------------
26
+ function runOsascript(script) {
27
+ return execFileSync("osascript", ["-e", script], {
28
+ encoding: "utf-8",
29
+ timeout: 30000,
30
+ }).trim();
31
+ }
32
+ function parseAppleDate(dateStr) {
33
+ try {
34
+ const d = new Date(dateStr);
35
+ return isNaN(d.getTime()) ? dateStr : d.toISOString();
36
+ }
37
+ catch {
38
+ return dateStr;
39
+ }
40
+ }
41
+ // ---------------------------------------------------------------------------
42
+ // Read operations
43
+ // ---------------------------------------------------------------------------
44
+ /**
45
+ * Get all notes with metadata (no body, for listing).
46
+ */
47
+ export function getAllNotesCompact() {
48
+ const script = `
49
+ tell application "Notes"
50
+ set output to ""
51
+ repeat with n in notes of default account
52
+ try
53
+ set noteId to id of n
54
+ set noteName to name of n
55
+ try
56
+ set noteFolder to name of container of n
57
+ on error
58
+ set noteFolder to ""
59
+ end try
60
+ set noteModDate to (modification date of n) as string
61
+ set noteCreateDate to (creation date of n) as string
62
+ set noteBody to plaintext of n
63
+ set bodyLen to count of noteBody
64
+ if bodyLen > 200 then
65
+ set notePreview to text 1 thru 200 of noteBody
66
+ else
67
+ set notePreview to noteBody
68
+ end if
69
+ set output to output & noteId & "\\t" & noteName & "\\t" & noteFolder & "\\t" & noteModDate & "\\t" & noteCreateDate & "\\t" & bodyLen & "\\t" & notePreview & linefeed
70
+ end try
71
+ end repeat
72
+ return output
73
+ end tell
74
+ `;
75
+ const raw = runOsascript(script);
76
+ if (!raw)
77
+ return [];
78
+ return raw.split("\n").filter(Boolean).map((line) => {
79
+ const parts = line.split("\t");
80
+ return {
81
+ id: parts[0] ?? "",
82
+ title: parts[1] ?? "(untitled)",
83
+ folder: parts[2] ?? "",
84
+ modifiedAt: parseAppleDate(parts[3] ?? ""),
85
+ createdAt: parseAppleDate(parts[4] ?? ""),
86
+ contentLength: parseInt(parts[5] ?? "0", 10),
87
+ preview: parts[6] ?? "",
88
+ };
89
+ });
90
+ }
91
+ /**
92
+ * Get full content of a single note by ID or title.
93
+ */
94
+ export function getNote(identifier) {
95
+ const isId = identifier.startsWith("x-coredata://");
96
+ const matchField = isId ? "id" : "name";
97
+ const script = `
98
+ tell application "Notes"
99
+ try
100
+ set matchedNote to first note of default account whose ${matchField} is "${identifier.replace(/"/g, '\\"')}"
101
+ set noteId to id of matchedNote
102
+ set noteName to name of matchedNote
103
+ try
104
+ set noteFolder to name of container of matchedNote
105
+ on error
106
+ set noteFolder to ""
107
+ end try
108
+ set noteModDate to (modification date of matchedNote) as string
109
+ set noteCreateDate to (creation date of matchedNote) as string
110
+ set noteBody to body of matchedNote
111
+ return noteId & "\\t" & noteName & "\\t" & noteFolder & "\\t" & noteModDate & "\\t" & noteCreateDate & "\\t" & noteBody
112
+ on error
113
+ return ""
114
+ end try
115
+ end tell
116
+ `;
117
+ const raw = runOsascript(script);
118
+ if (!raw)
119
+ return null;
120
+ const parts = raw.split("\t");
121
+ return {
122
+ id: parts[0] ?? "",
123
+ title: parts[1] ?? "(untitled)",
124
+ folder: parts[2] ?? "",
125
+ modifiedAt: parseAppleDate(parts[3] ?? ""),
126
+ createdAt: parseAppleDate(parts[4] ?? ""),
127
+ content: parts.slice(5).join("\t"),
128
+ };
129
+ }
130
+ /**
131
+ * Search notes by keyword (title and body).
132
+ */
133
+ export function searchNotes(query, limit = 20) {
134
+ const all = getAllNotesCompact();
135
+ const lowerQuery = query.toLowerCase();
136
+ return all
137
+ .filter((n) => n.title.toLowerCase().includes(lowerQuery) || n.preview.toLowerCase().includes(lowerQuery))
138
+ .slice(0, limit);
139
+ }
140
+ /**
141
+ * Get recently modified notes.
142
+ */
143
+ export function getRecentNotes(limit = 20) {
144
+ const all = getAllNotesCompact();
145
+ return all
146
+ .sort((a, b) => new Date(b.modifiedAt).getTime() - new Date(a.modifiedAt).getTime())
147
+ .slice(0, limit);
148
+ }
149
+ /**
150
+ * List all folders.
151
+ */
152
+ export function listFolders() {
153
+ const all = getAllNotesCompact();
154
+ const folderCounts = new Map();
155
+ for (const note of all) {
156
+ folderCounts.set(note.folder, (folderCounts.get(note.folder) ?? 0) + 1);
157
+ }
158
+ return [...folderCounts.entries()]
159
+ .map(([name, noteCount]) => ({ name, noteCount }))
160
+ .sort((a, b) => b.noteCount - a.noteCount);
161
+ }
162
+ /**
163
+ * Get notes in a specific folder.
164
+ */
165
+ export function getNotesByFolder(folder, limit = 50) {
166
+ const all = getAllNotesCompact();
167
+ return all
168
+ .filter((n) => n.folder.toLowerCase() === folder.toLowerCase())
169
+ .slice(0, limit);
170
+ }
171
+ // ---------------------------------------------------------------------------
172
+ // Write operations
173
+ // ---------------------------------------------------------------------------
174
+ /**
175
+ * Create a new note in Apple Notes.
176
+ */
177
+ export async function createNote(title, content, folder) {
178
+ const folderClause = folder
179
+ ? `in folder "${folder.replace(/"/g, '\\"')}" of default account`
180
+ : "in default account";
181
+ const script = `
182
+ tell application "Notes"
183
+ set newNote to make new note ${folderClause} with properties {name:"${title.replace(/"/g, '\\"')}", body:"${content.replace(/"/g, '\\"').replace(/\n/g, "\\n")}"}
184
+ return id of newNote
185
+ end tell
186
+ `;
187
+ return new Promise((resolve, reject) => {
188
+ execFile("osascript", ["-e", script], { timeout: 15000 }, (error, stdout) => {
189
+ if (error)
190
+ reject(error);
191
+ else
192
+ resolve(stdout.trim());
193
+ });
194
+ });
195
+ }
package/dist/bear.d.ts ADDED
@@ -0,0 +1,72 @@
1
+ /**
2
+ * Bear Notes Database Access
3
+ *
4
+ * Read-only access to Bear's local SQLite database via better-sqlite3.
5
+ * Write operations go through Bear's URL scheme (bear://x-callback-url/).
6
+ *
7
+ * Bear uses CoreData, so timestamps are seconds since Jan 1, 2001 (not Unix epoch).
8
+ * The offset is 978307200 seconds.
9
+ */
10
+ export interface BearNote {
11
+ id: string;
12
+ title: string;
13
+ content: string;
14
+ createdAt: string;
15
+ modifiedAt: string;
16
+ isPinned: boolean;
17
+ isArchived: boolean;
18
+ }
19
+ export interface BearNoteCompact {
20
+ id: string;
21
+ title: string;
22
+ createdAt: string;
23
+ modifiedAt: string;
24
+ isPinned: boolean;
25
+ isArchived: boolean;
26
+ contentLength: number;
27
+ preview: string;
28
+ }
29
+ export interface BearTag {
30
+ name: string;
31
+ noteCount: number;
32
+ }
33
+ export declare function initBearDb(): Promise<boolean>;
34
+ export declare function isBearAvailable(): boolean;
35
+ /**
36
+ * Search notes by query (matches title and content).
37
+ */
38
+ export declare function searchNotes(query: string, limit?: number): BearNoteCompact[];
39
+ /**
40
+ * Get a single note by its unique identifier or title.
41
+ */
42
+ export declare function getNote(identifier: string): BearNote | null;
43
+ /**
44
+ * Get recently modified notes.
45
+ */
46
+ export declare function getRecentNotes(limit?: number): BearNoteCompact[];
47
+ /**
48
+ * Get notes by tag name.
49
+ */
50
+ export declare function getNotesByTag(tag: string, limit?: number): BearNoteCompact[];
51
+ /**
52
+ * List all tags with their note counts.
53
+ */
54
+ export declare function listTags(): BearTag[];
55
+ /**
56
+ * Get backlinks for a note (other notes that link to it).
57
+ */
58
+ export declare function getBacklinks(identifier: string): BearNoteCompact[];
59
+ /**
60
+ * Create a new note in Bear.
61
+ */
62
+ export declare function createNote(title: string, content: string, tags?: string[]): Promise<void>;
63
+ /**
64
+ * Append content to an existing note.
65
+ */
66
+ export declare function appendToNote(identifier: string, content: string, options?: {
67
+ separator?: string;
68
+ }): Promise<void>;
69
+ /**
70
+ * Open a note in Bear.
71
+ */
72
+ export declare function openNote(identifier: string): Promise<void>;
package/dist/bear.js ADDED
@@ -0,0 +1,273 @@
1
+ /**
2
+ * Bear Notes Database Access
3
+ *
4
+ * Read-only access to Bear's local SQLite database via better-sqlite3.
5
+ * Write operations go through Bear's URL scheme (bear://x-callback-url/).
6
+ *
7
+ * Bear uses CoreData, so timestamps are seconds since Jan 1, 2001 (not Unix epoch).
8
+ * The offset is 978307200 seconds.
9
+ */
10
+ import { existsSync } from "node:fs";
11
+ import { homedir } from "node:os";
12
+ import { join } from "node:path";
13
+ import { execFile } from "node:child_process";
14
+ // CoreData epoch offset: seconds between Unix epoch (1970) and CoreData epoch (2001)
15
+ const CORE_DATA_EPOCH_OFFSET = 978307200;
16
+ const BEAR_DB_PATH = join(homedir(), "Library/Group Containers/9K33E3U3T4.net.shinyfrog.bear/Application Data/database.sqlite");
17
+ let db = null;
18
+ let dbLoadFailed = false;
19
+ function coreDataToISO(timestamp) {
20
+ return new Date((timestamp + CORE_DATA_EPOCH_OFFSET) * 1000).toISOString();
21
+ }
22
+ async function ensureDb() {
23
+ if (db)
24
+ return db;
25
+ const mod = await import("better-sqlite3");
26
+ const BetterSqlite3 = mod.default ?? mod;
27
+ db = new BetterSqlite3(BEAR_DB_PATH, { readonly: true });
28
+ return db;
29
+ }
30
+ function getDbSync() {
31
+ if (!db)
32
+ throw new Error("Bear database not initialized. Call initBearDb() first.");
33
+ return db;
34
+ }
35
+ export async function initBearDb() {
36
+ if (db)
37
+ return true;
38
+ if (dbLoadFailed)
39
+ return false;
40
+ if (process.platform !== "darwin")
41
+ return false;
42
+ if (!existsSync(BEAR_DB_PATH))
43
+ return false;
44
+ try {
45
+ await ensureDb();
46
+ return true;
47
+ }
48
+ catch {
49
+ dbLoadFailed = true;
50
+ return false;
51
+ }
52
+ }
53
+ function extractPreview(text, maxLen = 200) {
54
+ if (!text)
55
+ return "";
56
+ // Strip Bear markdown header (first line is often the title prefixed with #)
57
+ const lines = text.split("\n");
58
+ const start = lines[0]?.startsWith("#") ? 1 : 0;
59
+ const body = lines.slice(start).join("\n").trim();
60
+ return body.length > maxLen ? body.slice(0, maxLen) + "…" : body;
61
+ }
62
+ // ---------------------------------------------------------------------------
63
+ // Availability check
64
+ // ---------------------------------------------------------------------------
65
+ export function isBearAvailable() {
66
+ if (db)
67
+ return true;
68
+ if (dbLoadFailed)
69
+ return false;
70
+ if (process.platform !== "darwin")
71
+ return false;
72
+ return existsSync(BEAR_DB_PATH);
73
+ }
74
+ // ---------------------------------------------------------------------------
75
+ // Read operations (SQLite)
76
+ // ---------------------------------------------------------------------------
77
+ const BASE_SELECT = `
78
+ SELECT
79
+ ZUNIQUEIDENTIFIER as id,
80
+ ZTITLE as title,
81
+ ZTEXT as content,
82
+ ZCREATIONDATE as createdAt,
83
+ ZMODIFICATIONDATE as modifiedAt,
84
+ ZPINNED as isPinned,
85
+ ZARCHIVED as isArchived
86
+ FROM ZSFNOTE
87
+ WHERE ZTRASHED = 0
88
+ AND ZPERMANENTLYDELETED = 0
89
+ `;
90
+ const COMPACT_SELECT = `
91
+ SELECT
92
+ ZUNIQUEIDENTIFIER as id,
93
+ ZTITLE as title,
94
+ ZCREATIONDATE as createdAt,
95
+ ZMODIFICATIONDATE as modifiedAt,
96
+ ZPINNED as isPinned,
97
+ ZARCHIVED as isArchived,
98
+ LENGTH(ZTEXT) as contentLength,
99
+ ZTEXT as content
100
+ FROM ZSFNOTE
101
+ WHERE ZTRASHED = 0
102
+ AND ZPERMANENTLYDELETED = 0
103
+ `;
104
+ function mapNote(row) {
105
+ return {
106
+ id: row.id,
107
+ title: row.title ?? "(untitled)",
108
+ content: row.content ?? "",
109
+ createdAt: coreDataToISO(row.createdAt),
110
+ modifiedAt: coreDataToISO(row.modifiedAt),
111
+ isPinned: row.isPinned === 1,
112
+ isArchived: row.isArchived === 1,
113
+ };
114
+ }
115
+ function mapNoteCompact(row) {
116
+ return {
117
+ id: row.id,
118
+ title: row.title ?? "(untitled)",
119
+ createdAt: coreDataToISO(row.createdAt),
120
+ modifiedAt: coreDataToISO(row.modifiedAt),
121
+ isPinned: row.isPinned === 1,
122
+ isArchived: row.isArchived === 1,
123
+ contentLength: row.contentLength ?? 0,
124
+ preview: extractPreview(row.content),
125
+ };
126
+ }
127
+ /**
128
+ * Search notes by query (matches title and content).
129
+ */
130
+ export function searchNotes(query, limit = 20) {
131
+ const stmt = getDbSync().prepare(`
132
+ ${COMPACT_SELECT}
133
+ AND (ZTITLE LIKE ? OR ZTEXT LIKE ?)
134
+ ORDER BY ZMODIFICATIONDATE DESC
135
+ LIMIT ?
136
+ `);
137
+ const pattern = `%${query}%`;
138
+ const rows = stmt.all(pattern, pattern, limit);
139
+ return rows.map(mapNoteCompact);
140
+ }
141
+ /**
142
+ * Get a single note by its unique identifier or title.
143
+ */
144
+ export function getNote(identifier) {
145
+ // Try UUID first
146
+ let stmt = getDbSync().prepare(`${BASE_SELECT} AND ZUNIQUEIDENTIFIER = ?`);
147
+ let row = stmt.get(identifier);
148
+ if (!row) {
149
+ // Fallback to title match (case-insensitive)
150
+ stmt = getDbSync().prepare(`${BASE_SELECT} AND LOWER(ZTITLE) = LOWER(?)`);
151
+ row = stmt.get(identifier);
152
+ }
153
+ return row ? mapNote(row) : null;
154
+ }
155
+ /**
156
+ * Get recently modified notes.
157
+ */
158
+ export function getRecentNotes(limit = 20) {
159
+ const stmt = getDbSync().prepare(`
160
+ ${COMPACT_SELECT}
161
+ ORDER BY ZMODIFICATIONDATE DESC
162
+ LIMIT ?
163
+ `);
164
+ const rows = stmt.all(limit);
165
+ return rows.map(mapNoteCompact);
166
+ }
167
+ /**
168
+ * Get notes by tag name.
169
+ */
170
+ export function getNotesByTag(tag, limit = 50) {
171
+ const stmt = getDbSync().prepare(`
172
+ ${COMPACT_SELECT}
173
+ AND Z_PK IN (
174
+ SELECT Z_5NOTES FROM Z_5TAGS
175
+ WHERE Z_13TAGS IN (
176
+ SELECT Z_PK FROM ZSFNOTETAG WHERE ZTITLE = ?
177
+ )
178
+ )
179
+ ORDER BY ZMODIFICATIONDATE DESC
180
+ LIMIT ?
181
+ `);
182
+ const rows = stmt.all(tag, limit);
183
+ return rows.map(mapNoteCompact);
184
+ }
185
+ /**
186
+ * List all tags with their note counts.
187
+ */
188
+ export function listTags() {
189
+ const stmt = getDbSync().prepare(`
190
+ SELECT
191
+ t.ZTITLE as name,
192
+ COUNT(jt.Z_5NOTES) as noteCount
193
+ FROM ZSFNOTETAG t
194
+ LEFT JOIN Z_5TAGS jt ON jt.Z_13TAGS = t.Z_PK
195
+ LEFT JOIN ZSFNOTE n ON n.Z_PK = jt.Z_5NOTES
196
+ AND n.ZTRASHED = 0
197
+ AND n.ZPERMANENTLYDELETED = 0
198
+ WHERE t.ZTITLE IS NOT NULL
199
+ GROUP BY t.Z_PK
200
+ ORDER BY noteCount DESC
201
+ `);
202
+ return stmt.all();
203
+ }
204
+ /**
205
+ * Get backlinks for a note (other notes that link to it).
206
+ */
207
+ export function getBacklinks(identifier) {
208
+ // First resolve to Z_PK
209
+ const pkStmt = getDbSync().prepare("SELECT Z_PK FROM ZSFNOTE WHERE ZUNIQUEIDENTIFIER = ? AND ZTRASHED = 0 AND ZPERMANENTLYDELETED = 0");
210
+ const pkRow = pkStmt.get(identifier);
211
+ if (!pkRow)
212
+ return [];
213
+ const stmt = getDbSync().prepare(`
214
+ ${COMPACT_SELECT}
215
+ AND Z_PK IN (
216
+ SELECT ZLINKEDBY FROM ZSFNOTEBACKLINK WHERE ZLINKINGTO = ?
217
+ )
218
+ ORDER BY ZMODIFICATIONDATE DESC
219
+ `);
220
+ const rows = stmt.all(pkRow.Z_PK);
221
+ return rows.map(mapNoteCompact);
222
+ }
223
+ // ---------------------------------------------------------------------------
224
+ // Write operations (Bear URL scheme)
225
+ // ---------------------------------------------------------------------------
226
+ function openBearUrl(url) {
227
+ return new Promise((resolve, reject) => {
228
+ execFile("open", [url], (error) => {
229
+ if (error)
230
+ reject(error);
231
+ else
232
+ resolve();
233
+ });
234
+ });
235
+ }
236
+ /**
237
+ * Create a new note in Bear.
238
+ */
239
+ export async function createNote(title, content, tags) {
240
+ const params = new URLSearchParams({
241
+ title,
242
+ text: content,
243
+ open_note: "no",
244
+ new_window: "no",
245
+ });
246
+ if (tags?.length) {
247
+ params.set("tags", tags.join(","));
248
+ }
249
+ await openBearUrl(`bear://x-callback-url/create?${params.toString()}`);
250
+ }
251
+ /**
252
+ * Append content to an existing note.
253
+ */
254
+ export async function appendToNote(identifier, content, options) {
255
+ const params = new URLSearchParams({
256
+ id: identifier,
257
+ text: content,
258
+ mode: "append",
259
+ open_note: "no",
260
+ new_window: "no",
261
+ });
262
+ if (options?.separator) {
263
+ params.set("text", `${options.separator}\n${content}`);
264
+ }
265
+ await openBearUrl(`bear://x-callback-url/add-text?${params.toString()}`);
266
+ }
267
+ /**
268
+ * Open a note in Bear.
269
+ */
270
+ export async function openNote(identifier) {
271
+ const params = new URLSearchParams({ id: identifier });
272
+ await openBearUrl(`bear://x-callback-url/open-note?${params.toString()}`);
273
+ }
package/dist/cli.d.ts ADDED
@@ -0,0 +1,13 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Portable Notes Sync CLI
4
+ *
5
+ * Syncs local notes (Bear, Obsidian, Apple Notes) to the Portable platform
6
+ * so your experts can search and reference them.
7
+ *
8
+ * Usage:
9
+ * npx @portable/notes-sync --token pns_abc123...
10
+ * npx @portable/notes-sync --token pns_abc123... --source bear
11
+ * npx @portable/notes-sync --token pns_abc123... --source obsidian --verbose
12
+ */
13
+ export {};
package/dist/cli.js ADDED
@@ -0,0 +1,163 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Portable Notes Sync CLI
4
+ *
5
+ * Syncs local notes (Bear, Obsidian, Apple Notes) to the Portable platform
6
+ * so your experts can search and reference them.
7
+ *
8
+ * Usage:
9
+ * npx @portable/notes-sync --token pns_abc123...
10
+ * npx @portable/notes-sync --token pns_abc123... --source bear
11
+ * npx @portable/notes-sync --token pns_abc123... --source obsidian --verbose
12
+ */
13
+ import { syncSource } from "./sync.js";
14
+ import { isBearAvailable, initBearDb } from "./bear.js";
15
+ import { isObsidianAvailable, getVaultPath } from "./obsidian.js";
16
+ import { isAppleNotesAvailable } from "./apple-notes.js";
17
+ const DEFAULT_BASE_URL = "https://portable.expert";
18
+ function parseArgs() {
19
+ const args = process.argv.slice(2);
20
+ const parsed = {
21
+ token: "",
22
+ baseUrl: process.env.PORTABLE_BASE_URL ?? DEFAULT_BASE_URL,
23
+ verbose: false,
24
+ };
25
+ for (let i = 0; i < args.length; i++) {
26
+ switch (args[i]) {
27
+ case "--token":
28
+ case "-t":
29
+ parsed.token = args[++i] ?? "";
30
+ break;
31
+ case "--source":
32
+ case "-s":
33
+ parsed.source = args[++i];
34
+ break;
35
+ case "--url":
36
+ parsed.baseUrl = args[++i] ?? DEFAULT_BASE_URL;
37
+ break;
38
+ case "--limit":
39
+ parsed.limit = parseInt(args[++i] ?? "0", 10) || undefined;
40
+ break;
41
+ case "--verbose":
42
+ case "-v":
43
+ parsed.verbose = true;
44
+ break;
45
+ case "--help":
46
+ case "-h":
47
+ printHelp();
48
+ process.exit(0);
49
+ }
50
+ }
51
+ if (!parsed.token) {
52
+ parsed.token = process.env.PORTABLE_SYNC_TOKEN ?? "";
53
+ }
54
+ return parsed;
55
+ }
56
+ function printHelp() {
57
+ console.log(`
58
+ Portable Notes Sync
59
+
60
+ Syncs your local notes to the Portable platform so your experts can
61
+ search and reference them via RAG.
62
+
63
+ Usage:
64
+ portable-notes-sync --token <sync-token>
65
+
66
+ Options:
67
+ --token, -t Sync token from Settings > Connections (required)
68
+ --source, -s Only sync a specific source: bear, obsidian, apple-notes
69
+ --url Platform URL (default: https://portable.expert)
70
+ --limit Max notes to sync per source
71
+ --verbose, -v Show detailed progress
72
+ --help, -h Show this help
73
+
74
+ Environment:
75
+ PORTABLE_SYNC_TOKEN Alternative to --token
76
+ PORTABLE_BASE_URL Alternative to --url
77
+
78
+ Examples:
79
+ portable-notes-sync --token pns_abc123...
80
+ portable-notes-sync -t pns_abc123... --source bear -v
81
+ portable-notes-sync -t pns_abc123... --limit 100
82
+ `);
83
+ }
84
+ async function main() {
85
+ const args = parseArgs();
86
+ if (!args.token) {
87
+ console.error("Error: Sync token is required.");
88
+ console.error(" Get one from Settings > Connections in your Portable dashboard.");
89
+ console.error(" Then run: portable-notes-sync --token pns_...\n");
90
+ process.exit(1);
91
+ }
92
+ if (!args.token.startsWith("pns_")) {
93
+ console.error("Error: Token must start with pns_ (got something else).");
94
+ console.error(" This looks like a different kind of token.");
95
+ console.error(" Get a Notes Sync token from Settings > Connections.\n");
96
+ process.exit(1);
97
+ }
98
+ console.log("Portable Notes Sync");
99
+ console.log("====================\n");
100
+ // Initialize Bear DB (async dynamic import of better-sqlite3)
101
+ await initBearDb();
102
+ const sources = [
103
+ { name: "Bear", key: "bear", available: isBearAvailable() },
104
+ { name: "Obsidian", key: "obsidian", available: isObsidianAvailable(), detail: getVaultPath() ?? undefined },
105
+ { name: "Apple Notes", key: "apple-notes", available: isAppleNotesAvailable() },
106
+ ];
107
+ console.log("Detected note sources:");
108
+ for (const s of sources) {
109
+ const status = s.available ? "found" : "not found";
110
+ const detail = s.detail ? ` (${s.detail})` : "";
111
+ console.log(` ${s.available ? "+" : "-"} ${s.name}: ${status}${detail}`);
112
+ }
113
+ console.log();
114
+ const toSync = args.source
115
+ ? sources.filter((s) => s.key === args.source && s.available)
116
+ : sources.filter((s) => s.available);
117
+ if (toSync.length === 0) {
118
+ if (args.source) {
119
+ console.error(`Error: Source "${args.source}" not found on this machine.`);
120
+ }
121
+ else {
122
+ console.error("No note sources detected on this machine.");
123
+ }
124
+ process.exit(1);
125
+ }
126
+ const results = [];
127
+ for (const s of toSync) {
128
+ console.log(`Syncing ${s.name}...`);
129
+ const result = await syncSource(s.key, args.token, args.baseUrl, {
130
+ limit: args.limit,
131
+ verbose: args.verbose,
132
+ });
133
+ results.push(result);
134
+ if (result.errors.length > 0) {
135
+ for (const err of result.errors) {
136
+ console.error(` Error: ${err}`);
137
+ }
138
+ }
139
+ console.log(` Done: ${result.created} created, ${result.updated} updated, ${result.skipped} unchanged\n`);
140
+ }
141
+ console.log("Summary");
142
+ console.log("-------");
143
+ let totalCreated = 0;
144
+ let totalUpdated = 0;
145
+ let totalSkipped = 0;
146
+ for (const r of results) {
147
+ console.log(` ${r.source}: ${r.total} notes (${r.created} new, ${r.updated} updated, ${r.skipped} unchanged)`);
148
+ totalCreated += r.created;
149
+ totalUpdated += r.updated;
150
+ totalSkipped += r.skipped;
151
+ }
152
+ console.log(`\nTotal: ${totalCreated} created, ${totalUpdated} updated, ${totalSkipped} unchanged`);
153
+ const hasErrors = results.some((r) => r.errors.length > 0);
154
+ if (hasErrors) {
155
+ console.error("\nSome errors occurred during sync. Check the messages above.");
156
+ process.exit(1);
157
+ }
158
+ console.log("\nYour experts can now search your notes.");
159
+ }
160
+ main().catch((error) => {
161
+ console.error("Fatal error:", error);
162
+ process.exit(1);
163
+ });
@@ -0,0 +1,4 @@
1
+ export { isBearAvailable, initBearDb, searchNotes as bearSearch, getNote as bearGetNote, getRecentNotes as bearRecentNotes, listTags as bearListTags } from "./bear.js";
2
+ export { isObsidianAvailable, getVaultPath, searchNotes as obsidianSearch, getNote as obsidianGetNote, getRecentNotes as obsidianRecentNotes, listTags as obsidianListTags } from "./obsidian.js";
3
+ export { isAppleNotesAvailable, searchNotes as appleSearch, getNote as appleGetNote, getRecentNotes as appleRecentNotes, listFolders as appleListFolders } from "./apple-notes.js";
4
+ export { syncSource, type SyncNote, type SyncResult } from "./sync.js";
package/dist/index.js ADDED
@@ -0,0 +1,4 @@
1
+ export { isBearAvailable, initBearDb, searchNotes as bearSearch, getNote as bearGetNote, getRecentNotes as bearRecentNotes, listTags as bearListTags } from "./bear.js";
2
+ export { isObsidianAvailable, getVaultPath, searchNotes as obsidianSearch, getNote as obsidianGetNote, getRecentNotes as obsidianRecentNotes, listTags as obsidianListTags } from "./obsidian.js";
3
+ export { isAppleNotesAvailable, searchNotes as appleSearch, getNote as appleGetNote, getRecentNotes as appleRecentNotes, listFolders as appleListFolders } from "./apple-notes.js";
4
+ export { syncSource } from "./sync.js";
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Obsidian Vault Access
3
+ *
4
+ * Reads markdown files from an Obsidian vault directory.
5
+ * Writes via direct filesystem operations.
6
+ * Vault location is auto-detected or configured via OBSIDIAN_VAULT_PATH.
7
+ */
8
+ export interface ObsidianNote {
9
+ path: string;
10
+ title: string;
11
+ content: string;
12
+ createdAt: string;
13
+ modifiedAt: string;
14
+ tags: string[];
15
+ folder: string;
16
+ }
17
+ export interface ObsidianNoteCompact {
18
+ path: string;
19
+ title: string;
20
+ modifiedAt: string;
21
+ createdAt: string;
22
+ tags: string[];
23
+ folder: string;
24
+ contentLength: number;
25
+ preview: string;
26
+ }
27
+ export declare function isObsidianAvailable(): boolean;
28
+ export declare function getVaultPath(): string | null;
29
+ export declare function searchNotes(query: string, limit?: number): ObsidianNoteCompact[];
30
+ export declare function getNote(identifier: string): ObsidianNote | null;
31
+ export declare function getRecentNotes(limit?: number): ObsidianNoteCompact[];
32
+ export declare function getNotesByTag(tag: string, limit?: number): ObsidianNoteCompact[];
33
+ export declare function listTags(): Array<{
34
+ name: string;
35
+ noteCount: number;
36
+ }>;
37
+ export declare function createNote(title: string, content: string, folder?: string): string;
38
+ export declare function appendToNote(identifier: string, content: string): void;
@@ -0,0 +1,293 @@
1
+ /**
2
+ * Obsidian Vault Access
3
+ *
4
+ * Reads markdown files from an Obsidian vault directory.
5
+ * Writes via direct filesystem operations.
6
+ * Vault location is auto-detected or configured via OBSIDIAN_VAULT_PATH.
7
+ */
8
+ import { existsSync, readdirSync, readFileSync, statSync, writeFileSync, appendFileSync } from "node:fs";
9
+ import { join, relative, basename, extname } from "node:path";
10
+ import { homedir } from "node:os";
11
+ // ---------------------------------------------------------------------------
12
+ // Vault detection
13
+ // ---------------------------------------------------------------------------
14
+ let resolvedVaultPath = null;
15
+ const COMMON_VAULT_LOCATIONS = [
16
+ "Documents",
17
+ "Obsidian",
18
+ "obsidian",
19
+ "Desktop",
20
+ "Notes",
21
+ "Library/Mobile Documents/iCloud~md~obsidian/Documents",
22
+ ];
23
+ function findVaultPath() {
24
+ if (resolvedVaultPath)
25
+ return resolvedVaultPath;
26
+ const envPath = process.env.OBSIDIAN_VAULT_PATH?.trim();
27
+ if (envPath && existsSync(join(envPath, ".obsidian"))) {
28
+ resolvedVaultPath = envPath;
29
+ return resolvedVaultPath;
30
+ }
31
+ const home = homedir();
32
+ for (const loc of COMMON_VAULT_LOCATIONS) {
33
+ const candidate = join(home, loc);
34
+ if (!existsSync(candidate))
35
+ continue;
36
+ if (existsSync(join(candidate, ".obsidian"))) {
37
+ resolvedVaultPath = candidate;
38
+ return resolvedVaultPath;
39
+ }
40
+ // Check one level deeper for vaults inside common dirs
41
+ try {
42
+ const entries = readdirSync(candidate, { withFileTypes: true });
43
+ for (const entry of entries) {
44
+ if (entry.isDirectory() && existsSync(join(candidate, entry.name, ".obsidian"))) {
45
+ resolvedVaultPath = join(candidate, entry.name);
46
+ return resolvedVaultPath;
47
+ }
48
+ }
49
+ }
50
+ catch {
51
+ // Permission denied or similar
52
+ }
53
+ }
54
+ return null;
55
+ }
56
+ export function isObsidianAvailable() {
57
+ if (process.platform !== "darwin" && process.platform !== "linux" && process.platform !== "win32")
58
+ return false;
59
+ return findVaultPath() !== null;
60
+ }
61
+ export function getVaultPath() {
62
+ return findVaultPath();
63
+ }
64
+ // ---------------------------------------------------------------------------
65
+ // Helpers
66
+ // ---------------------------------------------------------------------------
67
+ function extractPreview(content, maxLen = 200) {
68
+ const lines = content.split("\n");
69
+ const start = lines[0]?.startsWith("#") ? 1 : 0;
70
+ const body = lines.slice(start).join("\n").trim();
71
+ return body.length > maxLen ? body.slice(0, maxLen) + "…" : body;
72
+ }
73
+ function parseFrontmatter(content) {
74
+ if (!content.startsWith("---"))
75
+ return { frontmatter: {}, body: content };
76
+ const endIdx = content.indexOf("\n---", 3);
77
+ if (endIdx === -1)
78
+ return { frontmatter: {}, body: content };
79
+ const yamlBlock = content.slice(4, endIdx).trim();
80
+ const body = content.slice(endIdx + 4).trim();
81
+ const frontmatter = {};
82
+ for (const line of yamlBlock.split("\n")) {
83
+ const colonIdx = line.indexOf(":");
84
+ if (colonIdx === -1)
85
+ continue;
86
+ const key = line.slice(0, colonIdx).trim();
87
+ const val = line.slice(colonIdx + 1).trim();
88
+ if (key === "tags") {
89
+ // tags can be [tag1, tag2] or a YAML list
90
+ const cleaned = val.replace(/^\[|\]$/g, "");
91
+ frontmatter[key] = cleaned
92
+ .split(",")
93
+ .map((t) => t.trim().replace(/^#/, ""))
94
+ .filter(Boolean);
95
+ }
96
+ else {
97
+ frontmatter[key] = val;
98
+ }
99
+ }
100
+ return { frontmatter, body };
101
+ }
102
+ function extractInlineTags(content) {
103
+ const tagPattern = /(?:^|\s)#([a-zA-Z][a-zA-Z0-9_/-]*)/g;
104
+ const tags = new Set();
105
+ let match;
106
+ while ((match = tagPattern.exec(content)) !== null) {
107
+ tags.add(match[1]);
108
+ }
109
+ return [...tags];
110
+ }
111
+ function walkMarkdownFiles(dir, vaultRoot) {
112
+ const results = [];
113
+ try {
114
+ const entries = readdirSync(dir, { withFileTypes: true });
115
+ for (const entry of entries) {
116
+ if (entry.name.startsWith("."))
117
+ continue;
118
+ if (entry.name === "node_modules")
119
+ continue;
120
+ const fullPath = join(dir, entry.name);
121
+ if (entry.isDirectory()) {
122
+ results.push(...walkMarkdownFiles(fullPath, vaultRoot));
123
+ }
124
+ else if (extname(entry.name) === ".md") {
125
+ results.push(fullPath);
126
+ }
127
+ }
128
+ }
129
+ catch {
130
+ // Permission denied
131
+ }
132
+ return results;
133
+ }
134
+ // ---------------------------------------------------------------------------
135
+ // Read operations
136
+ // ---------------------------------------------------------------------------
137
+ function readNoteFromPath(filePath, vaultRoot) {
138
+ const content = readFileSync(filePath, "utf-8");
139
+ const stat = statSync(filePath);
140
+ const { frontmatter, body } = parseFrontmatter(content);
141
+ const relPath = relative(vaultRoot, filePath);
142
+ const folder = relPath.includes("/") ? relPath.slice(0, relPath.lastIndexOf("/")) : "";
143
+ const fmTags = Array.isArray(frontmatter.tags) ? frontmatter.tags : [];
144
+ const inlineTags = extractInlineTags(body);
145
+ const allTags = [...new Set([...fmTags, ...inlineTags])];
146
+ return {
147
+ path: relPath,
148
+ title: basename(filePath, ".md"),
149
+ content,
150
+ createdAt: stat.birthtime.toISOString(),
151
+ modifiedAt: stat.mtime.toISOString(),
152
+ tags: allTags,
153
+ folder,
154
+ };
155
+ }
156
+ function toCompact(note) {
157
+ return {
158
+ path: note.path,
159
+ title: note.title,
160
+ modifiedAt: note.modifiedAt,
161
+ createdAt: note.createdAt,
162
+ tags: note.tags,
163
+ folder: note.folder,
164
+ contentLength: note.content.length,
165
+ preview: extractPreview(note.content),
166
+ };
167
+ }
168
+ export function searchNotes(query, limit = 20) {
169
+ const vault = findVaultPath();
170
+ if (!vault)
171
+ return [];
172
+ const files = walkMarkdownFiles(vault, vault);
173
+ const lowerQuery = query.toLowerCase();
174
+ const matches = [];
175
+ for (const file of files) {
176
+ if (matches.length >= limit)
177
+ break;
178
+ try {
179
+ const content = readFileSync(file, "utf-8");
180
+ const title = basename(file, ".md");
181
+ if (title.toLowerCase().includes(lowerQuery) || content.toLowerCase().includes(lowerQuery)) {
182
+ const note = readNoteFromPath(file, vault);
183
+ matches.push(toCompact(note));
184
+ }
185
+ }
186
+ catch {
187
+ // Skip unreadable files
188
+ }
189
+ }
190
+ return matches;
191
+ }
192
+ export function getNote(identifier) {
193
+ const vault = findVaultPath();
194
+ if (!vault)
195
+ return null;
196
+ // Try as direct path first
197
+ let filePath = join(vault, identifier);
198
+ if (!filePath.endsWith(".md"))
199
+ filePath += ".md";
200
+ if (existsSync(filePath)) {
201
+ return readNoteFromPath(filePath, vault);
202
+ }
203
+ // Fallback: search by title
204
+ const files = walkMarkdownFiles(vault, vault);
205
+ const lowerIdent = identifier.toLowerCase();
206
+ for (const file of files) {
207
+ if (basename(file, ".md").toLowerCase() === lowerIdent) {
208
+ return readNoteFromPath(file, vault);
209
+ }
210
+ }
211
+ return null;
212
+ }
213
+ export function getRecentNotes(limit = 20) {
214
+ const vault = findVaultPath();
215
+ if (!vault)
216
+ return [];
217
+ const files = walkMarkdownFiles(vault, vault);
218
+ const withMtime = files.map((f) => {
219
+ try {
220
+ return { path: f, mtime: statSync(f).mtimeMs };
221
+ }
222
+ catch {
223
+ return null;
224
+ }
225
+ }).filter((x) => x !== null);
226
+ withMtime.sort((a, b) => b.mtime - a.mtime);
227
+ return withMtime.slice(0, limit).map((f) => toCompact(readNoteFromPath(f.path, vault)));
228
+ }
229
+ export function getNotesByTag(tag, limit = 50) {
230
+ const vault = findVaultPath();
231
+ if (!vault)
232
+ return [];
233
+ const files = walkMarkdownFiles(vault, vault);
234
+ const results = [];
235
+ const lowerTag = tag.toLowerCase();
236
+ for (const file of files) {
237
+ if (results.length >= limit)
238
+ break;
239
+ try {
240
+ const note = readNoteFromPath(file, vault);
241
+ if (note.tags.some((t) => t.toLowerCase() === lowerTag)) {
242
+ results.push(toCompact(note));
243
+ }
244
+ }
245
+ catch {
246
+ // Skip
247
+ }
248
+ }
249
+ return results;
250
+ }
251
+ export function listTags() {
252
+ const vault = findVaultPath();
253
+ if (!vault)
254
+ return [];
255
+ const files = walkMarkdownFiles(vault, vault);
256
+ const tagCounts = new Map();
257
+ for (const file of files) {
258
+ try {
259
+ const note = readNoteFromPath(file, vault);
260
+ for (const tag of note.tags) {
261
+ tagCounts.set(tag, (tagCounts.get(tag) ?? 0) + 1);
262
+ }
263
+ }
264
+ catch {
265
+ // Skip
266
+ }
267
+ }
268
+ return [...tagCounts.entries()]
269
+ .map(([name, noteCount]) => ({ name, noteCount }))
270
+ .sort((a, b) => b.noteCount - a.noteCount);
271
+ }
272
+ // ---------------------------------------------------------------------------
273
+ // Write operations
274
+ // ---------------------------------------------------------------------------
275
+ export function createNote(title, content, folder) {
276
+ const vault = findVaultPath();
277
+ if (!vault)
278
+ throw new Error("No Obsidian vault found");
279
+ const dir = folder ? join(vault, folder) : vault;
280
+ const filePath = join(dir, `${title}.md`);
281
+ if (existsSync(filePath))
282
+ throw new Error(`Note already exists: ${filePath}`);
283
+ writeFileSync(filePath, content, "utf-8");
284
+ return relative(vault, filePath);
285
+ }
286
+ export function appendToNote(identifier, content) {
287
+ const note = getNote(identifier);
288
+ if (!note)
289
+ throw new Error(`Note not found: ${identifier}`);
290
+ const vault = findVaultPath();
291
+ const filePath = join(vault, note.path);
292
+ appendFileSync(filePath, `\n${content}`, "utf-8");
293
+ }
package/dist/sync.d.ts ADDED
@@ -0,0 +1,31 @@
1
+ /**
2
+ * Notes Sync Engine
3
+ *
4
+ * Reads notes from local sources (Bear, Obsidian, Apple Notes) and
5
+ * pushes them to the Portable platform via POST /api/pep/sync-notes.
6
+ *
7
+ * Used by both the standalone CLI (sync-entry.ts) and auto-sync on
8
+ * MCP server startup.
9
+ */
10
+ export interface SyncNote {
11
+ externalId: string;
12
+ title: string;
13
+ content: string;
14
+ source: "bear" | "obsidian" | "apple-notes";
15
+ tags?: string[];
16
+ folder?: string;
17
+ createdAt?: string;
18
+ modifiedAt?: string;
19
+ }
20
+ export interface SyncResult {
21
+ source: string;
22
+ total: number;
23
+ created: number;
24
+ updated: number;
25
+ skipped: number;
26
+ errors: string[];
27
+ }
28
+ export declare function syncSource(source: "bear" | "obsidian" | "apple-notes", token: string, baseUrl: string, options?: {
29
+ limit?: number;
30
+ verbose?: boolean;
31
+ }): Promise<SyncResult>;
package/dist/sync.js ADDED
@@ -0,0 +1,135 @@
1
+ /**
2
+ * Notes Sync Engine
3
+ *
4
+ * Reads notes from local sources (Bear, Obsidian, Apple Notes) and
5
+ * pushes them to the Portable platform via POST /api/pep/sync-notes.
6
+ *
7
+ * Used by both the standalone CLI (sync-entry.ts) and auto-sync on
8
+ * MCP server startup.
9
+ */
10
+ import { isBearAvailable, initBearDb, searchNotes as bearSearch, getNote as bearGetNote } from "./bear.js";
11
+ import { isObsidianAvailable, getRecentNotes as obsidianRecent, getNote as obsidianGetNote, searchNotes as obsidianSearch } from "./obsidian.js";
12
+ import { isAppleNotesAvailable, getRecentNotes as appleRecent, getNote as appleGetNote } from "./apple-notes.js";
13
+ const BATCH_SIZE = 50;
14
+ export async function syncSource(source, token, baseUrl, options = {}) {
15
+ const { limit, verbose } = options;
16
+ const result = { source, total: 0, created: 0, updated: 0, skipped: 0, errors: [] };
17
+ if (source === "bear")
18
+ await initBearDb();
19
+ let notes;
20
+ try {
21
+ notes = readLocalNotes(source, limit);
22
+ }
23
+ catch (err) {
24
+ const msg = err instanceof Error ? err.message : String(err);
25
+ result.errors.push(`Failed to read ${source} notes: ${msg}`);
26
+ return result;
27
+ }
28
+ result.total = notes.length;
29
+ if (verbose)
30
+ console.log(` Read ${notes.length} notes from ${source}`);
31
+ for (let i = 0; i < notes.length; i += BATCH_SIZE) {
32
+ const batch = notes.slice(i, i + BATCH_SIZE);
33
+ if (verbose)
34
+ console.log(` Syncing batch ${Math.floor(i / BATCH_SIZE) + 1}/${Math.ceil(notes.length / BATCH_SIZE)} (${batch.length} notes)...`);
35
+ try {
36
+ const response = await fetch(`${baseUrl}/api/pep/sync-notes`, {
37
+ method: "POST",
38
+ headers: {
39
+ "Content-Type": "application/json",
40
+ Authorization: `Bearer ${token}`,
41
+ },
42
+ body: JSON.stringify({ notes: batch }),
43
+ });
44
+ if (!response.ok) {
45
+ const errBody = await response.text();
46
+ result.errors.push(`HTTP ${response.status}: ${errBody.slice(0, 200)}`);
47
+ continue;
48
+ }
49
+ const data = (await response.json());
50
+ if (data.summary) {
51
+ result.created += data.summary.created;
52
+ result.updated += data.summary.updated;
53
+ result.skipped += data.summary.skipped;
54
+ }
55
+ }
56
+ catch (err) {
57
+ const msg = err instanceof Error ? err.message : String(err);
58
+ result.errors.push(`Network error: ${msg}`);
59
+ }
60
+ }
61
+ return result;
62
+ }
63
+ function readLocalNotes(source, limit) {
64
+ switch (source) {
65
+ case "bear":
66
+ return readBearNotes(limit);
67
+ case "obsidian":
68
+ return readObsidianNotes(limit);
69
+ case "apple-notes":
70
+ return readAppleNotes(limit);
71
+ }
72
+ }
73
+ function readBearNotes(limit) {
74
+ if (!isBearAvailable())
75
+ return [];
76
+ const compactNotes = bearSearch("", limit ?? 10000);
77
+ const notes = [];
78
+ for (const compact of compactNotes) {
79
+ const full = bearGetNote(compact.id);
80
+ if (!full)
81
+ continue;
82
+ notes.push({
83
+ externalId: full.id,
84
+ title: full.title,
85
+ content: full.content,
86
+ source: "bear",
87
+ createdAt: full.createdAt,
88
+ modifiedAt: full.modifiedAt,
89
+ });
90
+ }
91
+ return notes;
92
+ }
93
+ function readObsidianNotes(limit) {
94
+ if (!isObsidianAvailable())
95
+ return [];
96
+ const compactNotes = limit ? obsidianRecent(limit) : obsidianSearch("", 10000);
97
+ const notes = [];
98
+ for (const compact of compactNotes) {
99
+ const full = obsidianGetNote(compact.path);
100
+ if (!full)
101
+ continue;
102
+ notes.push({
103
+ externalId: compact.path,
104
+ title: full.title,
105
+ content: full.content,
106
+ source: "obsidian",
107
+ tags: full.tags,
108
+ folder: full.folder,
109
+ createdAt: full.createdAt,
110
+ modifiedAt: full.modifiedAt,
111
+ });
112
+ }
113
+ return notes;
114
+ }
115
+ function readAppleNotes(limit) {
116
+ if (!isAppleNotesAvailable())
117
+ return [];
118
+ const compactNotes = appleRecent(limit ?? 500);
119
+ const notes = [];
120
+ for (const compact of compactNotes) {
121
+ const full = appleGetNote(compact.id);
122
+ if (!full)
123
+ continue;
124
+ notes.push({
125
+ externalId: full.id,
126
+ title: full.title,
127
+ content: full.content,
128
+ source: "apple-notes",
129
+ folder: full.folder,
130
+ createdAt: full.createdAt,
131
+ modifiedAt: full.modifiedAt,
132
+ });
133
+ }
134
+ return notes;
135
+ }
package/package.json ADDED
@@ -0,0 +1,47 @@
1
+ {
2
+ "name": "@portablecore/notes-sync",
3
+ "version": "0.1.0",
4
+ "description": "Sync your local notes (Bear, Obsidian, Apple Notes) to the Portable platform",
5
+ "type": "module",
6
+ "bin": {
7
+ "portable-notes-sync": "./dist/cli.js"
8
+ },
9
+ "main": "./dist/index.js",
10
+ "types": "./dist/index.d.ts",
11
+ "exports": {
12
+ ".": {
13
+ "types": "./dist/index.d.ts",
14
+ "default": "./dist/index.js"
15
+ }
16
+ },
17
+ "files": [
18
+ "dist"
19
+ ],
20
+ "scripts": {
21
+ "build": "tsc",
22
+ "dev": "tsc --watch",
23
+ "clean": "rm -rf dist",
24
+ "typecheck": "tsc --noEmit"
25
+ },
26
+ "keywords": [
27
+ "portable",
28
+ "notes",
29
+ "bear",
30
+ "obsidian",
31
+ "apple-notes",
32
+ "sync"
33
+ ],
34
+ "author": "Portable",
35
+ "license": "UNLICENSED",
36
+ "optionalDependencies": {
37
+ "better-sqlite3": "^11.0.0"
38
+ },
39
+ "devDependencies": {
40
+ "typescript": "^5.7.2",
41
+ "@types/node": "^22.0.0",
42
+ "@types/better-sqlite3": "^7.6.0"
43
+ },
44
+ "engines": {
45
+ "node": ">=18"
46
+ }
47
+ }