@meechi-ai/core 1.0.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/LICENSE +624 -0
- package/README.md +59 -0
- package/dist/components/CalendarView.d.ts +3 -0
- package/dist/components/CalendarView.js +72 -0
- package/dist/components/ChatInterface.d.ts +6 -0
- package/dist/components/ChatInterface.js +105 -0
- package/dist/components/FileExplorer.d.ts +9 -0
- package/dist/components/FileExplorer.js +757 -0
- package/dist/components/Icon.d.ts +9 -0
- package/dist/components/Icon.js +44 -0
- package/dist/components/SourceEditor.d.ts +13 -0
- package/dist/components/SourceEditor.js +50 -0
- package/dist/components/ThemeProvider.d.ts +5 -0
- package/dist/components/ThemeProvider.js +105 -0
- package/dist/components/ThemeSwitcher.d.ts +1 -0
- package/dist/components/ThemeSwitcher.js +16 -0
- package/dist/components/voice/VoiceInputArea.d.ts +14 -0
- package/dist/components/voice/VoiceInputArea.js +190 -0
- package/dist/components/voice/VoiceOverlay.d.ts +7 -0
- package/dist/components/voice/VoiceOverlay.js +71 -0
- package/dist/hooks/useMeechi.d.ts +16 -0
- package/dist/hooks/useMeechi.js +461 -0
- package/dist/hooks/useSync.d.ts +8 -0
- package/dist/hooks/useSync.js +87 -0
- package/dist/index.d.ts +14 -0
- package/dist/index.js +22 -0
- package/dist/lib/ai/embeddings.d.ts +15 -0
- package/dist/lib/ai/embeddings.js +128 -0
- package/dist/lib/ai/gpu-lock.d.ts +19 -0
- package/dist/lib/ai/gpu-lock.js +43 -0
- package/dist/lib/ai/llm.worker.d.ts +1 -0
- package/dist/lib/ai/llm.worker.js +7 -0
- package/dist/lib/ai/local-llm.d.ts +30 -0
- package/dist/lib/ai/local-llm.js +211 -0
- package/dist/lib/ai/manager.d.ts +20 -0
- package/dist/lib/ai/manager.js +51 -0
- package/dist/lib/ai/parsing.d.ts +12 -0
- package/dist/lib/ai/parsing.js +56 -0
- package/dist/lib/ai/prompts.d.ts +2 -0
- package/dist/lib/ai/prompts.js +2 -0
- package/dist/lib/ai/providers/gemini.d.ts +6 -0
- package/dist/lib/ai/providers/gemini.js +88 -0
- package/dist/lib/ai/providers/groq.d.ts +6 -0
- package/dist/lib/ai/providers/groq.js +42 -0
- package/dist/lib/ai/registry.d.ts +29 -0
- package/dist/lib/ai/registry.js +52 -0
- package/dist/lib/ai/tools.d.ts +2 -0
- package/dist/lib/ai/tools.js +106 -0
- package/dist/lib/ai/types.d.ts +22 -0
- package/dist/lib/ai/types.js +1 -0
- package/dist/lib/ai/worker.d.ts +1 -0
- package/dist/lib/ai/worker.js +60 -0
- package/dist/lib/audio/input.d.ts +13 -0
- package/dist/lib/audio/input.js +121 -0
- package/dist/lib/audio/stt.d.ts +13 -0
- package/dist/lib/audio/stt.js +119 -0
- package/dist/lib/audio/tts.d.ts +12 -0
- package/dist/lib/audio/tts.js +128 -0
- package/dist/lib/audio/vad.d.ts +18 -0
- package/dist/lib/audio/vad.js +117 -0
- package/dist/lib/colors.d.ts +16 -0
- package/dist/lib/colors.js +67 -0
- package/dist/lib/extensions.d.ts +35 -0
- package/dist/lib/extensions.js +24 -0
- package/dist/lib/hooks/use-voice-loop.d.ts +13 -0
- package/dist/lib/hooks/use-voice-loop.js +313 -0
- package/dist/lib/mcp/McpClient.d.ts +19 -0
- package/dist/lib/mcp/McpClient.js +42 -0
- package/dist/lib/mcp/McpRegistry.d.ts +47 -0
- package/dist/lib/mcp/McpRegistry.js +117 -0
- package/dist/lib/mcp/native/GroqVoiceNative.d.ts +21 -0
- package/dist/lib/mcp/native/GroqVoiceNative.js +29 -0
- package/dist/lib/mcp/native/LocalSyncNative.d.ts +19 -0
- package/dist/lib/mcp/native/LocalSyncNative.js +26 -0
- package/dist/lib/mcp/native/LocalVoiceNative.d.ts +19 -0
- package/dist/lib/mcp/native/LocalVoiceNative.js +27 -0
- package/dist/lib/mcp/native/MeechiNativeCore.d.ts +25 -0
- package/dist/lib/mcp/native/MeechiNativeCore.js +209 -0
- package/dist/lib/mcp/native/index.d.ts +10 -0
- package/dist/lib/mcp/native/index.js +10 -0
- package/dist/lib/mcp/types.d.ts +35 -0
- package/dist/lib/mcp/types.js +1 -0
- package/dist/lib/pdf.d.ts +10 -0
- package/dist/lib/pdf.js +142 -0
- package/dist/lib/settings.d.ts +48 -0
- package/dist/lib/settings.js +87 -0
- package/dist/lib/storage/db.d.ts +57 -0
- package/dist/lib/storage/db.js +45 -0
- package/dist/lib/storage/local.d.ts +28 -0
- package/dist/lib/storage/local.js +534 -0
- package/dist/lib/storage/migrate.d.ts +3 -0
- package/dist/lib/storage/migrate.js +122 -0
- package/dist/lib/storage/types.d.ts +66 -0
- package/dist/lib/storage/types.js +1 -0
- package/dist/lib/sync/client-drive.d.ts +9 -0
- package/dist/lib/sync/client-drive.js +69 -0
- package/dist/lib/sync/engine.d.ts +18 -0
- package/dist/lib/sync/engine.js +517 -0
- package/dist/lib/sync/google-drive.d.ts +52 -0
- package/dist/lib/sync/google-drive.js +183 -0
- package/dist/lib/sync/merge.d.ts +1 -0
- package/dist/lib/sync/merge.js +68 -0
- package/dist/lib/yjs/YjsProvider.d.ts +11 -0
- package/dist/lib/yjs/YjsProvider.js +33 -0
- package/dist/lib/yjs/graph.d.ts +11 -0
- package/dist/lib/yjs/graph.js +7 -0
- package/dist/lib/yjs/hooks.d.ts +7 -0
- package/dist/lib/yjs/hooks.js +37 -0
- package/dist/lib/yjs/store.d.ts +4 -0
- package/dist/lib/yjs/store.js +19 -0
- package/dist/lib/yjs/syncGraph.d.ts +1 -0
- package/dist/lib/yjs/syncGraph.js +38 -0
- package/dist/providers/theme-provider.d.ts +3 -0
- package/dist/providers/theme-provider.js +18 -0
- package/dist/tsconfig.lib.tsbuildinfo +1 -0
- package/package.json +69 -0
|
@@ -0,0 +1,517 @@
|
|
|
1
|
+
import { db } from '../storage/db';
|
|
2
|
+
import { extractTextFromPdf } from '../pdf';
|
|
3
|
+
const SYNC_TOKEN_KEY = 'drive_sync_token';
|
|
4
|
+
// Changing root folder name to break legacy links and force fresh start
|
|
5
|
+
const ROOT_FOLDER_NAME = 'Meechi-Core';
|
|
6
|
+
export class SyncEngine {
|
|
7
|
+
constructor(drive, storage) {
|
|
8
|
+
this.syncing = false;
|
|
9
|
+
this.drive = drive;
|
|
10
|
+
this.storage = storage;
|
|
11
|
+
}
|
|
12
|
+
async sync(onProgress) {
|
|
13
|
+
if (this.syncing) {
|
|
14
|
+
console.log("Sync already in progress");
|
|
15
|
+
return;
|
|
16
|
+
}
|
|
17
|
+
this.syncing = true;
|
|
18
|
+
onProgress === null || onProgress === void 0 ? void 0 : onProgress("Starting Sync...");
|
|
19
|
+
console.log("Starting Sync...");
|
|
20
|
+
try {
|
|
21
|
+
// Attempt to pull changes first
|
|
22
|
+
try {
|
|
23
|
+
await this.syncDown(onProgress);
|
|
24
|
+
}
|
|
25
|
+
catch (downError) {
|
|
26
|
+
console.error("SyncDown failed (continuing to SyncUp):", downError);
|
|
27
|
+
onProgress === null || onProgress === void 0 ? void 0 : onProgress("Pull failed. Taking only local changes...");
|
|
28
|
+
}
|
|
29
|
+
// Always try to push local changes afterwards
|
|
30
|
+
await this.syncUp(onProgress);
|
|
31
|
+
onProgress === null || onProgress === void 0 ? void 0 : onProgress("Done");
|
|
32
|
+
console.log("Sync Completed.");
|
|
33
|
+
}
|
|
34
|
+
catch (error) {
|
|
35
|
+
console.error("Sync failed:", error);
|
|
36
|
+
throw error; // Propagate to caller
|
|
37
|
+
}
|
|
38
|
+
finally {
|
|
39
|
+
this.syncing = false;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
/* -------------------------------------------------------------------------- */
|
|
43
|
+
/* SYNC DOWN */
|
|
44
|
+
/* -------------------------------------------------------------------------- */
|
|
45
|
+
async syncDown(onProgress) {
|
|
46
|
+
onProgress === null || onProgress === void 0 ? void 0 : onProgress("Checking Remote Changes...");
|
|
47
|
+
// 1. Get saved sync token
|
|
48
|
+
onProgress === null || onProgress === void 0 ? void 0 : onProgress("DB: Reading Token...");
|
|
49
|
+
const tokenRecord = await db.settings.get(SYNC_TOKEN_KEY);
|
|
50
|
+
let pageToken = tokenRecord === null || tokenRecord === void 0 ? void 0 : tokenRecord.value;
|
|
51
|
+
onProgress === null || onProgress === void 0 ? void 0 : onProgress(`DB: Token = ${pageToken ? 'Found: ' + pageToken : 'Null'}`);
|
|
52
|
+
// 2. If no token, perform full initial sync (or listFiles)
|
|
53
|
+
if (!pageToken) {
|
|
54
|
+
onProgress === null || onProgress === void 0 ? void 0 : onProgress("Performing Initial Pull...");
|
|
55
|
+
console.log("No sync token found. Performing initial list...");
|
|
56
|
+
await this.performInitialPull(onProgress);
|
|
57
|
+
// Get a token for next time
|
|
58
|
+
pageToken = await this.drive.getStartPageToken();
|
|
59
|
+
await db.settings.put({ key: SYNC_TOKEN_KEY, value: pageToken });
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
// 3. List changes since token
|
|
63
|
+
let currentToken = pageToken;
|
|
64
|
+
let pagesCount = 0;
|
|
65
|
+
do {
|
|
66
|
+
pagesCount++;
|
|
67
|
+
const response = await this.drive.listChanges(currentToken);
|
|
68
|
+
const changes = response.changes || [];
|
|
69
|
+
console.log(`[SyncDown] Page ${pagesCount}: Received ${changes.length} changes.`);
|
|
70
|
+
for (const change of changes) {
|
|
71
|
+
if (change.removed) {
|
|
72
|
+
await this.handleRemoteDelete(change.fileId);
|
|
73
|
+
}
|
|
74
|
+
else if (change.file) {
|
|
75
|
+
await this.handleRemoteChange(change.file, onProgress);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
if (response.nextPageToken) {
|
|
79
|
+
currentToken = response.nextPageToken;
|
|
80
|
+
}
|
|
81
|
+
else if (response.newStartPageToken) {
|
|
82
|
+
// We are DONE. Save the new token for the next sync session.
|
|
83
|
+
console.log(`[SyncDown] Finished. New start token: ${response.newStartPageToken}`);
|
|
84
|
+
await db.settings.put({ key: SYNC_TOKEN_KEY, value: response.newStartPageToken });
|
|
85
|
+
break;
|
|
86
|
+
}
|
|
87
|
+
else {
|
|
88
|
+
console.warn("[SyncDown] No nextPageToken or newStartPageToken returned. Breaking.");
|
|
89
|
+
break;
|
|
90
|
+
}
|
|
91
|
+
// Absolute safety break
|
|
92
|
+
if (pagesCount > 100)
|
|
93
|
+
break;
|
|
94
|
+
} while (true);
|
|
95
|
+
}
|
|
96
|
+
async performInitialPull(onProgress) {
|
|
97
|
+
// Find our root folder ID first
|
|
98
|
+
onProgress === null || onProgress === void 0 ? void 0 : onProgress("Locating Root Folder...");
|
|
99
|
+
const rootId = await this.getRootFolderId();
|
|
100
|
+
onProgress === null || onProgress === void 0 ? void 0 : onProgress("Rebuilding local database from Cloud...");
|
|
101
|
+
console.log("Starting recursive initial pull from root:", rootId);
|
|
102
|
+
const seenRemoteIds = new Set();
|
|
103
|
+
await this.recursivePull(rootId, seenRemoteIds, onProgress);
|
|
104
|
+
// Clean up: Any local file that has a remoteId but WAS NOT seen in this pass
|
|
105
|
+
// should be removed (as it no longer exists on Drive or moved out of scope).
|
|
106
|
+
onProgress === null || onProgress === void 0 ? void 0 : onProgress("Cleaning ghost files...");
|
|
107
|
+
const localFiles = await db.files.toArray();
|
|
108
|
+
let deletedCount = 0;
|
|
109
|
+
for (const local of localFiles) {
|
|
110
|
+
if (local.remoteId && !seenRemoteIds.has(local.remoteId) && !local.dirty) {
|
|
111
|
+
console.log(`[SyncDown] Cleaning orphaned/deleted file: ${local.path}`);
|
|
112
|
+
await db.files.delete(local.path);
|
|
113
|
+
deletedCount++;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
console.log(`[SyncDown] Initial pull complete. Cleaned ${deletedCount} ghost files.`);
|
|
117
|
+
}
|
|
118
|
+
async recursivePull(folderId, seenRemoteIds, onProgress) {
|
|
119
|
+
const files = await this.drive.listFiles(`'${folderId}' in parents and trashed = false`);
|
|
120
|
+
console.log(`[SyncDown] Recursive pull for folder ${folderId}: found ${files.length} children.`);
|
|
121
|
+
for (const file of files) {
|
|
122
|
+
seenRemoteIds.add(file.id);
|
|
123
|
+
await this.handleRemoteChange(file, onProgress);
|
|
124
|
+
if (file.mimeType === 'application/vnd.google-apps.folder') {
|
|
125
|
+
await this.recursivePull(file.id, seenRemoteIds, onProgress);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
async handleRemoteDelete(fileId) {
|
|
130
|
+
// Find local file by remoteId
|
|
131
|
+
const localFile = await db.files.where('remoteId').equals(fileId).first();
|
|
132
|
+
if (localFile) {
|
|
133
|
+
await db.files.delete(localFile.path);
|
|
134
|
+
console.log(`Deleted local file ${localFile.path} (remote delete)`);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
async handleRemoteChange(driveFile, onProgress) {
|
|
138
|
+
var _a, _b;
|
|
139
|
+
// driveFile has id, name, mimeType, parents, modifiedTime, appProperties
|
|
140
|
+
const isFolder = driveFile.mimeType === 'application/vnd.google-apps.folder';
|
|
141
|
+
// 1. Determine Local Path & VERIFY SCOPE
|
|
142
|
+
const parentId = (_a = driveFile.parents) === null || _a === void 0 ? void 0 : _a[0];
|
|
143
|
+
let parentPath = '';
|
|
144
|
+
const rootId = await this.getRootFolderId();
|
|
145
|
+
if (parentId) {
|
|
146
|
+
if (parentId === rootId) {
|
|
147
|
+
// Direct child of Meechi Journal -> OK
|
|
148
|
+
parentPath = ''; // Root relative
|
|
149
|
+
}
|
|
150
|
+
else {
|
|
151
|
+
// Child of subfolder -> Check if we know the parent
|
|
152
|
+
const parentRecord = await db.files.where('remoteId').equals(parentId).first();
|
|
153
|
+
if (parentRecord) {
|
|
154
|
+
parentPath = parentRecord.path;
|
|
155
|
+
}
|
|
156
|
+
else {
|
|
157
|
+
// UNKNOWN PARENT -> IGNORE
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
else {
|
|
163
|
+
// No parent? Ignore.
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
const path = parentPath ? `${parentPath}/${driveFile.name}` : driveFile.name;
|
|
167
|
+
// 2. Download Content (if file)
|
|
168
|
+
let content = '';
|
|
169
|
+
if (!isFolder) {
|
|
170
|
+
const isText = driveFile.mimeType.startsWith('text/') ||
|
|
171
|
+
driveFile.mimeType === 'application/json' ||
|
|
172
|
+
driveFile.mimeType === 'application/javascript';
|
|
173
|
+
const isPdf = driveFile.mimeType === 'application/pdf';
|
|
174
|
+
if (isText) {
|
|
175
|
+
onProgress === null || onProgress === void 0 ? void 0 : onProgress(`Downloading text ${path}...`);
|
|
176
|
+
try {
|
|
177
|
+
content = await this.drive.downloadFile(driveFile.id);
|
|
178
|
+
}
|
|
179
|
+
catch (e) {
|
|
180
|
+
console.error(`Failed to download ${driveFile.name}`, e);
|
|
181
|
+
onProgress === null || onProgress === void 0 ? void 0 : onProgress(`Error downloading ${driveFile.name}`);
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
else if (isPdf) {
|
|
186
|
+
// For PDFs, we DON'T put content in the PDF record.
|
|
187
|
+
// We create a "Shadow Source" file: [filename].source.md
|
|
188
|
+
onProgress === null || onProgress === void 0 ? void 0 : onProgress(`Extracting PDF Source ${path}...`);
|
|
189
|
+
try {
|
|
190
|
+
const binary = await this.drive.downloadBinary(driveFile.id);
|
|
191
|
+
// Clone binary for extraction to avoid detachment issues if PDF.js transfers it
|
|
192
|
+
const extractedText = await extractTextFromPdf(binary.slice(0));
|
|
193
|
+
// Create/Update the Shadow Source File record
|
|
194
|
+
const sourcePath = `${path}.source.md`; // e.g. misc/paper.pdf.source.md
|
|
195
|
+
// Check if source exists to preserve any manual summary edits?
|
|
196
|
+
// For now, we overwrite the text body but maybe keep a header?
|
|
197
|
+
// Let's just overwrite for now to ensure accuracy.
|
|
198
|
+
const sourceContent = `## Source: ${driveFile.name}\n\n${extractedText}`;
|
|
199
|
+
await db.files.put({
|
|
200
|
+
path: sourcePath,
|
|
201
|
+
content: sourceContent,
|
|
202
|
+
type: 'file',
|
|
203
|
+
updatedAt: Date.now(),
|
|
204
|
+
dirty: 1, // Mark as dirty so it syncs back up to Cloud!
|
|
205
|
+
deleted: 0,
|
|
206
|
+
tags: [],
|
|
207
|
+
metadata: { isSource: true }
|
|
208
|
+
});
|
|
209
|
+
// Trigger semantic indexing for the newly created source
|
|
210
|
+
this.storage.indexFile(sourcePath, sourceContent);
|
|
211
|
+
// Update the actual PDF record with BINARY content (Local-First)
|
|
212
|
+
const existingPdf = await db.files.get(path);
|
|
213
|
+
await db.files.put({
|
|
214
|
+
path: path,
|
|
215
|
+
content: binary,
|
|
216
|
+
updatedAt: new Date(driveFile.modifiedTime || Date.now()).getTime(),
|
|
217
|
+
type: 'file',
|
|
218
|
+
remoteId: driveFile.id,
|
|
219
|
+
dirty: 0,
|
|
220
|
+
deleted: 0,
|
|
221
|
+
tags: (existingPdf === null || existingPdf === void 0 ? void 0 : existingPdf.tags) || [],
|
|
222
|
+
metadata: (existingPdf === null || existingPdf === void 0 ? void 0 : existingPdf.metadata) || {}
|
|
223
|
+
});
|
|
224
|
+
}
|
|
225
|
+
catch (e) {
|
|
226
|
+
console.error(`Failed to extra PDF ${driveFile.name}`, e);
|
|
227
|
+
onProgress === null || onProgress === void 0 ? void 0 : onProgress(`Error indexing PDF ${driveFile.name}`);
|
|
228
|
+
}
|
|
229
|
+
return; // CRITICAL: Return here to avoid overwriting the PDF record below with empty 'content' string
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
else {
|
|
233
|
+
onProgress === null || onProgress === void 0 ? void 0 : onProgress(`Syncing folder ${path}...`);
|
|
234
|
+
}
|
|
235
|
+
// 3. Determine Local Conflict / Movement
|
|
236
|
+
const localFile = await db.files.where('remoteId').equals(driveFile.id).first();
|
|
237
|
+
// 3b. Extract Metadata from AppProperties
|
|
238
|
+
let remoteTags = [];
|
|
239
|
+
let remoteMetadata = {};
|
|
240
|
+
if ((_b = driveFile.appProperties) === null || _b === void 0 ? void 0 : _b.meechi_meta) {
|
|
241
|
+
try {
|
|
242
|
+
const parsed = JSON.parse(driveFile.appProperties.meechi_meta);
|
|
243
|
+
remoteTags = parsed.tags || [];
|
|
244
|
+
remoteMetadata = parsed.metadata || {};
|
|
245
|
+
}
|
|
246
|
+
catch (e) {
|
|
247
|
+
console.warn(`[SyncDown] Failed to parse meechi_meta for ${path}`, e);
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
let finalContent = content;
|
|
251
|
+
let finalDirty = 0;
|
|
252
|
+
let finalUpdatedAt = new Date(driveFile.modifiedTime).getTime();
|
|
253
|
+
let finalTags = remoteTags;
|
|
254
|
+
let finalMetadata = remoteMetadata;
|
|
255
|
+
if (localFile) {
|
|
256
|
+
// Check if content matches (checksum) to avoid spurious writes
|
|
257
|
+
// Only verify checksum for STRING content. Binary diffing is expensive/complex.
|
|
258
|
+
if (localFile.remoteId === driveFile.id && typeof content === 'string' && typeof localFile.content === 'string') {
|
|
259
|
+
// Google drive doesn't provide MD5 for all files, but we can assume if timestamp differs we update anyway.
|
|
260
|
+
// For now, we rely on modifiedTime for non-dirty files.
|
|
261
|
+
// If remote modifiedTime is older or same as local, and local is not dirty, we can skip.
|
|
262
|
+
if (new Date(driveFile.modifiedTime).getTime() <= localFile.updatedAt && !localFile.dirty) {
|
|
263
|
+
// Content is up-to-date or local is newer and not dirty, no need to update content.
|
|
264
|
+
// We still proceed to check for path changes below.
|
|
265
|
+
finalContent = localFile.content; // Keep existing local content
|
|
266
|
+
finalUpdatedAt = localFile.updatedAt; // Keep existing local updated time
|
|
267
|
+
finalTags = localFile.tags || [];
|
|
268
|
+
finalMetadata = localFile.metadata || {};
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
// Keep local changes if dirty
|
|
272
|
+
if (localFile.dirty) {
|
|
273
|
+
finalContent = localFile.content;
|
|
274
|
+
finalDirty = 1;
|
|
275
|
+
finalUpdatedAt = localFile.updatedAt;
|
|
276
|
+
finalTags = localFile.tags || [];
|
|
277
|
+
finalMetadata = localFile.metadata || {};
|
|
278
|
+
}
|
|
279
|
+
// If path changed, remove old record
|
|
280
|
+
if (localFile.path !== path) {
|
|
281
|
+
console.log(`[SyncDown] Remote move detected: ${localFile.path} -> ${path}`);
|
|
282
|
+
// If it's a folder, recursively move children too (safety)
|
|
283
|
+
if (localFile.type === 'folder') {
|
|
284
|
+
const oldPrefix = localFile.path + '/';
|
|
285
|
+
const newPrefix = path + '/';
|
|
286
|
+
const children = await db.files.where('path').startsWith(oldPrefix).toArray();
|
|
287
|
+
for (const child of children) {
|
|
288
|
+
await db.files.delete(child.path);
|
|
289
|
+
await db.files.put(Object.assign(Object.assign({}, child), { path: child.path.replace(oldPrefix, newPrefix) }));
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
// When "moving" via delete+create, we must rely on 'localFile' variable to carry over metadata
|
|
293
|
+
// But wait, the final DB put below handles the PRIMARY file.
|
|
294
|
+
// This block handles CHILDREN.
|
|
295
|
+
await db.files.delete(localFile.path);
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
// 3. Update/Insert DB
|
|
299
|
+
// Preserve existing tags/metadata if updating
|
|
300
|
+
const existingRecord = await db.files.get(path);
|
|
301
|
+
await db.files.put({
|
|
302
|
+
path,
|
|
303
|
+
remoteId: driveFile.id,
|
|
304
|
+
type: isFolder ? 'folder' : 'file',
|
|
305
|
+
content: finalContent,
|
|
306
|
+
updatedAt: finalUpdatedAt,
|
|
307
|
+
dirty: finalDirty,
|
|
308
|
+
tags: finalTags,
|
|
309
|
+
metadata: finalMetadata
|
|
310
|
+
});
|
|
311
|
+
// Trigger semantic indexing for incoming files
|
|
312
|
+
if (typeof finalContent === 'string') {
|
|
313
|
+
this.storage.indexFile(path, finalContent);
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
/* -------------------------------------------------------------------------- */
|
|
317
|
+
/* SYNC UP */
|
|
318
|
+
/* -------------------------------------------------------------------------- */
|
|
319
|
+
async syncUp(onProgress) {
|
|
320
|
+
var _a, _b;
|
|
321
|
+
onProgress === null || onProgress === void 0 ? void 0 : onProgress("Checking Local Changes...");
|
|
322
|
+
// 1. Get all dirty files
|
|
323
|
+
const dirtyFiles = await db.files.where('dirty').equals(1).toArray();
|
|
324
|
+
console.log(`[SyncUp] Found ${dirtyFiles.length} dirty files:`, dirtyFiles.map(f => f.path));
|
|
325
|
+
// Sort by path depth so parents/folders processed before children
|
|
326
|
+
dirtyFiles.sort((a, b) => a.path.split('/').length - b.path.split('/').length);
|
|
327
|
+
if (dirtyFiles.length > 0) {
|
|
328
|
+
onProgress === null || onProgress === void 0 ? void 0 : onProgress(`Uploading ${dirtyFiles.length} files...`);
|
|
329
|
+
}
|
|
330
|
+
for (const file of dirtyFiles) {
|
|
331
|
+
try {
|
|
332
|
+
// HANDLE DELETE
|
|
333
|
+
if (file.deleted) {
|
|
334
|
+
if (file.remoteId) {
|
|
335
|
+
onProgress === null || onProgress === void 0 ? void 0 : onProgress(`Deleting ${this.getName(file.path)}...`);
|
|
336
|
+
try {
|
|
337
|
+
await this.drive.deleteFile(file.remoteId);
|
|
338
|
+
console.log(`Deleted remote file ${file.path}`);
|
|
339
|
+
}
|
|
340
|
+
catch (e) {
|
|
341
|
+
// Check if 403 Forbidden (App created vs User created file scope)
|
|
342
|
+
const msg = e.toString() + (e.message || "");
|
|
343
|
+
if (msg.includes("403") || msg.includes("not granted")) {
|
|
344
|
+
console.warn(`Ignored 403 Forbidden on delete for ${file.path} (likely user-owned). Removing local record anyway.`);
|
|
345
|
+
}
|
|
346
|
+
else {
|
|
347
|
+
throw e; // Retry later
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
// Hard delete local record after sync (or if ignored)
|
|
352
|
+
await db.files.delete(file.path);
|
|
353
|
+
continue;
|
|
354
|
+
}
|
|
355
|
+
if (file.remoteId) {
|
|
356
|
+
// UPDATE EXISTING
|
|
357
|
+
onProgress === null || onProgress === void 0 ? void 0 : onProgress(`Updating ${this.getName(file.path)}...`);
|
|
358
|
+
// 1. Update Content (if file)
|
|
359
|
+
if (file.type === 'file') {
|
|
360
|
+
await this.drive.updateFile(file.remoteId, file.content);
|
|
361
|
+
}
|
|
362
|
+
// 2. Update Metadata (Rename / Move)
|
|
363
|
+
// We need to check if name or parent changed.
|
|
364
|
+
const name = this.getName(file.path);
|
|
365
|
+
const parentId = await this.resolveParentId(file.path);
|
|
366
|
+
try {
|
|
367
|
+
const remoteFile = await this.drive.getFileMetadata(file.remoteId);
|
|
368
|
+
const updates = {};
|
|
369
|
+
// Check Name
|
|
370
|
+
if (remoteFile.name !== name) {
|
|
371
|
+
updates.name = name;
|
|
372
|
+
}
|
|
373
|
+
// Check Metadata Property
|
|
374
|
+
const metaJson = JSON.stringify({ tags: file.tags || [], metadata: file.metadata || {} });
|
|
375
|
+
if (((_a = remoteFile.appProperties) === null || _a === void 0 ? void 0 : _a.meechi_meta) !== metaJson) {
|
|
376
|
+
updates.appProperties = { meechi_meta: metaJson };
|
|
377
|
+
}
|
|
378
|
+
// Check Parent (Move)
|
|
379
|
+
const currentRemoteParent = (_b = remoteFile.parents) === null || _b === void 0 ? void 0 : _b[0];
|
|
380
|
+
if (parentId && currentRemoteParent !== parentId) {
|
|
381
|
+
updates.addParents = [parentId];
|
|
382
|
+
if (remoteFile.parents && remoteFile.parents.length > 0) {
|
|
383
|
+
updates.removeParents = remoteFile.parents;
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
if (Object.keys(updates).length > 0) {
|
|
387
|
+
await this.drive.updateMetadata(file.remoteId, updates);
|
|
388
|
+
console.log(`Updated metadata for ${file.path}`, updates);
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
catch (e) {
|
|
392
|
+
console.warn(`Metadata check failed for ${file.path}`, e);
|
|
393
|
+
}
|
|
394
|
+
// Mark clean
|
|
395
|
+
await db.files.update(file.path, { dirty: 0, updatedAt: Date.now() });
|
|
396
|
+
}
|
|
397
|
+
else {
|
|
398
|
+
// CREATE NEW
|
|
399
|
+
onProgress === null || onProgress === void 0 ? void 0 : onProgress(`Creating ${this.getName(file.path)}...`);
|
|
400
|
+
const parentId = await this.resolveParentId(file.path);
|
|
401
|
+
const name = this.getName(file.path);
|
|
402
|
+
let res;
|
|
403
|
+
if (file.type === 'folder') {
|
|
404
|
+
res = await this.drive.createFolder(name, parentId);
|
|
405
|
+
}
|
|
406
|
+
else {
|
|
407
|
+
const appProperties = {
|
|
408
|
+
meechi_meta: JSON.stringify({ tags: file.tags || [], metadata: file.metadata || {} })
|
|
409
|
+
};
|
|
410
|
+
res = await this.drive.createFile(name, parentId || null, file.content, appProperties);
|
|
411
|
+
}
|
|
412
|
+
// Update local with new Remote ID
|
|
413
|
+
await db.files.update(file.path, {
|
|
414
|
+
remoteId: res.id,
|
|
415
|
+
dirty: 0,
|
|
416
|
+
updatedAt: Date.now()
|
|
417
|
+
});
|
|
418
|
+
console.log(`Created remote file ${file.path}`);
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
catch (e) {
|
|
422
|
+
console.error(`Failed to sync up ${file.path}`, e);
|
|
423
|
+
onProgress === null || onProgress === void 0 ? void 0 : onProgress(`Error syncing ${this.getName(file.path)}`);
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
getName(path) {
|
|
428
|
+
return path.split('/').pop() || path;
|
|
429
|
+
}
|
|
430
|
+
async getRootFolderId() {
|
|
431
|
+
// Check cache with NEW key to force refresh
|
|
432
|
+
const cached = await db.settings.get('drive_root_id_writable');
|
|
433
|
+
if (cached)
|
|
434
|
+
return cached.value;
|
|
435
|
+
// Find remote (name = ROOT_FOLDER_NAME)
|
|
436
|
+
// Issue: 'drive.readonly' scope lets us see folders we can't write to.
|
|
437
|
+
// We must ensure we pick a folder we can write to.
|
|
438
|
+
const files = await this.drive.listFiles(`mimeType = 'application/vnd.google-apps.folder' and name = '${ROOT_FOLDER_NAME}' and trashed = false`);
|
|
439
|
+
let rootId;
|
|
440
|
+
const writableFolder = files.find(f => { var _a; return (_a = f.capabilities) === null || _a === void 0 ? void 0 : _a.canAddChildren; }); // Ensure we have write access
|
|
441
|
+
if (writableFolder) {
|
|
442
|
+
rootId = writableFolder.id; // Use existing writable folder
|
|
443
|
+
console.log(`Found existing writable root: ${rootId}`);
|
|
444
|
+
}
|
|
445
|
+
else {
|
|
446
|
+
// Create new
|
|
447
|
+
try {
|
|
448
|
+
const folder = await this.drive.createFolder(ROOT_FOLDER_NAME);
|
|
449
|
+
rootId = folder.id;
|
|
450
|
+
console.log(`Created new root: ${rootId}`);
|
|
451
|
+
}
|
|
452
|
+
catch (e) {
|
|
453
|
+
console.error("Failed to create Meechi Journal root", e);
|
|
454
|
+
// Fallback? Rethrow?
|
|
455
|
+
throw e;
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
// Cache it
|
|
459
|
+
await db.settings.put({ key: 'drive_root_id_writable', value: rootId });
|
|
460
|
+
return rootId;
|
|
461
|
+
}
|
|
462
|
+
async resolveParentId(path) {
|
|
463
|
+
const parts = path.split('/');
|
|
464
|
+
// If it's a top-level file (e.g. "misc", "history")
|
|
465
|
+
// The parent is the Root.
|
|
466
|
+
if (parts.length <= 1) {
|
|
467
|
+
return await this.getRootFolderId();
|
|
468
|
+
}
|
|
469
|
+
// Parent Path (e.g. "misc/Research")
|
|
470
|
+
const parentPath = parts.slice(0, -1).join('/');
|
|
471
|
+
const parentName = parts[parts.length - 2];
|
|
472
|
+
// 1. Check local DB for Parent
|
|
473
|
+
let parent = await db.files.get(parentPath);
|
|
474
|
+
// 2. If parent has remoteId, we are good.
|
|
475
|
+
if (parent === null || parent === void 0 ? void 0 : parent.remoteId)
|
|
476
|
+
return parent.remoteId;
|
|
477
|
+
console.log(`Parent ${parentPath} has no remoteId. Resolving...`);
|
|
478
|
+
// 3. Recursive Step: Get Grandparent ID
|
|
479
|
+
// (This ensures we build the tree from top down)
|
|
480
|
+
const grandParentId = await this.resolveParentId(parentPath);
|
|
481
|
+
// 4. Create this folder (if not clean)
|
|
482
|
+
// If we found it locally but no remoteId, create it.
|
|
483
|
+
// If we didn't find it locally, create it locally & remotely.
|
|
484
|
+
// Check if it already exists on remote (name + parent)?
|
|
485
|
+
// To avoid duplicates if we just lost the link.
|
|
486
|
+
// For simplicity/safety, let's list children of grandParentId with this name.
|
|
487
|
+
const existing = await this.drive.listFiles(`'${grandParentId}' in parents and name = '${parentName}' and trashed = false and mimeType = 'application/vnd.google-apps.folder'`);
|
|
488
|
+
let folderId;
|
|
489
|
+
if (existing.length > 0) {
|
|
490
|
+
folderId = existing[0].id;
|
|
491
|
+
console.log(`Found existing remote folder for ${parentPath}: ${folderId}`);
|
|
492
|
+
}
|
|
493
|
+
else {
|
|
494
|
+
console.log(`Creating new remote folder for ${parentPath}...`);
|
|
495
|
+
const created = await this.drive.createFolder(parentName, grandParentId);
|
|
496
|
+
folderId = created.id;
|
|
497
|
+
}
|
|
498
|
+
// 5. Update/Save Local Record
|
|
499
|
+
if (!parent) {
|
|
500
|
+
await db.files.put({
|
|
501
|
+
path: parentPath,
|
|
502
|
+
remoteId: folderId,
|
|
503
|
+
type: 'folder',
|
|
504
|
+
content: '',
|
|
505
|
+
updatedAt: Date.now(),
|
|
506
|
+
dirty: 0 // We just synced it effectively
|
|
507
|
+
});
|
|
508
|
+
}
|
|
509
|
+
else {
|
|
510
|
+
await db.files.update(parentPath, {
|
|
511
|
+
remoteId: folderId,
|
|
512
|
+
dirty: 0
|
|
513
|
+
});
|
|
514
|
+
}
|
|
515
|
+
return folderId;
|
|
516
|
+
}
|
|
517
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
export declare class GoogleDriveClient {
|
|
2
|
+
private accessToken;
|
|
3
|
+
constructor(accessToken: string);
|
|
4
|
+
private request;
|
|
5
|
+
/**
|
|
6
|
+
* Search for files.
|
|
7
|
+
* query: e.g. "name = 'foo.txt' and trashed = false"
|
|
8
|
+
*/
|
|
9
|
+
listFiles(query: string): Promise<any>;
|
|
10
|
+
/**
|
|
11
|
+
* Get Sync Token for tracking changes
|
|
12
|
+
*/
|
|
13
|
+
getStartPageToken(): Promise<any>;
|
|
14
|
+
/**
|
|
15
|
+
* List changes since token
|
|
16
|
+
*/
|
|
17
|
+
listChanges(pageToken: string): Promise<any>;
|
|
18
|
+
/**
|
|
19
|
+
* Download file content
|
|
20
|
+
*/
|
|
21
|
+
downloadFile(fileId: string): Promise<string>;
|
|
22
|
+
/**
|
|
23
|
+
* Download file content as Binary (ArrayBuffer)
|
|
24
|
+
*/
|
|
25
|
+
downloadBinary(fileId: string): Promise<ArrayBuffer>;
|
|
26
|
+
/**
|
|
27
|
+
* Get file metadata
|
|
28
|
+
*/
|
|
29
|
+
getFileMetadata(fileId: string): Promise<any>;
|
|
30
|
+
/**
|
|
31
|
+
* Update file content
|
|
32
|
+
*/
|
|
33
|
+
updateMetadata(fileId: string, metadata: {
|
|
34
|
+
name?: string;
|
|
35
|
+
addParents?: string[];
|
|
36
|
+
removeParents?: string[];
|
|
37
|
+
appProperties?: Record<string, string>;
|
|
38
|
+
}): Promise<any>;
|
|
39
|
+
updateFile(fileId: string, content: string | Blob | ArrayBuffer): Promise<any>;
|
|
40
|
+
/**
|
|
41
|
+
* Create file
|
|
42
|
+
*/
|
|
43
|
+
createFile(name: string, folderId: string | null, content: string | Blob | ArrayBuffer, appProperties?: Record<string, string>): Promise<any>;
|
|
44
|
+
/**
|
|
45
|
+
* Create Folder
|
|
46
|
+
*/
|
|
47
|
+
createFolder(name: string, parentId?: string): Promise<any>;
|
|
48
|
+
/**
|
|
49
|
+
* Delete file (Trash)
|
|
50
|
+
*/
|
|
51
|
+
deleteFile(fileId: string): Promise<void>;
|
|
52
|
+
}
|