dsh-plugin-daily-report 1.0.0 → 1.0.2

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/lib/collect.js CHANGED
@@ -1,1037 +1,1047 @@
1
- /**
2
- * Data collection for the daily-report plugin.
3
- *
4
- * Git sources are read with the system `git` executable, so the user's normal
5
- * credential helper / SSH agent keeps working. Remote repositories are cloned
6
- * into a partial bare cache under $DSH_HOME/daily-report/repos.
7
- */
8
-
9
- import { execFile } from "node:child_process";
10
- import { createHash } from "node:crypto";
11
- import { promises as fs, existsSync } from "node:fs";
12
- import { homedir } from "node:os";
13
- import { basename, extname, isAbsolute, join, relative, resolve, sep } from "node:path";
14
- import { promisify } from "node:util";
15
- import { inflateRawSync } from "node:zlib";
16
-
17
- const execFileAsync = promisify(execFile);
18
-
19
- const MAX_GIT_COMMITS = 5000;
20
- const MAX_AI_DIFF_COMMITS = 60;
21
- const MAX_DIFF_CHARS = 1200;
22
- const MAX_FILES_PER_COMMIT = 50;
23
- const MAX_DOC_FILES = 3000;
24
- const MAX_DOC_BYTES = 2 * 1024 * 1024;
25
- const MAX_DOC_EXCERPT = 220;
26
- const MAX_DAY_SUMMARY = 1000;
27
- const MAX_SCAN_DEPTH = 6;
28
- const MAX_SCAN_REPOS = 100;
29
- const GIT_LOG_TIMEOUT = 60000;
30
- const GIT_CLONE_TIMEOUT = 180000;
31
- const GIT_BUFFER = 64 * 1024 * 1024;
32
-
33
- export const SUPPORTED_DOC_EXTENSIONS = new Set([
34
- ".md",
35
- ".markdown",
36
- ".mdx",
37
- ".txt",
38
- ".text",
39
- ".log",
40
- ".docx",
41
- ".html",
42
- ".htm",
43
- ]);
44
-
45
- const IGNORED_DIRS = new Set([
46
- ".git",
47
- ".svn",
48
- ".hg",
49
- "node_modules",
50
- "dist",
51
- "build",
52
- "out",
53
- "target",
54
- "coverage",
55
- ".next",
56
- ".nuxt",
57
- ".cache",
58
- "__pycache__",
59
- ".venv",
60
- "venv",
61
- ".idea",
62
- ".vscode",
63
- "bin",
64
- "obj",
65
- ]);
66
-
67
- const WEEKDAY_CN = ["星期日", "星期一", "星期二", "星期三", "星期四", "星期五", "星期六"];
68
- const WEEKDAY_EN = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"];
69
-
70
- export function pad2(value) {
71
- return String(value).padStart(2, "0");
72
- }
73
-
74
- export function formatLocalDate(date) {
75
- return date.getFullYear() + "-" + pad2(date.getMonth() + 1) + "-" + pad2(date.getDate());
76
- }
77
-
78
- export function formatLocalDateTime(date) {
79
- return (
80
- formatLocalDate(date) +
81
- " " +
82
- pad2(date.getHours()) +
83
- ":" +
84
- pad2(date.getMinutes()) +
85
- ":" +
86
- pad2(date.getSeconds())
87
- );
88
- }
89
-
90
- export function parseIsoDate(value) {
91
- if (typeof value !== "string" || !/^\d{4}-\d{2}-\d{2}$/.test(value)) return null;
92
- const year = Number(value.slice(0, 4));
93
- const month = Number(value.slice(5, 7));
94
- const day = Number(value.slice(8, 10));
95
- const date = new Date(year, month - 1, day);
96
- if (date.getFullYear() !== year || date.getMonth() !== month - 1 || date.getDate() !== day) return null;
97
- return date;
98
- }
99
-
100
- export function defaultMonthRange(now = new Date()) {
101
- const start = new Date(now.getFullYear(), now.getMonth(), 1);
102
- const end = new Date(now.getFullYear(), now.getMonth() + 1, 0);
103
- return { start: formatLocalDate(start), end: formatLocalDate(end) };
104
- }
105
-
106
- export function enumerateDates(start, end) {
107
- const first = parseIsoDate(start);
108
- const last = parseIsoDate(end);
109
- if (!first || !last || first > last) return [];
110
- const dates = [];
111
- const cursor = new Date(first.getFullYear(), first.getMonth(), first.getDate());
112
- while (cursor <= last && dates.length < 400) {
113
- dates.push(formatLocalDate(cursor));
114
- cursor.setDate(cursor.getDate() + 1);
115
- }
116
- return dates;
117
- }
118
-
119
- export function weekdayLabel(dateIso, locale) {
120
- const date = parseIsoDate(dateIso);
121
- if (!date) return "";
122
- return locale === "en" ? WEEKDAY_EN[date.getDay()] : WEEKDAY_CN[date.getDay()];
123
- }
124
-
125
- function cleanText(value, maxLength) {
126
- if (typeof value !== "string") return "";
127
- const text = value.replace(/\u0000/g, "").trim();
128
- return maxLength > 0 ? text.slice(0, maxLength) : text;
129
- }
130
-
131
- export function sanitizeSettings(raw) {
132
- const source = raw && typeof raw === "object" ? raw : {};
133
- const git = Array.isArray(source.git)
134
- ? source.git
135
- .slice(0, 30)
136
- .map((item) => {
137
- const spec = item && typeof item === "object" ? item : {};
138
- return {
139
- location: cleanText(spec.location, 2000),
140
- branch: cleanText(spec.branch, 300),
141
- label: cleanText(spec.label, 120),
142
- };
143
- })
144
- .filter((item) => item.location.length > 0)
145
- : [];
146
- const docs = Array.isArray(source.docs)
147
- ? source.docs
148
- .slice(0, 50)
149
- .map((item) => {
150
- if (typeof item === "string") {
151
- return { location: cleanText(item, 2000), label: "" };
152
- }
153
- const spec = item && typeof item === "object" ? item : {};
154
- return {
155
- location: cleanText(spec.location, 2000),
156
- label: cleanText(spec.label, 120),
157
- };
158
- })
159
- .filter((item) => item.location.length > 0)
160
- : [];
161
- return { git, docs, aiEnabled: source.aiEnabled !== false, mineOnly: source.mineOnly !== false };
162
- }
163
-
164
- function run(command, args, options = {}) {
165
- return execFileAsync(command, args, {
166
- cwd: options.cwd,
167
- encoding: "utf8",
168
- timeout: options.timeout || 30000,
169
- maxBuffer: options.maxBuffer || GIT_BUFFER,
170
- windowsHide: true,
171
- signal: options.signal,
172
- env: {
173
- ...process.env,
174
- GIT_TERMINAL_PROMPT: "0",
175
- GIT_OPTIONAL_LOCKS: "0",
176
- },
177
- });
178
- }
179
-
180
- function gitErrorText(error) {
181
- const stderr = typeof error.stderr === "string" ? error.stderr.trim() : "";
182
- const stdout = typeof error.stdout === "string" ? error.stdout.trim() : "";
183
- const raw = stderr || stdout || error.message || "git command failed";
184
- const line = raw
185
- .split(/\r?\n/)
186
- .map((item) => item.trim())
187
- .find((item) => item.length > 0);
188
- return (line || "git command failed").slice(0, 400);
189
- }
190
-
191
- function sanitizeBranch(branch) {
192
- const value = cleanText(branch, 300);
193
- if (value.length === 0) return "";
194
- if (value.startsWith("-") || !/^[A-Za-z0-9._/@{}^~:+\-]+$/.test(value)) {
195
- throw new Error("分支名称不合法:" + value);
196
- }
197
- return value;
198
- }
199
-
200
- function isRemoteLocation(location, base) {
201
- if (/^[a-z][a-z0-9+.-]*:\/\//i.test(location)) return true;
202
- if (/^[^@\s]+@[^:\s]+:.+/.test(location)) return true;
203
- if (/^[A-Za-z]:[\\/]/.test(location) || location.startsWith("\\\\") || location.startsWith("/")) return false;
204
- if (existsSync(resolveLocalPath(location, base))) return false;
205
- return /\.git$/i.test(location);
206
- }
207
-
208
- function repoLabelFromLocation(location) {
209
- let value = location.replace(/[\\/]+$/, "");
210
- if (value.includes("://")) {
211
- try {
212
- const url = new URL(value);
213
- value = url.pathname;
214
- } catch {
215
- // Keep the raw value for scp-like URLs.
216
- }
217
- }
218
- if (value.includes(":") && !/^[A-Za-z]:[\\/]/.test(value)) {
219
- value = value.slice(value.lastIndexOf(":") + 1);
220
- }
221
- const base = basename(value).replace(/\.git$/i, "");
222
- return cleanText(base || "repository", 120);
223
- }
224
-
225
- function resolveLocalPath(location, cwd) {
226
- if (isAbsolute(location)) return resolve(location);
227
- return resolve(cwd || process.cwd(), location);
228
- }
229
-
230
- export async function discoverGitRepositories(directory, options = {}) {
231
- const root = resolveLocalPath(directory, options.cwd || process.cwd());
232
- let rootStat;
233
- try {
234
- rootStat = await fs.stat(root);
235
- } catch {
236
- throw new Error("目录不存在:" + root);
237
- }
238
- if (!rootStat.isDirectory()) throw new Error("路径不是目录:" + root);
239
-
240
- const repos = [];
241
- const warnings = [];
242
- const signal = options.signal;
243
-
244
- async function walk(current, depth) {
245
- if (signal && signal.aborted) throw signal.reason || new Error("已取消");
246
- if (repos.length >= MAX_SCAN_REPOS) {
247
- warnings.push("Git 仓库数量超过上限 " + MAX_SCAN_REPOS + " 个,仅添加前 " + MAX_SCAN_REPOS + " 个。");
248
- return;
249
- }
250
- let entries;
251
- try {
252
- entries = await fs.readdir(current, { withFileTypes: true });
253
- } catch (error) {
254
- warnings.push("无法读取目录 " + current + ":" + (error.message || "未知错误"));
255
- return;
256
- }
257
- const isRepo = entries.some((entry) => entry.name === ".git");
258
- if (isRepo) {
259
- try {
260
- let branch = (await run("git", ["-C", current, "branch", "--show-current"], { timeout: 5000, signal })).stdout.trim();
261
- if (branch.length === 0) {
262
- branch = (await run("git", ["-C", current, "rev-parse", "--short", "HEAD"], { timeout: 5000, signal })).stdout.trim();
263
- }
264
- const rel = displayPath(relative(root, current));
265
- repos.push({
266
- location: current,
267
- branch,
268
- label: rel.length > 0 ? rel : basename(root),
269
- });
270
- } catch (error) {
271
- if (signal && signal.aborted) throw signal.reason || error;
272
- warnings.push("跳过无法读取的 Git 仓库 " + current + ":" + gitErrorText(error));
273
- }
274
- return;
275
- }
276
- if (depth >= MAX_SCAN_DEPTH) return;
277
- entries.sort((left, right) => left.name.localeCompare(right.name));
278
- for (const entry of entries) {
279
- if (!entry.isDirectory() || entry.isSymbolicLink()) continue;
280
- if (entry.name.startsWith(".") || IGNORED_DIRS.has(entry.name)) continue;
281
- await walk(join(current, entry.name), depth + 1);
282
- }
283
- }
284
-
285
- await walk(root, 0);
286
- return { root, repos, warnings };
287
- }
288
-
289
- function makeCacheKey(location, branch) {
290
- return createHash("sha1").update(location + "\u0000" + branch).digest("hex").slice(0, 20);
291
- }
292
-
293
- async function pathIsDirectory(path) {
294
- try {
295
- return (await fs.stat(path)).isDirectory();
296
- } catch {
297
- return false;
298
- }
299
- }
300
-
301
- async function cloneRemote(location, branch, cacheRoot, signal) {
302
- await fs.mkdir(cacheRoot, { recursive: true });
303
- const dir = join(cacheRoot, makeCacheKey(location, branch));
304
- const existed = await pathIsDirectory(dir);
305
- let fetched = false;
306
- let warning = "";
307
-
308
- if (!existed) {
309
- const baseArgs = ["clone", "--bare", "--single-branch", "--no-tags"];
310
- if (branch.length > 0) baseArgs.push("--branch", branch);
311
- baseArgs.push("--filter=blob:none", location, dir);
312
- try {
313
- await run("git", baseArgs, { timeout: GIT_CLONE_TIMEOUT, signal });
314
- } catch (error) {
315
- await fs.rm(dir, { recursive: true, force: true }).catch(() => {});
316
- const fallbackArgs = ["clone", "--bare", "--single-branch", "--no-tags"];
317
- if (branch.length > 0) fallbackArgs.push("--branch", branch);
318
- fallbackArgs.push(location, dir);
319
- try {
320
- await run("git", fallbackArgs, { timeout: GIT_CLONE_TIMEOUT, signal });
321
- } catch (fallbackError) {
322
- throw new Error("克隆仓库失败:" + gitErrorText(fallbackError || error));
323
- }
324
- warning = "服务器不支持 blob 过滤,已退回完整克隆。";
325
- }
326
- } else {
327
- const fetchArgs = ["--git-dir", dir, "fetch", "--prune", "--no-tags"];
328
- if (branch.length > 0) {
329
- fetchArgs.push("origin", branch);
330
- } else {
331
- fetchArgs.push("origin", "HEAD");
332
- }
333
- try {
334
- await run("git", fetchArgs, { timeout: GIT_CLONE_TIMEOUT, signal });
335
- fetched = true;
336
- } catch (error) {
337
- warning = "拉取更新失败,使用本地缓存:" + gitErrorText(error);
338
- }
339
- }
340
-
341
- const rev = fetched ? "FETCH_HEAD" : branch.length > 0 ? branch : "HEAD";
342
- return { dir, rev, warning };
343
- }
344
-
345
- function parseNameStatus(line) {
346
- const text = line.trim();
347
- if (text.length === 0) return null;
348
- const parts = text.split("\t");
349
- if (parts.length < 2) return null;
350
- const statusCode = parts[0].charAt(0);
351
- const label =
352
- {
353
- A: "新增",
354
- M: "修改",
355
- D: "删除",
356
- R: "重命名",
357
- C: "复制",
358
- T: "类型变更",
359
- U: "冲突",
360
- }[statusCode] || statusCode;
361
- return { status: label, path: parts.slice(1).join("\t") };
362
- }
363
-
364
- function compactDiff(text, maxLength) {
365
- const lines = text.split(/\r?\n/).filter((line) => {
366
- const trimmed = line.trim();
367
- if (trimmed.length === 0) return false;
368
- return !/^(index |--- |\+\+\+ |new file mode |deleted file mode |similarity index |rename from |rename to |Binary files )/.test(trimmed);
369
- });
370
- const joined = lines.join("\n").trim();
371
- return joined.length > maxLength ? joined.slice(0, maxLength) + "…" : joined;
372
- }
373
-
374
- function parseGitLog(output, range) {
375
- const commits = [];
376
- const chunks = output.split("\u001e");
377
- for (const chunk of chunks) {
378
- const text = chunk.replace(/^\r?\n/, "");
379
- if (text.trim().length === 0) continue;
380
- const lines = text.split(/\r?\n/);
381
- const header = lines.shift() || "";
382
- const fields = header.split("\u001f");
383
- if (fields.length < 5) continue;
384
- const [hash, author, email, dateIso, subject] = fields;
385
- const date = new Date(dateIso);
386
- if (Number.isNaN(date.getTime())) continue;
387
- const dateKey = formatLocalDate(date);
388
- if (dateKey < range.start || dateKey > range.end) continue;
389
- const files = [];
390
- for (const line of lines) {
391
- const parsed = parseNameStatus(line);
392
- if (parsed) files.push(parsed);
393
- }
394
- commits.push({
395
- hash,
396
- shortHash: hash.slice(0, 8),
397
- author: cleanText(author, 120),
398
- email: cleanText(email, 200),
399
- date: dateKey,
400
- time: formatLocalDateTime(date),
401
- isoTime: date.toISOString(),
402
- subject: cleanText(subject, 500) || "(无提交说明)",
403
- files,
404
- fileCount: files.length,
405
- filesTruncated: files.length > MAX_FILES_PER_COMMIT,
406
- visibleFiles: files.slice(0, MAX_FILES_PER_COMMIT),
407
- });
408
- }
409
- commits.sort((left, right) => (left.isoTime < right.isoTime ? -1 : left.isoTime > right.isoTime ? 1 : 0));
410
- return commits;
411
- }
412
-
413
- async function resolveAuthorIdentity(repoDir, signal) {
414
- let email = "";
415
- let name = "";
416
- try {
417
- email = (await run("git", ["-C", repoDir, "config", "user.email"], { timeout: 5000, signal })).stdout.trim();
418
- } catch {
419
- email = "";
420
- }
421
- try {
422
- name = (await run("git", ["-C", repoDir, "config", "user.name"], { timeout: 5000, signal })).stdout.trim();
423
- } catch {
424
- name = "";
425
- }
426
- return {
427
- email: email.toLowerCase(),
428
- name: name.toLowerCase(),
429
- };
430
- }
431
-
432
- function matchesAuthor(commit, identity) {
433
- if (!identity || (identity.email.length === 0 && identity.name.length === 0)) return true;
434
- const email = typeof commit.email === "string" ? commit.email.toLowerCase() : "";
435
- const name = typeof commit.author === "string" ? commit.author.toLowerCase() : "";
436
- return (identity.email.length > 0 && email === identity.email) || (identity.name.length > 0 && name === identity.name);
437
- }
438
-
439
- async function collectGitSource(spec, range, context) {
440
- const location = spec.location;
441
- const branch = sanitizeBranch(spec.branch);
442
- const label = spec.label || repoLabelFromLocation(location);
443
- const warnings = [];
444
- let repoDir;
445
- let rev;
446
- let remote = false;
447
-
448
- if (isRemoteLocation(location, context.cwd)) {
449
- remote = true;
450
- const cloned = await cloneRemote(location, branch, context.cacheRoot, context.signal);
451
- repoDir = cloned.dir;
452
- rev = cloned.rev;
453
- if (cloned.warning) warnings.push(cloned.warning);
454
- } else {
455
- repoDir = resolveLocalPath(location, context.cwd);
456
- if (!(await pathIsDirectory(repoDir))) {
457
- throw new Error("本地仓库路径不存在:" + repoDir);
458
- }
459
- rev = branch || "HEAD";
460
- }
461
-
462
- const args = [
463
- "-C",
464
- repoDir,
465
- "log",
466
- rev,
467
- "--since=" + range.start + "T00:00:00",
468
- "--until=" + range.end + "T23:59:59",
469
- "--date=iso-strict",
470
- "--pretty=format:%x1e%H%x1f%an%x1f%ae%x1f%ad%x1f%s",
471
- "--name-status",
472
- "--no-renames",
473
- "--max-count=" + MAX_GIT_COMMITS,
474
- ];
475
- let output;
476
- try {
477
- output = (await run("git", args, { timeout: GIT_LOG_TIMEOUT, signal: context.signal })).stdout;
478
- } catch (error) {
479
- if (rev === "FETCH_HEAD") {
480
- try {
481
- const fallback = args.slice();
482
- fallback[3] = branch || "HEAD";
483
- output = (await run("git", fallback, { timeout: GIT_LOG_TIMEOUT, signal: context.signal })).stdout;
484
- } catch (fallbackError) {
485
- throw new Error("读取 Git 记录失败:" + gitErrorText(fallbackError || error));
486
- }
487
- } else {
488
- throw new Error("读取 Git 记录失败:" + gitErrorText(error));
489
- }
490
- }
491
-
492
- let commits = parseGitLog(output, range);
493
- let authorNote = "";
494
- if (context.mineOnly) {
495
- const identity = await resolveAuthorIdentity(repoDir, context.signal);
496
- if (identity.email.length === 0 && identity.name.length === 0) {
497
- warnings.push("未找到当前 Git 用户身份,未按作者过滤。");
498
- } else {
499
- const beforeFilter = commits.length;
500
- commits = commits.filter((commit) => matchesAuthor(commit, identity));
501
- authorNote = "仅统计 " + (identity.email || identity.name);
502
- if (beforeFilter > 0 && commits.length === 0) {
503
- warnings.push("没有匹配当前 Git 用户 " + (identity.email || identity.name) + " 的提交,请检查 `git config user.email`。");
504
- }
505
- }
506
- }
507
- if (context.includeDiff) {
508
- let diffFailures = 0;
509
- const candidates = commits.slice(0, MAX_AI_DIFF_COMMITS);
510
- for (let index = 0; index < candidates.length; index += 1) {
511
- const commit = candidates[index];
512
- if (typeof context.onDiffProgress === "function") {
513
- context.onDiffProgress(index + 1, candidates.length);
514
- }
515
- if (commit.files.length === 0) continue;
516
- try {
517
- const diff = await run(
518
- "git",
519
- ["-C", repoDir, "show", "--format=", "--unified=1", "--no-color", "--no-ext-diff", "--no-renames", commit.hash],
520
- { timeout: 20000, signal: context.signal, maxBuffer: 16 * 1024 * 1024 },
521
- );
522
- commit.diff = compactDiff(diff.stdout, MAX_DIFF_CHARS);
523
- } catch (error) {
524
- if (context.signal && context.signal.aborted) throw context.signal.reason || error;
525
- diffFailures += 1;
526
- commit.diff = "";
527
- }
528
- }
529
- if (commits.length > MAX_AI_DIFF_COMMITS) {
530
- warnings.push("提交较多,AI 分析只读取前 " + MAX_AI_DIFF_COMMITS + " 条提交的代码差异。");
531
- }
532
- if (diffFailures > 0) {
533
- warnings.push(diffFailures + " 条提交的代码差异读取失败,AI 分析将仅使用提交说明。");
534
- }
535
- }
536
- if (commits.length >= MAX_GIT_COMMITS) {
537
- warnings.push("提交数量达到上限 " + MAX_GIT_COMMITS + " 条,日报仅包含最近部分。");
538
- }
539
- return {
540
- kind: "git",
541
- label,
542
- location,
543
- branch: branch || "(默认分支)",
544
- remote,
545
- status: warnings.length > 0 ? "warn" : "ok",
546
- message: (warnings.join(" ") || "读取成功") + (authorNote ? "(" + authorNote + ")" : ""),
547
- warnings,
548
- commits,
549
- };
550
- }
551
-
552
- function decodeTextBuffer(buffer) {
553
- if (buffer.length >= 3 && buffer[0] === 0xef && buffer[1] === 0xbb && buffer[2] === 0xbf) {
554
- return buffer.subarray(3).toString("utf8");
555
- }
556
- if (buffer.length >= 2 && buffer[0] === 0xff && buffer[1] === 0xfe) {
557
- return buffer.subarray(2).toString("utf16le");
558
- }
559
- if (buffer.length >= 2 && buffer[0] === 0xfe && buffer[1] === 0xff) {
560
- const swapped = Buffer.from(buffer.subarray(2));
561
- swapped.swap16();
562
- return swapped.toString("utf16le");
563
- }
564
- return buffer.toString("utf8");
565
- }
566
-
567
- function decodeXmlEntities(value) {
568
- return value
569
- .replace(/&lt;/g, "<")
570
- .replace(/&gt;/g, ">")
571
- .replace(/&quot;/g, '"')
572
- .replace(/&apos;/g, "'")
573
- .replace(/&#(\d+);/g, (_, code) => String.fromCodePoint(Number(code)))
574
- .replace(/&#x([0-9a-f]+);/gi, (_, code) => String.fromCodePoint(parseInt(code, 16)))
575
- .replace(/&amp;/g, "&");
576
- }
577
-
578
- function readZipEntry(buffer, wantedName) {
579
- let eocd = -1;
580
- for (let i = buffer.length - 22; i >= 0 && i >= buffer.length - 65557; i -= 1) {
581
- if (buffer.readUInt32LE(i) === 0x06054b50) {
582
- eocd = i;
583
- break;
584
- }
585
- }
586
- if (eocd < 0) throw new Error("DOCX 不是有效的 ZIP 文件");
587
- const count = buffer.readUInt16LE(eocd + 10);
588
- const centralOffset = buffer.readUInt32LE(eocd + 16);
589
- let cursor = centralOffset;
590
- for (let i = 0; i < count; i += 1) {
591
- if (cursor + 46 > buffer.length || buffer.readUInt32LE(cursor) !== 0x02014b50) break;
592
- const method = buffer.readUInt16LE(cursor + 10);
593
- const compressedSize = buffer.readUInt32LE(cursor + 20);
594
- const nameLength = buffer.readUInt16LE(cursor + 28);
595
- const extraLength = buffer.readUInt16LE(cursor + 30);
596
- const commentLength = buffer.readUInt16LE(cursor + 32);
597
- const localOffset = buffer.readUInt32LE(cursor + 42);
598
- const name = buffer.toString("utf8", cursor + 46, cursor + 46 + nameLength);
599
- if (name === wantedName) {
600
- if (buffer.readUInt32LE(localOffset) !== 0x04034b50) break;
601
- const localNameLength = buffer.readUInt16LE(localOffset + 26);
602
- const localExtraLength = buffer.readUInt16LE(localOffset + 28);
603
- const dataStart = localOffset + 30 + localNameLength + localExtraLength;
604
- const compressed = buffer.subarray(dataStart, dataStart + compressedSize);
605
- if (method === 0) return compressed;
606
- if (method === 8) return inflateRawSync(compressed);
607
- throw new Error("DOCX 使用了不支持的压缩算法");
608
- }
609
- cursor += 46 + nameLength + extraLength + commentLength;
610
- }
611
- return null;
612
- }
613
-
614
- function docxToText(buffer) {
615
- const xmlBuffer = readZipEntry(buffer, "word/document.xml");
616
- if (!xmlBuffer) return "";
617
- let text = xmlBuffer.toString("utf8");
618
- text = text
619
- .replace(/<w:tab[^>]*\/>/g, "\t")
620
- .replace(/<w:br[^>]*\/>/g, "\n")
621
- .replace(/<\/w:p>/g, "\n")
622
- .replace(/<[^>]+>/g, "");
623
- return decodeXmlEntities(text).replace(/\n{3,}/g, "\n\n").trim();
624
- }
625
-
626
- function htmlToText(value) {
627
- let text = value
628
- .replace(/<script[\s\S]*?<\/script>/gi, " ")
629
- .replace(/<style[\s\S]*?<\/style>/gi, " ")
630
- .replace(/<br\s*\/?>/gi, "\n")
631
- .replace(/<\/(p|div|li|h[1-6]|tr|section|article)>/gi, "\n")
632
- .replace(/<[^>]+>/g, " ");
633
- return decodeXmlEntities(text).replace(/[ \t]+/g, " ").replace(/\n{3,}/g, "\n\n").trim();
634
- }
635
-
636
- function stripMarkdown(value) {
637
- return value
638
- .replace(/```[\s\S]*?```/g, " ")
639
- .replace(/`([^`]+)`/g, "$1")
640
- .replace(/!\[[^\]]*\]\([^)]*\)/g, " ")
641
- .replace(/\[([^\]]+)\]\([^)]*\)/g, "$1")
642
- .replace(/[*_~>#]+/g, " ")
643
- .replace(/\s+/g, " ")
644
- .trim();
645
- }
646
-
647
- function detectDateFromName(name) {
648
- const patterns = [
649
- /(20\d{2})[-_.年](\d{1,2})[-_.月](\d{1,2})/,
650
- /(20\d{2})(\d{2})(\d{2})/,
651
- ];
652
- for (const pattern of patterns) {
653
- const match = name.match(pattern);
654
- if (!match) continue;
655
- const iso = match[1] + "-" + pad2(Number(match[2])) + "-" + pad2(Number(match[3]));
656
- if (parseIsoDate(iso)) return iso;
657
- }
658
- return "";
659
- }
660
-
661
- function detectDateFromText(text) {
662
- const head = text.slice(0, 2000);
663
- const match = head.match(/^(?:date|日期|created|创建日期)\s*[::]\s*['"]?(20\d{2})[-/.](\d{1,2})[-/.](\d{1,2})/im);
664
- if (!match) return "";
665
- const iso = match[1] + "-" + pad2(Number(match[2])) + "-" + pad2(Number(match[3]));
666
- return parseIsoDate(iso) ? iso : "";
667
- }
668
-
669
- function detectTitle(text, fileName) {
670
- const head = text.slice(0, 2500);
671
- const front = head.match(/^title\s*[::]\s*(.+)$/im);
672
- if (front) {
673
- const value = stripMarkdown(front[1].replace(/^['"]|['"]$/g, ""));
674
- if (value) return value.slice(0, 100);
675
- }
676
- const heading = head.match(/^#{1,6}\s+(.+)$/m);
677
- if (heading) {
678
- const value = stripMarkdown(heading[1]);
679
- if (value) return value.slice(0, 100);
680
- }
681
- for (const line of text.split(/\r?\n/)) {
682
- const value = stripMarkdown(line);
683
- if (value.length > 0) return value.slice(0, 100);
684
- }
685
- return basename(fileName, extname(fileName)).slice(0, 100) || "未命名文档";
686
- }
687
-
688
- function buildExcerpt(text, title) {
689
- let body = text.replace(/^---[\s\S]*?---\s*/m, "");
690
- body = stripMarkdown(body);
691
- if (title && body.startsWith(title)) {
692
- body = body.slice(title.length).trim();
693
- }
694
- if (body.length === 0) return "";
695
- return body.slice(0, MAX_DOC_EXCERPT) + (body.length > MAX_DOC_EXCERPT ? "…" : "");
696
- }
697
-
698
- async function readDocFile(filePath) {
699
- const extension = extname(filePath).toLowerCase();
700
- const buffer = await fs.readFile(filePath);
701
- let text = "";
702
- if (extension === ".docx") {
703
- text = docxToText(buffer);
704
- } else if (extension === ".html" || extension === ".htm") {
705
- text = htmlToText(decodeTextBuffer(buffer));
706
- } else {
707
- text = decodeTextBuffer(buffer);
708
- }
709
- return { extension, text };
710
- }
711
-
712
- async function walkDocs(root, current, state, output) {
713
- if (state.truncated) return;
714
- let entries;
715
- try {
716
- entries = await fs.readdir(current, { withFileTypes: true });
717
- } catch (error) {
718
- state.warnings.push("无法读取目录 " + current + ":" + (error.message || "未知错误"));
719
- return;
720
- }
721
- entries.sort((left, right) => left.name.localeCompare(right.name));
722
- for (const entry of entries) {
723
- if (state.truncated) return;
724
- if (entry.isSymbolicLink()) continue;
725
- const full = join(current, entry.name);
726
- if (entry.isDirectory()) {
727
- if (IGNORED_DIRS.has(entry.name)) continue;
728
- await walkDocs(root, full, state, output);
729
- continue;
730
- }
731
- if (!entry.isFile()) continue;
732
- const extension = extname(entry.name).toLowerCase();
733
- if (!SUPPORTED_DOC_EXTENSIONS.has(extension)) continue;
734
- if (output.length >= MAX_DOC_FILES) {
735
- state.truncated = true;
736
- state.warnings.push("文档数量超过上限 " + MAX_DOC_FILES + " 个,仅扫描前 " + MAX_DOC_FILES + " 个。");
737
- return;
738
- }
739
- let stat;
740
- try {
741
- stat = await fs.stat(full);
742
- } catch {
743
- continue;
744
- }
745
- if (!stat.isFile()) continue;
746
- if (stat.size > MAX_DOC_BYTES) {
747
- state.warnings.push("跳过超过 2 MB 的文件:" + relative(root, full));
748
- continue;
749
- }
750
- if (stat.size === 0) continue;
751
- output.push({ full, relative: relative(root, full), stat });
752
- }
753
- }
754
-
755
- function displayPath(value) {
756
- return value.split(sep).join("/");
757
- }
758
-
759
- async function collectDocsSource(spec, range, context) {
760
- const location = resolveLocalPath(spec.location, context.cwd);
761
- const label = spec.label || cleanText(basename(location), 120) || "文档";
762
- let stat;
763
- try {
764
- stat = await fs.stat(location);
765
- } catch {
766
- throw new Error("路径不存在:" + location);
767
- }
768
- const root = stat.isDirectory() ? location : resolve(location, "..");
769
- const candidates = [];
770
- const warnings = [];
771
- if (stat.isFile()) {
772
- const extension = extname(location).toLowerCase();
773
- if (!SUPPORTED_DOC_EXTENSIONS.has(extension)) {
774
- throw new Error("不支持的文档类型:" + extension + "(支持 " + Array.from(SUPPORTED_DOC_EXTENSIONS).join("、") + ")");
775
- }
776
- if (stat.size > MAX_DOC_BYTES) throw new Error("文件超过 2 MB:" + location);
777
- candidates.push({ full: location, relative: basename(location), stat });
778
- } else if (stat.isDirectory()) {
779
- await walkDocs(location, location, { warnings, truncated: false }, candidates);
780
- } else {
781
- throw new Error("路径不是文件或目录:" + location);
782
- }
783
-
784
- const docs = [];
785
- for (const candidate of candidates) {
786
- if (context.signal && context.signal.aborted) throw context.signal.reason || new Error("已取消");
787
- let parsed;
788
- try {
789
- parsed = await readDocFile(candidate.full);
790
- } catch (error) {
791
- warnings.push("读取失败:" + candidate.relative + "(" + (error.message || "未知错误") + ")");
792
- continue;
793
- }
794
- const fileDate = detectDateFromName(candidate.relative);
795
- const textDate = detectDateFromText(parsed.text);
796
- const date = fileDate || textDate || formatLocalDate(candidate.stat.mtime);
797
- if (date < range.start || date > range.end) continue;
798
- const title = detectTitle(parsed.text, candidate.relative);
799
- docs.push({
800
- date,
801
- source: label,
802
- file: displayPath(candidate.relative),
803
- absolutePath: candidate.full,
804
- title,
805
- excerpt: buildExcerpt(parsed.text, title),
806
- size: candidate.stat.size,
807
- mtime: formatLocalDateTime(candidate.stat.mtime),
808
- extension: parsed.extension.replace(/^\./, "").toUpperCase(),
809
- dateFrom: fileDate ? "文件名" : textDate ? "文档日期" : "修改时间",
810
- });
811
- }
812
- docs.sort((left, right) => (left.date < right.date ? -1 : left.date > right.date ? 1 : left.file.localeCompare(right.file)));
813
- return {
814
- kind: "docs",
815
- label,
816
- location,
817
- branch: "",
818
- remote: false,
819
- status: warnings.length > 0 ? "warn" : "ok",
820
- message: warnings.length > 0 ? warnings.join(" ") : "读取成功",
821
- warnings,
822
- docs,
823
- fileCount: candidates.length,
824
- };
825
- }
826
-
827
- function resolveHome(homeDir) {
828
- if (typeof homeDir === "string" && homeDir.length > 0) return homeDir;
829
- if (process.env.DSH_HOME && process.env.DSH_HOME.length > 0) return process.env.DSH_HOME;
830
- return join(homedir(), ".dsh");
831
- }
832
-
833
- function uniqueStrings(values) {
834
- const seen = new Set();
835
- const output = [];
836
- for (const value of values) {
837
- const text = cleanText(value, 300);
838
- if (text.length === 0 || seen.has(text)) continue;
839
- seen.add(text);
840
- output.push(text);
841
- }
842
- return output;
843
- }
844
-
845
- function buildDays(range, commits, docs, locale) {
846
- const byDate = new Map();
847
- for (const date of enumerateDates(range.start, range.end)) {
848
- byDate.set(date, { date, commits: [], docs: [] });
849
- }
850
- for (const commit of commits) {
851
- if (byDate.has(commit.date)) byDate.get(commit.date).commits.push(commit);
852
- }
853
- for (const doc of docs) {
854
- if (byDate.has(doc.date)) byDate.get(doc.date).docs.push(doc);
855
- }
856
-
857
- const days = [];
858
- for (const [date, bucket] of byDate) {
859
- const dateValue = parseIsoDate(date);
860
- const isWeekend = dateValue ? dateValue.getDay() === 0 || dateValue.getDay() === 6 : false;
861
- const subjectGroups = new Map();
862
- for (const commit of bucket.commits) {
863
- if (!subjectGroups.has(commit.repo)) subjectGroups.set(commit.repo, []);
864
- subjectGroups.get(commit.repo).push(commit.subject);
865
- }
866
- const parts = [];
867
- for (const [repo, subjects] of subjectGroups) {
868
- const unique = uniqueStrings(subjects);
869
- const visible = unique.slice(0, 6);
870
- const suffix = unique.length > visible.length ? " 等 " + unique.length + " 项" : "";
871
- parts.push("【" + repo + "】" + visible.join(";") + suffix);
872
- }
873
- if (bucket.docs.length > 0) {
874
- const titles = uniqueStrings(bucket.docs.map((doc) => doc.title)).slice(0, 8);
875
- const suffix = bucket.docs.length > titles.length ? " 等 " + bucket.docs.length + " 份" : "";
876
- parts.push("【文档】" + titles.join(";") + suffix);
877
- }
878
- let summary = parts.join(";") || "无记录";
879
- if (summary.length > MAX_DAY_SUMMARY) summary = summary.slice(0, MAX_DAY_SUMMARY) + "…";
880
- const sources = uniqueStrings([
881
- ...bucket.commits.map((commit) => commit.repo),
882
- ...bucket.docs.map((doc) => doc.source),
883
- ]);
884
- days.push({
885
- date,
886
- weekday: weekdayLabel(date, locale),
887
- isWeekend,
888
- dayType: isWeekend ? (locale === "en" ? "Weekend" : "周末") : locale === "en" ? "Workday" : "工作日",
889
- commitCount: bucket.commits.length,
890
- docCount: bucket.docs.length,
891
- sources: sources.join(locale === "en" ? ", " : "、"),
892
- summary,
893
- commits: bucket.commits,
894
- docs: bucket.docs,
895
- });
896
- }
897
- return days;
898
- }
899
-
900
- export async function collectReport(options) {
901
- const settings = sanitizeSettings(options.settings);
902
- const start = options.start;
903
- const end = options.end;
904
- const locale = options.locale === "en" ? "en" : "zh";
905
- const signal = options.signal;
906
- const cwd = options.cwd || process.cwd();
907
- const cacheRoot = join(resolveHome(options.homeDir), "daily-report", "repos");
908
- const context = { cwd, cacheRoot, signal, includeDiff: options.includeDiff === true, mineOnly: options.mineOnly === true };
909
- const range = { start, end };
910
- const warnings = [];
911
- const sources = [];
912
- const commits = [];
913
- const docs = [];
914
- const report = (progress) => {
915
- if (typeof options.onProgress === "function") options.onProgress(progress);
916
- };
917
-
918
- if (settings.git.length === 0 && settings.docs.length === 0) {
919
- throw new Error("请至少添加一个 Git 仓库或本地文档。");
920
- }
921
-
922
- const gitTotal = settings.git.length;
923
- for (let index = 0; index < gitTotal; index += 1) {
924
- const spec = settings.git[index];
925
- const label = spec.label || repoLabelFromLocation(spec.location);
926
- const basePercent = 5 + (index / Math.max(1, gitTotal)) * 35;
927
- report({
928
- stage: "git",
929
- percent: basePercent,
930
- message: "正在读取 Git 仓库 " + (index + 1) + "/" + gitTotal + ":" + label,
931
- });
932
- try {
933
- const source = await collectGitSource(spec, range, {
934
- ...context,
935
- onDiffProgress: (current, total) => {
936
- report({
937
- stage: "git-diff",
938
- percent: 5 + ((index + current / Math.max(1, total)) / Math.max(1, gitTotal)) * 35,
939
- message: "正在读取代码变更:" + label + "(" + current + "/" + total + ")",
940
- });
941
- },
942
- });
943
- for (const commit of source.commits) {
944
- commit.repo = source.label;
945
- commit.branch = source.branch;
946
- }
947
- commits.push(...source.commits);
948
- for (const warning of source.warnings) warnings.push(source.label + ":" + warning);
949
- sources.push({
950
- kind: "git",
951
- label: source.label,
952
- location: source.location,
953
- branch: source.branch,
954
- status: source.status,
955
- message: source.message,
956
- count: source.commits.length,
957
- });
958
- } catch (error) {
959
- if (signal && signal.aborted) throw signal.reason || error;
960
- sources.push({
961
- kind: "git",
962
- label,
963
- location: spec.location,
964
- branch: spec.branch || "(默认分支)",
965
- status: "error",
966
- message: error.message || "读取失败",
967
- count: 0,
968
- });
969
- warnings.push((spec.label || spec.location) + ":" + (error.message || "读取失败"));
970
- }
971
- }
972
- report({ stage: "git-done", percent: 42, message: "Git 数据读取完成,正在扫描本地文档…" });
973
-
974
- const docTotal = settings.docs.length;
975
- for (let index = 0; index < docTotal; index += 1) {
976
- const spec = settings.docs[index];
977
- report({
978
- stage: "docs",
979
- percent: 44 + (index / Math.max(1, docTotal)) * 22,
980
- message: "正在扫描文档 " + (index + 1) + "/" + docTotal + ":" + (spec.label || basename(spec.location) || spec.location),
981
- });
982
- try {
983
- const source = await collectDocsSource(spec, range, context);
984
- docs.push(...source.docs);
985
- for (const warning of source.warnings) warnings.push(source.label + ":" + warning);
986
- sources.push({
987
- kind: "docs",
988
- label: source.label,
989
- location: source.location,
990
- branch: "",
991
- status: source.status,
992
- message: source.message,
993
- count: source.docs.length,
994
- });
995
- } catch (error) {
996
- if (signal && signal.aborted) throw signal.reason || error;
997
- sources.push({
998
- kind: "docs",
999
- label: spec.label || basename(spec.location) || "文档",
1000
- location: spec.location,
1001
- branch: "",
1002
- status: "error",
1003
- message: error.message || "读取失败",
1004
- count: 0,
1005
- });
1006
- warnings.push((spec.label || spec.location) + ":" + (error.message || "读取失败"));
1007
- }
1008
- }
1009
- report({ stage: "aggregate", percent: 68, message: "正在整理按天日报…" });
1010
-
1011
- const days = buildDays(range, commits, docs, locale);
1012
- const activeDays = days.filter((day) => day.commitCount + day.docCount > 0).length;
1013
- const stats = {
1014
- days: days.length,
1015
- activeDays,
1016
- commits: commits.length,
1017
- docs: docs.length,
1018
- sources: sources.length,
1019
- okSources: sources.filter((source) => source.status !== "error").length,
1020
- failedSources: sources.filter((source) => source.status === "error").length,
1021
- };
1022
-
1023
- return {
1024
- range,
1025
- days,
1026
- commits,
1027
- docs,
1028
- sources,
1029
- warnings: uniqueStrings(warnings),
1030
- stats,
1031
- settings,
1032
- locale,
1033
- generatedAt: options.now instanceof Date ? options.now : new Date(),
1034
- };
1035
- }
1036
-
1037
- export { parseGitLog };
1
+ /**
2
+ * Data collection for the daily-report plugin.
3
+ *
4
+ * Git sources are read with the system `git` executable, so the user's normal
5
+ * credential helper / SSH agent keeps working. Remote repositories are cloned
6
+ * into a partial bare cache under $DSH_HOME/daily-report/repos.
7
+ */
8
+
9
+ import { execFile } from "node:child_process";
10
+ import { createHash } from "node:crypto";
11
+ import { promises as fs, existsSync } from "node:fs";
12
+ import { homedir } from "node:os";
13
+ import { basename, extname, isAbsolute, join, relative, resolve, sep } from "node:path";
14
+ import { promisify } from "node:util";
15
+ import { inflateRawSync } from "node:zlib";
16
+
17
+ const execFileAsync = promisify(execFile);
18
+
19
+ const MAX_GIT_COMMITS = 5000;
20
+ const MAX_AI_DIFF_COMMITS = 60;
21
+ const MAX_DIFF_CHARS = 1200;
22
+ const MAX_FILES_PER_COMMIT = 50;
23
+ const MAX_DOC_FILES = 3000;
24
+ const MAX_DOC_BYTES = 2 * 1024 * 1024;
25
+ const MAX_DOC_EXCERPT = 220;
26
+ const MAX_DAY_SUMMARY = 1000;
27
+ const MAX_SCAN_DEPTH = 6;
28
+ const MAX_SCAN_REPOS = 100;
29
+ const GIT_LOG_TIMEOUT = 60000;
30
+ const GIT_CLONE_TIMEOUT = 180000;
31
+ const GIT_BUFFER = 64 * 1024 * 1024;
32
+
33
+ export const SUPPORTED_DOC_EXTENSIONS = new Set([
34
+ ".md",
35
+ ".markdown",
36
+ ".mdx",
37
+ ".txt",
38
+ ".text",
39
+ ".log",
40
+ ".docx",
41
+ ".html",
42
+ ".htm",
43
+ ]);
44
+
45
+ const IGNORED_DIRS = new Set([
46
+ ".git",
47
+ ".svn",
48
+ ".hg",
49
+ "node_modules",
50
+ "dist",
51
+ "build",
52
+ "out",
53
+ "target",
54
+ "coverage",
55
+ ".next",
56
+ ".nuxt",
57
+ ".cache",
58
+ "__pycache__",
59
+ ".venv",
60
+ "venv",
61
+ ".idea",
62
+ ".vscode",
63
+ "bin",
64
+ "obj",
65
+ ]);
66
+
67
+ const WEEKDAY_CN = ["星期日", "星期一", "星期二", "星期三", "星期四", "星期五", "星期六"];
68
+ const WEEKDAY_EN = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"];
69
+
70
+ export function pad2(value) {
71
+ return String(value).padStart(2, "0");
72
+ }
73
+
74
+ export function formatLocalDate(date) {
75
+ return date.getFullYear() + "-" + pad2(date.getMonth() + 1) + "-" + pad2(date.getDate());
76
+ }
77
+
78
+ export function formatLocalDateTime(date) {
79
+ return (
80
+ formatLocalDate(date) +
81
+ " " +
82
+ pad2(date.getHours()) +
83
+ ":" +
84
+ pad2(date.getMinutes()) +
85
+ ":" +
86
+ pad2(date.getSeconds())
87
+ );
88
+ }
89
+
90
+ export function parseIsoDate(value) {
91
+ if (typeof value !== "string" || !/^\d{4}-\d{2}-\d{2}$/.test(value)) return null;
92
+ const year = Number(value.slice(0, 4));
93
+ const month = Number(value.slice(5, 7));
94
+ const day = Number(value.slice(8, 10));
95
+ const date = new Date(year, month - 1, day);
96
+ if (date.getFullYear() !== year || date.getMonth() !== month - 1 || date.getDate() !== day) return null;
97
+ return date;
98
+ }
99
+
100
+ export function defaultMonthRange(now = new Date()) {
101
+ const start = new Date(now.getFullYear(), now.getMonth(), 1);
102
+ const end = new Date(now.getFullYear(), now.getMonth() + 1, 0);
103
+ return { start: formatLocalDate(start), end: formatLocalDate(end) };
104
+ }
105
+
106
+ export function enumerateDates(start, end) {
107
+ const first = parseIsoDate(start);
108
+ const last = parseIsoDate(end);
109
+ if (!first || !last || first > last) return [];
110
+ const dates = [];
111
+ const cursor = new Date(first.getFullYear(), first.getMonth(), first.getDate());
112
+ while (cursor <= last && dates.length < 400) {
113
+ dates.push(formatLocalDate(cursor));
114
+ cursor.setDate(cursor.getDate() + 1);
115
+ }
116
+ return dates;
117
+ }
118
+
119
+ export function weekdayLabel(dateIso, locale) {
120
+ const date = parseIsoDate(dateIso);
121
+ if (!date) return "";
122
+ return locale === "en" ? WEEKDAY_EN[date.getDay()] : WEEKDAY_CN[date.getDay()];
123
+ }
124
+
125
+ function cleanText(value, maxLength) {
126
+ if (typeof value !== "string") return "";
127
+ const text = value.replace(/\u0000/g, "").trim();
128
+ return maxLength > 0 ? text.slice(0, maxLength) : text;
129
+ }
130
+
131
+ export function sanitizeSettings(raw) {
132
+ const source = raw && typeof raw === "object" ? raw : {};
133
+ const git = Array.isArray(source.git)
134
+ ? source.git
135
+ .slice(0, 30)
136
+ .map((item) => {
137
+ const spec = item && typeof item === "object" ? item : {};
138
+ return {
139
+ location: cleanText(spec.location, 2000),
140
+ branch: cleanText(spec.branch, 300),
141
+ label: cleanText(spec.label, 120),
142
+ };
143
+ })
144
+ .filter((item) => item.location.length > 0)
145
+ : [];
146
+ const docs = Array.isArray(source.docs)
147
+ ? source.docs
148
+ .slice(0, 50)
149
+ .map((item) => {
150
+ if (typeof item === "string") {
151
+ return { location: cleanText(item, 2000), label: "" };
152
+ }
153
+ const spec = item && typeof item === "object" ? item : {};
154
+ return {
155
+ location: cleanText(spec.location, 2000),
156
+ label: cleanText(spec.label, 120),
157
+ };
158
+ })
159
+ .filter((item) => item.location.length > 0)
160
+ : [];
161
+ const range = source.range && typeof source.range === "object" ? source.range : null;
162
+ const start = range && parseIsoDate(range.start);
163
+ const end = range && parseIsoDate(range.end);
164
+ return {
165
+ git,
166
+ docs,
167
+ aiEnabled: source.aiEnabled !== false,
168
+ mineOnly: source.mineOnly !== false,
169
+ format: ["xlsx", "markdown", "csv", "html"].includes(source.format) ? source.format : "xlsx",
170
+ range: start && end && start <= end ? { start: range.start, end: range.end } : null,
171
+ };
172
+ }
173
+
174
+ function run(command, args, options = {}) {
175
+ return execFileAsync(command, args, {
176
+ cwd: options.cwd,
177
+ encoding: "utf8",
178
+ timeout: options.timeout || 30000,
179
+ maxBuffer: options.maxBuffer || GIT_BUFFER,
180
+ windowsHide: true,
181
+ signal: options.signal,
182
+ env: {
183
+ ...process.env,
184
+ GIT_TERMINAL_PROMPT: "0",
185
+ GIT_OPTIONAL_LOCKS: "0",
186
+ },
187
+ });
188
+ }
189
+
190
+ function gitErrorText(error) {
191
+ const stderr = typeof error.stderr === "string" ? error.stderr.trim() : "";
192
+ const stdout = typeof error.stdout === "string" ? error.stdout.trim() : "";
193
+ const raw = stderr || stdout || error.message || "git command failed";
194
+ const line = raw
195
+ .split(/\r?\n/)
196
+ .map((item) => item.trim())
197
+ .find((item) => item.length > 0);
198
+ return (line || "git command failed").slice(0, 400);
199
+ }
200
+
201
+ function sanitizeBranch(branch) {
202
+ const value = cleanText(branch, 300);
203
+ if (value.length === 0) return "";
204
+ if (value.startsWith("-") || !/^[A-Za-z0-9._/@{}^~:+\-]+$/.test(value)) {
205
+ throw new Error("分支名称不合法:" + value);
206
+ }
207
+ return value;
208
+ }
209
+
210
+ function isRemoteLocation(location, base) {
211
+ if (/^[a-z][a-z0-9+.-]*:\/\//i.test(location)) return true;
212
+ if (/^[^@\s]+@[^:\s]+:.+/.test(location)) return true;
213
+ if (/^[A-Za-z]:[\\/]/.test(location) || location.startsWith("\\\\") || location.startsWith("/")) return false;
214
+ if (existsSync(resolveLocalPath(location, base))) return false;
215
+ return /\.git$/i.test(location);
216
+ }
217
+
218
+ function repoLabelFromLocation(location) {
219
+ let value = location.replace(/[\\/]+$/, "");
220
+ if (value.includes("://")) {
221
+ try {
222
+ const url = new URL(value);
223
+ value = url.pathname;
224
+ } catch {
225
+ // Keep the raw value for scp-like URLs.
226
+ }
227
+ }
228
+ if (value.includes(":") && !/^[A-Za-z]:[\\/]/.test(value)) {
229
+ value = value.slice(value.lastIndexOf(":") + 1);
230
+ }
231
+ const base = basename(value).replace(/\.git$/i, "");
232
+ return cleanText(base || "repository", 120);
233
+ }
234
+
235
+ function resolveLocalPath(location, cwd) {
236
+ if (isAbsolute(location)) return resolve(location);
237
+ return resolve(cwd || process.cwd(), location);
238
+ }
239
+
240
+ export async function discoverGitRepositories(directory, options = {}) {
241
+ const root = resolveLocalPath(directory, options.cwd || process.cwd());
242
+ let rootStat;
243
+ try {
244
+ rootStat = await fs.stat(root);
245
+ } catch {
246
+ throw new Error("目录不存在:" + root);
247
+ }
248
+ if (!rootStat.isDirectory()) throw new Error("路径不是目录:" + root);
249
+
250
+ const repos = [];
251
+ const warnings = [];
252
+ const signal = options.signal;
253
+
254
+ async function walk(current, depth) {
255
+ if (signal && signal.aborted) throw signal.reason || new Error("已取消");
256
+ if (repos.length >= MAX_SCAN_REPOS) {
257
+ warnings.push("Git 仓库数量超过上限 " + MAX_SCAN_REPOS + " 个,仅添加前 " + MAX_SCAN_REPOS + " 个。");
258
+ return;
259
+ }
260
+ let entries;
261
+ try {
262
+ entries = await fs.readdir(current, { withFileTypes: true });
263
+ } catch (error) {
264
+ warnings.push("无法读取目录 " + current + ":" + (error.message || "未知错误"));
265
+ return;
266
+ }
267
+ const isRepo = entries.some((entry) => entry.name === ".git");
268
+ if (isRepo) {
269
+ try {
270
+ let branch = (await run("git", ["-C", current, "branch", "--show-current"], { timeout: 5000, signal })).stdout.trim();
271
+ if (branch.length === 0) {
272
+ branch = (await run("git", ["-C", current, "rev-parse", "--short", "HEAD"], { timeout: 5000, signal })).stdout.trim();
273
+ }
274
+ const rel = displayPath(relative(root, current));
275
+ repos.push({
276
+ location: current,
277
+ branch,
278
+ label: rel.length > 0 ? rel : basename(root),
279
+ });
280
+ } catch (error) {
281
+ if (signal && signal.aborted) throw signal.reason || error;
282
+ warnings.push("跳过无法读取的 Git 仓库 " + current + ":" + gitErrorText(error));
283
+ }
284
+ return;
285
+ }
286
+ if (depth >= MAX_SCAN_DEPTH) return;
287
+ entries.sort((left, right) => left.name.localeCompare(right.name));
288
+ for (const entry of entries) {
289
+ if (!entry.isDirectory() || entry.isSymbolicLink()) continue;
290
+ if (entry.name.startsWith(".") || IGNORED_DIRS.has(entry.name)) continue;
291
+ await walk(join(current, entry.name), depth + 1);
292
+ }
293
+ }
294
+
295
+ await walk(root, 0);
296
+ return { root, repos, warnings };
297
+ }
298
+
299
+ function makeCacheKey(location, branch) {
300
+ return createHash("sha1").update(location + "\u0000" + branch).digest("hex").slice(0, 20);
301
+ }
302
+
303
+ async function pathIsDirectory(path) {
304
+ try {
305
+ return (await fs.stat(path)).isDirectory();
306
+ } catch {
307
+ return false;
308
+ }
309
+ }
310
+
311
+ async function cloneRemote(location, branch, cacheRoot, signal) {
312
+ await fs.mkdir(cacheRoot, { recursive: true });
313
+ const dir = join(cacheRoot, makeCacheKey(location, branch));
314
+ const existed = await pathIsDirectory(dir);
315
+ let fetched = false;
316
+ let warning = "";
317
+
318
+ if (!existed) {
319
+ const baseArgs = ["clone", "--bare", "--single-branch", "--no-tags"];
320
+ if (branch.length > 0) baseArgs.push("--branch", branch);
321
+ baseArgs.push("--filter=blob:none", location, dir);
322
+ try {
323
+ await run("git", baseArgs, { timeout: GIT_CLONE_TIMEOUT, signal });
324
+ } catch (error) {
325
+ await fs.rm(dir, { recursive: true, force: true }).catch(() => {});
326
+ const fallbackArgs = ["clone", "--bare", "--single-branch", "--no-tags"];
327
+ if (branch.length > 0) fallbackArgs.push("--branch", branch);
328
+ fallbackArgs.push(location, dir);
329
+ try {
330
+ await run("git", fallbackArgs, { timeout: GIT_CLONE_TIMEOUT, signal });
331
+ } catch (fallbackError) {
332
+ throw new Error("克隆仓库失败:" + gitErrorText(fallbackError || error));
333
+ }
334
+ warning = "服务器不支持 blob 过滤,已退回完整克隆。";
335
+ }
336
+ } else {
337
+ const fetchArgs = ["--git-dir", dir, "fetch", "--prune", "--no-tags"];
338
+ if (branch.length > 0) {
339
+ fetchArgs.push("origin", branch);
340
+ } else {
341
+ fetchArgs.push("origin", "HEAD");
342
+ }
343
+ try {
344
+ await run("git", fetchArgs, { timeout: GIT_CLONE_TIMEOUT, signal });
345
+ fetched = true;
346
+ } catch (error) {
347
+ warning = "拉取更新失败,使用本地缓存:" + gitErrorText(error);
348
+ }
349
+ }
350
+
351
+ const rev = fetched ? "FETCH_HEAD" : branch.length > 0 ? branch : "HEAD";
352
+ return { dir, rev, warning };
353
+ }
354
+
355
+ function parseNameStatus(line) {
356
+ const text = line.trim();
357
+ if (text.length === 0) return null;
358
+ const parts = text.split("\t");
359
+ if (parts.length < 2) return null;
360
+ const statusCode = parts[0].charAt(0);
361
+ const label =
362
+ {
363
+ A: "新增",
364
+ M: "修改",
365
+ D: "删除",
366
+ R: "重命名",
367
+ C: "复制",
368
+ T: "类型变更",
369
+ U: "冲突",
370
+ }[statusCode] || statusCode;
371
+ return { status: label, path: parts.slice(1).join("\t") };
372
+ }
373
+
374
+ function compactDiff(text, maxLength) {
375
+ const lines = text.split(/\r?\n/).filter((line) => {
376
+ const trimmed = line.trim();
377
+ if (trimmed.length === 0) return false;
378
+ return !/^(index |--- |\+\+\+ |new file mode |deleted file mode |similarity index |rename from |rename to |Binary files )/.test(trimmed);
379
+ });
380
+ const joined = lines.join("\n").trim();
381
+ return joined.length > maxLength ? joined.slice(0, maxLength) + "…" : joined;
382
+ }
383
+
384
+ function parseGitLog(output, range) {
385
+ const commits = [];
386
+ const chunks = output.split("\u001e");
387
+ for (const chunk of chunks) {
388
+ const text = chunk.replace(/^\r?\n/, "");
389
+ if (text.trim().length === 0) continue;
390
+ const lines = text.split(/\r?\n/);
391
+ const header = lines.shift() || "";
392
+ const fields = header.split("\u001f");
393
+ if (fields.length < 5) continue;
394
+ const [hash, author, email, dateIso, subject] = fields;
395
+ const date = new Date(dateIso);
396
+ if (Number.isNaN(date.getTime())) continue;
397
+ const dateKey = formatLocalDate(date);
398
+ if (dateKey < range.start || dateKey > range.end) continue;
399
+ const files = [];
400
+ for (const line of lines) {
401
+ const parsed = parseNameStatus(line);
402
+ if (parsed) files.push(parsed);
403
+ }
404
+ commits.push({
405
+ hash,
406
+ shortHash: hash.slice(0, 8),
407
+ author: cleanText(author, 120),
408
+ email: cleanText(email, 200),
409
+ date: dateKey,
410
+ time: formatLocalDateTime(date),
411
+ isoTime: date.toISOString(),
412
+ subject: cleanText(subject, 500) || "(无提交说明)",
413
+ files,
414
+ fileCount: files.length,
415
+ filesTruncated: files.length > MAX_FILES_PER_COMMIT,
416
+ visibleFiles: files.slice(0, MAX_FILES_PER_COMMIT),
417
+ });
418
+ }
419
+ commits.sort((left, right) => (left.isoTime < right.isoTime ? -1 : left.isoTime > right.isoTime ? 1 : 0));
420
+ return commits;
421
+ }
422
+
423
+ async function resolveAuthorIdentity(repoDir, signal) {
424
+ let email = "";
425
+ let name = "";
426
+ try {
427
+ email = (await run("git", ["-C", repoDir, "config", "user.email"], { timeout: 5000, signal })).stdout.trim();
428
+ } catch {
429
+ email = "";
430
+ }
431
+ try {
432
+ name = (await run("git", ["-C", repoDir, "config", "user.name"], { timeout: 5000, signal })).stdout.trim();
433
+ } catch {
434
+ name = "";
435
+ }
436
+ return {
437
+ email: email.toLowerCase(),
438
+ name: name.toLowerCase(),
439
+ };
440
+ }
441
+
442
+ function matchesAuthor(commit, identity) {
443
+ if (!identity || (identity.email.length === 0 && identity.name.length === 0)) return true;
444
+ const email = typeof commit.email === "string" ? commit.email.toLowerCase() : "";
445
+ const name = typeof commit.author === "string" ? commit.author.toLowerCase() : "";
446
+ return (identity.email.length > 0 && email === identity.email) || (identity.name.length > 0 && name === identity.name);
447
+ }
448
+
449
+ async function collectGitSource(spec, range, context) {
450
+ const location = spec.location;
451
+ const branch = sanitizeBranch(spec.branch);
452
+ const label = spec.label || repoLabelFromLocation(location);
453
+ const warnings = [];
454
+ let repoDir;
455
+ let rev;
456
+ let remote = false;
457
+
458
+ if (isRemoteLocation(location, context.cwd)) {
459
+ remote = true;
460
+ const cloned = await cloneRemote(location, branch, context.cacheRoot, context.signal);
461
+ repoDir = cloned.dir;
462
+ rev = cloned.rev;
463
+ if (cloned.warning) warnings.push(cloned.warning);
464
+ } else {
465
+ repoDir = resolveLocalPath(location, context.cwd);
466
+ if (!(await pathIsDirectory(repoDir))) {
467
+ throw new Error("本地仓库路径不存在:" + repoDir);
468
+ }
469
+ rev = branch || "HEAD";
470
+ }
471
+
472
+ const args = [
473
+ "-C",
474
+ repoDir,
475
+ "log",
476
+ rev,
477
+ "--since=" + range.start + "T00:00:00",
478
+ "--until=" + range.end + "T23:59:59",
479
+ "--date=iso-strict",
480
+ "--pretty=format:%x1e%H%x1f%an%x1f%ae%x1f%ad%x1f%s",
481
+ "--name-status",
482
+ "--no-renames",
483
+ "--max-count=" + MAX_GIT_COMMITS,
484
+ ];
485
+ let output;
486
+ try {
487
+ output = (await run("git", args, { timeout: GIT_LOG_TIMEOUT, signal: context.signal })).stdout;
488
+ } catch (error) {
489
+ if (rev === "FETCH_HEAD") {
490
+ try {
491
+ const fallback = args.slice();
492
+ fallback[3] = branch || "HEAD";
493
+ output = (await run("git", fallback, { timeout: GIT_LOG_TIMEOUT, signal: context.signal })).stdout;
494
+ } catch (fallbackError) {
495
+ throw new Error("读取 Git 记录失败:" + gitErrorText(fallbackError || error));
496
+ }
497
+ } else {
498
+ throw new Error("读取 Git 记录失败:" + gitErrorText(error));
499
+ }
500
+ }
501
+
502
+ let commits = parseGitLog(output, range);
503
+ let authorNote = "";
504
+ if (context.mineOnly) {
505
+ const identity = await resolveAuthorIdentity(repoDir, context.signal);
506
+ if (identity.email.length === 0 && identity.name.length === 0) {
507
+ warnings.push("未找到当前 Git 用户身份,未按作者过滤。");
508
+ } else {
509
+ const beforeFilter = commits.length;
510
+ commits = commits.filter((commit) => matchesAuthor(commit, identity));
511
+ authorNote = "仅统计 " + (identity.email || identity.name);
512
+ if (beforeFilter > 0 && commits.length === 0) {
513
+ warnings.push("没有匹配当前 Git 用户 " + (identity.email || identity.name) + " 的提交,请检查 `git config user.email`。");
514
+ }
515
+ }
516
+ }
517
+ if (context.includeDiff) {
518
+ let diffFailures = 0;
519
+ const candidates = commits.slice(0, MAX_AI_DIFF_COMMITS);
520
+ for (let index = 0; index < candidates.length; index += 1) {
521
+ const commit = candidates[index];
522
+ if (typeof context.onDiffProgress === "function") {
523
+ context.onDiffProgress(index + 1, candidates.length);
524
+ }
525
+ if (commit.files.length === 0) continue;
526
+ try {
527
+ const diff = await run(
528
+ "git",
529
+ ["-C", repoDir, "show", "--format=", "--unified=1", "--no-color", "--no-ext-diff", "--no-renames", commit.hash],
530
+ { timeout: 20000, signal: context.signal, maxBuffer: 16 * 1024 * 1024 },
531
+ );
532
+ commit.diff = compactDiff(diff.stdout, MAX_DIFF_CHARS);
533
+ } catch (error) {
534
+ if (context.signal && context.signal.aborted) throw context.signal.reason || error;
535
+ diffFailures += 1;
536
+ commit.diff = "";
537
+ }
538
+ }
539
+ if (commits.length > MAX_AI_DIFF_COMMITS) {
540
+ warnings.push("提交较多,AI 分析只读取前 " + MAX_AI_DIFF_COMMITS + " 条提交的代码差异。");
541
+ }
542
+ if (diffFailures > 0) {
543
+ warnings.push(diffFailures + " 条提交的代码差异读取失败,AI 分析将仅使用提交说明。");
544
+ }
545
+ }
546
+ if (commits.length >= MAX_GIT_COMMITS) {
547
+ warnings.push("提交数量达到上限 " + MAX_GIT_COMMITS + " 条,日报仅包含最近部分。");
548
+ }
549
+ return {
550
+ kind: "git",
551
+ label,
552
+ location,
553
+ branch: branch || "(默认分支)",
554
+ remote,
555
+ status: warnings.length > 0 ? "warn" : "ok",
556
+ message: (warnings.join(" ") || "读取成功") + (authorNote ? "(" + authorNote + ")" : ""),
557
+ warnings,
558
+ commits,
559
+ };
560
+ }
561
+
562
+ function decodeTextBuffer(buffer) {
563
+ if (buffer.length >= 3 && buffer[0] === 0xef && buffer[1] === 0xbb && buffer[2] === 0xbf) {
564
+ return buffer.subarray(3).toString("utf8");
565
+ }
566
+ if (buffer.length >= 2 && buffer[0] === 0xff && buffer[1] === 0xfe) {
567
+ return buffer.subarray(2).toString("utf16le");
568
+ }
569
+ if (buffer.length >= 2 && buffer[0] === 0xfe && buffer[1] === 0xff) {
570
+ const swapped = Buffer.from(buffer.subarray(2));
571
+ swapped.swap16();
572
+ return swapped.toString("utf16le");
573
+ }
574
+ return buffer.toString("utf8");
575
+ }
576
+
577
+ function decodeXmlEntities(value) {
578
+ return value
579
+ .replace(/&lt;/g, "<")
580
+ .replace(/&gt;/g, ">")
581
+ .replace(/&quot;/g, '"')
582
+ .replace(/&apos;/g, "'")
583
+ .replace(/&#(\d+);/g, (_, code) => String.fromCodePoint(Number(code)))
584
+ .replace(/&#x([0-9a-f]+);/gi, (_, code) => String.fromCodePoint(parseInt(code, 16)))
585
+ .replace(/&amp;/g, "&");
586
+ }
587
+
588
+ function readZipEntry(buffer, wantedName) {
589
+ let eocd = -1;
590
+ for (let i = buffer.length - 22; i >= 0 && i >= buffer.length - 65557; i -= 1) {
591
+ if (buffer.readUInt32LE(i) === 0x06054b50) {
592
+ eocd = i;
593
+ break;
594
+ }
595
+ }
596
+ if (eocd < 0) throw new Error("DOCX 不是有效的 ZIP 文件");
597
+ const count = buffer.readUInt16LE(eocd + 10);
598
+ const centralOffset = buffer.readUInt32LE(eocd + 16);
599
+ let cursor = centralOffset;
600
+ for (let i = 0; i < count; i += 1) {
601
+ if (cursor + 46 > buffer.length || buffer.readUInt32LE(cursor) !== 0x02014b50) break;
602
+ const method = buffer.readUInt16LE(cursor + 10);
603
+ const compressedSize = buffer.readUInt32LE(cursor + 20);
604
+ const nameLength = buffer.readUInt16LE(cursor + 28);
605
+ const extraLength = buffer.readUInt16LE(cursor + 30);
606
+ const commentLength = buffer.readUInt16LE(cursor + 32);
607
+ const localOffset = buffer.readUInt32LE(cursor + 42);
608
+ const name = buffer.toString("utf8", cursor + 46, cursor + 46 + nameLength);
609
+ if (name === wantedName) {
610
+ if (buffer.readUInt32LE(localOffset) !== 0x04034b50) break;
611
+ const localNameLength = buffer.readUInt16LE(localOffset + 26);
612
+ const localExtraLength = buffer.readUInt16LE(localOffset + 28);
613
+ const dataStart = localOffset + 30 + localNameLength + localExtraLength;
614
+ const compressed = buffer.subarray(dataStart, dataStart + compressedSize);
615
+ if (method === 0) return compressed;
616
+ if (method === 8) return inflateRawSync(compressed);
617
+ throw new Error("DOCX 使用了不支持的压缩算法");
618
+ }
619
+ cursor += 46 + nameLength + extraLength + commentLength;
620
+ }
621
+ return null;
622
+ }
623
+
624
+ function docxToText(buffer) {
625
+ const xmlBuffer = readZipEntry(buffer, "word/document.xml");
626
+ if (!xmlBuffer) return "";
627
+ let text = xmlBuffer.toString("utf8");
628
+ text = text
629
+ .replace(/<w:tab[^>]*\/>/g, "\t")
630
+ .replace(/<w:br[^>]*\/>/g, "\n")
631
+ .replace(/<\/w:p>/g, "\n")
632
+ .replace(/<[^>]+>/g, "");
633
+ return decodeXmlEntities(text).replace(/\n{3,}/g, "\n\n").trim();
634
+ }
635
+
636
+ function htmlToText(value) {
637
+ let text = value
638
+ .replace(/<script[\s\S]*?<\/script>/gi, " ")
639
+ .replace(/<style[\s\S]*?<\/style>/gi, " ")
640
+ .replace(/<br\s*\/?>/gi, "\n")
641
+ .replace(/<\/(p|div|li|h[1-6]|tr|section|article)>/gi, "\n")
642
+ .replace(/<[^>]+>/g, " ");
643
+ return decodeXmlEntities(text).replace(/[ \t]+/g, " ").replace(/\n{3,}/g, "\n\n").trim();
644
+ }
645
+
646
+ function stripMarkdown(value) {
647
+ return value
648
+ .replace(/```[\s\S]*?```/g, " ")
649
+ .replace(/`([^`]+)`/g, "$1")
650
+ .replace(/!\[[^\]]*\]\([^)]*\)/g, " ")
651
+ .replace(/\[([^\]]+)\]\([^)]*\)/g, "$1")
652
+ .replace(/[*_~>#]+/g, " ")
653
+ .replace(/\s+/g, " ")
654
+ .trim();
655
+ }
656
+
657
+ function detectDateFromName(name) {
658
+ const patterns = [
659
+ /(20\d{2})[-_.年](\d{1,2})[-_.月](\d{1,2})/,
660
+ /(20\d{2})(\d{2})(\d{2})/,
661
+ ];
662
+ for (const pattern of patterns) {
663
+ const match = name.match(pattern);
664
+ if (!match) continue;
665
+ const iso = match[1] + "-" + pad2(Number(match[2])) + "-" + pad2(Number(match[3]));
666
+ if (parseIsoDate(iso)) return iso;
667
+ }
668
+ return "";
669
+ }
670
+
671
+ function detectDateFromText(text) {
672
+ const head = text.slice(0, 2000);
673
+ const match = head.match(/^(?:date|日期|created|创建日期)\s*[::]\s*['"]?(20\d{2})[-/.](\d{1,2})[-/.](\d{1,2})/im);
674
+ if (!match) return "";
675
+ const iso = match[1] + "-" + pad2(Number(match[2])) + "-" + pad2(Number(match[3]));
676
+ return parseIsoDate(iso) ? iso : "";
677
+ }
678
+
679
+ function detectTitle(text, fileName) {
680
+ const head = text.slice(0, 2500);
681
+ const front = head.match(/^title\s*[::]\s*(.+)$/im);
682
+ if (front) {
683
+ const value = stripMarkdown(front[1].replace(/^['"]|['"]$/g, ""));
684
+ if (value) return value.slice(0, 100);
685
+ }
686
+ const heading = head.match(/^#{1,6}\s+(.+)$/m);
687
+ if (heading) {
688
+ const value = stripMarkdown(heading[1]);
689
+ if (value) return value.slice(0, 100);
690
+ }
691
+ for (const line of text.split(/\r?\n/)) {
692
+ const value = stripMarkdown(line);
693
+ if (value.length > 0) return value.slice(0, 100);
694
+ }
695
+ return basename(fileName, extname(fileName)).slice(0, 100) || "未命名文档";
696
+ }
697
+
698
+ function buildExcerpt(text, title) {
699
+ let body = text.replace(/^---[\s\S]*?---\s*/m, "");
700
+ body = stripMarkdown(body);
701
+ if (title && body.startsWith(title)) {
702
+ body = body.slice(title.length).trim();
703
+ }
704
+ if (body.length === 0) return "";
705
+ return body.slice(0, MAX_DOC_EXCERPT) + (body.length > MAX_DOC_EXCERPT ? "…" : "");
706
+ }
707
+
708
+ async function readDocFile(filePath) {
709
+ const extension = extname(filePath).toLowerCase();
710
+ const buffer = await fs.readFile(filePath);
711
+ let text = "";
712
+ if (extension === ".docx") {
713
+ text = docxToText(buffer);
714
+ } else if (extension === ".html" || extension === ".htm") {
715
+ text = htmlToText(decodeTextBuffer(buffer));
716
+ } else {
717
+ text = decodeTextBuffer(buffer);
718
+ }
719
+ return { extension, text };
720
+ }
721
+
722
+ async function walkDocs(root, current, state, output) {
723
+ if (state.truncated) return;
724
+ let entries;
725
+ try {
726
+ entries = await fs.readdir(current, { withFileTypes: true });
727
+ } catch (error) {
728
+ state.warnings.push("无法读取目录 " + current + ":" + (error.message || "未知错误"));
729
+ return;
730
+ }
731
+ entries.sort((left, right) => left.name.localeCompare(right.name));
732
+ for (const entry of entries) {
733
+ if (state.truncated) return;
734
+ if (entry.isSymbolicLink()) continue;
735
+ const full = join(current, entry.name);
736
+ if (entry.isDirectory()) {
737
+ if (IGNORED_DIRS.has(entry.name)) continue;
738
+ await walkDocs(root, full, state, output);
739
+ continue;
740
+ }
741
+ if (!entry.isFile()) continue;
742
+ const extension = extname(entry.name).toLowerCase();
743
+ if (!SUPPORTED_DOC_EXTENSIONS.has(extension)) continue;
744
+ if (output.length >= MAX_DOC_FILES) {
745
+ state.truncated = true;
746
+ state.warnings.push("文档数量超过上限 " + MAX_DOC_FILES + " 个,仅扫描前 " + MAX_DOC_FILES + " 个。");
747
+ return;
748
+ }
749
+ let stat;
750
+ try {
751
+ stat = await fs.stat(full);
752
+ } catch {
753
+ continue;
754
+ }
755
+ if (!stat.isFile()) continue;
756
+ if (stat.size > MAX_DOC_BYTES) {
757
+ state.warnings.push("跳过超过 2 MB 的文件:" + relative(root, full));
758
+ continue;
759
+ }
760
+ if (stat.size === 0) continue;
761
+ output.push({ full, relative: relative(root, full), stat });
762
+ }
763
+ }
764
+
765
+ function displayPath(value) {
766
+ return value.split(sep).join("/");
767
+ }
768
+
769
+ async function collectDocsSource(spec, range, context) {
770
+ const location = resolveLocalPath(spec.location, context.cwd);
771
+ const label = spec.label || cleanText(basename(location), 120) || "文档";
772
+ let stat;
773
+ try {
774
+ stat = await fs.stat(location);
775
+ } catch {
776
+ throw new Error("路径不存在:" + location);
777
+ }
778
+ const root = stat.isDirectory() ? location : resolve(location, "..");
779
+ const candidates = [];
780
+ const warnings = [];
781
+ if (stat.isFile()) {
782
+ const extension = extname(location).toLowerCase();
783
+ if (!SUPPORTED_DOC_EXTENSIONS.has(extension)) {
784
+ throw new Error("不支持的文档类型:" + extension + "(支持 " + Array.from(SUPPORTED_DOC_EXTENSIONS).join("、") + ")");
785
+ }
786
+ if (stat.size > MAX_DOC_BYTES) throw new Error("文件超过 2 MB:" + location);
787
+ candidates.push({ full: location, relative: basename(location), stat });
788
+ } else if (stat.isDirectory()) {
789
+ await walkDocs(location, location, { warnings, truncated: false }, candidates);
790
+ } else {
791
+ throw new Error("路径不是文件或目录:" + location);
792
+ }
793
+
794
+ const docs = [];
795
+ for (const candidate of candidates) {
796
+ if (context.signal && context.signal.aborted) throw context.signal.reason || new Error("已取消");
797
+ let parsed;
798
+ try {
799
+ parsed = await readDocFile(candidate.full);
800
+ } catch (error) {
801
+ warnings.push("读取失败:" + candidate.relative + "(" + (error.message || "未知错误") + ")");
802
+ continue;
803
+ }
804
+ const fileDate = detectDateFromName(candidate.relative);
805
+ const textDate = detectDateFromText(parsed.text);
806
+ const date = fileDate || textDate || formatLocalDate(candidate.stat.mtime);
807
+ if (date < range.start || date > range.end) continue;
808
+ const title = detectTitle(parsed.text, candidate.relative);
809
+ docs.push({
810
+ date,
811
+ source: label,
812
+ file: displayPath(candidate.relative),
813
+ absolutePath: candidate.full,
814
+ title,
815
+ excerpt: buildExcerpt(parsed.text, title),
816
+ size: candidate.stat.size,
817
+ mtime: formatLocalDateTime(candidate.stat.mtime),
818
+ extension: parsed.extension.replace(/^\./, "").toUpperCase(),
819
+ dateFrom: fileDate ? "文件名" : textDate ? "文档日期" : "修改时间",
820
+ });
821
+ }
822
+ docs.sort((left, right) => (left.date < right.date ? -1 : left.date > right.date ? 1 : left.file.localeCompare(right.file)));
823
+ return {
824
+ kind: "docs",
825
+ label,
826
+ location,
827
+ branch: "",
828
+ remote: false,
829
+ status: warnings.length > 0 ? "warn" : "ok",
830
+ message: warnings.length > 0 ? warnings.join(" ") : "读取成功",
831
+ warnings,
832
+ docs,
833
+ fileCount: candidates.length,
834
+ };
835
+ }
836
+
837
+ function resolveHome(homeDir) {
838
+ if (typeof homeDir === "string" && homeDir.length > 0) return homeDir;
839
+ if (process.env.DSH_HOME && process.env.DSH_HOME.length > 0) return process.env.DSH_HOME;
840
+ return join(homedir(), ".dsh");
841
+ }
842
+
843
+ function uniqueStrings(values) {
844
+ const seen = new Set();
845
+ const output = [];
846
+ for (const value of values) {
847
+ const text = cleanText(value, 300);
848
+ if (text.length === 0 || seen.has(text)) continue;
849
+ seen.add(text);
850
+ output.push(text);
851
+ }
852
+ return output;
853
+ }
854
+
855
+ function buildDays(range, commits, docs, locale) {
856
+ const byDate = new Map();
857
+ for (const date of enumerateDates(range.start, range.end)) {
858
+ byDate.set(date, { date, commits: [], docs: [] });
859
+ }
860
+ for (const commit of commits) {
861
+ if (byDate.has(commit.date)) byDate.get(commit.date).commits.push(commit);
862
+ }
863
+ for (const doc of docs) {
864
+ if (byDate.has(doc.date)) byDate.get(doc.date).docs.push(doc);
865
+ }
866
+
867
+ const days = [];
868
+ for (const [date, bucket] of byDate) {
869
+ const dateValue = parseIsoDate(date);
870
+ const isWeekend = dateValue ? dateValue.getDay() === 0 || dateValue.getDay() === 6 : false;
871
+ const subjectGroups = new Map();
872
+ for (const commit of bucket.commits) {
873
+ if (!subjectGroups.has(commit.repo)) subjectGroups.set(commit.repo, []);
874
+ subjectGroups.get(commit.repo).push(commit.subject);
875
+ }
876
+ const parts = [];
877
+ for (const [repo, subjects] of subjectGroups) {
878
+ const unique = uniqueStrings(subjects);
879
+ const visible = unique.slice(0, 6);
880
+ const suffix = unique.length > visible.length ? " 等 " + unique.length + " 项" : "";
881
+ parts.push("【" + repo + "】" + visible.join(";") + suffix);
882
+ }
883
+ if (bucket.docs.length > 0) {
884
+ const titles = uniqueStrings(bucket.docs.map((doc) => doc.title)).slice(0, 8);
885
+ const suffix = bucket.docs.length > titles.length ? " 等 " + bucket.docs.length + " 份" : "";
886
+ parts.push("【文档】" + titles.join(";") + suffix);
887
+ }
888
+ let summary = parts.join(";") || "无记录";
889
+ if (summary.length > MAX_DAY_SUMMARY) summary = summary.slice(0, MAX_DAY_SUMMARY) + "…";
890
+ const sources = uniqueStrings([
891
+ ...bucket.commits.map((commit) => commit.repo),
892
+ ...bucket.docs.map((doc) => doc.source),
893
+ ]);
894
+ days.push({
895
+ date,
896
+ weekday: weekdayLabel(date, locale),
897
+ isWeekend,
898
+ dayType: isWeekend ? (locale === "en" ? "Weekend" : "周末") : locale === "en" ? "Workday" : "工作日",
899
+ commitCount: bucket.commits.length,
900
+ docCount: bucket.docs.length,
901
+ sources: sources.join(locale === "en" ? ", " : "、"),
902
+ summary,
903
+ commits: bucket.commits,
904
+ docs: bucket.docs,
905
+ });
906
+ }
907
+ return days;
908
+ }
909
+
910
+ export async function collectReport(options) {
911
+ const settings = sanitizeSettings(options.settings);
912
+ const start = options.start;
913
+ const end = options.end;
914
+ const locale = options.locale === "en" ? "en" : "zh";
915
+ const signal = options.signal;
916
+ const cwd = options.cwd || process.cwd();
917
+ const cacheRoot = join(resolveHome(options.homeDir), "daily-report", "repos");
918
+ const context = { cwd, cacheRoot, signal, includeDiff: options.includeDiff === true, mineOnly: options.mineOnly === true };
919
+ const range = { start, end };
920
+ const warnings = [];
921
+ const sources = [];
922
+ const commits = [];
923
+ const docs = [];
924
+ const report = (progress) => {
925
+ if (typeof options.onProgress === "function") options.onProgress(progress);
926
+ };
927
+
928
+ if (settings.git.length === 0 && settings.docs.length === 0) {
929
+ throw new Error("请至少添加一个 Git 仓库或本地文档。");
930
+ }
931
+
932
+ const gitTotal = settings.git.length;
933
+ for (let index = 0; index < gitTotal; index += 1) {
934
+ const spec = settings.git[index];
935
+ const label = spec.label || repoLabelFromLocation(spec.location);
936
+ const basePercent = 5 + (index / Math.max(1, gitTotal)) * 35;
937
+ report({
938
+ stage: "git",
939
+ percent: basePercent,
940
+ message: "正在读取 Git 仓库 " + (index + 1) + "/" + gitTotal + ":" + label,
941
+ });
942
+ try {
943
+ const source = await collectGitSource(spec, range, {
944
+ ...context,
945
+ onDiffProgress: (current, total) => {
946
+ report({
947
+ stage: "git-diff",
948
+ percent: 5 + ((index + current / Math.max(1, total)) / Math.max(1, gitTotal)) * 35,
949
+ message: "正在读取代码变更:" + label + "(" + current + "/" + total + ")",
950
+ });
951
+ },
952
+ });
953
+ for (const commit of source.commits) {
954
+ commit.repo = source.label;
955
+ commit.branch = source.branch;
956
+ }
957
+ commits.push(...source.commits);
958
+ for (const warning of source.warnings) warnings.push(source.label + ":" + warning);
959
+ sources.push({
960
+ kind: "git",
961
+ label: source.label,
962
+ location: source.location,
963
+ branch: source.branch,
964
+ status: source.status,
965
+ message: source.message,
966
+ count: source.commits.length,
967
+ });
968
+ } catch (error) {
969
+ if (signal && signal.aborted) throw signal.reason || error;
970
+ sources.push({
971
+ kind: "git",
972
+ label,
973
+ location: spec.location,
974
+ branch: spec.branch || "(默认分支)",
975
+ status: "error",
976
+ message: error.message || "读取失败",
977
+ count: 0,
978
+ });
979
+ warnings.push((spec.label || spec.location) + ":" + (error.message || "读取失败"));
980
+ }
981
+ }
982
+ report({ stage: "git-done", percent: 42, message: "Git 数据读取完成,正在扫描本地文档…" });
983
+
984
+ const docTotal = settings.docs.length;
985
+ for (let index = 0; index < docTotal; index += 1) {
986
+ const spec = settings.docs[index];
987
+ report({
988
+ stage: "docs",
989
+ percent: 44 + (index / Math.max(1, docTotal)) * 22,
990
+ message: "正在扫描文档 " + (index + 1) + "/" + docTotal + ":" + (spec.label || basename(spec.location) || spec.location),
991
+ });
992
+ try {
993
+ const source = await collectDocsSource(spec, range, context);
994
+ docs.push(...source.docs);
995
+ for (const warning of source.warnings) warnings.push(source.label + ":" + warning);
996
+ sources.push({
997
+ kind: "docs",
998
+ label: source.label,
999
+ location: source.location,
1000
+ branch: "",
1001
+ status: source.status,
1002
+ message: source.message,
1003
+ count: source.docs.length,
1004
+ });
1005
+ } catch (error) {
1006
+ if (signal && signal.aborted) throw signal.reason || error;
1007
+ sources.push({
1008
+ kind: "docs",
1009
+ label: spec.label || basename(spec.location) || "文档",
1010
+ location: spec.location,
1011
+ branch: "",
1012
+ status: "error",
1013
+ message: error.message || "读取失败",
1014
+ count: 0,
1015
+ });
1016
+ warnings.push((spec.label || spec.location) + ":" + (error.message || "读取失败"));
1017
+ }
1018
+ }
1019
+ report({ stage: "aggregate", percent: 68, message: "正在整理按天日报…" });
1020
+
1021
+ const days = buildDays(range, commits, docs, locale);
1022
+ const activeDays = days.filter((day) => day.commitCount + day.docCount > 0).length;
1023
+ const stats = {
1024
+ days: days.length,
1025
+ activeDays,
1026
+ commits: commits.length,
1027
+ docs: docs.length,
1028
+ sources: sources.length,
1029
+ okSources: sources.filter((source) => source.status !== "error").length,
1030
+ failedSources: sources.filter((source) => source.status === "error").length,
1031
+ };
1032
+
1033
+ return {
1034
+ range,
1035
+ days,
1036
+ commits,
1037
+ docs,
1038
+ sources,
1039
+ warnings: uniqueStrings(warnings),
1040
+ stats,
1041
+ settings,
1042
+ locale,
1043
+ generatedAt: options.now instanceof Date ? options.now : new Date(),
1044
+ };
1045
+ }
1046
+
1047
+ export { parseGitLog };