soulsync 1.0.22 → 1.2.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/index.js +41 -43
- package/package.json +5 -3
- package/src/api-client.js +84 -0
- package/src/daemon.js +94 -0
- package/src/sync-engine.js +745 -0
- package/debug.py +0 -221
- package/requirements.txt +0 -3
- package/setup.sh +0 -91
- package/src/__init__.py +0 -1
- package/src/client.py +0 -300
- package/src/main.py +0 -479
- package/src/profiles.py +0 -88
- package/src/sync.py +0 -210
- package/src/version_manager.py +0 -61
- package/src/watcher.py +0 -133
- package/test_ssl_fix.py +0 -58
|
@@ -0,0 +1,745 @@
|
|
|
1
|
+
const fs = require('fs');
|
|
2
|
+
const path = require('path');
|
|
3
|
+
const os = require('os');
|
|
4
|
+
const { EventEmitter } = require('events');
|
|
5
|
+
const chokidar = require('chokidar');
|
|
6
|
+
const WebSocket = require('ws');
|
|
7
|
+
const { APIClient } = require('./api-client');
|
|
8
|
+
|
|
9
|
+
const SCENE_FIRST_DEVICE = 'SCENE_FIRST_DEVICE';
|
|
10
|
+
const SCENE_EMPTY_DEVICE = 'SCENE_EMPTY_DEVICE';
|
|
11
|
+
const SCENE_USED_DEVICE = 'SCENE_USED_DEVICE';
|
|
12
|
+
const SCENE_NO_DATA = 'SCENE_NO_DATA';
|
|
13
|
+
|
|
14
|
+
const SYNC_FILES = ['SOUL.md', 'USER.md', 'MEMORY.md', 'TOOLS.md'];
|
|
15
|
+
const ADDITIVE_FILES = ['MEMORY.md', 'TOOLS.md'];
|
|
16
|
+
const EXCLUSIVE_FILES = ['SOUL.md'];
|
|
17
|
+
const BACKUP_FILE_PATTERN = /_backup_\d{8}_\d{6}_[a-zA-Z0-9]+\.md$/;
|
|
18
|
+
const BACKUP_KEEP_COUNT = 10;
|
|
19
|
+
|
|
20
|
+
class SyncEngine extends EventEmitter {
|
|
21
|
+
constructor(config) {
|
|
22
|
+
super();
|
|
23
|
+
this.config = config;
|
|
24
|
+
this.api = new APIClient(config.cloud_url, config.token);
|
|
25
|
+
this.profilesDir = path.join(os.homedir(), '.openclaw', 'workspace');
|
|
26
|
+
this.watcher = null;
|
|
27
|
+
this.ws = null;
|
|
28
|
+
this.connected = false;
|
|
29
|
+
this.localVersion = 0;
|
|
30
|
+
this.serverVersion = 0;
|
|
31
|
+
this.heartbeatInterval = null;
|
|
32
|
+
this.deviceId = this.config.device_id || 'unknown';
|
|
33
|
+
this.deviceIdShort = this.deviceId.substring(0, 8);
|
|
34
|
+
this.pendingChanges = [];
|
|
35
|
+
this.isSyncing = false;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
async initialize() {
|
|
39
|
+
console.log('[SoulSync] Initializing sync engine...');
|
|
40
|
+
console.log(`[SoulSync] Profiles directory: ${this.profilesDir}`);
|
|
41
|
+
console.log(`[SoulSync] Device ID: ${this.deviceIdShort}`);
|
|
42
|
+
|
|
43
|
+
const result = await this.api.getProfiles();
|
|
44
|
+
const serverContent = (result.status === 200 && result.body && result.body.content) ? result.body.content : {};
|
|
45
|
+
const serverVersion = (result.status === 200 && result.body) ? result.body.version || 0 : 0;
|
|
46
|
+
|
|
47
|
+
const { scene } = this.detectScene(serverContent);
|
|
48
|
+
this.serverVersion = serverVersion;
|
|
49
|
+
|
|
50
|
+
const sceneResult = await this.handleScene(scene, serverContent);
|
|
51
|
+
|
|
52
|
+
if (sceneResult && !sceneResult.success) {
|
|
53
|
+
console.log(`[SoulSync] ${sceneResult.message}`);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
await this.connectWebSocket();
|
|
57
|
+
|
|
58
|
+
console.log(`[SoulSync] Sync engine initialized - localVersion: ${this.localVersion}, serverVersion: ${this.serverVersion}`);
|
|
59
|
+
return sceneResult;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
detectScene(serverContent) {
|
|
63
|
+
const localFileStates = {};
|
|
64
|
+
let localNonEmptyCount = 0;
|
|
65
|
+
let localEmptyCount = 0;
|
|
66
|
+
|
|
67
|
+
for (const filename of SYNC_FILES) {
|
|
68
|
+
const filePath = path.join(this.profilesDir, filename);
|
|
69
|
+
const exists = fs.existsSync(filePath);
|
|
70
|
+
const stats = exists ? fs.statSync(filePath) : null;
|
|
71
|
+
const size = stats ? stats.size : 0;
|
|
72
|
+
const isNonEmpty = exists && size > 10;
|
|
73
|
+
|
|
74
|
+
localFileStates[filename] = { exists, size, isNonEmpty };
|
|
75
|
+
|
|
76
|
+
if (isNonEmpty) {
|
|
77
|
+
localNonEmptyCount++;
|
|
78
|
+
} else {
|
|
79
|
+
localEmptyCount++;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const serverHasData = serverContent && Object.keys(serverContent).length > 0;
|
|
84
|
+
const serverNonEmptyKeys = serverHasData ? Object.keys(serverContent).filter(k => serverContent[k] && serverContent[k].length > 10) : [];
|
|
85
|
+
const serverHasNonEmptyData = serverNonEmptyKeys.length > 0;
|
|
86
|
+
|
|
87
|
+
let scene;
|
|
88
|
+
if (!serverHasNonEmptyData && localNonEmptyCount > 0) {
|
|
89
|
+
scene = SCENE_FIRST_DEVICE;
|
|
90
|
+
} else if (serverHasNonEmptyData && localNonEmptyCount === 0) {
|
|
91
|
+
scene = SCENE_EMPTY_DEVICE;
|
|
92
|
+
} else if (serverHasNonEmptyData && localNonEmptyCount > 0) {
|
|
93
|
+
scene = SCENE_USED_DEVICE;
|
|
94
|
+
} else {
|
|
95
|
+
scene = SCENE_NO_DATA;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
console.log(`[SoulSync] Scene detected: ${scene}`);
|
|
99
|
+
console.log(`[SoulSync] Local non-empty: ${localNonEmptyCount}, Server has data: ${serverHasNonEmptyData}`);
|
|
100
|
+
|
|
101
|
+
return { scene, localFileStates };
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
async handleScene(scene, serverContent) {
|
|
105
|
+
switch (scene) {
|
|
106
|
+
case SCENE_FIRST_DEVICE:
|
|
107
|
+
console.log('[SoulSync] Uploading local data to cloud (first device)');
|
|
108
|
+
await this.uploadAll();
|
|
109
|
+
return { success: true, message: '已将本地灵魂数据上传至云端,完成初始化' };
|
|
110
|
+
|
|
111
|
+
case SCENE_EMPTY_DEVICE:
|
|
112
|
+
console.log('[SoulSync] Downloading cloud data to local (empty device)');
|
|
113
|
+
await this.downloadAll();
|
|
114
|
+
return { success: true, message: '已从云端拉取最新配置,设备已对齐全局基准' };
|
|
115
|
+
|
|
116
|
+
case SCENE_USED_DEVICE:
|
|
117
|
+
console.log('[SoulSync] Merging local and cloud data (used device)');
|
|
118
|
+
await this.mergeAll(serverContent);
|
|
119
|
+
return { success: true, message: '本地数据与云端数据已安全合并,历史内容均已保留' };
|
|
120
|
+
|
|
121
|
+
case SCENE_NO_DATA:
|
|
122
|
+
console.log('[SoulSync] No data available');
|
|
123
|
+
return { success: false, message: '未检测到灵魂数据,请初始化 OpenClaw 配置后重试' };
|
|
124
|
+
|
|
125
|
+
default:
|
|
126
|
+
return { success: false, message: 'Unknown scene' };
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
async downloadAll() {
|
|
131
|
+
try {
|
|
132
|
+
console.log('[SoulSync] downloadAll() called');
|
|
133
|
+
|
|
134
|
+
const result = await this.api.getProfiles();
|
|
135
|
+
console.log(`[SoulSync] API getProfiles response:`, JSON.stringify(result).substring(0, 200));
|
|
136
|
+
|
|
137
|
+
if (result.status === 200 && result.body) {
|
|
138
|
+
const profile = result.body;
|
|
139
|
+
console.log(`[SoulSync] Profile:`, JSON.stringify(profile).substring(0, 200));
|
|
140
|
+
|
|
141
|
+
if (profile.content) {
|
|
142
|
+
const { scene } = this.detectScene(profile.content);
|
|
143
|
+
|
|
144
|
+
if (scene === SCENE_NO_DATA) {
|
|
145
|
+
console.log('[SoulSync] No data in cloud');
|
|
146
|
+
this.serverVersion = profile.version || 0;
|
|
147
|
+
this.localVersion = this.getLocalVersion();
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
if (scene === SCENE_EMPTY_DEVICE) {
|
|
152
|
+
for (const [filename, content] of Object.entries(profile.content)) {
|
|
153
|
+
await this.writeFileSafe(filename, content);
|
|
154
|
+
}
|
|
155
|
+
} else if (scene === SCENE_USED_DEVICE) {
|
|
156
|
+
await this.mergeAll(profile.content);
|
|
157
|
+
} else if (scene === SCENE_FIRST_DEVICE) {
|
|
158
|
+
console.log('[SoulSync] First device - uploading local data');
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
this.serverVersion = profile.version || 0;
|
|
162
|
+
this.localVersion = this.getLocalVersion();
|
|
163
|
+
console.log(`[SoulSync] Updated versions - server: ${this.serverVersion}, local: ${this.localVersion}`);
|
|
164
|
+
} else {
|
|
165
|
+
console.log('[SoulSync] No content in profile');
|
|
166
|
+
this.localVersion = this.getLocalVersion();
|
|
167
|
+
}
|
|
168
|
+
} else {
|
|
169
|
+
console.log(`[SoulSync] getProfiles failed: ${result.status}`);
|
|
170
|
+
this.localVersion = this.getLocalVersion();
|
|
171
|
+
}
|
|
172
|
+
} catch (e) {
|
|
173
|
+
console.error('[SoulSync] downloadAll error:', e.message);
|
|
174
|
+
this.localVersion = this.getLocalVersion();
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
getLocalVersion() {
|
|
179
|
+
let maxMtime = 0;
|
|
180
|
+
for (const filename of SYNC_FILES) {
|
|
181
|
+
const filePath = path.join(this.profilesDir, filename);
|
|
182
|
+
if (fs.existsSync(filePath)) {
|
|
183
|
+
const mtime = fs.statSync(filePath).mtime.getTime();
|
|
184
|
+
if (mtime > maxMtime) {
|
|
185
|
+
maxMtime = mtime;
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
return maxMtime;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
async mergeAll(serverContent) {
|
|
193
|
+
for (const filename of SYNC_FILES) {
|
|
194
|
+
const serverData = serverContent ? serverContent[filename] : null;
|
|
195
|
+
const localPath = path.join(this.profilesDir, filename);
|
|
196
|
+
const localExists = fs.existsSync(localPath);
|
|
197
|
+
const localContent = localExists ? fs.readFileSync(localPath, 'utf-8') : '';
|
|
198
|
+
|
|
199
|
+
const localIsEmpty = !localExists || localContent.length <= 10;
|
|
200
|
+
const serverIsEmpty = !serverData || serverData.length <= 10;
|
|
201
|
+
|
|
202
|
+
if (localIsEmpty && serverIsEmpty) {
|
|
203
|
+
continue;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
if (localIsEmpty) {
|
|
207
|
+
await this.writeFileSafe(filename, serverData);
|
|
208
|
+
} else if (serverIsEmpty) {
|
|
209
|
+
continue;
|
|
210
|
+
} else {
|
|
211
|
+
const merged = await this.mergeFile(filename, localContent, serverData);
|
|
212
|
+
await this.writeFileSafe(filename, merged);
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
async mergeFile(filename, localContent, serverContent) {
|
|
218
|
+
if (ADDITIVE_FILES.includes(filename)) {
|
|
219
|
+
return this.mergeAdditive(localContent, serverContent);
|
|
220
|
+
} else if (EXCLUSIVE_FILES.includes(filename)) {
|
|
221
|
+
return this.mergeExclusive(filename, localContent, serverContent);
|
|
222
|
+
} else {
|
|
223
|
+
return localContent;
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
async mergeAdditive(localContent, serverContent) {
|
|
228
|
+
const localItems = this.parseAdditiveItems(localContent);
|
|
229
|
+
const serverItems = this.parseAdditiveItems(serverContent);
|
|
230
|
+
|
|
231
|
+
const localItemMap = new Map();
|
|
232
|
+
for (const item of localItems) {
|
|
233
|
+
const ts = this.extractItemTimestamp(item);
|
|
234
|
+
localItemMap.set(item, { item, timestamp: ts, source: 'local' });
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
const mergedItems = [...localItems.map(item => ({ item, timestamp: this.extractItemTimestamp(item), source: 'local' }))];
|
|
238
|
+
|
|
239
|
+
for (const item of serverItems) {
|
|
240
|
+
const isDuplicate = localItemMap.has(item);
|
|
241
|
+
if (!isDuplicate) {
|
|
242
|
+
mergedItems.push({ item, timestamp: this.extractItemTimestamp(item), source: 'server' });
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
mergedItems.sort((a, b) => {
|
|
247
|
+
if (a.timestamp && b.timestamp) {
|
|
248
|
+
return b.timestamp - a.timestamp;
|
|
249
|
+
}
|
|
250
|
+
if (a.timestamp && !b.timestamp) {
|
|
251
|
+
return -1;
|
|
252
|
+
}
|
|
253
|
+
if (!a.timestamp && b.timestamp) {
|
|
254
|
+
return 1;
|
|
255
|
+
}
|
|
256
|
+
return 0;
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
return mergedItems.map(entry => `- ${entry.item}`).join('\n');
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
extractItemTimestamp(itemContent) {
|
|
263
|
+
const match = itemContent.match(/^(\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}:\d{2})\s/);
|
|
264
|
+
if (match) {
|
|
265
|
+
return new Date(match[1]).getTime();
|
|
266
|
+
}
|
|
267
|
+
return null;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
parseAdditiveItems(content) {
|
|
271
|
+
if (!content || typeof content !== 'string') return [];
|
|
272
|
+
return content.split('\n')
|
|
273
|
+
.map(line => line.trim())
|
|
274
|
+
.filter(line => line.startsWith('- ') && line.length > 2)
|
|
275
|
+
.map(line => line.substring(2));
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
deduplicateItems(items) {
|
|
279
|
+
const seen = new Set();
|
|
280
|
+
const unique = [];
|
|
281
|
+
for (const item of items) {
|
|
282
|
+
if (!seen.has(item)) {
|
|
283
|
+
seen.add(item);
|
|
284
|
+
unique.push(item);
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
return unique;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
async mergeExclusive(filename, localContent, serverContent) {
|
|
291
|
+
const localData = this.parseSoulFields(localContent, filename);
|
|
292
|
+
const serverData = this.parseSoulFields(serverContent, filename);
|
|
293
|
+
|
|
294
|
+
const merged = { ...serverData };
|
|
295
|
+
|
|
296
|
+
for (const [field, localValue] of Object.entries(localData)) {
|
|
297
|
+
const serverValue = serverData[field];
|
|
298
|
+
const localTime = this.parseTimestamp(localValue?.last_modified);
|
|
299
|
+
const serverTime = this.parseTimestamp(serverValue?.last_modified);
|
|
300
|
+
|
|
301
|
+
if (!localTime && !serverTime) {
|
|
302
|
+
merged[field] = localValue;
|
|
303
|
+
} else if (!localTime) {
|
|
304
|
+
merged[field] = serverValue;
|
|
305
|
+
} else if (!serverTime) {
|
|
306
|
+
merged[field] = localValue;
|
|
307
|
+
} else if (localTime > serverTime) {
|
|
308
|
+
merged[field] = localValue;
|
|
309
|
+
} else {
|
|
310
|
+
merged[field] = serverValue;
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
return this.formatSoulContent(merged);
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
parseSoulFields(content, filename) {
|
|
318
|
+
if (!content || typeof content !== 'string') return {};
|
|
319
|
+
|
|
320
|
+
const fields = {};
|
|
321
|
+
|
|
322
|
+
const fieldDefs = [
|
|
323
|
+
{ name: 'ai_name', label: 'AI名称' },
|
|
324
|
+
{ name: 'core_personality', label: '核心人格' },
|
|
325
|
+
{ name: 'self_awareness', label: '自我认知' }
|
|
326
|
+
];
|
|
327
|
+
|
|
328
|
+
for (const { name, label } of fieldDefs) {
|
|
329
|
+
const valuePattern = new RegExp(`^[#\\s]*${label}[::]\\s*(.+?)\\s*$`, 'im');
|
|
330
|
+
const valueMatch = content.match(valuePattern);
|
|
331
|
+
|
|
332
|
+
const lastModifiedPattern = new RegExp(`^${name}_last_modified[::]\\s*(\\d{4}-\\d{2}-\\d{2}\\s+\\d{2}:\\d{2}:\\d{2})`, 'im');
|
|
333
|
+
const lastModifiedMatch = content.match(lastModifiedPattern);
|
|
334
|
+
|
|
335
|
+
const devicePattern = new RegExp(`^${name}_modified_device[::]\\s*(.+?)\\s*$`, 'im');
|
|
336
|
+
const deviceMatch = content.match(devicePattern);
|
|
337
|
+
|
|
338
|
+
if (valueMatch) {
|
|
339
|
+
let lastModified = null;
|
|
340
|
+
if (lastModifiedMatch) {
|
|
341
|
+
lastModified = this.parseTimestamp(lastModifiedMatch[1]);
|
|
342
|
+
}
|
|
343
|
+
if (!lastModified) {
|
|
344
|
+
const globalTimestamp = this.extractTimestamp(content);
|
|
345
|
+
if (globalTimestamp) {
|
|
346
|
+
lastModified = globalTimestamp;
|
|
347
|
+
} else {
|
|
348
|
+
lastModified = this.getFileMtime(filename);
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
fields[name] = {
|
|
353
|
+
value: valueMatch[1].trim(),
|
|
354
|
+
last_modified: lastModified,
|
|
355
|
+
modified_device: deviceMatch ? deviceMatch[1].trim() : null
|
|
356
|
+
};
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
return fields;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
extractTimestamp(content) {
|
|
364
|
+
const timeMatch = content.match(/最后更新[::]\s*(\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}:\d{2})/);
|
|
365
|
+
if (timeMatch) {
|
|
366
|
+
return this.parseTimestamp(timeMatch[1]);
|
|
367
|
+
}
|
|
368
|
+
return null;
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
parseTimestamp(timeStr) {
|
|
372
|
+
if (!timeStr) return 0;
|
|
373
|
+
try {
|
|
374
|
+
const date = new Date(timeStr.replace(/\s+/g, ' '));
|
|
375
|
+
if (isNaN(date.getTime())) return 0;
|
|
376
|
+
return date.getTime();
|
|
377
|
+
} catch {
|
|
378
|
+
return 0;
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
getFileMtime(filename) {
|
|
383
|
+
const filePath = path.join(this.profilesDir, filename);
|
|
384
|
+
if (fs.existsSync(filePath)) {
|
|
385
|
+
return fs.statSync(filePath).mtime.getTime();
|
|
386
|
+
}
|
|
387
|
+
return 0;
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
formatSoulContent(fields) {
|
|
391
|
+
let content = '';
|
|
392
|
+
const fieldLabels = {
|
|
393
|
+
ai_name: 'AI名称',
|
|
394
|
+
core_personality: '核心人格',
|
|
395
|
+
self_awareness: '自我认知'
|
|
396
|
+
};
|
|
397
|
+
|
|
398
|
+
for (const [field, data] of Object.entries(fields)) {
|
|
399
|
+
const label = fieldLabels[field];
|
|
400
|
+
if (label && data?.value) {
|
|
401
|
+
const timestamp = this.formatTimestamp(data.last_modified);
|
|
402
|
+
content += `${label}:${data.value}\n`;
|
|
403
|
+
if (timestamp) {
|
|
404
|
+
content += `最后更新:${timestamp}\n`;
|
|
405
|
+
}
|
|
406
|
+
content += '\n';
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
return content.trim();
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
formatTimestamp(timestamp) {
|
|
414
|
+
if (!timestamp) return '';
|
|
415
|
+
const date = new Date(timestamp);
|
|
416
|
+
return date.toISOString().slice(0, 19) + 'Z';
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
async writeFileSafe(filename, content) {
|
|
420
|
+
const filePath = path.join(this.profilesDir, filename);
|
|
421
|
+
|
|
422
|
+
await this.createBackup(filename);
|
|
423
|
+
|
|
424
|
+
const dir = path.dirname(filePath);
|
|
425
|
+
if (!fs.existsSync(dir)) {
|
|
426
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
fs.writeFileSync(filePath, content, 'utf-8');
|
|
430
|
+
console.log(`[SoulSync] Written: ${filename} (${content.length} chars)`);
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
async createBackup(filename) {
|
|
434
|
+
const filePath = path.join(this.profilesDir, filename);
|
|
435
|
+
if (!fs.existsSync(filePath)) {
|
|
436
|
+
return;
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
const timestamp = this.formatTimestampForBackup(Date.now());
|
|
440
|
+
const backupName = `${filename}_backup_${timestamp}_${this.deviceIdShort}.md`;
|
|
441
|
+
const backupPath = path.join(this.profilesDir, backupName);
|
|
442
|
+
|
|
443
|
+
try {
|
|
444
|
+
fs.copyFileSync(filePath, backupPath);
|
|
445
|
+
console.log(`[SoulSync] Backup created: ${backupName}`);
|
|
446
|
+
|
|
447
|
+
await this.cleanOldBackups(filename);
|
|
448
|
+
} catch (e) {
|
|
449
|
+
console.error(`[SoulSync] Failed to create backup: ${e.message}`);
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
formatTimestampForBackup(timestamp) {
|
|
454
|
+
const date = new Date(timestamp);
|
|
455
|
+
const year = date.getUTCFullYear();
|
|
456
|
+
const month = String(date.getUTCMonth() + 1).padStart(2, '0');
|
|
457
|
+
const day = String(date.getUTCDate()).padStart(2, '0');
|
|
458
|
+
const hours = String(date.getUTCHours()).padStart(2, '0');
|
|
459
|
+
const minutes = String(date.getUTCMinutes()).padStart(2, '0');
|
|
460
|
+
const seconds = String(date.getUTCSeconds()).padStart(2, '0');
|
|
461
|
+
return `${year}${month}${day}_${hours}${minutes}${seconds}`;
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
async cleanOldBackups(filename) {
|
|
465
|
+
const backupFiles = fs.readdirSync(this.profilesDir)
|
|
466
|
+
.filter(f => f.startsWith(`${filename}_backup_`) && f.endsWith('.md'))
|
|
467
|
+
.map(f => ({
|
|
468
|
+
name: f,
|
|
469
|
+
path: path.join(this.profilesDir, f),
|
|
470
|
+
mtime: fs.statSync(path.join(this.profilesDir, f)).mtime.getTime()
|
|
471
|
+
}))
|
|
472
|
+
.sort((a, b) => b.mtime - a.mtime);
|
|
473
|
+
|
|
474
|
+
if (backupFiles.length > BACKUP_KEEP_COUNT) {
|
|
475
|
+
const toDelete = backupFiles.slice(BACKUP_KEEP_COUNT);
|
|
476
|
+
for (const backup of toDelete) {
|
|
477
|
+
try {
|
|
478
|
+
fs.unlinkSync(backup.path);
|
|
479
|
+
console.log(`[SoulSync] Deleted old backup: ${backup.name}`);
|
|
480
|
+
} catch (e) {
|
|
481
|
+
console.error(`[SoulSync] Failed to delete backup: ${e.message}`);
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
isBackupFile(filename) {
|
|
488
|
+
return BACKUP_FILE_PATTERN.test(filename);
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
async uploadAll() {
|
|
492
|
+
try {
|
|
493
|
+
const profiles = await this.buildProfilesContent();
|
|
494
|
+
const result = await this.api.updateProfiles(profiles, this.serverVersion);
|
|
495
|
+
|
|
496
|
+
if (result.status === 200) {
|
|
497
|
+
const newVersion = result.body.version || this.serverVersion + 1;
|
|
498
|
+
this.localVersion = newVersion;
|
|
499
|
+
this.serverVersion = newVersion;
|
|
500
|
+
console.log(`[SoulSync] Uploaded all (version ${this.serverVersion})`);
|
|
501
|
+
} else if (result.status === 409) {
|
|
502
|
+
console.log('[SoulSync] Conflict on uploadAll, merging...');
|
|
503
|
+
const serverResult = await this.api.getProfiles();
|
|
504
|
+
if (serverResult.status === 200 && serverResult.body) {
|
|
505
|
+
await this.mergeAll(serverResult.body.content);
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
} catch (e) {
|
|
509
|
+
console.error(`[SoulSync] Failed to upload all: ${e.message}`);
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
async uploadFile(filename) {
|
|
514
|
+
try {
|
|
515
|
+
if (this.isBackupFile(filename)) {
|
|
516
|
+
console.log(`[SoulSync] Skipping backup file: ${filename}`);
|
|
517
|
+
return;
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
console.log(`[SoulSync] uploadFile() called for: ${filename}`);
|
|
521
|
+
const filePath = path.join(this.profilesDir, filename);
|
|
522
|
+
|
|
523
|
+
if (!fs.existsSync(filePath)) {
|
|
524
|
+
console.log(`[SoulSync] File not found: ${filename}`);
|
|
525
|
+
return;
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
const content = fs.readFileSync(filePath, 'utf-8');
|
|
529
|
+
const profiles = await this.buildProfilesContent();
|
|
530
|
+
profiles[filename] = content;
|
|
531
|
+
|
|
532
|
+
const result = await this.api.updateProfiles(profiles, this.serverVersion);
|
|
533
|
+
|
|
534
|
+
if (result.status === 200) {
|
|
535
|
+
const newVersion = result.body.version || this.serverVersion + 1;
|
|
536
|
+
this.localVersion = newVersion;
|
|
537
|
+
this.serverVersion = newVersion;
|
|
538
|
+
console.log(`[SoulSync] Uploaded: ${filename} (version ${this.serverVersion})`);
|
|
539
|
+
} else if (result.status === 409) {
|
|
540
|
+
console.log(`[SoulSync] Conflict detected for: ${filename}`);
|
|
541
|
+
await this.handleConflict(filename);
|
|
542
|
+
}
|
|
543
|
+
} catch (e) {
|
|
544
|
+
console.error(`[SoulSync] Failed to upload ${filename}:`, e.message);
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
async downloadFile(filename) {
|
|
549
|
+
try {
|
|
550
|
+
const result = await this.api.getProfiles();
|
|
551
|
+
if (result.status === 200 && result.body && result.body.content) {
|
|
552
|
+
const content = result.body.content[filename];
|
|
553
|
+
if (content) {
|
|
554
|
+
await this.writeFileSafe(filename, content);
|
|
555
|
+
console.log(`[SoulSync] Downloaded: ${filename}`);
|
|
556
|
+
return true;
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
} catch (e) {
|
|
560
|
+
console.error(`[SoulSync] Failed to download ${filename}: ${e.message}`);
|
|
561
|
+
}
|
|
562
|
+
return false;
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
async buildProfilesContent() {
|
|
566
|
+
const profiles = {};
|
|
567
|
+
|
|
568
|
+
for (const filename of SYNC_FILES) {
|
|
569
|
+
const filePath = path.join(this.profilesDir, filename);
|
|
570
|
+
if (fs.existsSync(filePath) && !this.isBackupFile(filename)) {
|
|
571
|
+
profiles[filename] = fs.readFileSync(filePath, 'utf-8');
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
return profiles;
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
async handleConflict(filename) {
|
|
579
|
+
const localPath = path.join(this.profilesDir, filename);
|
|
580
|
+
if (fs.existsSync(localPath)) {
|
|
581
|
+
await this.createBackup(filename);
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
const serverResult = await this.api.getProfiles();
|
|
585
|
+
if (serverResult.status === 200 && serverResult.body && serverResult.body.content) {
|
|
586
|
+
const serverContent = serverResult.body.content[filename];
|
|
587
|
+
if (serverContent) {
|
|
588
|
+
const localContent = fs.existsSync(localPath) ? fs.readFileSync(localPath, 'utf-8') : '';
|
|
589
|
+
const merged = await this.mergeFile(filename, localContent, serverContent);
|
|
590
|
+
await this.writeFileSafe(filename, merged);
|
|
591
|
+
console.log(`[SoulSync] Conflict resolved for: ${filename}`);
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
startWatching() {
|
|
597
|
+
if (this.watcher) {
|
|
598
|
+
return;
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
this.watcher = chokidar.watch(this.profilesDir, {
|
|
602
|
+
ignored: (path) => {
|
|
603
|
+
const basename = path.basename(path);
|
|
604
|
+
return this.isBackupFile(basename) || basename.includes('.conflict.');
|
|
605
|
+
},
|
|
606
|
+
persistent: true,
|
|
607
|
+
ignoreInitial: true
|
|
608
|
+
});
|
|
609
|
+
|
|
610
|
+
this.watcher.on('change', async (filePath) => {
|
|
611
|
+
const filename = path.basename(filePath);
|
|
612
|
+
if (this.isBackupFile(filename) || filename.includes('.conflict.')) {
|
|
613
|
+
return;
|
|
614
|
+
}
|
|
615
|
+
if (!SYNC_FILES.includes(filename)) {
|
|
616
|
+
return;
|
|
617
|
+
}
|
|
618
|
+
console.log(`[SoulSync] File changed: ${filename}`);
|
|
619
|
+
await this.uploadFile(filename);
|
|
620
|
+
});
|
|
621
|
+
|
|
622
|
+
this.watcher.on('add', async (filePath) => {
|
|
623
|
+
const filename = path.basename(filePath);
|
|
624
|
+
if (this.isBackupFile(filename) || filename.includes('.conflict.')) {
|
|
625
|
+
return;
|
|
626
|
+
}
|
|
627
|
+
if (!SYNC_FILES.includes(filename)) {
|
|
628
|
+
return;
|
|
629
|
+
}
|
|
630
|
+
console.log(`[SoulSync] File added: ${filename}`);
|
|
631
|
+
await this.uploadFile(filename);
|
|
632
|
+
});
|
|
633
|
+
|
|
634
|
+
this.watcher.on('unlink', (filePath) => {
|
|
635
|
+
const filename = path.basename(filePath);
|
|
636
|
+
console.log(`[SoulSync] File removed: ${filename}`);
|
|
637
|
+
});
|
|
638
|
+
|
|
639
|
+
console.log('[SoulSync] Started watching profiles directory');
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
stopWatching() {
|
|
643
|
+
if (this.watcher) {
|
|
644
|
+
this.watcher.close();
|
|
645
|
+
this.watcher = null;
|
|
646
|
+
console.log('[SoulSync] Stopped watching');
|
|
647
|
+
}
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
async connectWebSocket() {
|
|
651
|
+
try {
|
|
652
|
+
const wsUrl = this.config.cloud_url.replace(/^http/, 'ws') + '/ws';
|
|
653
|
+
this.ws = new WebSocket(wsUrl, {
|
|
654
|
+
headers: {
|
|
655
|
+
'Authorization': `Bearer ${this.config.token}`
|
|
656
|
+
}
|
|
657
|
+
});
|
|
658
|
+
|
|
659
|
+
this.ws.on('open', () => {
|
|
660
|
+
this.connected = true;
|
|
661
|
+
console.log('[SoulSync] WebSocket connected');
|
|
662
|
+
this.startWatching();
|
|
663
|
+
this.startHeartbeat();
|
|
664
|
+
});
|
|
665
|
+
|
|
666
|
+
this.ws.on('message', async (data) => {
|
|
667
|
+
try {
|
|
668
|
+
const msg = JSON.parse(data);
|
|
669
|
+
if (msg.type === 'pong') {
|
|
670
|
+
console.log('[SoulSync] Heartbeat received');
|
|
671
|
+
} else {
|
|
672
|
+
await this.handleWebSocketMessage(msg);
|
|
673
|
+
}
|
|
674
|
+
} catch (e) {
|
|
675
|
+
console.error('[SoulSync] WebSocket message parse error:', e.message);
|
|
676
|
+
}
|
|
677
|
+
});
|
|
678
|
+
|
|
679
|
+
this.ws.on('close', () => {
|
|
680
|
+
this.connected = false;
|
|
681
|
+
console.log('[SoulSync] WebSocket disconnected');
|
|
682
|
+
this.stopHeartbeat();
|
|
683
|
+
this.scheduleReconnect();
|
|
684
|
+
});
|
|
685
|
+
|
|
686
|
+
this.ws.on('error', (e) => {
|
|
687
|
+
console.error('[SoulSync] WebSocket error:', e.message);
|
|
688
|
+
});
|
|
689
|
+
} catch (e) {
|
|
690
|
+
console.error('[SoulSync] Failed to connect WebSocket:', e.message);
|
|
691
|
+
this.scheduleReconnect();
|
|
692
|
+
}
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
scheduleReconnect() {
|
|
696
|
+
setTimeout(() => {
|
|
697
|
+
if (!this.connected) {
|
|
698
|
+
console.log('[SoulSync] Attempting WebSocket reconnection...');
|
|
699
|
+
this.connectWebSocket();
|
|
700
|
+
}
|
|
701
|
+
}, 5000);
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
async handleWebSocketMessage(msg) {
|
|
705
|
+
switch (msg.type) {
|
|
706
|
+
case 'profile_updated':
|
|
707
|
+
console.log(`[SoulSync] Received update notification, version: ${msg.version}`);
|
|
708
|
+
await this.downloadAll();
|
|
709
|
+
break;
|
|
710
|
+
case 'pong':
|
|
711
|
+
break;
|
|
712
|
+
default:
|
|
713
|
+
console.log('[SoulSync] Unknown WebSocket message:', msg.type);
|
|
714
|
+
}
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
startHeartbeat() {
|
|
718
|
+
this.heartbeatInterval = setInterval(() => {
|
|
719
|
+
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
|
|
720
|
+
this.ws.send(JSON.stringify({ type: 'ping' }));
|
|
721
|
+
console.log('[SoulSync] Heartbeat sent');
|
|
722
|
+
}
|
|
723
|
+
}, 30000);
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
stopHeartbeat() {
|
|
727
|
+
if (this.heartbeatInterval) {
|
|
728
|
+
clearInterval(this.heartbeatInterval);
|
|
729
|
+
this.heartbeatInterval = null;
|
|
730
|
+
}
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
disconnect() {
|
|
734
|
+
this.stopWatching();
|
|
735
|
+
this.stopHeartbeat();
|
|
736
|
+
if (this.ws) {
|
|
737
|
+
this.ws.close();
|
|
738
|
+
this.ws = null;
|
|
739
|
+
}
|
|
740
|
+
this.connected = false;
|
|
741
|
+
console.log('[SoulSync] Disconnected');
|
|
742
|
+
}
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
module.exports = { SyncEngine, SCENE_FIRST_DEVICE, SCENE_EMPTY_DEVICE, SCENE_USED_DEVICE, SCENE_NO_DATA };
|