@chainlesschain/personal-data-hub 0.2.4 → 0.3.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/__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__/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/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,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
|
+
};
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
// win-recent-reader — lists .lnk shortcuts in %APPDATA%\Microsoft\Windows\
|
|
4
|
+
// \Recent\. Windows writes one .lnk per file/folder the user opens from
|
|
5
|
+
// any app (Explorer, Word, etc), so this is effectively a cross-application
|
|
6
|
+
// "what did I touch and when" timeline.
|
|
7
|
+
//
|
|
8
|
+
// v0.1 yields name + mtime only. Resolving the .lnk's actual target path
|
|
9
|
+
// requires parsing the Shell Link binary format (MS-SHLLINK) or shelling
|
|
10
|
+
// out to PowerShell COM — both deferred until we know users want it.
|
|
11
|
+
//
|
|
12
|
+
// AutomaticDestinations / CustomDestinations subdirectories hold Jump List
|
|
13
|
+
// data in opaque .automaticDestinations-ms / .customDestinations-ms binary
|
|
14
|
+
// blobs. Skipped for v0.1.
|
|
15
|
+
|
|
16
|
+
const fs = require("node:fs");
|
|
17
|
+
const path = require("node:path");
|
|
18
|
+
|
|
19
|
+
const RECENT_REL_PATH = ["Microsoft", "Windows", "Recent"];
|
|
20
|
+
const SKIP_SUBDIRS = new Set(["AutomaticDestinations", "CustomDestinations"]);
|
|
21
|
+
|
|
22
|
+
function defaultRecentDir() {
|
|
23
|
+
if (process.platform !== "win32") return null;
|
|
24
|
+
const appData = process.env.APPDATA;
|
|
25
|
+
if (!appData) return null;
|
|
26
|
+
return path.join(appData, ...RECENT_REL_PATH);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Yield one record per .lnk in the Recent dir. Records are sorted ascending
|
|
30
|
+
// by mtime so the registry watermark advances monotonically across syncs.
|
|
31
|
+
function* readRecent(recentDir, opts = {}) {
|
|
32
|
+
const fsMod = opts.fs || fs;
|
|
33
|
+
if (!fsMod.existsSync(recentDir)) return;
|
|
34
|
+
const sinceMs = Number.isInteger(opts.since) && opts.since > 0 ? opts.since : 0;
|
|
35
|
+
let entries;
|
|
36
|
+
try {
|
|
37
|
+
entries = fsMod.readdirSync(recentDir);
|
|
38
|
+
} catch {
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
const recs = [];
|
|
42
|
+
for (const e of entries) {
|
|
43
|
+
if (SKIP_SUBDIRS.has(e)) continue;
|
|
44
|
+
if (!e.toLowerCase().endsWith(".lnk")) continue;
|
|
45
|
+
const full = path.join(recentDir, e);
|
|
46
|
+
let stat;
|
|
47
|
+
try {
|
|
48
|
+
stat = fsMod.statSync(full);
|
|
49
|
+
} catch {
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
52
|
+
if (!stat.isFile()) continue;
|
|
53
|
+
const mtimeMs = Math.floor(stat.mtimeMs);
|
|
54
|
+
if (sinceMs > 0 && mtimeMs < sinceMs) continue;
|
|
55
|
+
const name = e.slice(0, e.length - 4); // strip .lnk
|
|
56
|
+
recs.push({
|
|
57
|
+
name,
|
|
58
|
+
mtimeMs,
|
|
59
|
+
size: stat.size,
|
|
60
|
+
lnkPath: full,
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
recs.sort((a, b) => a.mtimeMs - b.mtimeMs);
|
|
64
|
+
for (const r of recs) yield r;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
module.exports = {
|
|
68
|
+
defaultRecentDir,
|
|
69
|
+
readRecent,
|
|
70
|
+
RECENT_REL_PATH,
|
|
71
|
+
SKIP_SUBDIRS,
|
|
72
|
+
};
|