@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.
- package/dist/apple-notes.d.ts +58 -0
- package/dist/apple-notes.js +195 -0
- package/dist/bear.d.ts +72 -0
- package/dist/bear.js +273 -0
- package/dist/cli.d.ts +13 -0
- package/dist/cli.js +163 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.js +4 -0
- package/dist/obsidian.d.ts +38 -0
- package/dist/obsidian.js +293 -0
- package/dist/sync.d.ts +31 -0
- package/dist/sync.js +135 -0
- package/package.json +47 -0
|
@@ -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
|
+
});
|
package/dist/index.d.ts
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, 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;
|
package/dist/obsidian.js
ADDED
|
@@ -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
|
+
}
|