mirror of
https://github.com/fancive/obsidian-parallel-reader.git
synced 2026-07-22 06:53:43 +00:00
Compare commits
No commits in common. "main" and "1.0.18" have entirely different histories.
53 changed files with 364 additions and 3535 deletions
21
.c8rc.json
21
.c8rc.json
|
|
@ -1,21 +0,0 @@
|
|||
{
|
||||
"all": false,
|
||||
"src": ["src"],
|
||||
"include": ["src/**/*.ts", "main.ts"],
|
||||
"exclude": [
|
||||
"src/test-exports.ts",
|
||||
"tests/**",
|
||||
"scripts/**",
|
||||
".e2e/**",
|
||||
"**/node_modules/**"
|
||||
],
|
||||
"excludeAfterRemap": true,
|
||||
"reporter": ["text-summary", "text", "html", "json-summary"],
|
||||
"reportsDirectory": "coverage",
|
||||
"tempDirectory": "coverage/.tmp",
|
||||
"check-coverage": true,
|
||||
"branches": 100,
|
||||
"statements": 0,
|
||||
"lines": 0,
|
||||
"functions": 0
|
||||
}
|
||||
|
|
@ -98,10 +98,6 @@ class FakeElement {
|
|||
return this.createEl('div', options);
|
||||
}
|
||||
|
||||
createSpan(options = {}) {
|
||||
return this.createEl('span', options);
|
||||
}
|
||||
|
||||
addEventListener(type, handler) {
|
||||
if (typeof handler !== 'function') return;
|
||||
if (!this._listeners.has(type)) this._listeners.set(type, []);
|
||||
|
|
|
|||
9
.github/workflows/ci.yml
vendored
9
.github/workflows/ci.yml
vendored
|
|
@ -24,15 +24,6 @@ jobs:
|
|||
- name: Lint
|
||||
run: npm run lint
|
||||
|
||||
- name: Lint (Obsidian review rules)
|
||||
run: npm run lint:obsidian
|
||||
|
||||
- name: Lint (Obsidian strict review)
|
||||
run: npm run lint:obsidian:review:all
|
||||
|
||||
- name: Coverage (branch threshold)
|
||||
run: npm run coverage
|
||||
|
||||
- name: E2E gate
|
||||
run: timeout 600 npm run e2e
|
||||
|
||||
|
|
|
|||
12
.github/workflows/release.yml
vendored
12
.github/workflows/release.yml
vendored
|
|
@ -44,11 +44,7 @@ jobs:
|
|||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
tag="${GITHUB_REF#refs/tags/}"
|
||||
if gh release view "$tag" >/dev/null 2>&1; then
|
||||
gh release upload "$tag" --clobber main.js main.js.map manifest.json styles.css
|
||||
else
|
||||
gh release create "$tag" \
|
||||
--title "$tag" \
|
||||
--generate-notes \
|
||||
main.js main.js.map manifest.json styles.css
|
||||
fi
|
||||
gh release create "$tag" \
|
||||
--title "$tag" \
|
||||
--generate-notes \
|
||||
main.js main.js.map manifest.json styles.css
|
||||
|
|
|
|||
4
.gitignore
vendored
4
.gitignore
vendored
|
|
@ -18,10 +18,6 @@ dist/
|
|||
main.js
|
||||
main.js.map
|
||||
|
||||
# Coverage
|
||||
coverage/
|
||||
.test-bundles/
|
||||
|
||||
# Agent state
|
||||
.agent/
|
||||
.claude/
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@
|
|||
<a href="https://github.com/fancive/obsidian-parallel-reader/releases"><img src="https://img.shields.io/github/downloads/fancive/obsidian-parallel-reader/total?style=flat-square&color=4c1" alt="Total downloads"></a>
|
||||
<a href="https://github.com/fancive/obsidian-parallel-reader/actions/workflows/ci.yml"><img src="https://img.shields.io/github/actions/workflow/status/fancive/obsidian-parallel-reader/ci.yml?style=flat-square&label=CI" alt="CI status"></a>
|
||||
<a href="./LICENSE"><img src="https://img.shields.io/github/license/fancive/obsidian-parallel-reader?style=flat-square" alt="License"></a>
|
||||
<img src="https://img.shields.io/badge/Obsidian-%E2%89%A5%201.8.7-7c3aed?style=flat-square&logo=obsidian&logoColor=white" alt="Obsidian 1.8.7+">
|
||||
<img src="https://img.shields.io/badge/Obsidian-%E2%89%A5%201.4.0-7c3aed?style=flat-square&logo=obsidian&logoColor=white" alt="Obsidian 1.4+">
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
|
|
@ -39,7 +39,7 @@ Inspired by [this reading workflow demo](https://www.bilibili.com/video/BV1FxoGB
|
|||
- **Rich rendering** — cards render through Obsidian's `MarkdownRenderer`, so tables, bold, code, and wikilinks all work natively.
|
||||
- **Card editing** — right-click any card to copy, edit, delete, or jump to source.
|
||||
- **Export** — save cards as a Markdown note in your vault, or copy to clipboard.
|
||||
- **Multi-language UI and output** — UI supports Auto, Chinese, English, Japanese, Korean, French, German, and Spanish; generated titles, gists, and bullets can use the same fixed languages or follow the source document.
|
||||
- **Bilingual UI** — full Chinese and English support for commands, settings, and notices.
|
||||
|
||||
## Quick Start
|
||||
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@
|
|||
<a href="https://github.com/fancive/obsidian-parallel-reader/releases"><img src="https://img.shields.io/github/downloads/fancive/obsidian-parallel-reader/total?style=flat-square&color=4c1" alt="累计下载"></a>
|
||||
<a href="https://github.com/fancive/obsidian-parallel-reader/actions/workflows/ci.yml"><img src="https://img.shields.io/github/actions/workflow/status/fancive/obsidian-parallel-reader/ci.yml?style=flat-square&label=CI" alt="CI 状态"></a>
|
||||
<a href="./LICENSE"><img src="https://img.shields.io/github/license/fancive/obsidian-parallel-reader?style=flat-square" alt="开源协议"></a>
|
||||
<img src="https://img.shields.io/badge/Obsidian-%E2%89%A5%201.8.7-7c3aed?style=flat-square&logo=obsidian&logoColor=white" alt="Obsidian 1.8.7+">
|
||||
<img src="https://img.shields.io/badge/Obsidian-%E2%89%A5%201.4.0-7c3aed?style=flat-square&logo=obsidian&logoColor=white" alt="Obsidian 1.4+">
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
|
|
@ -39,7 +39,7 @@
|
|||
- **Markdown 渲染** — 通过 Obsidian 的 `MarkdownRenderer` 渲染,表格、加粗、代码、wikilink 均正常显示。
|
||||
- **卡片编辑** — 右键任意卡片:复制、编辑、删除、跳转原文。
|
||||
- **导出** — 保存为 Vault 中的 Markdown 文件,或复制到剪贴板。
|
||||
- **多语言 UI 与输出** — UI 支持 Auto、中文、英文、日文、韩文、法文、德文、西班牙文;生成的 title/gist/bullets 也可选择这些固定语言,或跟随原文语言。
|
||||
- **中英双语 UI** — 命令、设置、面板文案全部支持中文和英文。
|
||||
|
||||
## 快速开始
|
||||
|
||||
|
|
|
|||
|
|
@ -4,7 +4,12 @@ import { builtinModules } from 'module';
|
|||
const production = process.argv[2] === 'production';
|
||||
const watch = process.argv[2] === 'watch';
|
||||
|
||||
const external = ['obsidian', 'electron', ...builtinModules, ...builtinModules.map((m) => `node:${m}`)];
|
||||
const external = [
|
||||
'obsidian',
|
||||
'electron',
|
||||
...builtinModules,
|
||||
...builtinModules.map(m => `node:${m}`),
|
||||
];
|
||||
|
||||
const context = await esbuild.context({
|
||||
entryPoints: ['main.ts'],
|
||||
|
|
|
|||
|
|
@ -1,47 +0,0 @@
|
|||
import tsparser from "@typescript-eslint/parser";
|
||||
import { defineConfig } from "eslint/config";
|
||||
import obsidianmd from "eslint-plugin-obsidianmd";
|
||||
|
||||
export default defineConfig([
|
||||
...obsidianmd.configs.recommended,
|
||||
{
|
||||
files: ["main.ts", "src/**/*.ts"],
|
||||
languageOptions: {
|
||||
parser: tsparser,
|
||||
parserOptions: { project: "./tsconfig.json" },
|
||||
},
|
||||
rules: {
|
||||
// Keep this review check close to Obsidian policy rules while avoiding
|
||||
// project-specific dynamic-type noise that the ReviewBot did not report.
|
||||
"@typescript-eslint/no-unsafe-assignment": "off",
|
||||
"@typescript-eslint/no-unsafe-member-access": "off",
|
||||
"@typescript-eslint/no-unsafe-argument": "off",
|
||||
"@typescript-eslint/no-unsafe-call": "off",
|
||||
"@typescript-eslint/no-unsafe-return": "off",
|
||||
|
||||
// The ReviewBot currently reports underscore-only unused bindings as
|
||||
// Optional findings, while the published recommended config ignores them.
|
||||
"@typescript-eslint/no-unused-vars": [
|
||||
"warn",
|
||||
{
|
||||
args: "all",
|
||||
caughtErrors: "all",
|
||||
ignoreRestSiblings: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
ignores: [
|
||||
"node_modules/",
|
||||
"main.js",
|
||||
"main.js.map",
|
||||
"tests/",
|
||||
"scripts/",
|
||||
".e2e/",
|
||||
".orchestration/",
|
||||
".agent/",
|
||||
".codex-tmp/",
|
||||
],
|
||||
},
|
||||
]);
|
||||
44
main.ts
44
main.ts
|
|
@ -30,14 +30,7 @@ import { translate } from './src/i18n';
|
|||
import { cardsToMarkdown } from './src/markdown';
|
||||
import { confirmRegenerateEditedCards } from './src/modal';
|
||||
import { createRafThrottledHandler, visibleTopProbeY } from './src/scroll';
|
||||
import {
|
||||
cacheEntryMatches,
|
||||
DEFAULT_SETTINGS,
|
||||
getApiAuthType,
|
||||
getApiKey,
|
||||
isApiBackend,
|
||||
normalizeSettings,
|
||||
} from './src/settings';
|
||||
import { cacheEntryMatches, DEFAULT_SETTINGS, normalizeSettings } from './src/settings';
|
||||
import { ParallelReaderSettingTab } from './src/settings-tab';
|
||||
import type { StreamProgress } from './src/streaming';
|
||||
import type {
|
||||
|
|
@ -184,7 +177,7 @@ class ParallelReaderPlugin extends Plugin {
|
|||
}
|
||||
|
||||
onunload() {
|
||||
if (this.jobs) this.jobs.cancelAll();
|
||||
if (this.jobs) this.jobs.cancelAllWaiters();
|
||||
this.flushSettingsSave().catch((e: unknown) => console.error('[parallel-reader] flush settings on unload', e));
|
||||
this.flushCacheSave().catch((e: unknown) => console.error('[parallel-reader] flush cache on unload', e));
|
||||
}
|
||||
|
|
@ -562,9 +555,6 @@ class ParallelReaderPlugin extends Plugin {
|
|||
}
|
||||
const rawCards = sections.map((s) => ({ title: s.title, anchor: s.anchor, gist: s.gist, bullets: s.bullets }));
|
||||
job.setPhase('saving');
|
||||
// Check BEFORE persisting: a job cancelled during the generate→disk window must not
|
||||
// poison the cache (a later force=false run would otherwise serve the cancelled output).
|
||||
job.throwIfCancelled();
|
||||
await this.cacheManager.put(file.path, content, rawCards, this.settings);
|
||||
job.throwIfCancelled();
|
||||
if (view && shouldRender) view.loadFor(file, sections, false);
|
||||
|
|
@ -578,7 +568,7 @@ class ParallelReaderPlugin extends Plugin {
|
|||
outcome = 'generated';
|
||||
})
|
||||
.catch((e: unknown) => {
|
||||
this.handleGenerationError(e, file, view, force);
|
||||
this.handleGenerationError(e, file, view);
|
||||
if (e instanceof GenerationJobAlreadyRunningError) outcome = 'already-running';
|
||||
else if (e instanceof GenerationJobCancelledError) outcome = 'cancelled';
|
||||
else outcome = 'error';
|
||||
|
|
@ -600,7 +590,7 @@ class ParallelReaderPlugin extends Plugin {
|
|||
};
|
||||
}
|
||||
|
||||
private handleGenerationError(e: unknown, file: TFile, view: ParallelReaderView | null, force = false) {
|
||||
private handleGenerationError(e: unknown, file: TFile, view: ParallelReaderView | null) {
|
||||
if (e instanceof GenerationJobAlreadyRunningError) {
|
||||
new Notice(this.t('generationAlreadyRunning'));
|
||||
return;
|
||||
|
|
@ -618,8 +608,7 @@ class ParallelReaderPlugin extends Plugin {
|
|||
{
|
||||
app: this.app,
|
||||
settings: this.settings,
|
||||
openSettings: () => this.openSettings(),
|
||||
onRetry: () => void this.runForFile(file, force),
|
||||
openSettings: () => this.openPluginSettings(),
|
||||
},
|
||||
kind,
|
||||
e,
|
||||
|
|
@ -627,7 +616,7 @@ class ParallelReaderPlugin extends Plugin {
|
|||
);
|
||||
}
|
||||
|
||||
openSettings(): void {
|
||||
private openPluginSettings(): void {
|
||||
// Obsidian doesn't expose a typed API for opening a specific plugin tab; use the documented
|
||||
// (but technically internal) setting/openTabById path with safe fallbacks.
|
||||
const setting = (this.app as unknown as { setting?: { open: () => void; openTabById: (id: string) => void } })
|
||||
|
|
@ -641,23 +630,6 @@ class ParallelReaderPlugin extends Plugin {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* True when the active backend has a usable credential: an API key (direct or via
|
||||
* env var) for API backends, or any keyless/local provider (auth type 'none').
|
||||
* CLI backends authenticate out-of-band (claude/codex login), so they are treated
|
||||
* as configured. Used to show a first-run setup nudge instead of a dead-end.
|
||||
*/
|
||||
isCredentialConfigured(): boolean {
|
||||
if (!isApiBackend(this.settings.backend)) return true;
|
||||
try {
|
||||
if (getApiAuthType(this.settings) === 'none') return true;
|
||||
return !!getApiKey(this.settings);
|
||||
} catch {
|
||||
// Settings resolution can throw for half-configured custom providers — treat as not configured.
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async runBatchForFolder() {
|
||||
if (this.activeBatch) {
|
||||
new Notice(this.t('batchAlreadyRunning'));
|
||||
|
|
@ -784,7 +756,7 @@ class ParallelReaderPlugin extends Plugin {
|
|||
try {
|
||||
const pos = cm.posAtCoords({ x: rect.left + 20, y: topY });
|
||||
if (pos != null) topLine = cm.state.doc.lineAt(pos).number - 1;
|
||||
} catch {
|
||||
} catch (_: unknown) {
|
||||
/* posAtCoords/lineAt can throw during editor state transitions — safe to ignore */
|
||||
return;
|
||||
}
|
||||
|
|
@ -829,7 +801,7 @@ class ParallelReaderPlugin extends Plugin {
|
|||
try {
|
||||
mdView.editor.setCursor({ line, ch: 0 });
|
||||
mdView.editor.scrollIntoView({ from: { line, ch: 0 }, to: { line, ch: 0 } }, true);
|
||||
} catch {
|
||||
} catch (_: unknown) {
|
||||
/* setCursor/scrollIntoView can throw during view transitions — safe to ignore */
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"id": "parallel-reader",
|
||||
"name": "Parallel Reader",
|
||||
"version": "1.0.24",
|
||||
"version": "1.0.18",
|
||||
"minAppVersion": "1.8.7",
|
||||
"description": "AI-powered split-view reading: source note on the left, LLM-generated summary cards on the right with scroll-sync highlighting.",
|
||||
"author": "lancivez",
|
||||
|
|
|
|||
788
package-lock.json
generated
788
package-lock.json
generated
|
|
@ -11,7 +11,6 @@
|
|||
"@biomejs/biome": "^2.4.13",
|
||||
"@types/node": "^22.10.2",
|
||||
"@typescript-eslint/parser": "^8.59.1",
|
||||
"c8": "^10.1.3",
|
||||
"esbuild": "^0.24.2",
|
||||
"eslint": "^9.39.4",
|
||||
"eslint-plugin-obsidianmd": "^0.2.9",
|
||||
|
|
@ -19,16 +18,6 @@
|
|||
"typescript": "^5.7.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@bcoe/v8-coverage": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz",
|
||||
"integrity": "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@biomejs/biome": {
|
||||
"version": "2.4.13",
|
||||
"resolved": "https://registry.npmjs.org/@biomejs/biome/-/biome-2.4.13.tgz",
|
||||
|
|
@ -940,62 +929,6 @@
|
|||
"url": "https://github.com/sponsors/nzakas"
|
||||
}
|
||||
},
|
||||
"node_modules/@isaacs/cliui": {
|
||||
"version": "8.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz",
|
||||
"integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"string-width": "^5.1.2",
|
||||
"string-width-cjs": "npm:string-width@^4.2.0",
|
||||
"strip-ansi": "^7.0.1",
|
||||
"strip-ansi-cjs": "npm:strip-ansi@^6.0.1",
|
||||
"wrap-ansi": "^8.1.0",
|
||||
"wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/@istanbuljs/schema": {
|
||||
"version": "0.1.6",
|
||||
"resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.6.tgz",
|
||||
"integrity": "sha512-+Sg6GCR/wy1oSmQDFq4LQDAhm3ETKnorxN+y5nbLULOR3P0c14f2Wurzj3/xqPXtasLFfHd5iRFQ7AJt4KH2cw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/@jridgewell/resolve-uri": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
|
||||
"integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@jridgewell/sourcemap-codec": {
|
||||
"version": "1.5.5",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
|
||||
"integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@jridgewell/trace-mapping": {
|
||||
"version": "0.3.31",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz",
|
||||
"integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@jridgewell/resolve-uri": "^3.1.0",
|
||||
"@jridgewell/sourcemap-codec": "^1.4.14"
|
||||
}
|
||||
},
|
||||
"node_modules/@marijn/find-cluster-break": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@marijn/find-cluster-break/-/find-cluster-break-1.0.2.tgz",
|
||||
|
|
@ -1042,17 +975,6 @@
|
|||
"ret": "~0.1.10"
|
||||
}
|
||||
},
|
||||
"node_modules/@pkgjs/parseargs": {
|
||||
"version": "0.11.0",
|
||||
"resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz",
|
||||
"integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"engines": {
|
||||
"node": ">=14"
|
||||
}
|
||||
},
|
||||
"node_modules/@pkgr/core": {
|
||||
"version": "0.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@pkgr/core/-/core-0.1.2.tgz",
|
||||
|
|
@ -1101,13 +1023,6 @@
|
|||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/istanbul-lib-coverage": {
|
||||
"version": "2.0.6",
|
||||
"resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz",
|
||||
"integrity": "sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/json-schema": {
|
||||
"version": "7.0.15",
|
||||
"resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
|
||||
|
|
@ -1412,19 +1327,6 @@
|
|||
"url": "https://github.com/sponsors/epoberezkin"
|
||||
}
|
||||
},
|
||||
"node_modules/ansi-regex": {
|
||||
"version": "6.2.2",
|
||||
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz",
|
||||
"integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/chalk/ansi-regex?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/ansi-styles": {
|
||||
"version": "4.3.0",
|
||||
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
|
||||
|
|
@ -1657,40 +1559,6 @@
|
|||
"node": "18 || 20 || >=22"
|
||||
}
|
||||
},
|
||||
"node_modules/c8": {
|
||||
"version": "10.1.3",
|
||||
"resolved": "https://registry.npmjs.org/c8/-/c8-10.1.3.tgz",
|
||||
"integrity": "sha512-LvcyrOAaOnrrlMpW22n690PUvxiq4Uf9WMhQwNJ9vgagkL/ph1+D4uvjvDA5XCbykrc0sx+ay6pVi9YZ1GnhyA==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"@bcoe/v8-coverage": "^1.0.1",
|
||||
"@istanbuljs/schema": "^0.1.3",
|
||||
"find-up": "^5.0.0",
|
||||
"foreground-child": "^3.1.1",
|
||||
"istanbul-lib-coverage": "^3.2.0",
|
||||
"istanbul-lib-report": "^3.0.1",
|
||||
"istanbul-reports": "^3.1.6",
|
||||
"test-exclude": "^7.0.1",
|
||||
"v8-to-istanbul": "^9.0.0",
|
||||
"yargs": "^17.7.2",
|
||||
"yargs-parser": "^21.1.1"
|
||||
},
|
||||
"bin": {
|
||||
"c8": "bin/c8.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"monocart-coverage-reports": "^2"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"monocart-coverage-reports": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/call-bind": {
|
||||
"version": "1.0.9",
|
||||
"resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.9.tgz",
|
||||
|
|
@ -1768,84 +1636,6 @@
|
|||
"url": "https://github.com/chalk/chalk?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/cliui": {
|
||||
"version": "8.0.1",
|
||||
"resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz",
|
||||
"integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"string-width": "^4.2.0",
|
||||
"strip-ansi": "^6.0.1",
|
||||
"wrap-ansi": "^7.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/cliui/node_modules/ansi-regex": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
|
||||
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/cliui/node_modules/emoji-regex": {
|
||||
"version": "8.0.0",
|
||||
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
|
||||
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/cliui/node_modules/string-width": {
|
||||
"version": "4.2.3",
|
||||
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
|
||||
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"emoji-regex": "^8.0.0",
|
||||
"is-fullwidth-code-point": "^3.0.0",
|
||||
"strip-ansi": "^6.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/cliui/node_modules/strip-ansi": {
|
||||
"version": "6.0.1",
|
||||
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
|
||||
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-regex": "^5.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/cliui/node_modules/wrap-ansi": {
|
||||
"version": "7.0.0",
|
||||
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
|
||||
"integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-styles": "^4.0.0",
|
||||
"string-width": "^4.1.0",
|
||||
"strip-ansi": "^6.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/chalk/wrap-ansi?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/color-convert": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
|
||||
|
|
@ -1873,13 +1663,6 @@
|
|||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/convert-source-map": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
|
||||
"integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/crelt": {
|
||||
"version": "1.0.6",
|
||||
"resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.6.tgz",
|
||||
|
|
@ -2046,20 +1829,6 @@
|
|||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/eastasianwidth": {
|
||||
"version": "0.2.0",
|
||||
"resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz",
|
||||
"integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/emoji-regex": {
|
||||
"version": "9.2.2",
|
||||
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz",
|
||||
"integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/empathic": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/empathic/-/empathic-2.0.0.tgz",
|
||||
|
|
@ -2302,16 +2071,6 @@
|
|||
"@esbuild/win32-x64": "0.24.2"
|
||||
}
|
||||
},
|
||||
"node_modules/escalade": {
|
||||
"version": "3.2.0",
|
||||
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
|
||||
"integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/escape-string-regexp": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz",
|
||||
|
|
@ -3168,23 +2927,6 @@
|
|||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/foreground-child": {
|
||||
"version": "3.3.1",
|
||||
"resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz",
|
||||
"integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"cross-spawn": "^7.0.6",
|
||||
"signal-exit": "^4.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/isaacs"
|
||||
}
|
||||
},
|
||||
"node_modules/function-bind": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
|
||||
|
|
@ -3236,16 +2978,6 @@
|
|||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/get-caller-file": {
|
||||
"version": "2.0.5",
|
||||
"resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
|
||||
"integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": "6.* || 8.* || >= 10.*"
|
||||
}
|
||||
},
|
||||
"node_modules/get-intrinsic": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
|
||||
|
|
@ -3316,28 +3048,6 @@
|
|||
"url": "https://github.com/privatenumber/get-tsconfig?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/glob": {
|
||||
"version": "10.5.0",
|
||||
"resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz",
|
||||
"integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==",
|
||||
"deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"foreground-child": "^3.1.0",
|
||||
"jackspeak": "^3.1.2",
|
||||
"minimatch": "^9.0.4",
|
||||
"minipass": "^7.1.2",
|
||||
"package-json-from-dist": "^1.0.0",
|
||||
"path-scurry": "^1.11.1"
|
||||
},
|
||||
"bin": {
|
||||
"glob": "dist/esm/bin.mjs"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/isaacs"
|
||||
}
|
||||
},
|
||||
"node_modules/glob-parent": {
|
||||
"version": "6.0.2",
|
||||
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz",
|
||||
|
|
@ -3351,39 +3061,6 @@
|
|||
"node": ">=10.13.0"
|
||||
}
|
||||
},
|
||||
"node_modules/glob/node_modules/balanced-match": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
|
||||
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/glob/node_modules/brace-expansion": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.0.tgz",
|
||||
"integrity": "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"balanced-match": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/glob/node_modules/minimatch": {
|
||||
"version": "9.0.9",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz",
|
||||
"integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"brace-expansion": "^2.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=16 || 14 >=14.17"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/isaacs"
|
||||
}
|
||||
},
|
||||
"node_modules/globals": {
|
||||
"version": "14.0.0",
|
||||
"resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz",
|
||||
|
|
@ -3528,13 +3205,6 @@
|
|||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/html-escaper": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz",
|
||||
"integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/ignore": {
|
||||
"version": "5.3.2",
|
||||
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
|
||||
|
|
@ -3748,16 +3418,6 @@
|
|||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/is-fullwidth-code-point": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
|
||||
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/is-generator-function": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.1.2.tgz",
|
||||
|
|
@ -3993,45 +3653,6 @@
|
|||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/istanbul-lib-coverage": {
|
||||
"version": "3.2.2",
|
||||
"resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz",
|
||||
"integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==",
|
||||
"dev": true,
|
||||
"license": "BSD-3-Clause",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/istanbul-lib-report": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz",
|
||||
"integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==",
|
||||
"dev": true,
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"istanbul-lib-coverage": "^3.0.0",
|
||||
"make-dir": "^4.0.0",
|
||||
"supports-color": "^7.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/istanbul-reports": {
|
||||
"version": "3.2.0",
|
||||
"resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz",
|
||||
"integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==",
|
||||
"dev": true,
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"html-escaper": "^2.0.0",
|
||||
"istanbul-lib-report": "^3.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/iterator.prototype": {
|
||||
"version": "1.1.5",
|
||||
"resolved": "https://registry.npmjs.org/iterator.prototype/-/iterator.prototype-1.1.5.tgz",
|
||||
|
|
@ -4050,22 +3671,6 @@
|
|||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/jackspeak": {
|
||||
"version": "3.4.3",
|
||||
"resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz",
|
||||
"integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==",
|
||||
"dev": true,
|
||||
"license": "BlueOak-1.0.0",
|
||||
"dependencies": {
|
||||
"@isaacs/cliui": "^8.0.2"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/isaacs"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@pkgjs/parseargs": "^0.11.0"
|
||||
}
|
||||
},
|
||||
"node_modules/js-tokens": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
|
||||
|
|
@ -4280,29 +3885,6 @@
|
|||
"loose-envify": "cli.js"
|
||||
}
|
||||
},
|
||||
"node_modules/lru-cache": {
|
||||
"version": "10.4.3",
|
||||
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz",
|
||||
"integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==",
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/make-dir": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz",
|
||||
"integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"semver": "^7.5.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/math-intrinsics": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
|
||||
|
|
@ -4339,16 +3921,6 @@
|
|||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/minipass": {
|
||||
"version": "7.1.3",
|
||||
"resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz",
|
||||
"integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==",
|
||||
"dev": true,
|
||||
"license": "BlueOak-1.0.0",
|
||||
"engines": {
|
||||
"node": ">=16 || 14 >=14.17"
|
||||
}
|
||||
},
|
||||
"node_modules/module-replacements": {
|
||||
"version": "2.11.0",
|
||||
"resolved": "https://registry.npmjs.org/module-replacements/-/module-replacements-2.11.0.tgz",
|
||||
|
|
@ -4615,13 +4187,6 @@
|
|||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/package-json-from-dist": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz",
|
||||
"integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==",
|
||||
"dev": true,
|
||||
"license": "BlueOak-1.0.0"
|
||||
},
|
||||
"node_modules/parent-module": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
|
||||
|
|
@ -4662,23 +4227,6 @@
|
|||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/path-scurry": {
|
||||
"version": "1.11.1",
|
||||
"resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz",
|
||||
"integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==",
|
||||
"dev": true,
|
||||
"license": "BlueOak-1.0.0",
|
||||
"dependencies": {
|
||||
"lru-cache": "^10.2.0",
|
||||
"minipass": "^5.0.0 || ^6.0.2 || ^7.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=16 || 14 >=14.18"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/isaacs"
|
||||
}
|
||||
},
|
||||
"node_modules/picomatch": {
|
||||
"version": "4.0.4",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
|
||||
|
|
@ -4795,16 +4343,6 @@
|
|||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/require-directory": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
|
||||
"integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/require-from-string": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
|
||||
|
|
@ -5116,19 +4654,6 @@
|
|||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/signal-exit": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz",
|
||||
"integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=14"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/isaacs"
|
||||
}
|
||||
},
|
||||
"node_modules/stop-iteration-iterator": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz",
|
||||
|
|
@ -5143,70 +4668,6 @@
|
|||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/string-width": {
|
||||
"version": "5.1.2",
|
||||
"resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz",
|
||||
"integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"eastasianwidth": "^0.2.0",
|
||||
"emoji-regex": "^9.2.2",
|
||||
"strip-ansi": "^7.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/string-width-cjs": {
|
||||
"name": "string-width",
|
||||
"version": "4.2.3",
|
||||
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
|
||||
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"emoji-regex": "^8.0.0",
|
||||
"is-fullwidth-code-point": "^3.0.0",
|
||||
"strip-ansi": "^6.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/string-width-cjs/node_modules/ansi-regex": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
|
||||
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/string-width-cjs/node_modules/emoji-regex": {
|
||||
"version": "8.0.0",
|
||||
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
|
||||
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/string-width-cjs/node_modules/strip-ansi": {
|
||||
"version": "6.0.1",
|
||||
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
|
||||
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-regex": "^5.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/string.prototype.matchall": {
|
||||
"version": "4.0.12",
|
||||
"resolved": "https://registry.npmjs.org/string.prototype.matchall/-/string.prototype.matchall-4.0.12.tgz",
|
||||
|
|
@ -5305,46 +4766,6 @@
|
|||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/strip-ansi": {
|
||||
"version": "7.2.0",
|
||||
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz",
|
||||
"integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-regex": "^6.2.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/chalk/strip-ansi?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/strip-ansi-cjs": {
|
||||
"name": "strip-ansi",
|
||||
"version": "6.0.1",
|
||||
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
|
||||
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-regex": "^5.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/strip-ansi-cjs/node_modules/ansi-regex": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
|
||||
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/strip-bom": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz",
|
||||
|
|
@ -5433,21 +4854,6 @@
|
|||
"url": "https://opencollective.com/webpack"
|
||||
}
|
||||
},
|
||||
"node_modules/test-exclude": {
|
||||
"version": "7.0.2",
|
||||
"resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-7.0.2.tgz",
|
||||
"integrity": "sha512-u9E6A+ZDYdp7a4WnarkXPZOx8Ilz46+kby6p1yZ8zsGTz9gYa6FIS7lj2oezzNKmtdyyJNNmmXDppga5GB7kSw==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"@istanbuljs/schema": "^0.1.2",
|
||||
"glob": "^10.4.1",
|
||||
"minimatch": "^10.2.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/tinyglobby": {
|
||||
"version": "0.2.16",
|
||||
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz",
|
||||
|
|
@ -5705,21 +5111,6 @@
|
|||
"punycode": "^2.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/v8-to-istanbul": {
|
||||
"version": "9.3.0",
|
||||
"resolved": "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-9.3.0.tgz",
|
||||
"integrity": "sha512-kiGUalWN+rgBJ/1OHZsBtU4rXZOfj/7rKQxULKlIzwzQSvMJUUNgPwJEEh7gU6xEVxC0ahoOBvN2YI8GH6FNgA==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"@jridgewell/trace-mapping": "^0.3.12",
|
||||
"@types/istanbul-lib-coverage": "^2.0.1",
|
||||
"convert-source-map": "^2.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/w3c-keyname": {
|
||||
"version": "2.2.8",
|
||||
"resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz",
|
||||
|
|
@ -5843,111 +5234,6 @@
|
|||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/wrap-ansi": {
|
||||
"version": "8.1.0",
|
||||
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz",
|
||||
"integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-styles": "^6.1.0",
|
||||
"string-width": "^5.0.1",
|
||||
"strip-ansi": "^7.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/chalk/wrap-ansi?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/wrap-ansi-cjs": {
|
||||
"name": "wrap-ansi",
|
||||
"version": "7.0.0",
|
||||
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
|
||||
"integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-styles": "^4.0.0",
|
||||
"string-width": "^4.1.0",
|
||||
"strip-ansi": "^6.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/chalk/wrap-ansi?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/wrap-ansi-cjs/node_modules/ansi-regex": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
|
||||
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/wrap-ansi-cjs/node_modules/emoji-regex": {
|
||||
"version": "8.0.0",
|
||||
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
|
||||
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/wrap-ansi-cjs/node_modules/string-width": {
|
||||
"version": "4.2.3",
|
||||
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
|
||||
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"emoji-regex": "^8.0.0",
|
||||
"is-fullwidth-code-point": "^3.0.0",
|
||||
"strip-ansi": "^6.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/wrap-ansi-cjs/node_modules/strip-ansi": {
|
||||
"version": "6.0.1",
|
||||
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
|
||||
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-regex": "^5.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/wrap-ansi/node_modules/ansi-styles": {
|
||||
"version": "6.2.3",
|
||||
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz",
|
||||
"integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/y18n": {
|
||||
"version": "5.0.8",
|
||||
"resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",
|
||||
"integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/yaml": {
|
||||
"version": "2.8.4",
|
||||
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.4.tgz",
|
||||
|
|
@ -5994,80 +5280,6 @@
|
|||
"url": "https://opencollective.com/eslint"
|
||||
}
|
||||
},
|
||||
"node_modules/yargs": {
|
||||
"version": "17.7.2",
|
||||
"resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz",
|
||||
"integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"cliui": "^8.0.1",
|
||||
"escalade": "^3.1.1",
|
||||
"get-caller-file": "^2.0.5",
|
||||
"require-directory": "^2.1.1",
|
||||
"string-width": "^4.2.3",
|
||||
"y18n": "^5.0.5",
|
||||
"yargs-parser": "^21.1.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/yargs-parser": {
|
||||
"version": "21.1.1",
|
||||
"resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz",
|
||||
"integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/yargs/node_modules/ansi-regex": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
|
||||
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/yargs/node_modules/emoji-regex": {
|
||||
"version": "8.0.0",
|
||||
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
|
||||
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/yargs/node_modules/string-width": {
|
||||
"version": "4.2.3",
|
||||
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
|
||||
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"emoji-regex": "^8.0.0",
|
||||
"is-fullwidth-code-point": "^3.0.0",
|
||||
"strip-ansi": "^6.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/yargs/node_modules/strip-ansi": {
|
||||
"version": "6.0.1",
|
||||
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
|
||||
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-regex": "^5.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/yocto-queue": {
|
||||
"version": "0.1.0",
|
||||
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
|
||||
|
|
|
|||
|
|
@ -7,12 +7,9 @@
|
|||
"build": "node esbuild.config.mjs production",
|
||||
"dev": "node esbuild.config.mjs watch",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"coverage": "c8 node scripts/run-tests.mjs --all; status=$?; rm -rf .test-bundles; exit $status",
|
||||
"lint": "biome check main.ts src/ tests/ scripts/ .e2e/scripts/",
|
||||
"lint:fix": "biome check --write main.ts src/ tests/ scripts/ .e2e/scripts/",
|
||||
"lint:obsidian": "eslint main.ts src/",
|
||||
"lint:obsidian:review": "eslint --config eslint.obsidian-review.config.mjs --no-config-lookup main.ts src/",
|
||||
"lint:obsidian:review:all": "npm run lint:obsidian:review -- --max-warnings=0",
|
||||
"version": "node scripts/bump-version.mjs",
|
||||
"test": "npm run build && npm run typecheck && node scripts/run-tests.mjs",
|
||||
"test:only": "node scripts/run-tests.mjs",
|
||||
|
|
@ -27,7 +24,6 @@
|
|||
"@biomejs/biome": "^2.4.13",
|
||||
"@types/node": "^22.10.2",
|
||||
"@typescript-eslint/parser": "^8.59.1",
|
||||
"c8": "^10.1.3",
|
||||
"esbuild": "^0.24.2",
|
||||
"eslint": "^9.39.4",
|
||||
"eslint-plugin-obsidianmd": "^0.2.9",
|
||||
|
|
|
|||
|
|
@ -1,33 +1,7 @@
|
|||
'use strict';
|
||||
|
||||
// Precompute the byte offset where each line starts (offsets[k] = start of
|
||||
// line k). Built once per document so callers resolving many anchors over the
|
||||
// same content avoid an O(n) newline rescan per anchor.
|
||||
export function buildLineOffsets(content: string): number[] {
|
||||
const offsets = [0];
|
||||
for (let i = 0; i < content.length; i++) {
|
||||
if (content.charCodeAt(i) === 10) offsets.push(i + 1);
|
||||
}
|
||||
return offsets;
|
||||
}
|
||||
|
||||
// Largest line index whose start offset is <= charOffset, i.e. the 0-based
|
||||
// line containing charOffset. Equivalent to counting '\n' before charOffset
|
||||
// but O(log n) instead of O(n).
|
||||
function offsetToLine(offsets: number[], charOffset: number): number {
|
||||
let lo = 0;
|
||||
let hi = offsets.length - 1;
|
||||
while (lo < hi) {
|
||||
const mid = (lo + hi + 1) >> 1;
|
||||
if (offsets[mid] <= charOffset) lo = mid;
|
||||
else hi = mid - 1;
|
||||
}
|
||||
return lo;
|
||||
}
|
||||
|
||||
export function findLineForAnchor(content: string, anchor: string, lineOffsets?: number[]): number {
|
||||
export function findLineForAnchor(content: string, anchor: string): number {
|
||||
if (!anchor) return -1;
|
||||
const offsets = lineOffsets ?? buildLineOffsets(content);
|
||||
const normalize = (s: string) => s.replace(/\s+/g, ' ').trim();
|
||||
const normalizeWithMap = (s: string) => {
|
||||
const chars: string[] = [];
|
||||
|
|
@ -35,18 +9,7 @@ export function findLineForAnchor(content: string, anchor: string, lineOffsets?:
|
|||
let pendingWhitespace = false;
|
||||
for (let i = 0; i < s.length; i++) {
|
||||
const c = s[i];
|
||||
const code = s.charCodeAt(i);
|
||||
// ASCII whitespace fast path; defer to /\s/ only for rare non-ASCII
|
||||
// code points so the exact RegExp semantics are preserved.
|
||||
const isWhitespace =
|
||||
code === 32 ||
|
||||
code === 9 ||
|
||||
code === 10 ||
|
||||
code === 13 ||
|
||||
code === 12 ||
|
||||
code === 11 ||
|
||||
(code > 127 && /\s/.test(c));
|
||||
if (isWhitespace) {
|
||||
if (/\s/.test(c)) {
|
||||
pendingWhitespace = chars.length > 0;
|
||||
continue;
|
||||
}
|
||||
|
|
@ -64,7 +27,9 @@ export function findLineForAnchor(content: string, anchor: string, lineOffsets?:
|
|||
if (!needle) return -1;
|
||||
const idx = content.indexOf(needle);
|
||||
if (idx === -1) return -1;
|
||||
return offsetToLine(offsets, idx);
|
||||
let line = 0;
|
||||
for (let i = 0; i < idx; i++) if (content[i] === '\n') line++;
|
||||
return line;
|
||||
};
|
||||
|
||||
let line = tryAt(anchor);
|
||||
|
|
@ -86,5 +51,7 @@ export function findLineForAnchor(content: string, anchor: string, lineOffsets?:
|
|||
if (normIdx === -1) return -1;
|
||||
const originalIdx = normDoc.map[normIdx];
|
||||
if (originalIdx == null) return -1;
|
||||
return offsetToLine(offsets, originalIdx);
|
||||
let l = 0;
|
||||
for (let j = 0; j < originalIdx; j++) if (content[j] === '\n') l++;
|
||||
return l;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,60 +0,0 @@
|
|||
'use strict';
|
||||
|
||||
import { requestUrl } from 'obsidian';
|
||||
import { resolveCliPath, runCli, summarizeViaClaudeCode, summarizeViaCodex } from './cli';
|
||||
import type { RequestUrlFunction } from './provider-request';
|
||||
import { testApiBackend } from './providers';
|
||||
import { normalizeCliTimeoutMs } from './settings';
|
||||
import type { PluginSettings } from './types';
|
||||
|
||||
const CLI_TEST_SYSTEM =
|
||||
'Return only valid JSON with exactly one card: {"cards":[{"title":"CLI smoke","anchor":"parallel reader smoke anchor","gist":"ok","bullets":["ok"]}]}';
|
||||
const CLI_TEST_USER = 'parallel reader smoke anchor';
|
||||
const CLI_TEST_TIMEOUT_MS = 60000;
|
||||
|
||||
type SpawnImpl = Parameters<typeof runCli>[5];
|
||||
|
||||
interface BackendTestDeps {
|
||||
requestUrlImpl?: RequestUrlFunction;
|
||||
spawnImpl?: SpawnImpl;
|
||||
}
|
||||
|
||||
function cliSmokeSettings(settings: PluginSettings): PluginSettings {
|
||||
return {
|
||||
...settings,
|
||||
cliTimeoutMs: Math.min(normalizeCliTimeoutMs(settings.cliTimeoutMs), CLI_TEST_TIMEOUT_MS),
|
||||
minCards: 1,
|
||||
maxCards: 1,
|
||||
maxDocChars: 1000,
|
||||
};
|
||||
}
|
||||
|
||||
export async function testBackend(settings: PluginSettings, deps: BackendTestDeps = {}): Promise<string> {
|
||||
if (settings.backend === 'claude-code') {
|
||||
const cmd = resolveCliPath('claude', settings.cliPath);
|
||||
const version = await runCli(cmd, ['--version'], '', 10000, undefined, deps.spawnImpl);
|
||||
const cards = await summarizeViaClaudeCode(
|
||||
CLI_TEST_SYSTEM,
|
||||
CLI_TEST_USER,
|
||||
cliSmokeSettings(settings),
|
||||
undefined,
|
||||
deps.spawnImpl,
|
||||
);
|
||||
return `claude @ ${cmd}\n${version.stdout.trim()}\nsmoke: ${cards.length} card`;
|
||||
}
|
||||
|
||||
if (settings.backend === 'codex') {
|
||||
const cmd = resolveCliPath('codex', settings.cliPath);
|
||||
const version = await runCli(cmd, ['--version'], '', 10000, undefined, deps.spawnImpl);
|
||||
const cards = await summarizeViaCodex(
|
||||
CLI_TEST_SYSTEM,
|
||||
CLI_TEST_USER,
|
||||
cliSmokeSettings(settings),
|
||||
undefined,
|
||||
deps.spawnImpl,
|
||||
);
|
||||
return `codex @ ${cmd}\n${version.stdout.trim()}\nsmoke: ${cards.length} card`;
|
||||
}
|
||||
|
||||
return testApiBackend(deps.requestUrlImpl || requestUrl, settings);
|
||||
}
|
||||
|
|
@ -53,7 +53,7 @@ export class CacheManager {
|
|||
try {
|
||||
if (typeof this.adapter.exists === 'function' && (await this.adapter.exists(dir))) return;
|
||||
await this.adapter.mkdir(dir);
|
||||
} catch {
|
||||
} catch (_) {
|
||||
/* ignore race */
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
'use strict';
|
||||
|
||||
import { buildLineOffsets, findLineForAnchor } from './anchor';
|
||||
import { findLineForAnchor } from './anchor';
|
||||
import type { CardPatch, RawCard, ResolvedCard } from './types';
|
||||
|
||||
export function removeCardAt<T extends RawCard>(cards: T[], index: number): T[] {
|
||||
|
|
@ -29,15 +29,12 @@ export function updateCardAt<T extends RawCard>(cards: T[], index: number, patch
|
|||
}
|
||||
|
||||
export function resolveCardAnchors(content: string, rawCards: RawCard[]): ResolvedCard[] {
|
||||
// Build the line-offset index once and reuse it for every card instead of
|
||||
// rescanning the whole document per anchor.
|
||||
const lineOffsets = buildLineOffsets(content);
|
||||
const resolved: ResolvedCard[] = (rawCards || []).map((c: RawCard) => ({
|
||||
title: c.title,
|
||||
level: 2,
|
||||
anchor: c.anchor,
|
||||
gist: c.gist,
|
||||
startLine: findLineForAnchor(content, c.anchor, lineOffsets),
|
||||
startLine: findLineForAnchor(content, c.anchor),
|
||||
bullets: c.bullets || [],
|
||||
}));
|
||||
resolved.sort((a, b) => {
|
||||
|
|
|
|||
188
src/cli.ts
188
src/cli.ts
|
|
@ -44,7 +44,7 @@ export function resolveCliPath(name: string, override: string): string {
|
|||
const p = path.join(dir, name + ext);
|
||||
try {
|
||||
if (fs.existsSync(p)) return p;
|
||||
} catch {
|
||||
} catch (_) {
|
||||
// Ignore unreadable candidate paths and keep searching.
|
||||
}
|
||||
}
|
||||
|
|
@ -52,14 +52,26 @@ export function resolveCliPath(name: string, override: string): string {
|
|||
return name;
|
||||
}
|
||||
|
||||
type CliFailureReason = 'wall-timeout' | 'exit-nonzero' | 'streams-unavailable' | 'spawn-failure' | 'startup-error';
|
||||
export interface RunCliOptions {
|
||||
/** Idle timeout in ms — fail if no stdout/stderr data arrives for this long. 0 disables. */
|
||||
idleTimeoutMs?: number;
|
||||
/** When true, log spawn/settle lifecycle to console for diagnostics. */
|
||||
debug?: boolean;
|
||||
}
|
||||
|
||||
export type CliFailureReason =
|
||||
| 'wall-timeout'
|
||||
| 'idle-timeout'
|
||||
| 'exit-nonzero'
|
||||
| 'streams-unavailable'
|
||||
| 'spawn-failure'
|
||||
| 'startup-error';
|
||||
|
||||
export interface CliErrorDetails {
|
||||
reason: CliFailureReason;
|
||||
cmd: string;
|
||||
pid: number | null;
|
||||
elapsedMs: number;
|
||||
/** ms since the last byte of stdout/stderr — useful to tell hung-mid-call from hung-from-start. */
|
||||
idleMs: number | null;
|
||||
stdoutBytes: number;
|
||||
stderrBytes: number;
|
||||
|
|
@ -70,6 +82,7 @@ export interface CliErrorDetails {
|
|||
exitCode: number | null;
|
||||
signal: NodeJS.Signals | null;
|
||||
timeoutMs: number;
|
||||
idleTimeoutMs: number;
|
||||
}
|
||||
|
||||
export class CliProcessError extends Error {
|
||||
|
|
@ -82,9 +95,8 @@ export class CliProcessError extends Error {
|
|||
}
|
||||
}
|
||||
|
||||
const DIAG_STDERR_TAIL_CHARS = 1500;
|
||||
const DIAG_STDOUT_TAIL_CHARS = 1500;
|
||||
const EMPTY_MCP_CONFIG = '{"mcpServers":{}}';
|
||||
const DIAG_STDERR_TAIL_CHARS = 800;
|
||||
const DIAG_STDOUT_TAIL_CHARS = 200;
|
||||
|
||||
function tail(text: string, max: number): string {
|
||||
if (text.length <= max) return text;
|
||||
|
|
@ -104,38 +116,6 @@ function utf8ByteLength(text: string): number {
|
|||
return Buffer.byteLength(text, 'utf8');
|
||||
}
|
||||
|
||||
function claudeResultText(stdout: string): string {
|
||||
const events: Array<Record<string, unknown>> = [];
|
||||
try {
|
||||
const parsed = JSON.parse(stdout);
|
||||
if (Array.isArray(parsed)) {
|
||||
events.push(...(parsed.filter((e) => e && typeof e === 'object') as Array<Record<string, unknown>>));
|
||||
} else if (parsed && typeof parsed === 'object') {
|
||||
events.push(parsed as Record<string, unknown>);
|
||||
}
|
||||
} catch {
|
||||
for (const line of stdout.split(/\r?\n/)) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) continue;
|
||||
try {
|
||||
const parsed = JSON.parse(trimmed);
|
||||
if (parsed && typeof parsed === 'object') events.push(parsed as Record<string, unknown>);
|
||||
} catch {
|
||||
// Ignore non-JSON progress lines and keep scanning for result events.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (let i = events.length - 1; i >= 0; i--) {
|
||||
const event = events[i];
|
||||
if (event.type === 'result' && typeof event.result === 'string') return event.result;
|
||||
}
|
||||
const single = events[0];
|
||||
if (single && typeof single.result === 'string') return single.result;
|
||||
if (single && typeof single.content === 'string') return single.content;
|
||||
return '';
|
||||
}
|
||||
|
||||
export function runCli(
|
||||
cmd: string,
|
||||
args: string[],
|
||||
|
|
@ -143,11 +123,15 @@ export function runCli(
|
|||
timeoutMs: number,
|
||||
job?: GenerationJob,
|
||||
spawnImpl: typeof spawn = spawn,
|
||||
options: RunCliOptions = {},
|
||||
): Promise<{ stdout: string; stderr: string }> {
|
||||
const idleTimeoutMs = Math.max(0, Math.floor(options.idleTimeoutMs ?? 0));
|
||||
const debug = !!options.debug;
|
||||
return new Promise<{ stdout: string; stderr: string }>((resolve, reject) => {
|
||||
let child: ReturnType<typeof spawn>;
|
||||
let settled = false;
|
||||
let timer: number | null = null;
|
||||
let idleTimer: number | null = null;
|
||||
const startedAt = Date.now();
|
||||
let lastActivityAt = startedAt;
|
||||
const clearTimers = () => {
|
||||
|
|
@ -155,40 +139,42 @@ export function runCli(
|
|||
activeWindow.clearTimeout(timer);
|
||||
timer = null;
|
||||
}
|
||||
if (idleTimer) {
|
||||
activeWindow.clearTimeout(idleTimer);
|
||||
idleTimer = null;
|
||||
}
|
||||
};
|
||||
const fail = (err: Error) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimers();
|
||||
console.warn('[parallel-reader] cli failed', {
|
||||
cmd,
|
||||
pid: child?.pid,
|
||||
elapsedMs: Date.now() - startedAt,
|
||||
error: err.message,
|
||||
});
|
||||
if (debug) {
|
||||
console.warn('[parallel-reader] cli failed', {
|
||||
cmd,
|
||||
pid: child?.pid,
|
||||
elapsedMs: Date.now() - startedAt,
|
||||
error: err.message,
|
||||
});
|
||||
}
|
||||
reject(err);
|
||||
};
|
||||
const succeed = (value: { stdout: string; stderr: string }) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimers();
|
||||
console.debug('[parallel-reader] cli ok', {
|
||||
cmd,
|
||||
pid: child?.pid,
|
||||
elapsedMs: Date.now() - startedAt,
|
||||
stdoutBytes: utf8ByteLength(value.stdout),
|
||||
stderrBytes: utf8ByteLength(value.stderr),
|
||||
});
|
||||
if (debug) {
|
||||
console.debug('[parallel-reader] cli ok', {
|
||||
cmd,
|
||||
pid: child?.pid,
|
||||
elapsedMs: Date.now() - startedAt,
|
||||
stdoutBytes: utf8ByteLength(value.stdout),
|
||||
stderrBytes: utf8ByteLength(value.stderr),
|
||||
});
|
||||
}
|
||||
resolve(value);
|
||||
};
|
||||
try {
|
||||
child = spawnImpl(cmd, args, {
|
||||
// Lock cwd to the user's home dir. Inheriting Obsidian's cwd (often the Vault path)
|
||||
// can break Claude CLI's per-project memory writes — iCloud-synced Vault paths
|
||||
// contain characters like `~md~` that produce malformed `~/.claude/projects/...`
|
||||
// slugs and an immediate exit-1 with empty modelUsage. Home dir is stable and
|
||||
// writable; the LLM call itself is independent of cwd.
|
||||
cwd: os.homedir(),
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
env: {
|
||||
...process.env,
|
||||
|
|
@ -205,7 +191,7 @@ export function runCli(
|
|||
});
|
||||
} catch (e: unknown) {
|
||||
const message = `Failed to start ${cmd}: ${e instanceof Error ? e.message : String(e)}`;
|
||||
console.warn('[parallel-reader] cli spawn failed', { cmd, error: message });
|
||||
if (debug) console.warn('[parallel-reader] cli spawn failed', { cmd, error: message });
|
||||
return reject(
|
||||
new CliProcessError(message, {
|
||||
reason: 'spawn-failure',
|
||||
|
|
@ -220,16 +206,20 @@ export function runCli(
|
|||
exitCode: null,
|
||||
signal: null,
|
||||
timeoutMs,
|
||||
idleTimeoutMs,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
console.debug('[parallel-reader] cli spawn', {
|
||||
cmd,
|
||||
argCount: args.length,
|
||||
pid: child?.pid,
|
||||
timeoutMs,
|
||||
});
|
||||
if (debug) {
|
||||
console.debug('[parallel-reader] cli spawn', {
|
||||
cmd,
|
||||
argCount: args.length,
|
||||
pid: child?.pid,
|
||||
timeoutMs,
|
||||
idleTimeoutMs,
|
||||
});
|
||||
}
|
||||
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
|
|
@ -251,6 +241,7 @@ export function runCli(
|
|||
exitCode: extras.exitCode ?? null,
|
||||
signal: extras.signal ?? null,
|
||||
timeoutMs,
|
||||
idleTimeoutMs,
|
||||
});
|
||||
|
||||
const buildDiagSuffix = (details: CliErrorDetails): string => {
|
||||
|
|
@ -273,6 +264,22 @@ export function runCli(
|
|||
fail(new CliProcessError(`CLI timed out (${timeoutMs}ms)${buildDiagSuffix(details)}`, details));
|
||||
}, timeoutMs);
|
||||
|
||||
const armIdleTimer = () => {
|
||||
if (settled || !idleTimeoutMs) return;
|
||||
if (idleTimer) activeWindow.clearTimeout(idleTimer);
|
||||
idleTimer = activeWindow.setTimeout(() => {
|
||||
const idleMs = Date.now() - lastActivityAt;
|
||||
try {
|
||||
child.kill('SIGKILL');
|
||||
} catch (e: unknown) {
|
||||
console.warn('[parallel-reader] failed to kill idle-timed-out CLI process', e);
|
||||
}
|
||||
const details = collectDetails('idle-timeout', idleMs, { signal: 'SIGKILL' });
|
||||
fail(new CliProcessError(`CLI idle timeout (${idleTimeoutMs}ms)${buildDiagSuffix(details)}`, details));
|
||||
}, idleTimeoutMs);
|
||||
};
|
||||
armIdleTimer();
|
||||
|
||||
if (job) {
|
||||
job.onCancel(() => {
|
||||
try {
|
||||
|
|
@ -296,6 +303,7 @@ export function runCli(
|
|||
const noteActivity = () => {
|
||||
if (settled) return;
|
||||
lastActivityAt = Date.now();
|
||||
armIdleTimer();
|
||||
};
|
||||
|
||||
childStdout.on('data', (d) => {
|
||||
|
|
@ -320,14 +328,7 @@ export function runCli(
|
|||
if (code !== 0) {
|
||||
const idleMs = Date.now() - lastActivityAt;
|
||||
const details = collectDetails('exit-nonzero', idleMs, { exitCode: code, signal });
|
||||
// Some CLIs (notably `claude --output-format json`) emit error events to stdout
|
||||
// instead of stderr. Always include both tails so the failure mode is debuggable
|
||||
// even when stderr is empty.
|
||||
let suffix = '';
|
||||
if (details.stderrTail) suffix += `\nstderr:\n${details.stderrTail}`;
|
||||
if (details.stdoutTail) suffix += `\nstdout:\n${details.stdoutTail}`;
|
||||
if (!suffix) suffix = '\n(no output on either stream)';
|
||||
return fail(new CliProcessError(`CLI exited with code ${code} (signal=${signal ?? 'none'})${suffix}`, details));
|
||||
return fail(new CliProcessError(`CLI exited with code ${code}\nstderr:\n${stderr.slice(0, 1000)}`, details));
|
||||
}
|
||||
succeed({ stdout, stderr });
|
||||
});
|
||||
|
|
@ -336,7 +337,7 @@ export function runCli(
|
|||
try {
|
||||
childStdin.write(stdinText);
|
||||
childStdin.end();
|
||||
} catch {
|
||||
} catch (_e) {
|
||||
// Child may have exited before stdin was written.
|
||||
}
|
||||
} else {
|
||||
|
|
@ -360,33 +361,41 @@ export async function summarizeViaClaudeCode(
|
|||
const args = [
|
||||
'-p',
|
||||
'--output-format',
|
||||
'stream-json',
|
||||
'--verbose',
|
||||
'json',
|
||||
'--append-system-prompt',
|
||||
system,
|
||||
'--tools',
|
||||
'',
|
||||
'--disallowed-tools',
|
||||
'Bash,Read,Write,Edit,Glob,Grep,WebFetch,WebSearch,TodoWrite,Task,LSP,NotebookEdit',
|
||||
'--no-session-persistence',
|
||||
'--disable-slash-commands',
|
||||
'--no-chrome',
|
||||
'--strict-mcp-config',
|
||||
'--mcp-config',
|
||||
EMPTY_MCP_CONFIG,
|
||||
'Bash,Read,Write,Edit,Glob,Grep,WebFetch,WebSearch,TodoWrite,Task',
|
||||
];
|
||||
const claudeModel = (settings.model || '').trim();
|
||||
if (claudeModel) args.push('--model', claudeModel);
|
||||
const { stdout } = await runCli(cmd, args, user, settings.cliTimeoutMs, job, spawnImpl);
|
||||
const { stdout } = await runCli(cmd, args, user, settings.cliTimeoutMs, job, spawnImpl, {
|
||||
idleTimeoutMs: settings.cliIdleTimeoutMs,
|
||||
debug: settings.debugLogging,
|
||||
});
|
||||
|
||||
const resultText = claudeResultText(stdout);
|
||||
if (!resultText && stdout.trim()) {
|
||||
// --output-format json produces a JSON array of event objects.
|
||||
// Find the "result" entry and extract its .result text field.
|
||||
let resultText = '';
|
||||
try {
|
||||
const events = JSON.parse(stdout);
|
||||
if (Array.isArray(events)) {
|
||||
const resultEvent = events.find((e: Record<string, unknown>) => e.type === 'result');
|
||||
if (resultEvent && typeof resultEvent.result === 'string') {
|
||||
resultText = resultEvent.result;
|
||||
}
|
||||
} else if (events && typeof events === 'object') {
|
||||
// Older CLI versions return a single object
|
||||
resultText = events.result || events.content || '';
|
||||
}
|
||||
} catch (_) {
|
||||
console.warn(
|
||||
'[parallel-reader] claude CLI returned unexpected output. length=',
|
||||
stdout.length,
|
||||
'head=',
|
||||
stdout.slice(0, 80),
|
||||
);
|
||||
throw new Error(translate(settings, 'errorClaudeCliBadJson', { length: String(stdout.length) }));
|
||||
}
|
||||
|
||||
if (!resultText) {
|
||||
|
|
@ -415,6 +424,9 @@ export async function summarizeViaCodex(
|
|||
// (claude-sonnet-4-6), which would break Codex if passed verbatim. Codex
|
||||
// uses its own config.toml profile for model selection.
|
||||
const args = ['exec', '--skip-git-repo-check', '--sandbox', 'read-only', '-'];
|
||||
const { stdout } = await runCli(cmd, args, combined, settings.cliTimeoutMs, job, spawnImpl);
|
||||
const { stdout } = await runCli(cmd, args, combined, settings.cliTimeoutMs, job, spawnImpl, {
|
||||
idleTimeoutMs: settings.cliIdleTimeoutMs,
|
||||
debug: settings.debugLogging,
|
||||
});
|
||||
return parseCardsJson(stdout, settings);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,13 +6,11 @@ import { translate } from './i18n';
|
|||
import type { ErrorKind, PluginSettings } from './types';
|
||||
import { copyToClipboard } from './ui-helpers';
|
||||
|
||||
interface GenerationErrorContext {
|
||||
export interface GenerationErrorContext {
|
||||
app: App;
|
||||
settings: PluginSettings;
|
||||
/** Open the plugin settings tab (best-effort; falls back to no-op if unavailable). */
|
||||
openSettings: () => void;
|
||||
/** Re-run the generation that failed (used by retryable error kinds such as network). */
|
||||
onRetry?: () => void;
|
||||
}
|
||||
|
||||
interface ActionableNoticeAction {
|
||||
|
|
@ -50,21 +48,7 @@ function showActionableNotice(message: string, actions: ActionableNoticeAction[]
|
|||
return notice;
|
||||
}
|
||||
|
||||
function reasonCopyKeys(reason: CliErrorDetails['reason']): { titleKey: string; reasonKey: string } {
|
||||
switch (reason) {
|
||||
case 'wall-timeout':
|
||||
return { titleKey: 'errorModalTimeoutTitle', reasonKey: 'errorModalReasonWall' };
|
||||
case 'exit-nonzero':
|
||||
return { titleKey: 'errorModalExitTitle', reasonKey: 'errorModalReasonExit' };
|
||||
case 'spawn-failure':
|
||||
case 'startup-error':
|
||||
return { titleKey: 'errorModalStartupTitle', reasonKey: 'errorModalReasonStartup' };
|
||||
default:
|
||||
return { titleKey: 'errorModalTimeoutTitle', reasonKey: 'errorModalReasonWall' };
|
||||
}
|
||||
}
|
||||
|
||||
class CliDiagnosticsModal extends Modal {
|
||||
class TimeoutDiagnosticsModal extends Modal {
|
||||
private readonly settings: PluginSettings;
|
||||
private readonly details: CliErrorDetails;
|
||||
private readonly fullMessage: string;
|
||||
|
|
@ -88,18 +72,10 @@ class CliDiagnosticsModal extends Modal {
|
|||
const { contentEl } = this;
|
||||
contentEl.empty();
|
||||
contentEl.addClass('parallel-reader-error-modal');
|
||||
const { titleKey, reasonKey } = reasonCopyKeys(this.details.reason);
|
||||
contentEl.createEl('h2', { text: translate(this.settings, titleKey) });
|
||||
contentEl.createEl('h2', { text: translate(this.settings, 'errorModalTimeoutTitle') });
|
||||
|
||||
const reasonKey = this.details.reason === 'idle-timeout' ? 'errorModalReasonIdle' : 'errorModalReasonWall';
|
||||
contentEl.createEl('p', { text: translate(this.settings, reasonKey) });
|
||||
if (this.details.exitCode != null) {
|
||||
contentEl.createEl('p', {
|
||||
cls: 'parallel-reader-error-modal-exit',
|
||||
text: translate(this.settings, 'errorModalFieldExit', {
|
||||
code: String(this.details.exitCode),
|
||||
signal: this.details.signal ?? 'none',
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
const grid = contentEl.createDiv({ cls: 'parallel-reader-error-modal-grid' });
|
||||
const addRow = (label: string, value: string) => {
|
||||
|
|
@ -119,18 +95,13 @@ class CliDiagnosticsModal extends Modal {
|
|||
`stdout ${this.details.stdoutBytes}B / stderr ${this.details.stderrBytes}B`,
|
||||
);
|
||||
|
||||
let hasOutput = false;
|
||||
if (this.details.stderrTail) {
|
||||
contentEl.createEl('h3', { text: translate(this.settings, 'errorModalStderrTail') });
|
||||
contentEl.createEl('pre', { cls: 'parallel-reader-error-modal-tail', text: this.details.stderrTail });
|
||||
hasOutput = true;
|
||||
}
|
||||
if (this.details.stdoutTail) {
|
||||
} else if (this.details.stdoutTail) {
|
||||
contentEl.createEl('h3', { text: translate(this.settings, 'errorModalStdoutTail') });
|
||||
contentEl.createEl('pre', { cls: 'parallel-reader-error-modal-tail', text: this.details.stdoutTail });
|
||||
hasOutput = true;
|
||||
}
|
||||
if (!hasOutput) {
|
||||
} else {
|
||||
contentEl.createEl('p', {
|
||||
cls: 'parallel-reader-error-modal-empty',
|
||||
text: translate(this.settings, 'errorModalNoOutput'),
|
||||
|
|
@ -168,17 +139,13 @@ export function showGenerationError(
|
|||
): void {
|
||||
const tr = (k: string, vars?: Record<string, string | number>) => translate(ctx.settings, k, vars);
|
||||
|
||||
// Any structured CLI error opens the diagnostics modal — even when classify falls
|
||||
// through to 'unknown' (e.g. exit-nonzero with empty stderr) we want the stdout tail
|
||||
// surfaced so the failure is debuggable.
|
||||
const cliDetails = (error as { details?: CliErrorDetails }).details;
|
||||
const isStructuredCliError = !!cliDetails && typeof cliDetails.reason === 'string';
|
||||
if (isStructuredCliError) {
|
||||
new CliDiagnosticsModal(ctx.app, ctx.settings, cliDetails, message, ctx.openSettings).open();
|
||||
return;
|
||||
}
|
||||
|
||||
if (kind === 'timeout') {
|
||||
const details = (error as { details?: CliErrorDetails }).details;
|
||||
if (details) {
|
||||
// Open a modal so users can read the stderr tail at leisure and copy it.
|
||||
new TimeoutDiagnosticsModal(ctx.app, ctx.settings, details, message, ctx.openSettings).open();
|
||||
return;
|
||||
}
|
||||
// API streaming timeout: no structured details, show actionable notice with raw message tail.
|
||||
showActionableNotice(tr('errorNoticeTimeout'), [
|
||||
{
|
||||
|
|
@ -207,17 +174,6 @@ export function showGenerationError(
|
|||
return;
|
||||
}
|
||||
|
||||
if (kind === 'network') {
|
||||
const actions: ActionableNoticeAction[] = [];
|
||||
if (ctx.onRetry) actions.push({ label: tr('errorActionRetry'), primary: true, onClick: ctx.onRetry });
|
||||
actions.push({
|
||||
label: tr('errorActionCopyDetails'),
|
||||
onClick: () => void copyToClipboard(message, tr('errorModalCopySuccess')),
|
||||
});
|
||||
showActionableNotice(tr('errorNoticeNetwork'), actions);
|
||||
return;
|
||||
}
|
||||
|
||||
if (kind === 'schema') {
|
||||
showActionableNotice(tr('errorNoticeSchema'), [
|
||||
{
|
||||
|
|
|
|||
|
|
@ -189,20 +189,6 @@ export class GenerationJobManager {
|
|||
if (!job) return false;
|
||||
return job.cancel();
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel every in-flight job — firing each job's cancel handlers, which abort the
|
||||
* streaming HTTP request and SIGKILL any CLI child process — and reject all queued
|
||||
* waiters. Used on plugin unload so nothing keeps running after teardown. Returns
|
||||
* the total number of jobs and waiters cancelled.
|
||||
*/
|
||||
cancelAll(): number {
|
||||
let cancelled = 0;
|
||||
for (const job of this.jobs.values()) {
|
||||
if (job.cancel()) cancelled++;
|
||||
}
|
||||
return cancelled + this.cancelAllWaiters();
|
||||
}
|
||||
}
|
||||
|
||||
export function classifyGenerationError(error: unknown): ErrorKind {
|
||||
|
|
@ -216,6 +202,7 @@ export function classifyGenerationError(error: unknown): ErrorKind {
|
|||
if (details && typeof details.reason === 'string') {
|
||||
switch (details.reason) {
|
||||
case 'wall-timeout':
|
||||
case 'idle-timeout':
|
||||
return 'timeout';
|
||||
case 'spawn-failure':
|
||||
case 'startup-error':
|
||||
|
|
@ -230,12 +217,6 @@ export function classifyGenerationError(error: unknown): ErrorKind {
|
|||
if (/api key|unauthorized|401|403|认证|权限/i.test(message)) return 'auth';
|
||||
if (/timeout|超时|timed out/i.test(message)) return 'timeout';
|
||||
if (/429|rate limit|too many requests/i.test(message)) return 'rate-limit';
|
||||
if (
|
||||
/ECONNREFUSED|ENOTFOUND|ENETUNREACH|EAI_AGAIN|ECONNRESET|EHOSTUNREACH|Failed to fetch|NetworkError|net::ERR_|fetch failed/i.test(
|
||||
message,
|
||||
)
|
||||
)
|
||||
return 'network';
|
||||
if (
|
||||
/非 JSON|非预期输出|没有返回结果|non-JSON|unexpected output|no result|json_schema|schema|structured/i.test(message)
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
'use strict';
|
||||
|
||||
import { requestUrl } from 'obsidian';
|
||||
import { buildLineOffsets, findLineForAnchor } from './anchor';
|
||||
import { findLineForAnchor } from './anchor';
|
||||
import { summarizeViaClaudeCode, summarizeViaCodex } from './cli';
|
||||
import type { GenerationJob } from './generation-job-manager';
|
||||
import { buildPrompts } from './prompt';
|
||||
|
|
@ -44,13 +44,12 @@ export async function summarizeDocument(
|
|||
break;
|
||||
}
|
||||
}
|
||||
const lineOffsets = buildLineOffsets(content);
|
||||
const resolved: ResolvedCard[] = cards.map((c) => ({
|
||||
title: c.title,
|
||||
level: 2,
|
||||
anchor: c.anchor,
|
||||
gist: c.gist,
|
||||
startLine: findLineForAnchor(content, c.anchor, lineOffsets),
|
||||
startLine: findLineForAnchor(content, c.anchor),
|
||||
bullets: c.bullets,
|
||||
}));
|
||||
resolved.sort((a, b) => {
|
||||
|
|
|
|||
1235
src/i18n-strings.ts
1235
src/i18n-strings.ts
File diff suppressed because it is too large
Load diff
17
src/i18n.ts
17
src/i18n.ts
|
|
@ -3,23 +3,14 @@
|
|||
import { STRINGS } from './i18n-strings';
|
||||
import type { PluginSettings } from './types';
|
||||
|
||||
export { LOCALE_OVERRIDES, STRINGS } from './i18n-strings';
|
||||
|
||||
function supportedBaseLanguage(value: unknown): string | null {
|
||||
if (typeof value !== 'string') {
|
||||
return null;
|
||||
}
|
||||
const base = value.trim().toLowerCase().split(/[-_]/)[0];
|
||||
return base && STRINGS[base] ? base : null;
|
||||
}
|
||||
export { STRINGS } from './i18n-strings';
|
||||
|
||||
export function resolveUiLanguage(settings: Pick<PluginSettings, 'uiLanguage'> | null): string {
|
||||
const configured = settings?.uiLanguage;
|
||||
if (configured && configured !== 'auto') {
|
||||
return supportedBaseLanguage(configured) || 'en';
|
||||
}
|
||||
if (configured === 'zh' || configured === 'en') return configured;
|
||||
const nav = typeof navigator !== 'undefined' ? navigator : null;
|
||||
return supportedBaseLanguage(nav?.language) || 'en';
|
||||
const language = String(nav?.language || '').toLowerCase();
|
||||
return language.startsWith('zh') ? 'zh' : 'en';
|
||||
}
|
||||
|
||||
export function translate(
|
||||
|
|
|
|||
|
|
@ -3,51 +3,21 @@
|
|||
import { DEFAULT_SETTINGS, MAX_DOC_CHARS, normalizeCardCount, PROMPT_LANGUAGES } from './settings';
|
||||
import type { PluginSettings, PromptPair } from './types';
|
||||
|
||||
const PROMPT_LANGUAGE_INSTRUCTIONS: Record<string, string> = {
|
||||
auto: 'Write title, gist, and bullets in the main language of the source document.',
|
||||
zh: '用中文输出 title、gist 和 bullets。',
|
||||
en: 'Write title, gist, and bullets in English.',
|
||||
ja: 'Write title, gist, and bullets in Japanese.',
|
||||
ko: 'Write title, gist, and bullets in Korean.',
|
||||
fr: 'Write title, gist, and bullets in French.',
|
||||
de: 'Write title, gist, and bullets in German.',
|
||||
es: 'Write title, gist, and bullets in Spanish.',
|
||||
};
|
||||
|
||||
const PROMPT_SCHEMA_EXAMPLES: Record<string, string> = {
|
||||
zh: `{"cards":[
|
||||
{"title":"U 型收益曲线","anchor":"那谁又会被 AI 所受益?整体来看,它把整个分数变成了一分到七分","gist":"AI 生产力收益呈 U 型,两端受益最大、中间层塌陷","bullets":["最高薪岗位(软件管理)通过加速既有工作受益最大","最低薪岗位(外卖员、园艺工)用 AI 开副业创造新收入","中间层科学家、律师收益最少,部分因对 prompt 精度信任不足","全体均分 5.1/7,42% 报告收益模糊"]}
|
||||
]}`,
|
||||
en: `{"cards":[
|
||||
{"title":"U-shaped gains","anchor":"Who benefits from AI? Overall, it shifts the score from one to seven","gist":"AI productivity gains form a U shape, with both ends benefiting most","bullets":["Top-paid software managers benefit by accelerating existing work","Low-paid workers use AI to create new side income","Middle-layer specialists gain less because prompt precision is hard to trust","Average reported benefit is 5.1/7, with 42% describing gains as unclear"]}
|
||||
]}`,
|
||||
ja: `{"cards":[
|
||||
{"title":"U字型の利益","anchor":"Who benefits from AI? Overall, it shifts the score from one to seven","gist":"AIによる生産性向上はU字型になり、両端の層がもっとも大きな恩恵を受ける","bullets":["高所得のソフトウェア管理職は既存業務を加速できるため大きく恩恵を受ける","低所得の労働者はAIを使って新しい副収入を作り出せる","中間層の専門職は、プロンプト精度への信頼が難しいため利益が小さい","平均利益は5.1/7で、42%が効果は不明確だと報告している"]}
|
||||
]}`,
|
||||
ko: `{"cards":[
|
||||
{"title":"U자형 이득","anchor":"Who benefits from AI? Overall, it shifts the score from one to seven","gist":"AI 생산성 향상은 U자형을 보이며 양끝 집단이 가장 큰 혜택을 얻는다","bullets":["고소득 소프트웨어 관리자는 기존 업무를 더 빠르게 처리해 큰 이득을 얻는다","저소득 노동자는 AI로 새로운 부수입 기회를 만들 수 있다","중간층 전문가는 프롬프트 정확도를 신뢰하기 어려워 상대적으로 이득이 작다","평균 체감 이득은 5.1/7이며 42%는 효과가 불명확하다고 답했다"]}
|
||||
]}`,
|
||||
fr: `{"cards":[
|
||||
{"title":"Gains en U","anchor":"Who benefits from AI? Overall, it shifts the score from one to seven","gist":"Les gains de productivité liés à l'AI forment une courbe en U, les deux extrémités en profitant le plus","bullets":["Les managers logiciels très rémunérés gagnent surtout en accélérant leur travail existant","Les travailleurs peu rémunérés utilisent l'AI pour créer de nouveaux revenus complémentaires","Les spécialistes intermédiaires gagnent moins, car la précision des prompts reste difficile à fiabiliser","Le bénéfice moyen déclaré est de 5,1/7, avec 42% de gains jugés peu clairs"]}
|
||||
]}`,
|
||||
de: `{"cards":[
|
||||
{"title":"U-förmige Gewinne","anchor":"Who benefits from AI? Overall, it shifts the score from one to seven","gist":"AI-Produktivitätsgewinne bilden eine U-Form, bei der beide Enden am stärksten profitieren","bullets":["Hochbezahlte Softwaremanager profitieren, weil sie bestehende Arbeit beschleunigen","Geringverdienende nutzen AI, um neue Nebeneinnahmen zu schaffen","Spezialisten in der Mitte gewinnen weniger, weil präzise Prompts schwer zu vertrauen sind","Der gemeldete Durchschnittsnutzen liegt bei 5,1/7; 42% beschreiben die Gewinne als unklar"]}
|
||||
]}`,
|
||||
es: `{"cards":[
|
||||
{"title":"Ganancias en forma de U","anchor":"Who benefits from AI? Overall, it shifts the score from one to seven","gist":"Las mejoras de productividad con AI forman una U: los extremos son quienes más se benefician","bullets":["Los gerentes de software mejor pagados se benefician al acelerar su trabajo existente","Los trabajadores con menores ingresos usan AI para crear nuevas fuentes de ingreso","Los especialistas intermedios ganan menos porque es difícil confiar en la precisión del prompt","El beneficio medio reportado es 5,1/7, con un 42% que describe las ganancias como poco claras"]}
|
||||
]}`,
|
||||
};
|
||||
|
||||
function usesEnglishPromptShell(language: string): boolean {
|
||||
return language !== 'zh' && language !== 'auto';
|
||||
}
|
||||
|
||||
export function promptLanguageInstruction(language: string): string {
|
||||
return PROMPT_LANGUAGE_INSTRUCTIONS[language] || PROMPT_LANGUAGE_INSTRUCTIONS.zh;
|
||||
if (language === 'en') return 'Write title, gist, and bullets in English.';
|
||||
if (language === 'auto') return 'Write title, gist, and bullets in the main language of the source document.';
|
||||
return '用中文输出 title、gist 和 bullets。';
|
||||
}
|
||||
|
||||
export function promptSchemaExample(language: string): string {
|
||||
return PROMPT_SCHEMA_EXAMPLES[language] || PROMPT_SCHEMA_EXAMPLES.zh;
|
||||
if (language === 'en') {
|
||||
return `{"cards":[
|
||||
{"title":"U-shaped gains","anchor":"Who benefits from AI? Overall, it shifts the score from one to seven","gist":"AI productivity gains form a U shape, with both ends benefiting most","bullets":["Top-paid software managers benefit by accelerating existing work","Low-paid workers use AI to create new side income","Middle-layer specialists gain less because prompt precision is hard to trust","Average reported benefit is 5.1/7, with 42% describing gains as unclear"]}
|
||||
]}`;
|
||||
}
|
||||
return `{"cards":[
|
||||
{"title":"U 型收益曲线","anchor":"那谁又会被 AI 所受益?整体来看,它把整个分数变成了一分到七分","gist":"AI 生产力收益呈 U 型,两端受益最大、中间层塌陷","bullets":["最高薪岗位(软件管理)通过加速既有工作受益最大","最低薪岗位(外卖员、园艺工)用 AI 开副业创造新收入","中间层科学家、律师收益最少,部分因对 prompt 精度信任不足","全体均分 5.1/7,42% 报告收益模糊"]}
|
||||
]}`;
|
||||
}
|
||||
|
||||
export function renderPromptTemplate(template: string, vars: Record<string, string | number>): string {
|
||||
|
|
@ -64,7 +34,7 @@ function defaultSystemPrompt(
|
|||
schema: string,
|
||||
example: string,
|
||||
): string {
|
||||
if (usesEnglishPromptShell(language)) {
|
||||
if (language === 'en') {
|
||||
return `You are a long-form reading summary assistant. After reading the full document, split it into ${minCards}-${maxCards} natural topic units. They do not need to match markdown headings; use a complete argument or topic as the unit, merging short sections and splitting long ones when needed.
|
||||
|
||||
Each card has one guiding sentence plus several bullets. Bullets carry details; gist is the lead-in.
|
||||
|
|
@ -128,7 +98,7 @@ function systemPromptContract(
|
|||
languageInstruction: string,
|
||||
schema: string,
|
||||
): string {
|
||||
if (usesEnglishPromptShell(language)) {
|
||||
if (language === 'en') {
|
||||
return `Non-overridable output contract:
|
||||
- Must output ${minCards}-${maxCards} cards.
|
||||
- ${languageInstruction}
|
||||
|
|
@ -155,7 +125,7 @@ export function buildPrompts(content: string, settings: PluginSettings): PromptP
|
|||
const doc =
|
||||
content.length > maxDocChars
|
||||
? content.slice(0, maxDocChars) +
|
||||
(usesEnglishPromptShell(promptLanguage) ? '\n\n[Document truncated]' : '\n\n[文档过长,已截断]')
|
||||
(promptLanguage === 'en' ? '\n\n[Document truncated]' : '\n\n[文档过长,已截断]')
|
||||
: content;
|
||||
|
||||
const schema = '{"cards":[{"title":"...","anchor":"...","gist":"...","bullets":["...","..."]}]}';
|
||||
|
|
@ -171,8 +141,6 @@ export function buildPrompts(content: string, settings: PluginSettings): PromptP
|
|||
${contract}`
|
||||
: defaultSystem;
|
||||
|
||||
const user = usesEnglishPromptShell(promptLanguage)
|
||||
? `Source document:\n\n${doc}`
|
||||
: `以下是需要处理的文档全文:\n\n${doc}`;
|
||||
const user = promptLanguage === 'en' ? `Source document:\n\n${doc}` : `以下是需要处理的文档全文:\n\n${doc}`;
|
||||
return { system, user };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ import type { PluginSettings } from './types';
|
|||
|
||||
/* ---------- Body type interfaces ---------- */
|
||||
|
||||
interface AnthropicMessagesBody {
|
||||
export interface AnthropicMessagesBody {
|
||||
model: string;
|
||||
max_tokens: number;
|
||||
system: string;
|
||||
|
|
@ -22,7 +22,7 @@ interface AnthropicMessagesBody {
|
|||
stream?: boolean;
|
||||
}
|
||||
|
||||
interface OpenAiChatBody {
|
||||
export interface OpenAiChatBody {
|
||||
model: string;
|
||||
messages: Array<{ role: string; content: string }>;
|
||||
response_format?: unknown;
|
||||
|
|
@ -30,7 +30,7 @@ interface OpenAiChatBody {
|
|||
[tokenField: string]: unknown;
|
||||
}
|
||||
|
||||
interface OpenAiResponsesBody {
|
||||
export interface OpenAiResponsesBody {
|
||||
model: string;
|
||||
instructions: string;
|
||||
input: string;
|
||||
|
|
@ -46,7 +46,7 @@ interface GeminiGenerationConfig {
|
|||
responseJsonSchema?: unknown;
|
||||
}
|
||||
|
||||
interface GeminiBody {
|
||||
export interface GeminiBody {
|
||||
systemInstruction: { parts: Array<{ text: string }> };
|
||||
contents: Array<{ role: string; parts: Array<{ text: string }> }>;
|
||||
generationConfig: GeminiGenerationConfig;
|
||||
|
|
|
|||
|
|
@ -11,22 +11,6 @@ export type RequestUrlFunction = (params: {
|
|||
throw?: boolean;
|
||||
}) => Promise<{ status: number; json: unknown; text: string }>;
|
||||
|
||||
/**
|
||||
* Thrown when a provider responds with HTTP >= 400. Carries the raw status code
|
||||
* and response body so callers can make locale-independent decisions (e.g. the
|
||||
* structured-output fallback) instead of pattern-matching a translated message.
|
||||
*/
|
||||
export class ProviderApiError extends Error {
|
||||
readonly status: number;
|
||||
readonly body: string;
|
||||
constructor(message: string, status: number, body: string) {
|
||||
super(message);
|
||||
this.name = 'ProviderApiError';
|
||||
this.status = status;
|
||||
this.body = body;
|
||||
}
|
||||
}
|
||||
|
||||
export function endpointUrl(baseUrl: string, suffixes: string[]) {
|
||||
const base = baseUrl.replace(/\/+$/, '');
|
||||
for (const suffix of suffixes) {
|
||||
|
|
@ -81,43 +65,24 @@ export async function requestJsonBody(
|
|||
}
|
||||
|
||||
if (resp.status >= 400) {
|
||||
throw new ProviderApiError(
|
||||
throw new Error(
|
||||
translate(settings || null, 'errorProviderApiStatus', {
|
||||
label,
|
||||
status: resp.status,
|
||||
excerpt: (resp.text || '').slice(0, 500),
|
||||
}),
|
||||
resp.status,
|
||||
resp.text || '',
|
||||
);
|
||||
}
|
||||
return responseJson(resp, label, settings);
|
||||
}
|
||||
|
||||
// Bare `unknown`/`unrecognized` were intentionally dropped: they false-positive on
|
||||
// model-name errors ("unknown model", "unrecognized model ID") and trigger a wasted
|
||||
// fallback retry. The specific feature tokens + bare `schema` cover real structured-
|
||||
// output rejections (e.g. "Unknown field: responseSchema" still matches `schema`).
|
||||
const STRUCTURED_OUTPUT_REJECTION_KEYWORDS =
|
||||
/response_format|json_schema|responseJsonSchema|responseMimeType|tools?|tool_choice|unsupported|schema/i;
|
||||
const STRUCTURED_OUTPUT_FALLBACK_STATUSES = new Set([400, 404, 422]);
|
||||
|
||||
export function shouldRetryWithoutStructuredOutput(error: unknown): boolean {
|
||||
// Preferred path: decide on the locale-independent HTTP status + raw provider body.
|
||||
// The error message is i18n-translated, so matching it would only work for the two
|
||||
// languages whose templates happen to contain the English/Chinese status phrasing.
|
||||
if (error instanceof ProviderApiError) {
|
||||
if (!STRUCTURED_OUTPUT_FALLBACK_STATUSES.has(error.status)) return false;
|
||||
return (
|
||||
STRUCTURED_OUTPUT_REJECTION_KEYWORDS.test(error.body) || STRUCTURED_OUTPUT_REJECTION_KEYWORDS.test(error.message)
|
||||
);
|
||||
}
|
||||
// Fallback for errors without a status (e.g. wrapped transport failures): keep the
|
||||
// legacy English/Chinese template match so existing behavior is preserved.
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
if (!/(?:API (?:400|404|422):|API returned HTTP (?:400|404|422)|API 返回 HTTP (?:400|404|422))/.test(message))
|
||||
return false;
|
||||
return STRUCTURED_OUTPUT_REJECTION_KEYWORDS.test(message);
|
||||
return /response_format|json_schema|responseJsonSchema|responseMimeType|tools?|tool_choice|unsupported|unrecognized|unknown|schema/i.test(
|
||||
message,
|
||||
);
|
||||
}
|
||||
|
||||
export async function requestJsonBodyWithStructuredFallback(
|
||||
|
|
|
|||
|
|
@ -48,7 +48,7 @@ export function extractJson(text: string): string {
|
|||
try {
|
||||
JSON.parse(raw);
|
||||
return raw;
|
||||
} catch {
|
||||
} catch (_) {
|
||||
/* continue */
|
||||
}
|
||||
|
||||
|
|
@ -58,7 +58,7 @@ export function extractJson(text: string): string {
|
|||
try {
|
||||
JSON.parse(fenced);
|
||||
return fenced;
|
||||
} catch {
|
||||
} catch (_) {
|
||||
/* continue */
|
||||
}
|
||||
}
|
||||
|
|
@ -69,7 +69,7 @@ export function extractJson(text: string): string {
|
|||
try {
|
||||
JSON.parse(c);
|
||||
return c;
|
||||
} catch {
|
||||
} catch (_) {
|
||||
/* skip */
|
||||
}
|
||||
}
|
||||
|
|
@ -94,7 +94,7 @@ export function repairTruncatedCardsJson(text: string): string | null {
|
|||
try {
|
||||
JSON.parse(c);
|
||||
validCards.push(c);
|
||||
} catch {
|
||||
} catch (_) {
|
||||
/* skip malformed card */
|
||||
}
|
||||
}
|
||||
|
|
@ -107,7 +107,7 @@ export function parseCardsJson(text: string, settings?: PluginSettings | null):
|
|||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(jsonText);
|
||||
} catch {
|
||||
} catch (_e) {
|
||||
// Attempt to salvage complete cards from truncated output
|
||||
const repaired = repairTruncatedCardsJson(text);
|
||||
if (repaired) {
|
||||
|
|
@ -119,7 +119,7 @@ export function parseCardsJson(text: string, settings?: PluginSettings | null):
|
|||
'complete cards. Consider increasing max tokens.',
|
||||
);
|
||||
return normalizeCardsPayload(parsed, settings);
|
||||
} catch {
|
||||
} catch (_) {
|
||||
/* repair failed, fall through to error */
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
'use strict';
|
||||
|
||||
import { type App, Notice, type Plugin, PluginSettingTab, Setting } from 'obsidian';
|
||||
import { testBackend } from './backend-test';
|
||||
import { type App, Notice, type Plugin, PluginSettingTab, requestUrl, Setting } from 'obsidian';
|
||||
import { resolveCliPath, runCli } from './cli';
|
||||
import { testApiBackend } from './providers';
|
||||
import {
|
||||
API_AUTH_TYPES,
|
||||
API_FORMATS,
|
||||
|
|
@ -12,6 +13,7 @@ import {
|
|||
getApiFormat,
|
||||
getApiPreset,
|
||||
normalizeCardCount,
|
||||
normalizeCliIdleTimeoutMs,
|
||||
normalizeCliTimeoutMs,
|
||||
normalizeStreamingTimeoutMs,
|
||||
PROMPT_LANGUAGES,
|
||||
|
|
@ -19,6 +21,20 @@ import {
|
|||
} from './settings';
|
||||
import type { PluginHost, PluginSettings } from './types';
|
||||
|
||||
async function testBackend(settings: PluginSettings) {
|
||||
if (settings.backend === 'claude-code') {
|
||||
const cmd = resolveCliPath('claude', settings.cliPath);
|
||||
const { stdout } = await runCli(cmd, ['--version'], '', 10000);
|
||||
return `claude @ ${cmd}\n${stdout.trim()}`;
|
||||
}
|
||||
if (settings.backend === 'codex') {
|
||||
const cmd = resolveCliPath('codex', settings.cliPath);
|
||||
const { stdout } = await runCli(cmd, ['--version'], '', 10000);
|
||||
return `codex @ ${cmd}\n${stdout.trim()}`;
|
||||
}
|
||||
return testApiBackend(requestUrl, settings);
|
||||
}
|
||||
|
||||
/** Detect whether the user has departed from preset defaults. If so we keep the
|
||||
* Advanced connection section open so they can find what they configured. */
|
||||
function shouldOpenAdvancedConnection(settings: PluginSettings): boolean {
|
||||
|
|
@ -209,16 +225,11 @@ export class ParallelReaderSettingTab extends PluginSettingTab {
|
|||
.setDesc(isCliBacked ? this.tr('settingTestBackendDescCli') : this.tr('settingTestBackendDescApi'))
|
||||
.addButton((b) =>
|
||||
b.setButtonText(this.tr('settingTestBackendButton')).onClick(async () => {
|
||||
b.setDisabled(true);
|
||||
b.setButtonText(this.tr('settingTestBackendButtonRunning'));
|
||||
try {
|
||||
const result = await testBackend(this.plugin.settings);
|
||||
new Notice(`✓ ${result.slice(0, 180)}`, 8000);
|
||||
} catch (e: unknown) {
|
||||
new Notice(this.tr('backendTestFailed', { error: e instanceof Error ? e.message : String(e) }), 10000);
|
||||
} finally {
|
||||
b.setButtonText(this.tr('settingTestBackendButton'));
|
||||
b.setDisabled(false);
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
|
@ -414,7 +425,13 @@ export class ParallelReaderSettingTab extends PluginSettingTab {
|
|||
private renderAdvancedConnectionCli(containerEl: HTMLElement) {
|
||||
const settings = this.plugin.settings;
|
||||
const userChangedTimeout = !!(settings.cliTimeoutMs && settings.cliTimeoutMs !== DEFAULT_SETTINGS.cliTimeoutMs);
|
||||
const details = this.openCollapsibleSection(containerEl, 'sectionAdvancedConnection', userChangedTimeout);
|
||||
const userChangedIdleTimeout = !!settings.cliIdleTimeoutMs;
|
||||
const userEnabledDebug = !!settings.debugLogging;
|
||||
const details = this.openCollapsibleSection(
|
||||
containerEl,
|
||||
'sectionAdvancedConnection',
|
||||
userChangedTimeout || userChangedIdleTimeout || userEnabledDebug,
|
||||
);
|
||||
new Setting(details)
|
||||
.setName(this.tr('settingCliTimeoutName'))
|
||||
.setDesc(this.tr('settingCliTimeoutDesc'))
|
||||
|
|
@ -427,6 +444,29 @@ export class ParallelReaderSettingTab extends PluginSettingTab {
|
|||
this.plugin.saveSettingsDebounced();
|
||||
}),
|
||||
);
|
||||
|
||||
new Setting(details)
|
||||
.setName(this.tr('settingCliIdleTimeoutName'))
|
||||
.setDesc(this.tr('settingCliIdleTimeoutDesc'))
|
||||
.addText((t) =>
|
||||
t
|
||||
.setPlaceholder('0')
|
||||
.setValue(String(this.plugin.settings.cliIdleTimeoutMs ?? 0))
|
||||
.onChange((v) => {
|
||||
this.plugin.settings.cliIdleTimeoutMs = normalizeCliIdleTimeoutMs(v);
|
||||
this.plugin.saveSettingsDebounced();
|
||||
}),
|
||||
);
|
||||
|
||||
new Setting(details)
|
||||
.setName(this.tr('settingDebugLoggingName'))
|
||||
.setDesc(this.tr('settingDebugLoggingDesc'))
|
||||
.addToggle((toggle) =>
|
||||
toggle.setValue(!!this.plugin.settings.debugLogging).onChange(async (v) => {
|
||||
this.plugin.settings.debugLogging = v;
|
||||
await this.plugin.saveSettings();
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/* ---------- 4. Advanced prompt (collapsed by default) ---------- */
|
||||
|
|
|
|||
|
|
@ -8,31 +8,21 @@ import type { ApiFormat, ApiProviderPreset, CacheEntry, PluginSettings } from '.
|
|||
export { API_PROVIDER_PRESETS } from './provider-presets';
|
||||
|
||||
export const MAX_DOC_CHARS = 100000;
|
||||
const PROMPT_VERSION = 2;
|
||||
export const PROMPT_VERSION = 2;
|
||||
export const CACHE_SCHEMA_VERSION = 2;
|
||||
export const DEFAULT_MAX_CACHE_ENTRIES = 100;
|
||||
const DEFAULT_CLI_TIMEOUT_MS = 300000;
|
||||
const MIN_STREAMING_TIMEOUT_MS = 1000;
|
||||
const MIN_CLI_TIMEOUT_MS = 1000;
|
||||
export const MIN_STREAMING_TIMEOUT_MS = 1000;
|
||||
export const MIN_CLI_TIMEOUT_MS = 1000;
|
||||
export const MIN_CLI_IDLE_TIMEOUT_MS = 1000;
|
||||
export const PROMPT_LANGUAGES = {
|
||||
auto: 'Auto-detect',
|
||||
zh: '中文',
|
||||
en: 'English',
|
||||
ja: '日本語',
|
||||
ko: '한국어',
|
||||
fr: 'Français',
|
||||
de: 'Deutsch',
|
||||
es: 'Español',
|
||||
auto: 'Auto-detect',
|
||||
};
|
||||
export const UI_LANGUAGES = {
|
||||
auto: 'Auto',
|
||||
zh: '中文',
|
||||
en: 'English',
|
||||
ja: '日本語',
|
||||
ko: '한국어',
|
||||
fr: 'Français',
|
||||
de: 'Deutsch',
|
||||
es: 'Español',
|
||||
};
|
||||
|
||||
export const DEFAULT_SETTINGS: PluginSettings = {
|
||||
|
|
@ -49,18 +39,17 @@ export const DEFAULT_SETTINGS: PluginSettings = {
|
|||
apiMaxTokens: 4096,
|
||||
maxDocChars: MAX_DOC_CHARS,
|
||||
maxCacheEntries: DEFAULT_MAX_CACHE_ENTRIES,
|
||||
// 'auto' = match the source document's main language, so a new user reading an
|
||||
// English (or any non-Chinese) note gets summaries in that language by default
|
||||
// instead of forced Chinese. Existing users keep whatever they have persisted.
|
||||
promptLanguage: 'auto',
|
||||
promptLanguage: 'zh',
|
||||
minCards: 5,
|
||||
maxCards: 15,
|
||||
customSystemPrompt: '',
|
||||
model: 'claude-sonnet-4-6',
|
||||
exportFolder: 'Reading/Articles',
|
||||
cliTimeoutMs: DEFAULT_CLI_TIMEOUT_MS,
|
||||
cliTimeoutMs: 120000,
|
||||
cliIdleTimeoutMs: 0,
|
||||
streaming: true,
|
||||
streamingTimeoutMs: 120000,
|
||||
debugLogging: false,
|
||||
};
|
||||
|
||||
export const API_FORMATS: Record<string, ApiFormat> = {
|
||||
|
|
@ -229,7 +218,9 @@ export function normalizeSettings(settings: Readonly<PluginSettings>): PluginSet
|
|||
if (out.maxCards < out.minCards) out.maxCards = out.minCards;
|
||||
out.streamingTimeoutMs = normalizeStreamingTimeoutMs(out.streamingTimeoutMs);
|
||||
out.cliTimeoutMs = normalizeCliTimeoutMs(out.cliTimeoutMs);
|
||||
out.cliIdleTimeoutMs = normalizeCliIdleTimeoutMs(out.cliIdleTimeoutMs);
|
||||
if (typeof out.customSystemPrompt !== 'string') out.customSystemPrompt = '';
|
||||
out.debugLogging = !!out.debugLogging;
|
||||
return out;
|
||||
}
|
||||
|
||||
|
|
@ -257,6 +248,14 @@ export function normalizeCliTimeoutMs(value: unknown): number {
|
|||
return n;
|
||||
}
|
||||
|
||||
export function normalizeCliIdleTimeoutMs(value: unknown): number {
|
||||
const n = Math.floor(Number(value));
|
||||
if (!Number.isFinite(n) || n < 0) return 0;
|
||||
if (n === 0) return 0;
|
||||
if (n < MIN_CLI_IDLE_TIMEOUT_MS) return 0;
|
||||
return n;
|
||||
}
|
||||
|
||||
function cacheEntryTime(entry: CacheEntry): number {
|
||||
const value = entry && (entry.lastAccessedAt || entry.generatedAt || entry.updatedAt);
|
||||
const timestamp = Date.parse(value || '');
|
||||
|
|
@ -281,24 +280,7 @@ export function pruneCacheEntries(cache: Record<string, CacheEntry>, maxEntries:
|
|||
return removed;
|
||||
}
|
||||
|
||||
// Memoize by settings object identity. The plugin replaces this.settings with
|
||||
// a fresh object only on load/save, so the same reference always yields the
|
||||
// same fingerprint — this collapses the SHA-1 + stableStringify work done on
|
||||
// every file-open / cache check down to once per settings change.
|
||||
const fingerprintCache = new WeakMap<object, string>();
|
||||
|
||||
export function generationFingerprint(settings: PluginSettings): string {
|
||||
const cacheKey = settings as unknown as object;
|
||||
if (cacheKey) {
|
||||
const cached = fingerprintCache.get(cacheKey);
|
||||
if (cached !== undefined) return cached;
|
||||
}
|
||||
const result = computeGenerationFingerprint(settings);
|
||||
if (cacheKey) fingerprintCache.set(cacheKey, result);
|
||||
return result;
|
||||
}
|
||||
|
||||
function computeGenerationFingerprint(settings: PluginSettings): string {
|
||||
const normalized = normalizeSettings(Object.assign({}, DEFAULT_SETTINGS, settings || {}));
|
||||
const apiBackend = isApiBackend(normalized.backend);
|
||||
const preset = getApiPreset(normalized);
|
||||
|
|
|
|||
|
|
@ -26,32 +26,6 @@ function anthropicDelta(json: Record<string, unknown>): string {
|
|||
return '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect a provider error delivered as an SSE payload inside an HTTP 200 stream.
|
||||
* These bypass the `response.status >= 400` guard, so without this they would be
|
||||
* extracted as empty deltas and later misreported as "non-JSON LLM output".
|
||||
* Anthropic: { type: 'error', error: { type, message } }
|
||||
* OpenAI-compatible: { error: { message, type, code } }
|
||||
* Returns a human-readable error message for an error payload, or null otherwise.
|
||||
*/
|
||||
export function streamErrorMessage(json: Record<string, unknown>): string | null {
|
||||
const messageFromError = (value: unknown): string | null => {
|
||||
if (!value || typeof value !== 'object') return null;
|
||||
const err = value as { message?: unknown; type?: unknown };
|
||||
if (typeof err.message === 'string' && err.message) return err.message;
|
||||
if (typeof err.type === 'string' && err.type) return err.type;
|
||||
return null;
|
||||
};
|
||||
// Anthropic: an explicit error event is an error even if its details are sparse.
|
||||
if (json.type === 'error') {
|
||||
return messageFromError(json.error) ?? 'Provider returned a streaming error';
|
||||
}
|
||||
// OpenAI-compatible: { error: { message, type, code } }. Only treat it as an error
|
||||
// when it actually carries a message/type, so a stray empty `error: {}` (or a
|
||||
// code-only object) in an otherwise-normal chunk does not abort the stream.
|
||||
return messageFromError(json.error);
|
||||
}
|
||||
|
||||
export type DeltaExtractor = (json: Record<string, unknown>) => string;
|
||||
|
||||
export function deltaExtractorForFormat(format: string): DeltaExtractor | null {
|
||||
|
|
@ -87,18 +61,13 @@ export function parseSseBuffer(buffer: string, extractDelta: DeltaExtractor): {
|
|||
|
||||
const data = dataLines.join('\n');
|
||||
if (data.trim() === '[DONE]') continue;
|
||||
let json: Record<string, unknown>;
|
||||
try {
|
||||
json = JSON.parse(data) as Record<string, unknown>;
|
||||
const json = JSON.parse(data) as Record<string, unknown>;
|
||||
const delta = extractDelta(json);
|
||||
if (delta) deltas.push(delta);
|
||||
} catch {
|
||||
continue; // skip non-JSON SSE lines (keep-alives, partial frames)
|
||||
// skip non-JSON SSE lines
|
||||
}
|
||||
// Provider errors arrive as a 200-status SSE payload — surface them instead of
|
||||
// swallowing them, so a transient overload/quota error is not misreported downstream.
|
||||
const errorMessage = streamErrorMessage(json);
|
||||
if (errorMessage) throw new Error(errorMessage);
|
||||
const delta = extractDelta(json);
|
||||
if (delta) deltas.push(delta);
|
||||
}
|
||||
return { deltas, rest };
|
||||
}
|
||||
|
|
@ -181,22 +150,18 @@ export async function streamingRequestUrl(
|
|||
|
||||
if (signal) {
|
||||
if (signal.aborted) throw new Error('Streaming request aborted');
|
||||
abortPromise = (async () => {
|
||||
await new Promise<void>((resolve) => {
|
||||
abortListener = resolve;
|
||||
signal.addEventListener('abort', abortListener, { once: true });
|
||||
});
|
||||
throw new Error('Streaming request aborted');
|
||||
})();
|
||||
abortPromise = new Promise<never>((_, reject) => {
|
||||
abortListener = () => reject(new Error('Streaming request aborted'));
|
||||
signal.addEventListener('abort', abortListener, { once: true });
|
||||
});
|
||||
}
|
||||
|
||||
let timeoutId: number | null = null;
|
||||
const timeoutPromise = (async () => {
|
||||
await new Promise<void>((resolve) => {
|
||||
timeoutId = activeWindow.setTimeout(resolve, timeoutMs);
|
||||
});
|
||||
throw new Error(`Streaming timed out after ${timeoutMs}ms`);
|
||||
})();
|
||||
const timeoutPromise = new Promise<never>((_, reject) => {
|
||||
timeoutId = activeWindow.setTimeout(() => {
|
||||
reject(new Error(`Streaming timed out after ${timeoutMs}ms`));
|
||||
}, timeoutMs);
|
||||
});
|
||||
|
||||
try {
|
||||
const requestPromise = doStreamingRequestUrl(
|
||||
|
|
|
|||
|
|
@ -1,8 +1,7 @@
|
|||
'use strict';
|
||||
|
||||
export { default as ParallelReaderPlugin } from '../main';
|
||||
export { buildLineOffsets, findLineForAnchor } from './anchor';
|
||||
export { testBackend } from './backend-test';
|
||||
export { findLineForAnchor } from './anchor';
|
||||
export {
|
||||
batchProgressVars,
|
||||
createBatchRunState,
|
||||
|
|
@ -33,14 +32,6 @@ export { translate } from './i18n';
|
|||
export { cardsToMarkdown } from './markdown';
|
||||
export { activeSectionLine, nextCardIndex } from './navigation';
|
||||
export { buildPrompts } from './prompt';
|
||||
export {
|
||||
endpointUrl,
|
||||
ProviderApiError,
|
||||
requestJsonBody,
|
||||
requestJsonBodyWithStructuredFallback,
|
||||
responseJson,
|
||||
shouldRetryWithoutStructuredOutput,
|
||||
} from './provider-request';
|
||||
export {
|
||||
buildAnthropicMessagesBody,
|
||||
buildGeminiBody,
|
||||
|
|
@ -61,12 +52,13 @@ export {
|
|||
getApiBaseUrl,
|
||||
modelForApi,
|
||||
normalizeCardCount,
|
||||
normalizeCliIdleTimeoutMs,
|
||||
normalizeCliTimeoutMs,
|
||||
normalizeSettings,
|
||||
normalizeStreamingTimeoutMs,
|
||||
pruneCacheEntries,
|
||||
} from './settings';
|
||||
export { deltaExtractorForFormat, parseSseBuffer, streamErrorMessage } from './streaming';
|
||||
export { deltaExtractorForFormat, parseSseBuffer } from './streaming';
|
||||
export { addIconButton, addTextButton, copyToClipboard } from './ui-helpers';
|
||||
export { folderPathsForTarget } from './vault';
|
||||
export { ParallelReaderView } from './view';
|
||||
|
|
|
|||
15
src/types.ts
15
src/types.ts
|
|
@ -37,6 +37,11 @@ export interface CacheEntry {
|
|||
updatedAt?: string;
|
||||
}
|
||||
|
||||
export interface CacheFile {
|
||||
version: number;
|
||||
entries: Record<string, CacheEntry>;
|
||||
}
|
||||
|
||||
/* ---------- Settings types ---------- */
|
||||
|
||||
export interface PluginSettings {
|
||||
|
|
@ -60,8 +65,10 @@ export interface PluginSettings {
|
|||
model: string;
|
||||
exportFolder: string;
|
||||
cliTimeoutMs: number;
|
||||
cliIdleTimeoutMs: number;
|
||||
streaming: boolean;
|
||||
streamingTimeoutMs: number;
|
||||
debugLogging: boolean;
|
||||
}
|
||||
|
||||
/* ---------- Provider types ---------- */
|
||||
|
|
@ -96,7 +103,7 @@ export type GenerationPhase =
|
|||
| 'done'
|
||||
| 'cancelled';
|
||||
|
||||
export type ErrorKind = 'auth' | 'timeout' | 'rate-limit' | 'network' | 'schema' | 'config' | 'cancelled' | 'unknown';
|
||||
export type ErrorKind = 'auth' | 'timeout' | 'rate-limit' | 'schema' | 'config' | 'cancelled' | 'unknown';
|
||||
|
||||
export interface RunForFileOptions {
|
||||
rethrowErrors?: boolean;
|
||||
|
|
@ -159,10 +166,6 @@ export interface PluginHost {
|
|||
cache: Record<string, CacheEntry>;
|
||||
manifest: PluginManifest;
|
||||
t(key: string, vars?: Record<string, string | number>): string;
|
||||
/** Open the plugin's settings tab (best-effort; no-op if the API is unavailable). */
|
||||
openSettings(): void;
|
||||
/** True if a usable credential is configured for the current backend (API key, env var, or keyless local provider). */
|
||||
isCredentialConfigured(): boolean;
|
||||
isGeneratingFile(file: TFile | null): boolean;
|
||||
cancelGenerationForFile(file: TFile | null): boolean;
|
||||
runForFile(
|
||||
|
|
@ -170,7 +173,7 @@ export interface PluginHost {
|
|||
force: boolean,
|
||||
options?: RunForFileOptions,
|
||||
preloadedContent?: string,
|
||||
): Promise<RunForFileResult>;
|
||||
): Promise<RunForFileResult | void>;
|
||||
copyCurrentViewMarkdown(): Promise<void>;
|
||||
scrollEditorToLine(line: number, file: TFile | null): Promise<void>;
|
||||
cacheReplaceCards(filePath: string, cards: ResolvedCard[]): Promise<boolean>;
|
||||
|
|
|
|||
|
|
@ -14,11 +14,7 @@ export function folderPathsForTarget(folderPath: string): string[] {
|
|||
const normalized = normalizeVaultPath(folderPath);
|
||||
if (!normalized) return [];
|
||||
const parts = normalized.split('/');
|
||||
const folders: string[] = [];
|
||||
for (let idx = 0; idx < parts.length; idx++) {
|
||||
folders.push(parts.slice(0, idx + 1).join('/'));
|
||||
}
|
||||
return folders;
|
||||
return parts.map((_, idx) => parts.slice(0, idx + 1).join('/'));
|
||||
}
|
||||
|
||||
export async function ensureVaultFolder(app: App, folderPath: string) {
|
||||
|
|
|
|||
24
src/view.ts
24
src/view.ts
|
|
@ -20,8 +20,6 @@ export class ParallelReaderView extends ItemView {
|
|||
stale = false;
|
||||
loadingMessage = '';
|
||||
errorMessage = '';
|
||||
private keydownHandler: ((e: KeyboardEvent) => void) | null = null;
|
||||
private keydownTarget: Element | null = null;
|
||||
|
||||
constructor(leaf: WorkspaceLeaf, plugin: PluginHost) {
|
||||
super(leaf);
|
||||
|
|
@ -47,20 +45,13 @@ export class ParallelReaderView extends ItemView {
|
|||
container.empty();
|
||||
container.addClass('parallel-reader-container');
|
||||
container.setAttr('tabindex', '0');
|
||||
this.keydownHandler = (e) => this.handleKeydown(e);
|
||||
this.keydownTarget = container;
|
||||
container.addEventListener('keydown', this.keydownHandler as EventListener);
|
||||
container.addEventListener('keydown', (e) => this.handleKeydown(e as KeyboardEvent));
|
||||
this.renderEmpty();
|
||||
this.focusSummaryPane();
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
onClose() {
|
||||
if (this.keydownHandler && this.keydownTarget) {
|
||||
this.keydownTarget.removeEventListener('keydown', this.keydownHandler as EventListener);
|
||||
}
|
||||
this.keydownHandler = null;
|
||||
this.keydownTarget = null;
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
|
|
@ -76,18 +67,6 @@ export class ParallelReaderView extends ItemView {
|
|||
hint.createEl('h3', { text: this.plugin.t('appTitle') });
|
||||
hint.createEl('p', { text: this.plugin.t('emptyOpenNote') });
|
||||
hint.createEl('code', { text: this.plugin.t('commandGenerate') });
|
||||
this.appendSetupNudge(hint);
|
||||
}
|
||||
|
||||
/**
|
||||
* When no credential is configured, append a "set up your AI provider" call-to-action
|
||||
* so a first-run user does not hit a dead-end (Generate → immediate API-key error).
|
||||
*/
|
||||
private appendSetupNudge(parent: HTMLElement): boolean {
|
||||
if (this.plugin.isCredentialConfigured()) return false;
|
||||
parent.createEl('p', { cls: 'parallel-reader-setup-hint', text: this.plugin.t('emptyNeedsSetup') });
|
||||
addTextButton(parent, 'settings', this.plugin.t('actionSetupProvider'), () => this.plugin.openSettings());
|
||||
return true;
|
||||
}
|
||||
|
||||
focusSummaryPane() {
|
||||
|
|
@ -169,7 +148,6 @@ export class ParallelReaderView extends ItemView {
|
|||
hint.createEl('h3', { text: file.basename });
|
||||
hint.createEl('p', { text: this.plugin.t('emptyNoCache') });
|
||||
hint.createEl('code', { text: this.plugin.t('commandGenerate') });
|
||||
this.appendSetupNudge(hint);
|
||||
addTextButton(hint, null, this.plugin.t('actionGenerate'), () => {
|
||||
if (this.plugin.isGeneratingFile(file)) return;
|
||||
void this.plugin.runForFile(file, false);
|
||||
|
|
|
|||
|
|
@ -22,22 +22,4 @@ assert.strictEqual(
|
|||
'unicode anchor match',
|
||||
);
|
||||
|
||||
// ── perf: precomputed line-offset index must yield identical results ──
|
||||
const doc = 'l0\nl1\nl2 needle here\nl3\n\nl5 tail';
|
||||
const offsets = t.buildLineOffsets(doc);
|
||||
assert.deepStrictEqual(offsets, [0, 3, 6, 21, 24, 25], 'buildLineOffsets marks each line start');
|
||||
assert.strictEqual(
|
||||
t.findLineForAnchor(doc, 'needle here', offsets),
|
||||
t.findLineForAnchor(doc, 'needle here'),
|
||||
'threaded offsets match self-computed result (exact path)',
|
||||
);
|
||||
assert.strictEqual(t.findLineForAnchor(doc, 'tail', offsets), 5, 'threaded offsets resolve trailing line');
|
||||
// Non-ASCII whitespace (NBSP ) must still normalize like /\s/ did.
|
||||
assert.strictEqual(
|
||||
t.findLineForAnchor('x\ny\nAlpha beta gamma\nz', 'Alpha beta gamma'),
|
||||
2,
|
||||
'NBSP collapses to single space in normalized fallback match',
|
||||
);
|
||||
assert.strictEqual(t.buildLineOffsets('')[0], 0, 'empty content still has line 0');
|
||||
|
||||
console.log('anchor tests passed');
|
||||
|
|
|
|||
|
|
@ -1,221 +0,0 @@
|
|||
const { assert, baseSettings, EventEmitter, openAiCardsResponse, t } = require('./test-setup');
|
||||
|
||||
function createFakeChild(stdoutText, stderrText = '', code = 0) {
|
||||
const child = new EventEmitter();
|
||||
child.stdout = new EventEmitter();
|
||||
child.stderr = new EventEmitter();
|
||||
child.stdin = {
|
||||
written: '',
|
||||
ended: false,
|
||||
write(value) {
|
||||
this.written += value;
|
||||
},
|
||||
end() {
|
||||
this.ended = true;
|
||||
},
|
||||
};
|
||||
child.pid = 1234;
|
||||
child.kill = () => true;
|
||||
|
||||
process.nextTick(() => {
|
||||
if (stdoutText) child.stdout.emit('data', Buffer.from(stdoutText));
|
||||
if (stderrText) child.stderr.emit('data', Buffer.from(stderrText));
|
||||
child.emit('close', code, null);
|
||||
});
|
||||
|
||||
return child;
|
||||
}
|
||||
|
||||
async function testClaudeBackendRunsVersionAndSmoke() {
|
||||
const calls = [];
|
||||
const streamJson = [
|
||||
JSON.stringify({ type: 'system', subtype: 'init', tools: ['LSP'] }),
|
||||
JSON.stringify({
|
||||
type: 'result',
|
||||
result: '{"cards":[{"title":"CLI smoke","anchor":"parallel reader smoke anchor","gist":"ok","bullets":["ok"]}]}',
|
||||
}),
|
||||
'',
|
||||
].join('\n');
|
||||
const spawnImpl = (cmd, args) => {
|
||||
calls.push({ cmd, args });
|
||||
if (args.includes('--version')) return createFakeChild('2.1.133 (Claude Code)\n');
|
||||
return createFakeChild(streamJson);
|
||||
};
|
||||
|
||||
const result = await t.testBackend(
|
||||
{
|
||||
...baseSettings,
|
||||
backend: 'claude-code',
|
||||
cliPath: '/usr/bin/claude',
|
||||
cliTimeoutMs: 300000,
|
||||
model: 'claude-sonnet-4-6',
|
||||
promptLanguage: 'zh',
|
||||
minCards: 5,
|
||||
maxCards: 15,
|
||||
maxDocChars: 100000,
|
||||
},
|
||||
{ spawnImpl },
|
||||
);
|
||||
|
||||
assert.match(result, /claude @ \/usr\/bin\/claude/, 'result shows resolved Claude command');
|
||||
assert.match(result, /2\.1\.133/, 'result includes CLI version output');
|
||||
assert.match(result, /smoke: 1 card/, 'result includes parsed smoke-card count');
|
||||
assert.deepStrictEqual(calls[0].args, ['--version'], 'first Claude call checks version');
|
||||
|
||||
const smokeArgs = calls[1].args;
|
||||
assert.strictEqual(smokeArgs[0], '-p', 'Claude smoke uses print mode');
|
||||
assert.strictEqual(
|
||||
smokeArgs[smokeArgs.indexOf('--output-format') + 1],
|
||||
'stream-json',
|
||||
'Claude smoke uses stream-json for incremental output',
|
||||
);
|
||||
assert.ok(
|
||||
smokeArgs.includes('--verbose'),
|
||||
'Claude smoke must include --verbose (required alongside stream-json on CLI 2.1.131+)',
|
||||
);
|
||||
assert.strictEqual(smokeArgs[smokeArgs.indexOf('--tools') + 1], '', 'Claude smoke disables tools');
|
||||
assert.ok(smokeArgs.includes('--strict-mcp-config'), 'Claude smoke ignores user/project MCP servers');
|
||||
assert.strictEqual(
|
||||
smokeArgs[smokeArgs.indexOf('--mcp-config') + 1],
|
||||
'{"mcpServers":{}}',
|
||||
'Claude smoke passes an empty MCP config',
|
||||
);
|
||||
}
|
||||
|
||||
async function testCodexBackendRunsVersionAndSmoke() {
|
||||
const calls = [];
|
||||
const resultJson =
|
||||
'{"cards":[{"title":"CLI smoke","anchor":"parallel reader smoke anchor","gist":"ok","bullets":["ok"]}]}';
|
||||
const spawnImpl = (cmd, args) => {
|
||||
calls.push({ cmd, args });
|
||||
if (args.includes('--version')) return createFakeChild('codex-cli 1.2.3\n');
|
||||
return createFakeChild(resultJson);
|
||||
};
|
||||
|
||||
const result = await t.testBackend(
|
||||
{
|
||||
...baseSettings,
|
||||
backend: 'codex',
|
||||
cliPath: '/usr/bin/codex',
|
||||
cliTimeoutMs: 300000,
|
||||
promptLanguage: 'zh',
|
||||
minCards: 5,
|
||||
maxCards: 15,
|
||||
maxDocChars: 100000,
|
||||
},
|
||||
{ spawnImpl },
|
||||
);
|
||||
|
||||
assert.match(result, /codex @ \/usr\/bin\/codex/, 'result shows resolved Codex command');
|
||||
assert.match(result, /codex-cli 1\.2\.3/, 'result includes CLI version output');
|
||||
assert.match(result, /smoke: 1 card/, 'result includes parsed smoke-card count');
|
||||
assert.deepStrictEqual(calls[0].args, ['--version'], 'first Codex call checks version');
|
||||
assert.strictEqual(calls[1].args[0], 'exec', 'Codex smoke runs through codex exec');
|
||||
assert.strictEqual(calls[1].args[calls[1].args.indexOf('--sandbox') + 1], 'read-only', 'Codex smoke is read-only');
|
||||
}
|
||||
|
||||
async function testApiBackendUsesInjectedRequestUrl() {
|
||||
let called = false;
|
||||
const result = await t.testBackend(
|
||||
{
|
||||
...baseSettings,
|
||||
apiHeaders: '',
|
||||
maxDocChars: 100000,
|
||||
promptLanguage: 'zh',
|
||||
minCards: 5,
|
||||
maxCards: 15,
|
||||
},
|
||||
{
|
||||
requestUrlImpl: async () => {
|
||||
called = true;
|
||||
return openAiCardsResponse([]);
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
assert.strictEqual(called, true, 'API backend test should use injected requestUrl');
|
||||
assert.strictEqual(result, 'OpenAI / OpenAI Chat Completions', 'API backend test reports provider and format');
|
||||
}
|
||||
|
||||
// Regression: smoke flow must propagate the canary stderr that real Claude CLI
|
||||
// emits when --output-format=stream-json is passed without --verbose.
|
||||
async function testClaudeBackendSurfacesVerboseError() {
|
||||
const spawnImpl = (_cmd, args) => {
|
||||
if (args.includes('--version')) return createFakeChild('2.1.133 (Claude Code)\n');
|
||||
// smoke call: simulate the exact stderr the real CLI emits when --verbose is missing
|
||||
return createFakeChild('', 'Error: When using --print, --output-format=stream-json requires --verbose', 1);
|
||||
};
|
||||
|
||||
await assert.rejects(
|
||||
() =>
|
||||
t.testBackend(
|
||||
{
|
||||
...baseSettings,
|
||||
backend: 'claude-code',
|
||||
cliPath: '/usr/bin/claude',
|
||||
cliTimeoutMs: 300000,
|
||||
model: 'claude-sonnet-4-6',
|
||||
promptLanguage: 'zh',
|
||||
minCards: 5,
|
||||
maxCards: 15,
|
||||
maxDocChars: 100000,
|
||||
},
|
||||
{ spawnImpl },
|
||||
),
|
||||
(err) => {
|
||||
assert.ok(err instanceof t.CliProcessError, 'verbose-regression smoke surfaces CliProcessError');
|
||||
assert.match(err.details.stderrTail, /requires --verbose/, 'stderr tail preserves the verbose-flag hint');
|
||||
assert.strictEqual(err.details.exitCode, 1, 'preserves CLI exit code so UI can classify');
|
||||
return true;
|
||||
},
|
||||
'testBackend must propagate Claude smoke failures (including verbose-flag regression) as structured errors',
|
||||
);
|
||||
}
|
||||
|
||||
// Regression: if `<cli> --version` itself fails, testBackend must throw before reaching smoke,
|
||||
// not silently report success with garbage version output.
|
||||
async function testCodexBackendPropagatesVersionFailure() {
|
||||
const calls = [];
|
||||
const spawnImpl = (_cmd, args) => {
|
||||
calls.push(args);
|
||||
if (args.includes('--version')) return createFakeChild('', 'codex: command not found', 127);
|
||||
return createFakeChild('{"cards":[]}');
|
||||
};
|
||||
|
||||
await assert.rejects(
|
||||
() =>
|
||||
t.testBackend(
|
||||
{
|
||||
...baseSettings,
|
||||
backend: 'codex',
|
||||
cliPath: '/usr/bin/codex',
|
||||
cliTimeoutMs: 300000,
|
||||
promptLanguage: 'zh',
|
||||
minCards: 5,
|
||||
maxCards: 15,
|
||||
maxDocChars: 100000,
|
||||
},
|
||||
{ spawnImpl },
|
||||
),
|
||||
(err) => {
|
||||
assert.ok(err instanceof t.CliProcessError, 'version failure surfaces structured CliProcessError');
|
||||
assert.strictEqual(err.details.exitCode, 127, 'preserves shell command-not-found exit code');
|
||||
return true;
|
||||
},
|
||||
'testBackend must not swallow version-probe failures',
|
||||
);
|
||||
assert.strictEqual(calls.length, 1, 'smoke call must NOT run after version probe fails');
|
||||
assert.deepStrictEqual(calls[0], ['--version'], 'only the version probe should have been spawned');
|
||||
}
|
||||
|
||||
(async () => {
|
||||
await testClaudeBackendRunsVersionAndSmoke();
|
||||
await testClaudeBackendSurfacesVerboseError();
|
||||
await testCodexBackendRunsVersionAndSmoke();
|
||||
await testCodexBackendPropagatesVersionFailure();
|
||||
await testApiBackendUsesInjectedRequestUrl();
|
||||
console.log('backend-test tests passed');
|
||||
})().catch((err) => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
|
|
@ -38,7 +38,6 @@
|
|||
"description": "Tests for provider protocols, CLI command contracts, exported test surfaces, and architecture invariants.",
|
||||
"files": [
|
||||
"architecture.test.js",
|
||||
"backend-test.test.js",
|
||||
"cli.test.js",
|
||||
"direct-providers.test.js",
|
||||
"providers.test.js",
|
||||
|
|
|
|||
|
|
@ -79,6 +79,44 @@ async function testRunCliEdgeCases() {
|
|||
);
|
||||
assert.strictEqual(timeoutChild.killedWith, 'SIGKILL', 'runCli kills timed out processes');
|
||||
|
||||
// Idle timeout fires when no data within idle window even if wall clock is generous.
|
||||
const idleChild = createFakeChild();
|
||||
idleChild.pid = 5252;
|
||||
await assert.rejects(
|
||||
() => t.runCli('fake', [], '', 60000, undefined, () => idleChild, { idleTimeoutMs: 15 }),
|
||||
(err) => {
|
||||
assert.match(err.message, /CLI idle timeout \(15ms\)/, 'idle timeout error message');
|
||||
assert.match(err.message, /pid=5252/, 'idle timeout carries pid');
|
||||
assert.ok(err instanceof t.CliProcessError, 'idle-timeout throws CliProcessError');
|
||||
assert.strictEqual(err.details.reason, 'idle-timeout', 'idle-timeout reason set');
|
||||
assert.strictEqual(err.details.idleTimeoutMs, 15, 'details.idleTimeoutMs reflects idle window');
|
||||
return true;
|
||||
},
|
||||
'runCli idle timeout fires when no output',
|
||||
);
|
||||
assert.strictEqual(idleChild.killedWith, 'SIGKILL', 'idle timeout kills the process');
|
||||
|
||||
// Idle timer must reset on data — child keeps emitting and only wall clock should fire.
|
||||
const heartbeatChild = createFakeChild();
|
||||
let cancelHeartbeat = false;
|
||||
const heartbeatPromise = t.runCli('fake', [], '', 80, undefined, () => heartbeatChild, { idleTimeoutMs: 30 });
|
||||
const beat = () => {
|
||||
if (cancelHeartbeat) return;
|
||||
heartbeatChild.stdout.emit('data', Buffer.from('.'));
|
||||
setTimeout(beat, 10);
|
||||
};
|
||||
beat();
|
||||
await assert.rejects(
|
||||
() => heartbeatPromise,
|
||||
(err) => {
|
||||
cancelHeartbeat = true;
|
||||
assert.match(err.message, /CLI timed out \(80ms\)/, 'wall-clock fires when idle resets keep happening');
|
||||
assert.doesNotMatch(err.message, /CLI idle timeout/, 'idle timeout must not fire while data flows');
|
||||
return true;
|
||||
},
|
||||
'runCli idle timer resets on incoming data',
|
||||
);
|
||||
|
||||
const cancelChild = createFakeChild();
|
||||
let cancelHandler = null;
|
||||
const cancelPromise = t.runCli(
|
||||
|
|
@ -258,79 +296,13 @@ async function testClaudeCodeArgs() {
|
|||
// Verify key expected flags are present
|
||||
assert.ok(capturedArgs.includes('-p'), 'should include -p (print mode)');
|
||||
assert.ok(capturedArgs.includes('--output-format'), 'should include --output-format');
|
||||
assert.strictEqual(
|
||||
capturedArgs[capturedArgs.indexOf('--output-format') + 1],
|
||||
'stream-json',
|
||||
'claude output should stream JSON events',
|
||||
);
|
||||
assert.ok(capturedArgs.includes('--verbose'), 'stream-json requires --verbose in Claude CLI 2.1.131+');
|
||||
assert.ok(capturedArgs.includes('--append-system-prompt'), 'should include --append-system-prompt');
|
||||
assert.ok(capturedArgs.includes('--tools'), 'should explicitly restrict Claude tools');
|
||||
assert.strictEqual(capturedArgs[capturedArgs.indexOf('--tools') + 1], '', 'Claude tools should be disabled');
|
||||
assert.ok(capturedArgs.includes('--disallowed-tools'), 'should include --disallowed-tools');
|
||||
assert.ok(capturedArgs.includes('--no-session-persistence'), 'should avoid session persistence for plugin calls');
|
||||
assert.ok(capturedArgs.includes('--disable-slash-commands'), 'should disable slash commands for plugin calls');
|
||||
assert.ok(capturedArgs.includes('--no-chrome'), 'should suppress Claude TUI chrome');
|
||||
assert.ok(capturedArgs.includes('--strict-mcp-config'), 'should ignore user/project MCP servers');
|
||||
assert.strictEqual(
|
||||
capturedArgs[capturedArgs.indexOf('--mcp-config') + 1],
|
||||
'{"mcpServers":{}}',
|
||||
'--mcp-config must pair with an empty MCP server map',
|
||||
);
|
||||
assert.strictEqual(
|
||||
capturedArgs[capturedArgs.indexOf('--disallowed-tools') + 1],
|
||||
'Bash,Read,Write,Edit,Glob,Grep,WebFetch,WebSearch,TodoWrite,Task,LSP,NotebookEdit',
|
||||
'--disallowed-tools must list every tool that could exfiltrate or mutate',
|
||||
);
|
||||
// Ordering: -p must come before --output-format so Claude treats the run as print-mode.
|
||||
assert.ok(
|
||||
capturedArgs.indexOf('-p') < capturedArgs.indexOf('--output-format'),
|
||||
'-p must appear before --output-format',
|
||||
);
|
||||
// --verbose must accompany --output-format stream-json (Claude CLI 2.1.131+ requirement).
|
||||
// The CLI does not constrain position; we only check that --verbose is not consumed as the value of
|
||||
// another flag — i.e. it must not appear at index `--output-format` + 1 (where 'stream-json' belongs).
|
||||
const outputFmtIdx = capturedArgs.indexOf('--output-format');
|
||||
const verboseIdx = capturedArgs.indexOf('--verbose');
|
||||
assert.notStrictEqual(verboseIdx, outputFmtIdx + 1, '--verbose must not be parsed as the --output-format value');
|
||||
|
||||
// Verify --max-tokens is NOT present (it's not a valid claude CLI flag)
|
||||
assert.ok(!capturedArgs.includes('--max-tokens'), 'should NOT include --max-tokens');
|
||||
}
|
||||
|
||||
async function testClaudeCodeStreamJsonOutput() {
|
||||
const streamJson = [
|
||||
JSON.stringify({ type: 'system', subtype: 'init', tools: ['LSP'] }),
|
||||
JSON.stringify({
|
||||
type: 'result',
|
||||
result: '{"cards":[{"title":"T","anchor":"hello world test anchor","gist":"G","bullets":["B"]}]}',
|
||||
}),
|
||||
'',
|
||||
].join('\n');
|
||||
const fakeSpawn = () => {
|
||||
const child = createFakeChild();
|
||||
process.nextTick(() => {
|
||||
child.stdout.emit('data', Buffer.from(streamJson));
|
||||
child.emit('close', 0);
|
||||
});
|
||||
return child;
|
||||
};
|
||||
|
||||
const settings = {
|
||||
backend: 'claude-code',
|
||||
cliPath: '/usr/bin/claude',
|
||||
cliTimeoutMs: 5000,
|
||||
promptLanguage: 'zh',
|
||||
minCards: 5,
|
||||
maxCards: 15,
|
||||
maxDocChars: 100000,
|
||||
};
|
||||
|
||||
const cards = await t.summarizeViaClaudeCode('system prompt', 'user content', settings, undefined, fakeSpawn);
|
||||
assert.strictEqual(cards.length, 1, 'claude stream-json result event is parsed');
|
||||
assert.strictEqual(cards[0].title, 'T', 'parsed card title preserved');
|
||||
}
|
||||
|
||||
// ── summarizeViaCodex args validation ──
|
||||
|
||||
// Known flags/subcommands supported by `codex exec` (from `codex exec --help`).
|
||||
|
|
@ -490,195 +462,6 @@ async function testClaudeCodeArgsWithModel() {
|
|||
assert.strictEqual(capturedArgs[modelIdx + 1], 'claude-opus-4-7', 'claude --model value matches settings.model');
|
||||
}
|
||||
|
||||
// ── Claude stream-json parse resilience ──
|
||||
|
||||
async function runClaudeCodeWithStdout(stdoutText) {
|
||||
const fakeSpawn = () => {
|
||||
const child = createFakeChild();
|
||||
process.nextTick(() => {
|
||||
if (stdoutText) child.stdout.emit('data', Buffer.from(stdoutText));
|
||||
child.emit('close', 0);
|
||||
});
|
||||
return child;
|
||||
};
|
||||
const settings = {
|
||||
backend: 'claude-code',
|
||||
cliPath: '/usr/bin/claude',
|
||||
cliTimeoutMs: 5000,
|
||||
promptLanguage: 'zh',
|
||||
minCards: 1,
|
||||
maxCards: 15,
|
||||
maxDocChars: 100000,
|
||||
};
|
||||
return t.summarizeViaClaudeCode('system', 'user', settings, undefined, fakeSpawn);
|
||||
}
|
||||
|
||||
async function testClaudeCodeStreamJsonResilience() {
|
||||
// B1: NDJSON with non-JSON banner line — must skip banner and still parse the result event.
|
||||
const withBanner = [
|
||||
'Claude Code 2.1.131',
|
||||
JSON.stringify({ type: 'system', subtype: 'init', tools: ['LSP'] }),
|
||||
JSON.stringify({
|
||||
type: 'result',
|
||||
result: '{"cards":[{"title":"B1","anchor":"hello world test anchor","gist":"G","bullets":["B"]}]}',
|
||||
}),
|
||||
].join('\n');
|
||||
const b1 = await runClaudeCodeWithStdout(withBanner);
|
||||
assert.strictEqual(b1.length, 1, 'B1: banner line is skipped and result parsed');
|
||||
assert.strictEqual(b1[0].title, 'B1', 'B1: card title preserved');
|
||||
|
||||
// B2: Missing result event — must throw a recognizable "no result" error (not a crash inside parser).
|
||||
// Match both zh ("没有返回结果") and en ("returned no result") so the assertion survives translate() fallback differences.
|
||||
const noResultRegex = /没有返回结果|returned no result/i;
|
||||
const onlyInit = JSON.stringify({ type: 'system', subtype: 'init', tools: [] }) + '\n';
|
||||
await assert.rejects(
|
||||
() => runClaudeCodeWithStdout(onlyInit),
|
||||
(err) => noResultRegex.test(err.message),
|
||||
'B2: missing result event throws a descriptive "no result" error',
|
||||
);
|
||||
|
||||
// B3: Empty stdout — same behavior: must throw, not crash on undefined / silent success.
|
||||
await assert.rejects(
|
||||
() => runClaudeCodeWithStdout(''),
|
||||
(err) => noResultRegex.test(err.message),
|
||||
'B3: empty stdout throws a descriptive "no result" error',
|
||||
);
|
||||
|
||||
// B4: Multiple result events — last one wins (claudeResultText scans in reverse).
|
||||
const twoResults = [
|
||||
JSON.stringify({
|
||||
type: 'result',
|
||||
result: '{"cards":[{"title":"FIRST","anchor":"hello world test anchor","gist":"G","bullets":["B"]}]}',
|
||||
}),
|
||||
JSON.stringify({
|
||||
type: 'result',
|
||||
result: '{"cards":[{"title":"LAST","anchor":"hello world test anchor","gist":"G","bullets":["B"]}]}',
|
||||
}),
|
||||
].join('\n');
|
||||
const b4 = await runClaudeCodeWithStdout(twoResults);
|
||||
assert.strictEqual(b4.length, 1, 'B4: latest result event is used');
|
||||
assert.strictEqual(b4[0].title, 'LAST', 'B4: last result wins over earlier ones');
|
||||
|
||||
// B5: Single-event object with `content` (not `result`) — covers the legacy fallback path
|
||||
// where claudeResultText reads `events[0].content` when no `type: result` event exists.
|
||||
const contentFallback = JSON.stringify({
|
||||
content: '{"cards":[{"title":"B5","anchor":"hello world test anchor","gist":"G","bullets":["B"]}]}',
|
||||
});
|
||||
const b5 = await runClaudeCodeWithStdout(contentFallback);
|
||||
assert.strictEqual(b5.length, 1, 'B5: events[0].content fallback parses card');
|
||||
assert.strictEqual(b5[0].title, 'B5', 'B5: content-fallback card title preserved');
|
||||
}
|
||||
|
||||
// ── Claude verbose-missing error-path plumbing ──
|
||||
// This does NOT guard the --verbose flag itself (that is locked by
|
||||
// `testClaudeCodeArgs`'s `capturedArgs.includes('--verbose')` assertion, which fails
|
||||
// in fake-spawn before any stderr is even simulated).
|
||||
// Instead, this test simulates the *real* stderr that Claude CLI emits when
|
||||
// --verbose is missing, and asserts that the error-propagation pipeline preserves
|
||||
// exit code + stderr tail so the UI can render a useful diagnostic. If we ever
|
||||
// rework error handling and accidentally swallow CLI stderr, this catches it.
|
||||
|
||||
async function testClaudeCodeVerboseRegressionCanary() {
|
||||
const fakeSpawn = () => {
|
||||
const child = createFakeChild();
|
||||
process.nextTick(() => {
|
||||
child.stderr.emit(
|
||||
'data',
|
||||
Buffer.from('Error: When using --print, --output-format=stream-json requires --verbose'),
|
||||
);
|
||||
child.emit('close', 1, null);
|
||||
});
|
||||
return child;
|
||||
};
|
||||
const settings = {
|
||||
backend: 'claude-code',
|
||||
cliPath: '/usr/bin/claude',
|
||||
cliTimeoutMs: 5000,
|
||||
promptLanguage: 'zh',
|
||||
minCards: 1,
|
||||
maxCards: 15,
|
||||
maxDocChars: 100000,
|
||||
};
|
||||
await assert.rejects(
|
||||
() => t.summarizeViaClaudeCode('system', 'user', settings, undefined, fakeSpawn),
|
||||
(err) => {
|
||||
assert.ok(err instanceof t.CliProcessError, 'canary: throws CliProcessError on exit-nonzero');
|
||||
assert.strictEqual(err.details.reason, 'exit-nonzero', 'canary: exit-nonzero reason');
|
||||
assert.strictEqual(err.details.exitCode, 1, 'canary: surfaces CLI exit code');
|
||||
assert.match(err.details.stderrTail, /requires --verbose/, 'canary: preserves verbose-flag hint in stderr');
|
||||
return true;
|
||||
},
|
||||
'Claude CLI verbose-missing stderr surfaces as a structured CliProcessError',
|
||||
);
|
||||
}
|
||||
|
||||
// ── Codex error path ──
|
||||
|
||||
async function testCodexErrorPath() {
|
||||
const fakeSpawn = () => {
|
||||
const child = createFakeChild();
|
||||
process.nextTick(() => {
|
||||
child.stderr.emit('data', Buffer.from('error: not authenticated, please run codex login'));
|
||||
child.emit('close', 2, null);
|
||||
});
|
||||
return child;
|
||||
};
|
||||
const settings = {
|
||||
backend: 'codex',
|
||||
cliPath: '/usr/bin/codex',
|
||||
cliTimeoutMs: 5000,
|
||||
promptLanguage: 'zh',
|
||||
minCards: 1,
|
||||
maxCards: 15,
|
||||
maxDocChars: 100000,
|
||||
};
|
||||
await assert.rejects(
|
||||
() => t.summarizeViaCodex('system', 'user', settings, undefined, fakeSpawn),
|
||||
(err) => {
|
||||
assert.ok(err instanceof t.CliProcessError, 'codex: throws CliProcessError on exit-nonzero');
|
||||
assert.strictEqual(err.details.exitCode, 2, 'codex: exit code preserved');
|
||||
assert.match(err.details.stderrTail, /not authenticated/, 'codex: stderr tail preserves auth hint');
|
||||
return true;
|
||||
},
|
||||
'codex non-zero exit propagates structured error',
|
||||
);
|
||||
}
|
||||
|
||||
// ── Codex args minimal contract ──
|
||||
// Lock in the exact minimal arg shape: any drift means we are leaking new flags
|
||||
// into a CLI that is intentionally kept minimal (no --model, no --cd, no -c).
|
||||
|
||||
async function testCodexArgsMinimalContract() {
|
||||
let capturedArgs = null;
|
||||
const resultJson = '{"cards":[{"title":"T","anchor":"hello world test anchor","gist":"G","bullets":["B"]}]}';
|
||||
const fakeSpawn = (_cmd, args) => {
|
||||
capturedArgs = args;
|
||||
const child = createFakeChild();
|
||||
process.nextTick(() => {
|
||||
child.stdout.emit('data', Buffer.from(resultJson));
|
||||
child.emit('close', 0);
|
||||
});
|
||||
return child;
|
||||
};
|
||||
const settings = {
|
||||
backend: 'codex',
|
||||
cliPath: '/usr/bin/codex',
|
||||
cliTimeoutMs: 5000,
|
||||
promptLanguage: 'zh',
|
||||
minCards: 1,
|
||||
maxCards: 15,
|
||||
maxDocChars: 100000,
|
||||
};
|
||||
await t.summarizeViaCodex('system', 'user', settings, undefined, fakeSpawn);
|
||||
|
||||
// Exact list lock: changes here are intentional and require updating the test.
|
||||
assert.deepStrictEqual(
|
||||
capturedArgs,
|
||||
['exec', '--skip-git-repo-check', '--sandbox', 'read-only', '-'],
|
||||
'codex exec args must remain minimal (exec + skip-git + read-only sandbox + stdin marker)',
|
||||
);
|
||||
}
|
||||
|
||||
// ── classifyGenerationError ──
|
||||
|
||||
assert.strictEqual(t.classifyGenerationError(new Error('API key 未设置')), 'auth');
|
||||
|
|
@ -700,6 +483,7 @@ const wallTimeout = new t.CliProcessError('whatever message text', {
|
|||
exitCode: null,
|
||||
signal: null,
|
||||
timeoutMs: 0,
|
||||
idleTimeoutMs: 0,
|
||||
});
|
||||
assert.strictEqual(t.classifyGenerationError(wallTimeout), 'timeout', 'wall-timeout details → timeout kind');
|
||||
const spawnFailure = new t.CliProcessError('Failed to start claude', {
|
||||
|
|
@ -715,6 +499,7 @@ const spawnFailure = new t.CliProcessError('Failed to start claude', {
|
|||
exitCode: null,
|
||||
signal: null,
|
||||
timeoutMs: 0,
|
||||
idleTimeoutMs: 0,
|
||||
});
|
||||
assert.strictEqual(t.classifyGenerationError(spawnFailure), 'config', 'spawn-failure details → config kind');
|
||||
// exit-nonzero falls through so stderr-derived auth/rate-limit messages can still classify.
|
||||
|
|
@ -731,6 +516,7 @@ const exitWithAuthStderr = new t.CliProcessError('CLI exited with code 1\nstderr
|
|||
exitCode: 1,
|
||||
signal: null,
|
||||
timeoutMs: 0,
|
||||
idleTimeoutMs: 0,
|
||||
});
|
||||
assert.strictEqual(
|
||||
t.classifyGenerationError(exitWithAuthStderr),
|
||||
|
|
@ -774,14 +560,9 @@ assert.strictEqual(t.classifyGenerationError('string error'), 'unknown', 'string
|
|||
(async () => {
|
||||
await testRunCliEdgeCases();
|
||||
await testClaudeCodeArgs();
|
||||
await testClaudeCodeStreamJsonOutput();
|
||||
await testClaudeCodeStreamJsonResilience();
|
||||
await testClaudeCodeVerboseRegressionCanary();
|
||||
await testClaudeCodeArgsWithModel();
|
||||
await testCodexArgs();
|
||||
await testCodexArgsIgnoresClaudeDefaultModel();
|
||||
await testCodexArgsMinimalContract();
|
||||
await testCodexErrorPath();
|
||||
console.log('cli tests passed');
|
||||
})().catch((e) => {
|
||||
console.error(e);
|
||||
|
|
|
|||
|
|
@ -1,42 +0,0 @@
|
|||
'use strict';
|
||||
|
||||
// esbuild emits inline source maps whose `sources` are relative to the bundle
|
||||
// file (deep under .test-bundles/) with a `sourceRoot`. v8-to-istanbul / the
|
||||
// source-map library resolve those inconsistently and produce wrong paths like
|
||||
// `/Users/.../github.com/src/batch.ts`, so c8 finds nothing to report.
|
||||
//
|
||||
// This rewrites the inline map in-place so every `source` is an absolute path
|
||||
// to the real repo file and `sourceRoot` is cleared — making the remap exact.
|
||||
// No-op when coverage is not active.
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const MARKER = '//# sourceMappingURL=data:application/json;base64,';
|
||||
|
||||
function fixInlineSourceMap(outfile, repoRoot) {
|
||||
if (!process.env.NODE_V8_COVERAGE) return;
|
||||
const code = fs.readFileSync(outfile, 'utf8');
|
||||
const idx = code.lastIndexOf(MARKER);
|
||||
if (idx === -1) return;
|
||||
const b64 = code.slice(idx + MARKER.length).trim();
|
||||
const map = JSON.parse(Buffer.from(b64, 'base64').toString('utf8'));
|
||||
// esbuild keeps `sources` relative to the bundle file's directory regardless
|
||||
// of `sourceRoot`; resolving against sourceRoot double-counts and corrupts
|
||||
// the path. Always resolve against the bundle directory.
|
||||
const base = path.dirname(outfile);
|
||||
|
||||
map.sources = map.sources.map((src) => {
|
||||
if (src.startsWith('stub:') || src.includes('obsidian-stub')) return src;
|
||||
const abs = path.resolve(base, src);
|
||||
// Strip any leftover climbing artifacts; keep the real repo path.
|
||||
const i = abs.indexOf(repoRoot);
|
||||
return i === -1 ? abs : abs.slice(i);
|
||||
});
|
||||
delete map.sourceRoot;
|
||||
|
||||
const fixed = Buffer.from(JSON.stringify(map), 'utf8').toString('base64');
|
||||
fs.writeFileSync(outfile, code.slice(0, idx) + MARKER + fixed);
|
||||
}
|
||||
|
||||
module.exports = { fixInlineSourceMap };
|
||||
|
|
@ -35,31 +35,6 @@ function createFakeAdapter() {
|
|||
assert.strictEqual(cacheEntry.lastAccessedAt, undefined, 'direct cache import keeps cache entries immutable');
|
||||
assert.strictEqual(JSON.parse(cache.serializeCacheFile({ 'a.md': { cards: [] } })).version, 1);
|
||||
|
||||
// touchCacheEntry: null entry short-circuits to null
|
||||
assert.strictEqual(cache.touchCacheEntry(null), null, 'touchCacheEntry returns null for null entry');
|
||||
// touchCacheEntry: omitted `now` falls back to a generated ISO timestamp
|
||||
const touchedDefault = cache.touchCacheEntry({ generatedAt: '2024-01-01T00:00:00.000Z' });
|
||||
assert.ok(
|
||||
/^\d{4}-\d{2}-\d{2}T/.test(touchedDefault.lastAccessedAt),
|
||||
'touchCacheEntry defaults lastAccessedAt to current ISO time',
|
||||
);
|
||||
// serializeCacheFile: falsy entries map serializes to an empty object
|
||||
assert.deepStrictEqual(
|
||||
JSON.parse(cache.serializeCacheFile(null)).entries,
|
||||
{},
|
||||
'serializeCacheFile coerces falsy entries to {}',
|
||||
);
|
||||
// shouldConfirmRegenerate: every guard branch
|
||||
assert.strictEqual(cache.shouldConfirmRegenerate(null, true), false, 'no entry → false');
|
||||
assert.strictEqual(cache.shouldConfirmRegenerate({ updatedAt: 'x' }, false), false, 'force=false → false');
|
||||
assert.strictEqual(cache.shouldConfirmRegenerate({}, true), false, 'missing updatedAt → false');
|
||||
assert.strictEqual(cache.shouldConfirmRegenerate({ updatedAt: ' ' }, true), false, 'blank updatedAt → false');
|
||||
assert.strictEqual(
|
||||
cache.shouldConfirmRegenerate({ updatedAt: '2024-01-01' }, true),
|
||||
true,
|
||||
'force + non-blank updatedAt → true',
|
||||
);
|
||||
|
||||
// ── CacheManager: load + prune ──
|
||||
const adapter = createFakeAdapter();
|
||||
const manager = new cacheManagerModule.CacheManager(adapter, '.obsidian', 'parallel-reader', () => ({
|
||||
|
|
|
|||
|
|
@ -28,65 +28,16 @@ const { assert, requireBundledModule, cleanup } = require('./direct-test-setup')
|
|||
|
||||
assert.strictEqual(i18n.resolveUiLanguage({ uiLanguage: 'en' }), 'en');
|
||||
assert.strictEqual(i18n.resolveUiLanguage({ uiLanguage: 'zh' }), 'zh');
|
||||
assert.strictEqual(i18n.resolveUiLanguage({ uiLanguage: 'ja' }), 'ja');
|
||||
assert.strictEqual(i18n.resolveUiLanguage({ uiLanguage: 'ko' }), 'ko');
|
||||
assert.strictEqual(i18n.resolveUiLanguage({ uiLanguage: 'fr' }), 'fr');
|
||||
assert.strictEqual(i18n.resolveUiLanguage({ uiLanguage: 'de' }), 'de');
|
||||
assert.strictEqual(i18n.resolveUiLanguage({ uiLanguage: 'es' }), 'es');
|
||||
assert.strictEqual(i18n.resolveUiLanguage(null), 'en');
|
||||
assert.strictEqual(i18n.resolveUiLanguage({ uiLanguage: 'auto' }), 'en');
|
||||
assert.strictEqual(i18n.resolveUiLanguage({ uiLanguage: { toString: () => 'ja' } }), 'en');
|
||||
|
||||
const originalNavigator = Object.getOwnPropertyDescriptor(globalThis, 'navigator');
|
||||
const setNavigatorLanguage = (language) => {
|
||||
Object.defineProperty(globalThis, 'navigator', {
|
||||
value: { language },
|
||||
configurable: true,
|
||||
});
|
||||
};
|
||||
try {
|
||||
setNavigatorLanguage('ja-JP');
|
||||
assert.strictEqual(i18n.resolveUiLanguage({ uiLanguage: 'auto' }), 'ja');
|
||||
setNavigatorLanguage('ko-KR');
|
||||
assert.strictEqual(i18n.resolveUiLanguage({ uiLanguage: 'auto' }), 'ko');
|
||||
setNavigatorLanguage('fr-CA');
|
||||
assert.strictEqual(i18n.resolveUiLanguage({ uiLanguage: 'auto' }), 'fr');
|
||||
setNavigatorLanguage('de_DE');
|
||||
assert.strictEqual(i18n.resolveUiLanguage({ uiLanguage: 'auto' }), 'de');
|
||||
setNavigatorLanguage('es-MX');
|
||||
assert.strictEqual(i18n.resolveUiLanguage({ uiLanguage: 'auto' }), 'es');
|
||||
setNavigatorLanguage('it-IT');
|
||||
assert.strictEqual(i18n.resolveUiLanguage({ uiLanguage: 'auto' }), 'en');
|
||||
} finally {
|
||||
if (originalNavigator) {
|
||||
Object.defineProperty(globalThis, 'navigator', originalNavigator);
|
||||
} else {
|
||||
delete globalThis.navigator;
|
||||
}
|
||||
}
|
||||
|
||||
assert.ok(i18n.STRINGS.zh);
|
||||
assert.ok(i18n.STRINGS.en);
|
||||
assert.ok(i18n.STRINGS.ja);
|
||||
assert.ok(i18n.STRINGS.ko);
|
||||
assert.ok(i18n.STRINGS.fr);
|
||||
assert.ok(i18n.STRINGS.de);
|
||||
assert.ok(i18n.STRINGS.es);
|
||||
const supportedLocales = ['zh', 'en', 'ja', 'ko', 'fr', 'de', 'es'];
|
||||
const enKeys = Object.keys(i18n.STRINGS.en).sort();
|
||||
for (const locale of supportedLocales) {
|
||||
const keys = Object.keys(i18n.STRINGS[locale]).sort();
|
||||
assert.deepStrictEqual(keys, enKeys, `${locale} keys must match en`);
|
||||
for (const key of enKeys) {
|
||||
assert.ok(i18n.STRINGS[locale][key], `${locale} empty key: ${key}`);
|
||||
}
|
||||
}
|
||||
for (const locale of ['ja', 'ko', 'fr', 'de', 'es']) {
|
||||
const overrideKeys = Object.keys(i18n.LOCALE_OVERRIDES[locale]).sort();
|
||||
assert.deepStrictEqual(overrideKeys, enKeys, `${locale} raw override keys must match en`);
|
||||
for (const key of enKeys) {
|
||||
assert.ok(i18n.LOCALE_OVERRIDES[locale][key], `${locale} empty raw override: ${key}`);
|
||||
}
|
||||
const zhKeys = Object.keys(i18n.STRINGS.zh);
|
||||
const enKeys = Object.keys(i18n.STRINGS.en);
|
||||
assert.strictEqual(zhKeys.length, enKeys.length);
|
||||
for (const key of zhKeys) {
|
||||
assert.ok(i18n.STRINGS.en[key], `en missing key: ${key}`);
|
||||
}
|
||||
|
||||
console.log('direct i18n tests passed');
|
||||
|
|
|
|||
|
|
@ -60,9 +60,6 @@ const { assert, requireBundledModule, cleanup } = require('./direct-test-setup')
|
|||
const plainMinimal = md.cardToPlain({ title: 'T', anchor: '', gist: '', bullets: [] });
|
||||
assert.strictEqual(plainMinimal, 'T', 'title only when gist and bullets empty');
|
||||
|
||||
const plainNullBullets = md.cardToPlain({ title: 'T', anchor: '', gist: 'G' });
|
||||
assert.strictEqual(plainNullBullets, 'T\nG', 'undefined bullets coerced to [] (no crash)');
|
||||
|
||||
// ── cardsToMarkdown ──
|
||||
const multi = md.cardsToMarkdown('My Title', [
|
||||
{ title: 'A', anchor: 'q', gist: 'g', bullets: ['b'] },
|
||||
|
|
|
|||
|
|
@ -13,13 +13,8 @@ const { assert, requireBundledModule, cleanup } = require('./direct-test-setup')
|
|||
prompt.promptLanguageInstruction('auto'),
|
||||
'Write title, gist, and bullets in the main language of the source document.',
|
||||
);
|
||||
assert.strictEqual(prompt.promptLanguageInstruction('ja'), 'Write title, gist, and bullets in Japanese.');
|
||||
assert.strictEqual(prompt.promptLanguageInstruction('ko'), 'Write title, gist, and bullets in Korean.');
|
||||
assert.strictEqual(prompt.promptLanguageInstruction('fr'), 'Write title, gist, and bullets in French.');
|
||||
assert.strictEqual(prompt.promptLanguageInstruction('de'), 'Write title, gist, and bullets in German.');
|
||||
assert.strictEqual(prompt.promptLanguageInstruction('es'), 'Write title, gist, and bullets in Spanish.');
|
||||
assert.strictEqual(
|
||||
prompt.promptLanguageInstruction('xx'),
|
||||
prompt.promptLanguageInstruction('fr'),
|
||||
'用中文输出 title、gist 和 bullets。',
|
||||
'unknown language falls to zh',
|
||||
);
|
||||
|
|
@ -34,11 +29,6 @@ const { assert, requireBundledModule, cleanup } = require('./direct-test-setup')
|
|||
assert.ok(zhExample.includes('"cards"'), 'zh example is JSON-like');
|
||||
|
||||
assert.ok(prompt.promptSchemaExample('auto').includes('U 型收益曲线'), 'auto falls to zh example');
|
||||
assert.ok(prompt.promptSchemaExample('ja').includes('U字型の利益'), 'ja example has expected title');
|
||||
assert.ok(prompt.promptSchemaExample('ko').includes('U자형 이득'), 'ko example has expected title');
|
||||
assert.ok(prompt.promptSchemaExample('fr').includes('Gains en U'), 'fr example has expected title');
|
||||
assert.ok(prompt.promptSchemaExample('de').includes('U-förmige Gewinne'), 'de example has expected title');
|
||||
assert.ok(prompt.promptSchemaExample('es').includes('Ganancias en forma de U'), 'es example has expected title');
|
||||
|
||||
// ── renderPromptTemplate ──
|
||||
assert.strictEqual(prompt.renderPromptTemplate('Hello {name}!', { name: 'World' }), 'Hello World!');
|
||||
|
|
@ -65,19 +55,6 @@ const { assert, requireBundledModule, cleanup } = require('./direct-test-setup')
|
|||
const autoResult = prompt.buildPrompts('content', { ...base, promptLanguage: 'auto' });
|
||||
assert.ok(autoResult.system.includes('main language'), 'auto language instruction');
|
||||
|
||||
// ── buildPrompts: expanded output languages ──
|
||||
for (const [language, expected] of [
|
||||
['ja', 'Japanese'],
|
||||
['ko', 'Korean'],
|
||||
['fr', 'French'],
|
||||
['de', 'German'],
|
||||
['es', 'Spanish'],
|
||||
]) {
|
||||
const result = prompt.buildPrompts('content', { ...base, promptLanguage: language });
|
||||
assert.ok(result.system.includes(expected), `${language} language instruction`);
|
||||
assert.ok(result.user.includes('Source document:'), `${language} uses provider-facing source prefix`);
|
||||
}
|
||||
|
||||
// ── buildPrompts: truncation ──
|
||||
const longDoc = 'x'.repeat(25000);
|
||||
const truncResult = prompt.buildPrompts(longDoc, { ...base, maxDocChars: 20000 });
|
||||
|
|
@ -104,11 +81,9 @@ const { assert, requireBundledModule, cleanup } = require('./direct-test-setup')
|
|||
const wsCustom = prompt.buildPrompts('doc', { ...base, customSystemPrompt: ' ' });
|
||||
assert.ok(wsCustom.system.includes('long-form reading'), 'default prompt used when custom is whitespace-only');
|
||||
|
||||
// ── buildPrompts: invalid promptLanguage falls back to the default ('auto') ──
|
||||
// ── buildPrompts: invalid promptLanguage falls back ──
|
||||
const badLang = prompt.buildPrompts('doc', { ...base, promptLanguage: 'xyz' });
|
||||
const autoFallback = prompt.buildPrompts('doc', { ...base, promptLanguage: 'auto' });
|
||||
assert.strictEqual(badLang.system, autoFallback.system, 'invalid promptLanguage falls back to the default (auto)');
|
||||
assert.ok(badLang.system.includes('main language'), 'fallback uses the auto language instruction');
|
||||
assert.ok(badLang.system.includes('中文'), 'invalid promptLanguage falls back to zh default');
|
||||
|
||||
// ── buildPrompts: card count normalization ──
|
||||
// 0 is falsy so || picks DEFAULT (5), then Math.max(1, 5) = 5
|
||||
|
|
|
|||
|
|
@ -4,7 +4,6 @@ const { assert, requireBundledModule, cleanup } = require('./direct-test-setup')
|
|||
try {
|
||||
const generation = await requireBundledModule('src/generation.ts');
|
||||
const providerParsers = await requireBundledModule('src/provider-parsers.ts');
|
||||
const providerRequest = await requireBundledModule('src/provider-request.ts');
|
||||
|
||||
// ── generation.ts ──
|
||||
assert.strictEqual(
|
||||
|
|
@ -89,76 +88,6 @@ const { assert, requireBundledModule, cleanup } = require('./direct-test-setup')
|
|||
);
|
||||
assert.strictEqual(providerParsers.cardsFromAnthropicToolUse({}), null);
|
||||
|
||||
// ── provider-request: structured-output fallback keyed on HTTP status, not localized text ──
|
||||
const { ProviderApiError, shouldRetryWithoutStructuredOutput, requestJsonBodyWithStructuredFallback } =
|
||||
providerRequest;
|
||||
|
||||
// 400 + body keyword ⇒ retry, regardless of the (i18n-translated) message language.
|
||||
assert.strictEqual(
|
||||
shouldRetryWithoutStructuredOutput(
|
||||
new ProviderApiError('英語以外のエラーメッセージ', 400, 'response_format is not supported'),
|
||||
),
|
||||
true,
|
||||
'status-400 + body keyword ⇒ retry even when message is non-English/Chinese',
|
||||
);
|
||||
assert.strictEqual(
|
||||
shouldRetryWithoutStructuredOutput(
|
||||
new ProviderApiError("L'API a renvoyé une erreur", 422, 'json_schema unsupported'),
|
||||
),
|
||||
true,
|
||||
'status-422 + body keyword ⇒ retry for a French-localized message',
|
||||
);
|
||||
// 5xx is a server error, never a structured-output problem ⇒ do not retry.
|
||||
assert.strictEqual(
|
||||
shouldRetryWithoutStructuredOutput(new ProviderApiError('server error', 500, 'response_format')),
|
||||
false,
|
||||
);
|
||||
// 400 without any schema-related keyword ⇒ do not retry.
|
||||
assert.strictEqual(
|
||||
shouldRetryWithoutStructuredOutput(new ProviderApiError('bad request', 400, 'missing required field: model')),
|
||||
false,
|
||||
);
|
||||
// Model-name errors ("unknown model" / "unrecognized model ID") must NOT trigger a
|
||||
// wasted fallback retry — bare unknown/unrecognized keywords were dropped for this.
|
||||
assert.strictEqual(
|
||||
shouldRetryWithoutStructuredOutput(new ProviderApiError('bad request', 400, 'unknown model: gpt-5')),
|
||||
false,
|
||||
);
|
||||
assert.strictEqual(
|
||||
shouldRetryWithoutStructuredOutput(new ProviderApiError('bad request', 400, 'unrecognized model ID: x')),
|
||||
false,
|
||||
);
|
||||
// Legacy path: a plain Error carrying the English template still works (back-compat).
|
||||
assert.strictEqual(
|
||||
shouldRetryWithoutStructuredOutput(new Error('OpenAI API returned HTTP 400: response_format not supported')),
|
||||
true,
|
||||
);
|
||||
assert.strictEqual(shouldRetryWithoutStructuredOutput(new Error('some unrelated error')), false);
|
||||
|
||||
// End-to-end: a 400 returned alongside a NON-English locale still triggers the fallback body.
|
||||
{
|
||||
const calls = [];
|
||||
const fakeRequestUrl = async ({ body }) => {
|
||||
calls.push(JSON.parse(body));
|
||||
if (calls.length === 1) {
|
||||
return { status: 400, json: null, text: 'response_format is not supported by this model' };
|
||||
}
|
||||
return { status: 200, json: { ok: true }, text: '' };
|
||||
};
|
||||
const result = await requestJsonBodyWithStructuredFallback(
|
||||
fakeRequestUrl,
|
||||
'OpenAI-compatible Chat',
|
||||
'https://example.test',
|
||||
{},
|
||||
{ model: 'x', response_format: { type: 'json_schema' } },
|
||||
{ model: 'x' },
|
||||
{ uiLanguage: 'fr' }, // localized error message must NOT block the fallback
|
||||
);
|
||||
assert.deepStrictEqual(result, { ok: true });
|
||||
assert.strictEqual(calls.length, 2, 'should retry exactly once without structured output');
|
||||
assert.ok(!('response_format' in calls[1]), 'fallback body omits response_format');
|
||||
}
|
||||
|
||||
console.log('direct providers tests passed');
|
||||
} finally {
|
||||
cleanup();
|
||||
|
|
|
|||
|
|
@ -4,10 +4,6 @@ const { assert, requireBundledModule, cleanup } = require('./direct-test-setup')
|
|||
try {
|
||||
const settings = await requireBundledModule('src/settings.ts');
|
||||
const base = settings.DEFAULT_SETTINGS;
|
||||
const supportedLanguageIds = ['auto', 'zh', 'en', 'ja', 'ko', 'fr', 'de', 'es'];
|
||||
|
||||
assert.deepStrictEqual(Object.keys(settings.PROMPT_LANGUAGES), supportedLanguageIds, 'prompt language order');
|
||||
assert.deepStrictEqual(Object.keys(settings.UI_LANGUAGES), supportedLanguageIds, 'ui language order');
|
||||
|
||||
// ── stableStringify ──
|
||||
assert.strictEqual(settings.stableStringify(42), '42', 'number');
|
||||
|
|
@ -103,19 +99,6 @@ const { assert, requireBundledModule, cleanup } = require('./direct-test-setup')
|
|||
assert.strictEqual(normed.maxDocChars, base.maxDocChars, 'maxDocChars <1000 → default');
|
||||
assert.strictEqual(normed.customSystemPrompt, '', 'non-string customSystemPrompt → empty string');
|
||||
|
||||
for (const language of supportedLanguageIds) {
|
||||
assert.strictEqual(
|
||||
settings.normalizeSettings({ ...base, uiLanguage: language }).uiLanguage,
|
||||
language,
|
||||
`normalizeSettings preserves uiLanguage ${language}`,
|
||||
);
|
||||
assert.strictEqual(
|
||||
settings.normalizeSettings({ ...base, promptLanguage: language }).promptLanguage,
|
||||
language,
|
||||
`normalizeSettings preserves promptLanguage ${language}`,
|
||||
);
|
||||
}
|
||||
|
||||
// ── pruneCacheEntries edge cases ──
|
||||
assert.deepStrictEqual(settings.pruneCacheEntries(null, 10), [], 'null cache');
|
||||
assert.deepStrictEqual(settings.pruneCacheEntries({}, 10), [], 'empty cache');
|
||||
|
|
|
|||
|
|
@ -74,47 +74,6 @@ const { assert, requireBundledModule, cleanup } = require('./direct-test-setup')
|
|||
const empty = streaming.parseSseBuffer('', openAiExtractor);
|
||||
assert.deepStrictEqual(empty.deltas, []);
|
||||
|
||||
// ── streamErrorMessage: provider errors delivered as a 200-status SSE payload ──
|
||||
assert.strictEqual(
|
||||
streaming.streamErrorMessage({ type: 'error', error: { type: 'overloaded_error', message: 'Overloaded' } }),
|
||||
'Overloaded',
|
||||
);
|
||||
assert.strictEqual(
|
||||
streaming.streamErrorMessage({ type: 'error', error: { type: 'overloaded_error' } }),
|
||||
'overloaded_error',
|
||||
);
|
||||
assert.strictEqual(streaming.streamErrorMessage({ type: 'error' }), 'Provider returned a streaming error');
|
||||
assert.strictEqual(streaming.streamErrorMessage({ error: { message: 'Quota exceeded' } }), 'Quota exceeded');
|
||||
// OpenAI-style error object without a message/type is NOT treated as an error
|
||||
// (avoids aborting a normal chunk that carries a stray empty/code-only `error`).
|
||||
assert.strictEqual(streaming.streamErrorMessage({ error: { code: 'insufficient_quota' } }), null);
|
||||
assert.strictEqual(streaming.streamErrorMessage({ error: {} }), null);
|
||||
assert.strictEqual(streaming.streamErrorMessage({ choices: [{ delta: { content: 'hi' } }] }), null);
|
||||
assert.strictEqual(streaming.streamErrorMessage({ type: 'content_block_delta', delta: { text: 'x' } }), null);
|
||||
assert.strictEqual(streaming.streamErrorMessage({ error: null }), null);
|
||||
|
||||
// ── parseSseBuffer: surface in-stream provider errors instead of swallowing them ──
|
||||
assert.throws(
|
||||
() =>
|
||||
streaming.parseSseBuffer(
|
||||
'data: {"type":"error","error":{"type":"overloaded_error","message":"Overloaded"}}\n\n',
|
||||
anthropicExtractor,
|
||||
),
|
||||
/Overloaded/,
|
||||
'anthropic in-stream error must throw, not be swallowed as a non-JSON line',
|
||||
);
|
||||
assert.throws(
|
||||
() => streaming.parseSseBuffer('data: {"error":{"message":"Rate limit reached"}}\n\n', openAiExtractor),
|
||||
/Rate limit reached/,
|
||||
'openai-compatible in-stream error must throw',
|
||||
);
|
||||
// Normal deltas through the same parse path still work.
|
||||
const okAfterError = streaming.parseSseBuffer(
|
||||
'data: {"choices":[{"delta":{"content":"fine"}}]}\n\n',
|
||||
openAiExtractor,
|
||||
);
|
||||
assert.deepStrictEqual(okAfterError.deltas, ['fine']);
|
||||
|
||||
// ── streamingRequestUrl ──
|
||||
function trackedSignal() {
|
||||
const controller = new AbortController();
|
||||
|
|
|
|||
|
|
@ -10,12 +10,7 @@ if (typeof globalThis.activeWindow === 'undefined') {
|
|||
}
|
||||
|
||||
const repoRoot = path.join(__dirname, '..');
|
||||
// Under c8, bundles must live inside the repo so c8 processes their coverage
|
||||
// (it ignores scripts outside cwd before source-map remap). Otherwise /tmp.
|
||||
const bundleParent = process.env.NODE_V8_COVERAGE
|
||||
? fs.mkdirSync(path.join(repoRoot, '.test-bundles'), { recursive: true }) || path.join(repoRoot, '.test-bundles')
|
||||
: os.tmpdir();
|
||||
const tempDir = fs.mkdtempSync(path.join(bundleParent, 'parallel-reader-tests-'));
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'parallel-reader-tests-'));
|
||||
|
||||
async function requireBundledModule(relativePath) {
|
||||
const entry = path.join(repoRoot, relativePath);
|
||||
|
|
@ -26,9 +21,6 @@ async function requireBundledModule(relativePath) {
|
|||
platform: 'node',
|
||||
format: 'cjs',
|
||||
outfile,
|
||||
sourcemap: 'inline',
|
||||
sourcesContent: true,
|
||||
sourceRoot: repoRoot,
|
||||
plugins: [
|
||||
{
|
||||
name: 'obsidian-stub',
|
||||
|
|
@ -43,14 +35,10 @@ async function requireBundledModule(relativePath) {
|
|||
},
|
||||
],
|
||||
});
|
||||
require('./coverage-sourcemap').fixInlineSourceMap(outfile, repoRoot);
|
||||
return require(outfile);
|
||||
}
|
||||
|
||||
function cleanup() {
|
||||
// Skipped under c8 coverage: c8 reads V8 coverage at exit and needs the
|
||||
// bundled file (with its inline source map) still on disk to remap to src/.
|
||||
if (process.env.NODE_V8_COVERAGE) return;
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -166,38 +166,6 @@ async function testCancelAllWaiters() {
|
|||
assert.strictEqual(manager.waitingCount(), 0);
|
||||
}
|
||||
|
||||
async function testCancelAll() {
|
||||
const manager = new GenerationJobManager(1);
|
||||
let release;
|
||||
const blocker = new Promise((r) => {
|
||||
release = r;
|
||||
});
|
||||
let runningCancelHook = false;
|
||||
|
||||
const running = manager.start('run.md', async (job) => {
|
||||
job.onCancel(() => {
|
||||
runningCancelHook = true;
|
||||
});
|
||||
await blocker;
|
||||
job.throwIfCancelled();
|
||||
return 'run';
|
||||
});
|
||||
const queued = manager.start('q.md', async () => 'q');
|
||||
|
||||
await new Promise((r) => setTimeout(r, 5));
|
||||
assert.strictEqual(manager.isRunning('run.md'), true, 'one running');
|
||||
assert.strictEqual(manager.waitingCount(), 1, 'one queued');
|
||||
|
||||
const cancelled = manager.cancelAll();
|
||||
assert.strictEqual(cancelled, 2, 'cancelAll cancels the running job and drains the queued waiter');
|
||||
assert.strictEqual(runningCancelHook, true, 'running job cancel handler fired (aborts in-flight HTTP/CLI)');
|
||||
|
||||
release();
|
||||
await assert.rejects(running, GenerationJobCancelledError, 'cancelled running job rejects');
|
||||
await assert.rejects(queued, GenerationJobCancelledError, 'queued job rejects');
|
||||
assert.strictEqual(manager.waitingCount(), 0);
|
||||
}
|
||||
|
||||
async function testCancelSignalsRunnerAndCleansUp() {
|
||||
const manager = new GenerationJobManager();
|
||||
let cancelHookCalled = false;
|
||||
|
|
@ -222,12 +190,6 @@ function testErrorClassification() {
|
|||
assert.strictEqual(classifyGenerationError(new Error('API key 未设置')), 'auth');
|
||||
assert.strictEqual(classifyGenerationError(new Error('CLI 超时 (120000ms)')), 'timeout');
|
||||
assert.strictEqual(classifyGenerationError(new Error('OpenAI API 429: rate limit')), 'rate-limit');
|
||||
assert.strictEqual(
|
||||
classifyGenerationError(new Error('request to https://api failed, reason: ECONNREFUSED')),
|
||||
'network',
|
||||
);
|
||||
assert.strictEqual(classifyGenerationError(new Error('Failed to fetch')), 'network');
|
||||
assert.strictEqual(classifyGenerationError(new Error('getaddrinfo ENOTFOUND api.openai.com')), 'network');
|
||||
assert.strictEqual(classifyGenerationError(new Error('LLM 返回非 JSON')), 'schema');
|
||||
assert.strictEqual(classifyGenerationError(new Error('Model 未设置')), 'config');
|
||||
assert.strictEqual(classifyGenerationError(new Error('something else')), 'unknown');
|
||||
|
|
@ -238,7 +200,6 @@ function testErrorClassification() {
|
|||
await testGlobalConcurrencyLimit();
|
||||
await testNoOverbookingDuringRelease();
|
||||
await testCancelAllWaiters();
|
||||
await testCancelAll();
|
||||
await testCancelSignalsRunnerAndCleansUp();
|
||||
testErrorClassification();
|
||||
console.log('generation job manager tests passed');
|
||||
|
|
|
|||
|
|
@ -2,22 +2,12 @@ const { assert, t } = require('./test-setup');
|
|||
|
||||
assert.strictEqual(t.translate({ uiLanguage: 'zh' }, 'appTitle'), '对照阅读笔记', 'zh translation');
|
||||
assert.strictEqual(t.translate({ uiLanguage: 'en' }, 'appTitle'), 'Parallel Reader', 'en translation');
|
||||
assert.strictEqual(t.translate({ uiLanguage: 'ja' }, 'settingTestBackendButton'), 'テスト');
|
||||
assert.strictEqual(t.translate({ uiLanguage: 'ko' }, 'settingTestBackendButton'), '테스트');
|
||||
assert.strictEqual(t.translate({ uiLanguage: 'fr' }, 'settingTestBackendButton'), 'Tester');
|
||||
assert.strictEqual(t.translate({ uiLanguage: 'de' }, 'settingTestBackendButton'), 'Testen');
|
||||
assert.strictEqual(t.translate({ uiLanguage: 'es' }, 'settingTestBackendButton'), 'Probar');
|
||||
assert.strictEqual(t.translate({ uiLanguage: 'en' }, 'nonexistent_key'), 'nonexistent_key', 'missing key returns key');
|
||||
assert.strictEqual(
|
||||
t.translate({ uiLanguage: 'en' }, 'cacheClearedAll', { count: 5 }),
|
||||
'Cleared 5 cache entries',
|
||||
'variable interpolation',
|
||||
);
|
||||
assert.strictEqual(
|
||||
t.translate({ uiLanguage: 'fr' }, 'cacheClearedAll', { count: 5 }),
|
||||
'5 entrées de cache effacées',
|
||||
'new locale interpolation',
|
||||
);
|
||||
assert.strictEqual(
|
||||
t.translate({ uiLanguage: 'en' }, 'exported', { path: 'foo/bar.md' }),
|
||||
'Exported → foo/bar.md',
|
||||
|
|
|
|||
|
|
@ -103,14 +103,27 @@ assert.strictEqual(t.normalizeCardCount('15', 5), 15, 'numeric string accepted')
|
|||
|
||||
// normalizeCliTimeoutMs (mirrors normalizeStreamingTimeoutMs)
|
||||
assert.strictEqual(t.normalizeCliTimeoutMs('60000'), 60000, 'cli timeout accepts numeric strings');
|
||||
assert.strictEqual(t.normalizeCliTimeoutMs(999), 300000, 'cli timeout below minimum falls back to default');
|
||||
assert.strictEqual(t.normalizeCliTimeoutMs('bad'), 300000, 'cli timeout rejects non-numeric values');
|
||||
assert.strictEqual(t.normalizeCliTimeoutMs(999), 120000, 'cli timeout below minimum falls back to default');
|
||||
assert.strictEqual(t.normalizeCliTimeoutMs('bad'), 120000, 'cli timeout rejects non-numeric values');
|
||||
assert.strictEqual(
|
||||
t.normalizeSettings({ ...baseSettings, cliTimeoutMs: 500 }).cliTimeoutMs,
|
||||
300000,
|
||||
120000,
|
||||
'normalizeSettings protects invalid cli timeout values',
|
||||
);
|
||||
|
||||
// normalizeCliIdleTimeoutMs: 0 means disabled, anything below MIN coerces to 0, valid passes through
|
||||
assert.strictEqual(t.normalizeCliIdleTimeoutMs(0), 0, 'idle timeout 0 stays disabled');
|
||||
assert.strictEqual(t.normalizeCliIdleTimeoutMs(45000), 45000, 'idle timeout valid value passes through');
|
||||
assert.strictEqual(t.normalizeCliIdleTimeoutMs('30000'), 30000, 'idle timeout numeric string accepted');
|
||||
assert.strictEqual(t.normalizeCliIdleTimeoutMs(500), 0, 'idle timeout below 1000ms disables (treats as off)');
|
||||
assert.strictEqual(t.normalizeCliIdleTimeoutMs(-1), 0, 'idle timeout negative coerces to 0');
|
||||
assert.strictEqual(t.normalizeCliIdleTimeoutMs('bad'), 0, 'idle timeout non-numeric coerces to 0');
|
||||
assert.strictEqual(
|
||||
t.normalizeSettings({ ...baseSettings, cliIdleTimeoutMs: 250 }).cliIdleTimeoutMs,
|
||||
0,
|
||||
'normalizeSettings disables sub-minimum idle timeout',
|
||||
);
|
||||
|
||||
// applyApiProviderPreset clears credentials but keeps preset baseUrl (security: prevent cross-provider key leak)
|
||||
{
|
||||
const previous = {
|
||||
|
|
|
|||
|
|
@ -11,7 +11,6 @@ const expectedExports = [
|
|||
'runCli',
|
||||
'buildPrompts',
|
||||
'buildOpenAiChatBody',
|
||||
'buildLineOffsets',
|
||||
'extractJson',
|
||||
'findLineForAnchor',
|
||||
'folderPathsForTarget',
|
||||
|
|
|
|||
|
|
@ -17,12 +17,7 @@ const { getRequestUrlMock, setRequestUrlMock } = require('./obsidian-mock');
|
|||
// Bundle test-exports.ts synchronously with obsidian marked as external.
|
||||
// The bundled code will require('obsidian') at runtime, hitting our mock.
|
||||
const repoRoot = path.join(__dirname, '..');
|
||||
// Under c8, bundles must live inside the repo so c8 processes their coverage
|
||||
// (it ignores scripts outside cwd before source-map remap). Otherwise /tmp.
|
||||
const bundleParent = process.env.NODE_V8_COVERAGE
|
||||
? fs.mkdirSync(path.join(repoRoot, '.test-bundles'), { recursive: true }) || path.join(repoRoot, '.test-bundles')
|
||||
: os.tmpdir();
|
||||
const tempDir = fs.mkdtempSync(path.join(bundleParent, 'parallel-reader-test-setup-'));
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'parallel-reader-test-setup-'));
|
||||
const outfile = path.join(tempDir, 'test-exports.cjs');
|
||||
|
||||
esbuild.buildSync({
|
||||
|
|
@ -32,20 +27,12 @@ esbuild.buildSync({
|
|||
format: 'cjs',
|
||||
outfile,
|
||||
external: ['obsidian'],
|
||||
sourcemap: 'inline',
|
||||
sourcesContent: true,
|
||||
sourceRoot: repoRoot,
|
||||
});
|
||||
|
||||
require('./coverage-sourcemap').fixInlineSourceMap(outfile, repoRoot);
|
||||
|
||||
const t = require(outfile);
|
||||
|
||||
// Clean up temp bundle on process exit. Skipped under c8 coverage:
|
||||
// c8 reads V8 coverage at exit and needs the bundled file (with its
|
||||
// inline source map) still on disk to remap back to src/*.ts.
|
||||
// Clean up temp bundle on process exit.
|
||||
process.on('exit', () => {
|
||||
if (process.env.NODE_V8_COVERAGE) return;
|
||||
try {
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
} catch (_) {
|
||||
|
|
|
|||
|
|
@ -17,11 +17,5 @@
|
|||
"1.0.15": "1.7.2",
|
||||
"1.0.16": "1.7.2",
|
||||
"1.0.17": "1.7.2",
|
||||
"1.0.18": "1.8.7",
|
||||
"1.0.19": "1.8.7",
|
||||
"1.0.20": "1.8.7",
|
||||
"1.0.21": "1.8.7",
|
||||
"1.0.22": "1.8.7",
|
||||
"1.0.23": "1.8.7",
|
||||
"1.0.24": "1.8.7"
|
||||
"1.0.18": "1.8.7"
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue