@chainlesschain/personal-data-hub 0.2.4 → 0.3.1
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/__tests__/adapters/browser-history-chrome.test.js +377 -0
- package/__tests__/adapters/browser-history-edge.test.js +159 -0
- package/__tests__/adapters/git-activity.test.js +216 -0
- package/__tests__/adapters/local-files.test.js +264 -0
- package/__tests__/adapters/shell-history.test.js +180 -0
- package/__tests__/adapters/system-data-android.test.js +104 -3
- package/__tests__/adapters/vscode.test.js +299 -0
- package/__tests__/adapters/win-recent.test.js +192 -0
- package/__tests__/analysis.test.js +840 -1
- package/__tests__/categories.test.js +92 -0
- package/__tests__/e2e/local-data-adapters-cli.e2e.test.js +146 -0
- package/__tests__/entity-resolver-vault.test.js +5 -2
- package/__tests__/integration/local-data-adapters-pipeline.test.js +373 -0
- package/__tests__/query-parser.test.js +66 -0
- package/__tests__/registry.test.js +114 -0
- package/__tests__/sidecar-contacts-cross-validate.test.js +24 -1
- package/__tests__/sidecar-supervisor.test.js +9 -1
- package/__tests__/social-kuaishou-snapshot.test.js +55 -2
- package/__tests__/social-toutiao-snapshot.test.js +54 -2
- package/__tests__/travel-adapters.test.js +97 -5
- package/__tests__/vault-search-helpers.test.js +104 -0
- package/__tests__/vault-search.test.js +423 -0
- package/__tests__/vault.test.js +77 -3
- package/lib/adapters/browser-history-chrome/adapter.js +247 -0
- package/lib/adapters/browser-history-chrome/bookmarks-reader.js +79 -0
- package/lib/adapters/browser-history-chrome/chrome-db-reader.js +223 -0
- package/lib/adapters/browser-history-chrome/index.js +23 -0
- package/lib/adapters/browser-history-edge/adapter.js +34 -0
- package/lib/adapters/browser-history-edge/index.js +13 -0
- package/lib/adapters/git-activity/adapter.js +155 -0
- package/lib/adapters/git-activity/git-reader.js +125 -0
- package/lib/adapters/git-activity/index.js +17 -0
- package/lib/adapters/local-files/adapter.js +149 -0
- package/lib/adapters/local-files/file-walker.js +125 -0
- package/lib/adapters/local-files/index.js +18 -0
- package/lib/adapters/shell-history/adapter.js +137 -0
- package/lib/adapters/shell-history/index.js +17 -0
- package/lib/adapters/shell-history/shell-reader.js +100 -0
- package/lib/adapters/social-kuaishou/index.js +57 -1
- package/lib/adapters/social-toutiao/index.js +59 -1
- package/lib/adapters/system-data-android/adapter.js +220 -3
- package/lib/adapters/travel-12306/index.js +215 -29
- package/lib/adapters/vscode/adapter.js +285 -0
- package/lib/adapters/vscode/index.js +18 -0
- package/lib/adapters/vscode/vscode-reader.js +191 -0
- package/lib/adapters/win-recent/adapter.js +150 -0
- package/lib/adapters/win-recent/index.js +16 -0
- package/lib/adapters/win-recent/win-recent-reader.js +72 -0
- package/lib/analysis.js +227 -9
- package/lib/categories.js +101 -0
- package/lib/index.js +61 -0
- package/lib/migrations.js +146 -0
- package/lib/query-parser.js +74 -0
- package/lib/registry.js +162 -0
- package/lib/vault.js +363 -2
- package/package.json +2 -1
- package/scripts/run-native-tests-sandbox.sh +53 -0
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
// VSCodeAdapter — pulls VSCode workspace history + global terminal history
|
|
4
|
+
// from on-disk state. Desktop-local, zero network, no extension required.
|
|
5
|
+
//
|
|
6
|
+
// Sources (all under `%APPDATA%\Code\` on Win, equivalent on macOS/Linux):
|
|
7
|
+
// - User/workspaceStorage/<hash>/workspace.json → each opened project
|
|
8
|
+
// - User/globalStorage/state.vscdb → terminal command history
|
|
9
|
+
//
|
|
10
|
+
// Yields:
|
|
11
|
+
// kind="workspace" → Item(LINK, category="code-project")
|
|
12
|
+
// kind="terminal-command" → Event(OTHER, content.title=cmd[0..80])
|
|
13
|
+
// kind="terminal-dir" → Event(OTHER, content.title=cd <dir>)
|
|
14
|
+
//
|
|
15
|
+
// Caveat: terminal history has NO per-entry timestamp in VSCode — only a
|
|
16
|
+
// single "snapshot updated" ts. We anchor every command/dir to that ts and
|
|
17
|
+
// add `sourceIndex` to extra so callers can reconstruct order. Re-syncing
|
|
18
|
+
// after a new command lands gives that command (and only that command) a
|
|
19
|
+
// fresher snapshot ts.
|
|
20
|
+
|
|
21
|
+
const path = require("node:path");
|
|
22
|
+
|
|
23
|
+
const {
|
|
24
|
+
ENTITY_TYPES,
|
|
25
|
+
EVENT_SUBTYPES,
|
|
26
|
+
ITEM_SUBTYPES,
|
|
27
|
+
CAPTURED_BY,
|
|
28
|
+
} = require("../../constants");
|
|
29
|
+
|
|
30
|
+
const {
|
|
31
|
+
defaultVscodeRoot,
|
|
32
|
+
decodeFileUri,
|
|
33
|
+
readWorkspaces,
|
|
34
|
+
readTerminalHistory,
|
|
35
|
+
} = require("./vscode-reader");
|
|
36
|
+
|
|
37
|
+
const NAME = "vscode";
|
|
38
|
+
const VERSION = "0.1.0";
|
|
39
|
+
|
|
40
|
+
class VSCodeAdapter {
|
|
41
|
+
constructor(opts = {}) {
|
|
42
|
+
this.name = NAME;
|
|
43
|
+
this.version = VERSION;
|
|
44
|
+
this.capabilities = [
|
|
45
|
+
"sync:vscode-workspace-storage",
|
|
46
|
+
"sync:vscode-globalstorage-sqlite",
|
|
47
|
+
];
|
|
48
|
+
this.extractMode = "file-import";
|
|
49
|
+
this.rateLimits = { perDay: 96 };
|
|
50
|
+
this.dataDisclosure = {
|
|
51
|
+
fields: [
|
|
52
|
+
"workspaces:hash,folderUri,folderPath,lastOpenedMs",
|
|
53
|
+
"terminal-commands:command,shellType,sourceIndex,snapshotTs",
|
|
54
|
+
"terminal-dirs:dir,shellType,sourceIndex,snapshotTs",
|
|
55
|
+
],
|
|
56
|
+
sensitivity: "high",
|
|
57
|
+
legalGate: false,
|
|
58
|
+
defaultInclude: { workspaces: true, terminal: true },
|
|
59
|
+
};
|
|
60
|
+
this._deps = {
|
|
61
|
+
fs: require("node:fs"),
|
|
62
|
+
defaultRoot: defaultVscodeRoot,
|
|
63
|
+
};
|
|
64
|
+
this._rootOverride = typeof opts.vscodeRoot === "string" ? opts.vscodeRoot : null;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
_resolveRoot(opts) {
|
|
68
|
+
if (typeof opts?.vscodeRoot === "string" && opts.vscodeRoot.length > 0) {
|
|
69
|
+
return opts.vscodeRoot;
|
|
70
|
+
}
|
|
71
|
+
if (this._rootOverride) return this._rootOverride;
|
|
72
|
+
return this._deps.defaultRoot();
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
async authenticate(ctx = {}) {
|
|
76
|
+
const root = this._resolveRoot(ctx);
|
|
77
|
+
if (!root) {
|
|
78
|
+
return {
|
|
79
|
+
ok: false,
|
|
80
|
+
reason: "VSCODE_ROOT_UNRESOLVED",
|
|
81
|
+
message: "no default VSCode root on this platform; pass opts.vscodeRoot",
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
const wsRoot = path.join(root, "User", "workspaceStorage");
|
|
85
|
+
const stateDb = path.join(root, "User", "globalStorage", "state.vscdb");
|
|
86
|
+
const wsExists = this._deps.fs.existsSync(wsRoot);
|
|
87
|
+
const stateExists = this._deps.fs.existsSync(stateDb);
|
|
88
|
+
if (!wsExists && !stateExists) {
|
|
89
|
+
return {
|
|
90
|
+
ok: false,
|
|
91
|
+
reason: "VSCODE_NOT_FOUND",
|
|
92
|
+
message: `no VSCode state at ${root} — install VS Code / open it at least once, or pass opts.vscodeRoot`,
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
return {
|
|
96
|
+
ok: true,
|
|
97
|
+
mode: "file-import",
|
|
98
|
+
vscodeRoot: root,
|
|
99
|
+
hasWorkspaces: wsExists,
|
|
100
|
+
hasTerminalHistory: stateExists,
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
async healthCheck() {
|
|
105
|
+
const root = this._resolveRoot({});
|
|
106
|
+
const ok =
|
|
107
|
+
!!root &&
|
|
108
|
+
(this._deps.fs.existsSync(path.join(root, "User", "workspaceStorage")) ||
|
|
109
|
+
this._deps.fs.existsSync(path.join(root, "User", "globalStorage", "state.vscdb")));
|
|
110
|
+
return { ok, lastChecked: Date.now() };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
async *sync(opts = {}) {
|
|
114
|
+
const root = this._resolveRoot(opts);
|
|
115
|
+
if (!root) {
|
|
116
|
+
throw new Error("vscode.sync: no VSCode root resolved — pass opts.vscodeRoot");
|
|
117
|
+
}
|
|
118
|
+
const includeWorkspaces = opts.include?.workspaces !== false;
|
|
119
|
+
const includeTerminal = opts.include?.terminal !== false;
|
|
120
|
+
const limit = Number.isInteger(opts.limit) && opts.limit > 0 ? opts.limit : Infinity;
|
|
121
|
+
const capturedAt = Date.now();
|
|
122
|
+
let emitted = 0;
|
|
123
|
+
|
|
124
|
+
if (includeWorkspaces) {
|
|
125
|
+
for (const w of readWorkspaces(root, { fs: this._deps.fs, since: opts.since })) {
|
|
126
|
+
if (emitted >= limit) return;
|
|
127
|
+
yield {
|
|
128
|
+
kind: "workspace",
|
|
129
|
+
originalId: `vscode-workspace:${w.hash}`,
|
|
130
|
+
capturedAt,
|
|
131
|
+
payload: w,
|
|
132
|
+
};
|
|
133
|
+
emitted += 1;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
if (includeTerminal) {
|
|
138
|
+
const hist = readTerminalHistory(root, { fs: this._deps.fs });
|
|
139
|
+
const cmdTs = Number.isInteger(hist.commandsTimestampMs)
|
|
140
|
+
? hist.commandsTimestampMs
|
|
141
|
+
: capturedAt;
|
|
142
|
+
const dirTs = Number.isInteger(hist.dirsTimestampMs)
|
|
143
|
+
? hist.dirsTimestampMs
|
|
144
|
+
: capturedAt;
|
|
145
|
+
if (opts.include?.terminalCommands !== false) {
|
|
146
|
+
for (const c of hist.commands) {
|
|
147
|
+
if (emitted >= limit) return;
|
|
148
|
+
if (Number.isInteger(opts.since) && cmdTs < opts.since) break;
|
|
149
|
+
yield {
|
|
150
|
+
kind: "terminal-command",
|
|
151
|
+
// Index disambiguates entries that re-occur with identical command
|
|
152
|
+
// text — keeps registry.putRawEvent's UNIQUE(source.originalId) happy.
|
|
153
|
+
originalId: `vscode-terminal-cmd:${c.sourceIndex}:${hashCommand(c.value)}`,
|
|
154
|
+
capturedAt,
|
|
155
|
+
payload: { ...c, snapshotTs: cmdTs },
|
|
156
|
+
};
|
|
157
|
+
emitted += 1;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
if (opts.include?.terminalDirs !== false) {
|
|
161
|
+
for (const d of hist.dirs) {
|
|
162
|
+
if (emitted >= limit) return;
|
|
163
|
+
if (Number.isInteger(opts.since) && dirTs < opts.since) break;
|
|
164
|
+
yield {
|
|
165
|
+
kind: "terminal-dir",
|
|
166
|
+
originalId: `vscode-terminal-dir:${d.sourceIndex}:${hashCommand(d.value)}`,
|
|
167
|
+
capturedAt,
|
|
168
|
+
payload: { ...d, snapshotTs: dirTs },
|
|
169
|
+
};
|
|
170
|
+
emitted += 1;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
normalize(raw) {
|
|
177
|
+
const ingestedAt = Date.now();
|
|
178
|
+
const source = (originalId) => ({
|
|
179
|
+
adapter: NAME,
|
|
180
|
+
adapterVersion: VERSION,
|
|
181
|
+
capturedAt: raw.capturedAt,
|
|
182
|
+
capturedBy: CAPTURED_BY.SQLITE,
|
|
183
|
+
originalId,
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
if (raw.kind === "workspace") {
|
|
187
|
+
const p = raw.payload || {};
|
|
188
|
+
const uri = p.folderUri || p.workspaceUri || `vscode-hash:${p.hash}`;
|
|
189
|
+
const name =
|
|
190
|
+
(p.folderPath && p.folderPath.split(/[\\/]/).filter(Boolean).pop()) ||
|
|
191
|
+
decodeFileUri(uri) ||
|
|
192
|
+
uri;
|
|
193
|
+
const item = {
|
|
194
|
+
id: `item-vscode-workspace-${p.hash}`,
|
|
195
|
+
type: ENTITY_TYPES.ITEM,
|
|
196
|
+
subtype: ITEM_SUBTYPES.LINK,
|
|
197
|
+
name: name || "(无名工程)",
|
|
198
|
+
category: "code-project",
|
|
199
|
+
ingestedAt,
|
|
200
|
+
source: source(`vscode-workspace:${p.hash}`),
|
|
201
|
+
extra: {
|
|
202
|
+
folderUri: p.folderUri || null,
|
|
203
|
+
workspaceUri: p.workspaceUri || null,
|
|
204
|
+
folderPath: p.folderPath || null,
|
|
205
|
+
lastOpenedMs: Number.isInteger(p.lastOpenedMs) ? p.lastOpenedMs : null,
|
|
206
|
+
editor: "vscode",
|
|
207
|
+
},
|
|
208
|
+
};
|
|
209
|
+
return { events: [], persons: [], places: [], items: [item], topics: [] };
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
if (raw.kind === "terminal-command") {
|
|
213
|
+
const p = raw.payload || {};
|
|
214
|
+
const cmd = typeof p.value === "string" ? p.value : "";
|
|
215
|
+
const event = {
|
|
216
|
+
id: `event-vscode-terminal-cmd-${p.sourceIndex}-${shortHash(cmd)}`,
|
|
217
|
+
type: ENTITY_TYPES.EVENT,
|
|
218
|
+
subtype: EVENT_SUBTYPES.OTHER,
|
|
219
|
+
occurredAt: Number.isInteger(p.snapshotTs) ? p.snapshotTs : raw.capturedAt,
|
|
220
|
+
ingestedAt,
|
|
221
|
+
source: source(raw.originalId),
|
|
222
|
+
actor: "self",
|
|
223
|
+
content: {
|
|
224
|
+
title: cmd.length > 80 ? cmd.substring(0, 80) + "…" : cmd,
|
|
225
|
+
text: cmd,
|
|
226
|
+
},
|
|
227
|
+
extra: {
|
|
228
|
+
kind: "terminal-command",
|
|
229
|
+
shellType: p.shellType || null,
|
|
230
|
+
sourceIndex: p.sourceIndex,
|
|
231
|
+
editor: "vscode",
|
|
232
|
+
},
|
|
233
|
+
};
|
|
234
|
+
return { events: [event], persons: [], places: [], items: [], topics: [] };
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
if (raw.kind === "terminal-dir") {
|
|
238
|
+
const p = raw.payload || {};
|
|
239
|
+
const dir = typeof p.value === "string" ? p.value : "";
|
|
240
|
+
const event = {
|
|
241
|
+
id: `event-vscode-terminal-dir-${p.sourceIndex}-${shortHash(dir)}`,
|
|
242
|
+
type: ENTITY_TYPES.EVENT,
|
|
243
|
+
subtype: EVENT_SUBTYPES.OTHER,
|
|
244
|
+
occurredAt: Number.isInteger(p.snapshotTs) ? p.snapshotTs : raw.capturedAt,
|
|
245
|
+
ingestedAt,
|
|
246
|
+
source: source(raw.originalId),
|
|
247
|
+
actor: "self",
|
|
248
|
+
content: {
|
|
249
|
+
title: `cd ${dir.length > 76 ? dir.substring(0, 76) + "…" : dir}`,
|
|
250
|
+
text: dir,
|
|
251
|
+
},
|
|
252
|
+
extra: {
|
|
253
|
+
kind: "terminal-dir",
|
|
254
|
+
shellType: p.shellType || null,
|
|
255
|
+
sourceIndex: p.sourceIndex,
|
|
256
|
+
editor: "vscode",
|
|
257
|
+
},
|
|
258
|
+
};
|
|
259
|
+
return { events: [event], persons: [], places: [], items: [], topics: [] };
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
throw new Error(`vscode.normalize: unknown raw.kind=${raw.kind}`);
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// Cheap non-crypto hash for de-duping originalId + event id collisions.
|
|
267
|
+
// Don't need cryptographic strength here — the registry UNIQUE constraint
|
|
268
|
+
// gives the real guarantee.
|
|
269
|
+
function hashCommand(s) {
|
|
270
|
+
let h = 5381;
|
|
271
|
+
for (let i = 0; i < s.length; i++) {
|
|
272
|
+
h = ((h << 5) + h + s.charCodeAt(i)) >>> 0;
|
|
273
|
+
}
|
|
274
|
+
return h.toString(36);
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
function shortHash(s) {
|
|
278
|
+
return hashCommand(s).substring(0, 8);
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
module.exports = {
|
|
282
|
+
VSCodeAdapter,
|
|
283
|
+
VSCODE_NAME: NAME,
|
|
284
|
+
VSCODE_VERSION: VERSION,
|
|
285
|
+
};
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const {
|
|
4
|
+
VSCodeAdapter,
|
|
5
|
+
VSCODE_NAME,
|
|
6
|
+
VSCODE_VERSION,
|
|
7
|
+
} = require("./adapter");
|
|
8
|
+
const reader = require("./vscode-reader");
|
|
9
|
+
|
|
10
|
+
module.exports = {
|
|
11
|
+
VSCodeAdapter,
|
|
12
|
+
VSCODE_NAME,
|
|
13
|
+
VSCODE_VERSION,
|
|
14
|
+
defaultVscodeRoot: reader.defaultVscodeRoot,
|
|
15
|
+
decodeFileUri: reader.decodeFileUri,
|
|
16
|
+
readWorkspaces: reader.readWorkspaces,
|
|
17
|
+
readTerminalHistory: reader.readTerminalHistory,
|
|
18
|
+
};
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
// vscode-reader — pulls workspace folders + terminal history out of VSCode's
|
|
4
|
+
// own state files. Two on-disk sources, both desktop-local:
|
|
5
|
+
//
|
|
6
|
+
// 1. `%APPDATA%\Code\User\workspaceStorage\<hash>\workspace.json`
|
|
7
|
+
// Each `workspace.json` carries `{ folder: "file:///..." }` — the
|
|
8
|
+
// decoded URI is the project root the user opened. Folder mtime gives
|
|
9
|
+
// us a "last opened" timestamp (when VSCode last touched the storage).
|
|
10
|
+
//
|
|
11
|
+
// 2. `%APPDATA%\Code\User\globalStorage\state.vscdb` (plain SQLite)
|
|
12
|
+
// ItemTable contains JSON blobs keyed by `terminal.history.entries.*`.
|
|
13
|
+
// Single snapshot timestamp at `terminal.history.timestamp.*` — there
|
|
14
|
+
// is no per-command timestamp, only the "last updated" of the whole
|
|
15
|
+
// list. We anchor every command/dir to that timestamp.
|
|
16
|
+
//
|
|
17
|
+
// Like the Chromium readers, we copy the SQLite file first — VSCode keeps
|
|
18
|
+
// state.vscdb open while running, and a direct read would fight WAL.
|
|
19
|
+
|
|
20
|
+
const fs = require("node:fs");
|
|
21
|
+
const path = require("node:path");
|
|
22
|
+
const os = require("node:os");
|
|
23
|
+
// Dual-load: bs3mc tracks Electron's ABI 140 (runtime path), plain
|
|
24
|
+
// better-sqlite3 tracks Node's ABI 127 (test path). Whichever loads
|
|
25
|
+
// wins. See chrome-db-reader.js for the same pattern + rationale.
|
|
26
|
+
function loadDatabase() {
|
|
27
|
+
for (const mod of ["better-sqlite3-multiple-ciphers", "better-sqlite3"]) {
|
|
28
|
+
let cls;
|
|
29
|
+
try {
|
|
30
|
+
// eslint-disable-next-line global-require
|
|
31
|
+
cls = require(mod);
|
|
32
|
+
} catch (_e) {
|
|
33
|
+
continue;
|
|
34
|
+
}
|
|
35
|
+
try {
|
|
36
|
+
const probe = new cls(":memory:");
|
|
37
|
+
probe.close();
|
|
38
|
+
return cls;
|
|
39
|
+
} catch (_e) {
|
|
40
|
+
/* ABI mismatch, try next */
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
throw new Error(
|
|
44
|
+
"vscode-reader: neither better-sqlite3-multiple-ciphers nor better-sqlite3 loaded — both ABI-mismatched",
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
const Database = loadDatabase();
|
|
48
|
+
|
|
49
|
+
function defaultVscodeRoot() {
|
|
50
|
+
if (process.platform === "win32") {
|
|
51
|
+
const appData = process.env.APPDATA;
|
|
52
|
+
if (!appData) return null;
|
|
53
|
+
return path.join(appData, "Code");
|
|
54
|
+
}
|
|
55
|
+
if (process.platform === "darwin") {
|
|
56
|
+
return path.join(os.homedir(), "Library", "Application Support", "Code");
|
|
57
|
+
}
|
|
58
|
+
return path.join(os.homedir(), ".config", "Code");
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Decode a file:// URI into a Windows / Posix path. Returns null when the
|
|
62
|
+
// URI scheme isn't file:// (could be vscode-remote://, ssh://, etc — those
|
|
63
|
+
// stay as URIs for the caller).
|
|
64
|
+
function decodeFileUri(uri) {
|
|
65
|
+
if (typeof uri !== "string" || !uri.startsWith("file://")) return null;
|
|
66
|
+
// file:///c%3A/code/foo → /c:/code/foo → c:/code/foo on win32
|
|
67
|
+
let p = decodeURIComponent(uri.slice("file://".length));
|
|
68
|
+
if (process.platform === "win32") {
|
|
69
|
+
// Strip leading slash and normalise separators
|
|
70
|
+
if (p.startsWith("/")) p = p.slice(1);
|
|
71
|
+
return p.replace(/\//g, "\\");
|
|
72
|
+
}
|
|
73
|
+
return p;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function* readWorkspaces(vscodeRoot, opts = {}) {
|
|
77
|
+
const fsMod = opts.fs || fs;
|
|
78
|
+
const wsRoot = path.join(vscodeRoot, "User", "workspaceStorage");
|
|
79
|
+
if (!fsMod.existsSync(wsRoot)) return;
|
|
80
|
+
const sinceMs = Number.isInteger(opts.since) && opts.since > 0 ? opts.since : 0;
|
|
81
|
+
let hashes;
|
|
82
|
+
try {
|
|
83
|
+
hashes = fsMod.readdirSync(wsRoot);
|
|
84
|
+
} catch {
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
for (const h of hashes) {
|
|
88
|
+
const wsFile = path.join(wsRoot, h, "workspace.json");
|
|
89
|
+
if (!fsMod.existsSync(wsFile)) continue;
|
|
90
|
+
let stat;
|
|
91
|
+
let body;
|
|
92
|
+
try {
|
|
93
|
+
stat = fsMod.statSync(wsFile);
|
|
94
|
+
body = JSON.parse(fsMod.readFileSync(wsFile, "utf-8"));
|
|
95
|
+
} catch {
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
98
|
+
const lastOpenedMs = Math.floor(stat.mtimeMs);
|
|
99
|
+
if (sinceMs > 0 && lastOpenedMs < sinceMs) continue;
|
|
100
|
+
const folderUri = typeof body?.folder === "string" ? body.folder : null;
|
|
101
|
+
const workspaceUri = typeof body?.workspace === "string" ? body.workspace : null;
|
|
102
|
+
if (!folderUri && !workspaceUri) continue;
|
|
103
|
+
yield {
|
|
104
|
+
hash: h,
|
|
105
|
+
folderUri,
|
|
106
|
+
workspaceUri,
|
|
107
|
+
folderPath: folderUri ? decodeFileUri(folderUri) : null,
|
|
108
|
+
lastOpenedMs,
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Read terminal command + dir history from state.vscdb. Returns
|
|
114
|
+
// { commands: [...], dirs: [...], commandsTimestampMs, dirsTimestampMs }.
|
|
115
|
+
// Each entry is { value, shellType, sourceIndex } — the index lets us
|
|
116
|
+
// reconstruct order across syncs.
|
|
117
|
+
function readTerminalHistory(vscodeRoot, opts = {}) {
|
|
118
|
+
const fsMod = opts.fs || fs;
|
|
119
|
+
const src = path.join(vscodeRoot, "User", "globalStorage", "state.vscdb");
|
|
120
|
+
if (!fsMod.existsSync(src)) {
|
|
121
|
+
return { commands: [], dirs: [], commandsTimestampMs: null, dirsTimestampMs: null };
|
|
122
|
+
}
|
|
123
|
+
const tmp = path.join(
|
|
124
|
+
os.tmpdir(),
|
|
125
|
+
`pdh-vscode-${Date.now()}-${Math.random().toString(36).slice(2, 8)}.db`,
|
|
126
|
+
);
|
|
127
|
+
fsMod.copyFileSync(src, tmp);
|
|
128
|
+
// VSCode uses plain SQLite (not WAL by default for state.vscdb), but copy
|
|
129
|
+
// the WAL sidecar if it exists just in case.
|
|
130
|
+
for (const ext of ["-wal", "-shm"]) {
|
|
131
|
+
const w = src + ext;
|
|
132
|
+
if (fsMod.existsSync(w)) {
|
|
133
|
+
try {
|
|
134
|
+
fsMod.copyFileSync(w, tmp + ext);
|
|
135
|
+
} catch {}
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
try {
|
|
139
|
+
const db = new Database(tmp, { readonly: true });
|
|
140
|
+
const get = (k) => {
|
|
141
|
+
try {
|
|
142
|
+
const r = db.prepare("SELECT value FROM ItemTable WHERE key=?").get(k);
|
|
143
|
+
return r ? r.value : null;
|
|
144
|
+
} catch {
|
|
145
|
+
return null;
|
|
146
|
+
}
|
|
147
|
+
};
|
|
148
|
+
const parseEntries = (jsonStr) => {
|
|
149
|
+
if (!jsonStr) return [];
|
|
150
|
+
try {
|
|
151
|
+
const parsed = JSON.parse(jsonStr);
|
|
152
|
+
const arr = Array.isArray(parsed?.entries) ? parsed.entries : [];
|
|
153
|
+
return arr
|
|
154
|
+
.map((e, i) => ({
|
|
155
|
+
value: typeof e?.key === "string" ? e.key : "",
|
|
156
|
+
shellType: e?.value?.shellType || null,
|
|
157
|
+
sourceIndex: i,
|
|
158
|
+
}))
|
|
159
|
+
.filter((e) => e.value.length > 0);
|
|
160
|
+
} catch {
|
|
161
|
+
return [];
|
|
162
|
+
}
|
|
163
|
+
};
|
|
164
|
+
const parseTs = (raw) => {
|
|
165
|
+
if (!raw) return null;
|
|
166
|
+
const n = Number(raw);
|
|
167
|
+
return Number.isFinite(n) && n > 0 ? Math.floor(n) : null;
|
|
168
|
+
};
|
|
169
|
+
const out = {
|
|
170
|
+
commands: parseEntries(get("terminal.history.entries.commands")),
|
|
171
|
+
dirs: parseEntries(get("terminal.history.entries.dirs")),
|
|
172
|
+
commandsTimestampMs: parseTs(get("terminal.history.timestamp.commands")),
|
|
173
|
+
dirsTimestampMs: parseTs(get("terminal.history.timestamp.dirs")),
|
|
174
|
+
};
|
|
175
|
+
db.close();
|
|
176
|
+
return out;
|
|
177
|
+
} finally {
|
|
178
|
+
for (const ext of ["", "-wal", "-shm"]) {
|
|
179
|
+
try {
|
|
180
|
+
fsMod.unlinkSync(tmp + ext);
|
|
181
|
+
} catch {}
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
module.exports = {
|
|
187
|
+
defaultVscodeRoot,
|
|
188
|
+
decodeFileUri,
|
|
189
|
+
readWorkspaces,
|
|
190
|
+
readTerminalHistory,
|
|
191
|
+
};
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
// WinRecentAdapter — surfaces Windows' cross-application "recently opened"
|
|
4
|
+
// shortcut list as an Event(OTHER) stream. Windows-only; gracefully fails
|
|
5
|
+
// authenticate() on macOS/Linux.
|
|
6
|
+
|
|
7
|
+
const path = require("node:path");
|
|
8
|
+
|
|
9
|
+
const {
|
|
10
|
+
ENTITY_TYPES,
|
|
11
|
+
EVENT_SUBTYPES,
|
|
12
|
+
CAPTURED_BY,
|
|
13
|
+
} = require("../../constants");
|
|
14
|
+
|
|
15
|
+
const {
|
|
16
|
+
defaultRecentDir,
|
|
17
|
+
readRecent,
|
|
18
|
+
} = require("./win-recent-reader");
|
|
19
|
+
|
|
20
|
+
const NAME = "win-recent";
|
|
21
|
+
const VERSION = "0.1.0";
|
|
22
|
+
|
|
23
|
+
class WinRecentAdapter {
|
|
24
|
+
constructor(opts = {}) {
|
|
25
|
+
this.name = NAME;
|
|
26
|
+
this.version = VERSION;
|
|
27
|
+
this.capabilities = ["sync:win-recent-shortcuts"];
|
|
28
|
+
this.extractMode = "file-import";
|
|
29
|
+
this.rateLimits = { perDay: 96 };
|
|
30
|
+
this.dataDisclosure = {
|
|
31
|
+
fields: ["recent:name,mtimeMs,size,lnkPath"],
|
|
32
|
+
sensitivity: "high",
|
|
33
|
+
legalGate: false,
|
|
34
|
+
defaultInclude: { recent: true },
|
|
35
|
+
};
|
|
36
|
+
this._deps = {
|
|
37
|
+
fs: require("node:fs"),
|
|
38
|
+
defaultDir: defaultRecentDir,
|
|
39
|
+
};
|
|
40
|
+
this._dirOverride = typeof opts.recentDir === "string" ? opts.recentDir : null;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
_resolveDir(opts) {
|
|
44
|
+
if (typeof opts?.recentDir === "string" && opts.recentDir.length > 0) {
|
|
45
|
+
return opts.recentDir;
|
|
46
|
+
}
|
|
47
|
+
if (this._dirOverride) return this._dirOverride;
|
|
48
|
+
return this._deps.defaultDir();
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
async authenticate(ctx = {}) {
|
|
52
|
+
const dir = this._resolveDir(ctx);
|
|
53
|
+
if (!dir) {
|
|
54
|
+
return {
|
|
55
|
+
ok: false,
|
|
56
|
+
reason: "PLATFORM_UNSUPPORTED",
|
|
57
|
+
message: "Windows Recent shortcuts only exist on win32; pass opts.recentDir to point at a directory on other platforms",
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
if (!this._deps.fs.existsSync(dir)) {
|
|
61
|
+
return {
|
|
62
|
+
ok: false,
|
|
63
|
+
reason: "RECENT_DIR_NOT_FOUND",
|
|
64
|
+
message: `no Recent dir at ${dir}`,
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
return { ok: true, mode: "file-import", recentDir: dir };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
async healthCheck() {
|
|
71
|
+
const dir = this._resolveDir({});
|
|
72
|
+
return { ok: !!dir && this._deps.fs.existsSync(dir), lastChecked: Date.now() };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
async *sync(opts = {}) {
|
|
76
|
+
const dir = this._resolveDir(opts);
|
|
77
|
+
if (!dir || !this._deps.fs.existsSync(dir)) {
|
|
78
|
+
throw new Error(`win-recent.sync: no Recent dir at ${dir || "?"} — set opts.recentDir`);
|
|
79
|
+
}
|
|
80
|
+
const limit = Number.isInteger(opts.limit) && opts.limit > 0 ? opts.limit : Infinity;
|
|
81
|
+
const capturedAt = Date.now();
|
|
82
|
+
let emitted = 0;
|
|
83
|
+
for (const r of readRecent(dir, { fs: this._deps.fs, since: opts.since })) {
|
|
84
|
+
if (emitted >= limit) return;
|
|
85
|
+
yield {
|
|
86
|
+
kind: "recent-file",
|
|
87
|
+
// Path is unique within the device; mtime gets folded into the
|
|
88
|
+
// event id so re-opening the same target produces a new row.
|
|
89
|
+
originalId: `win-recent:${r.lnkPath}:${r.mtimeMs}`,
|
|
90
|
+
capturedAt,
|
|
91
|
+
payload: r,
|
|
92
|
+
};
|
|
93
|
+
emitted += 1;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
normalize(raw) {
|
|
98
|
+
const ingestedAt = Date.now();
|
|
99
|
+
const source = (originalId) => ({
|
|
100
|
+
adapter: NAME,
|
|
101
|
+
adapterVersion: VERSION,
|
|
102
|
+
capturedAt: raw.capturedAt,
|
|
103
|
+
capturedBy: CAPTURED_BY.SQLITE,
|
|
104
|
+
originalId,
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
if (raw.kind === "recent-file") {
|
|
108
|
+
const p = raw.payload || {};
|
|
109
|
+
const name = typeof p.name === "string" && p.name.length > 0 ? p.name : "(无名)";
|
|
110
|
+
const event = {
|
|
111
|
+
id: `event-win-recent-${hashOriginal(raw.originalId)}`,
|
|
112
|
+
type: ENTITY_TYPES.EVENT,
|
|
113
|
+
subtype: EVENT_SUBTYPES.OTHER,
|
|
114
|
+
occurredAt: Number.isInteger(p.mtimeMs) ? p.mtimeMs : raw.capturedAt,
|
|
115
|
+
ingestedAt,
|
|
116
|
+
source: source(raw.originalId),
|
|
117
|
+
actor: "self",
|
|
118
|
+
content: {
|
|
119
|
+
title: `打开了 ${name.length > 70 ? name.substring(0, 70) + "…" : name}`,
|
|
120
|
+
text: name,
|
|
121
|
+
},
|
|
122
|
+
extra: {
|
|
123
|
+
kind: "recent-file",
|
|
124
|
+
targetName: name,
|
|
125
|
+
lnkPath: typeof p.lnkPath === "string" ? p.lnkPath : null,
|
|
126
|
+
lnkSize: Number.isInteger(p.size) ? p.size : null,
|
|
127
|
+
source: "win-recent",
|
|
128
|
+
},
|
|
129
|
+
};
|
|
130
|
+
return { events: [event], persons: [], places: [], items: [], topics: [] };
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
throw new Error(`win-recent.normalize: unknown raw.kind=${raw.kind}`);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function hashOriginal(s) {
|
|
138
|
+
let h = 5381;
|
|
139
|
+
const str = typeof s === "string" ? s : "";
|
|
140
|
+
for (let i = 0; i < str.length; i++) {
|
|
141
|
+
h = ((h << 5) + h + str.charCodeAt(i)) >>> 0;
|
|
142
|
+
}
|
|
143
|
+
return h.toString(36);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
module.exports = {
|
|
147
|
+
WinRecentAdapter,
|
|
148
|
+
WIN_RECENT_NAME: NAME,
|
|
149
|
+
WIN_RECENT_VERSION: VERSION,
|
|
150
|
+
};
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const {
|
|
4
|
+
WinRecentAdapter,
|
|
5
|
+
WIN_RECENT_NAME,
|
|
6
|
+
WIN_RECENT_VERSION,
|
|
7
|
+
} = require("./adapter");
|
|
8
|
+
const reader = require("./win-recent-reader");
|
|
9
|
+
|
|
10
|
+
module.exports = {
|
|
11
|
+
WinRecentAdapter,
|
|
12
|
+
WIN_RECENT_NAME,
|
|
13
|
+
WIN_RECENT_VERSION,
|
|
14
|
+
defaultRecentDir: reader.defaultRecentDir,
|
|
15
|
+
readRecent: reader.readRecent,
|
|
16
|
+
};
|