diff --git a/README.md b/README.md index bcac8ee..2c28161 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Apply OpenCode -AI-powered frontmatter enhancement and title generation for Obsidian, using [OpenCode](https://opencode.ai) CLI. +AI-powered note enhancement for Obsidian, using [OpenCode](https://opencode.ai) CLI. ## Features @@ -17,8 +17,8 @@ AI-powered frontmatter enhancement and title generation for Obsidian, using [Ope - Bulk rename all "Untitled" files at once ### Content Generation -- Generate 500-1000 characters at cursor position -- Select text to replace it with AI-generated content +- Select text to edit/replace it with AI-generated content +- Or place cursor to append content at that position - Uses note title, frontmatter, and surrounding context for relevance - Optional instruction to guide generation (e.g., "summarize", "expand", "add examples") @@ -30,6 +30,24 @@ AI-powered frontmatter enhancement and title generation for Obsidian, using [Ope - Smart selection handling: processes selection if present, otherwise full note - Safety guaranteed: only adds `[[` and `]]` brackets, never modifies content +### Base File Generation +- Create Obsidian Base files (.base) from natural language descriptions +- Edit existing Base files with AI assistance +- Uses vault context (folders, tags, properties) for accurate references +- Includes examples from your existing .base files + +### Canvas Generation +- Create JSON Canvas files (.canvas) from natural language descriptions +- Edit existing Canvas files with AI assistance +- Smart layout rules prevent overlapping nodes +- Uses vault context for accurate file/folder references + +### Weekly Summary +- Analyze all notes created or modified in the past 7 days +- Generates insights on themes, progress, connections, and open threads +- Creates a new summary note with reflection prompts +- Includes activity statistics by tag, folder, and day + ## Installation ### Using BRAT (Recommended) @@ -63,10 +81,10 @@ AI-powered frontmatter enhancement and title generation for Obsidian, using [Ope 2. Click the brain icon in the ribbon, or run command: **Apply OpenCode: Generate AI title for current file** 3. File is renamed based on content -### Generate Content -1. Place cursor where you want content, or select text to replace -2. Run command: **Apply OpenCode: Generate content at cursor** -3. Optionally type an instruction (e.g., "add a conclusion"), or leave empty for natural continuation +### Edit or Append Content +1. Select text to replace, or place cursor where you want content +2. Run command: **Apply OpenCode: Edit or append content at selection** +3. Optionally type an instruction (e.g., "add a conclusion"), or leave empty 4. Press **Enter** to generate ### Identify Wiki Links @@ -76,6 +94,23 @@ AI-powered frontmatter enhancement and title generation for Obsidian, using [Ope 4. Click on any green line to remove that link 5. Click "Apply changes" to add the wiki links +### Create Base File +1. Run command: **Apply OpenCode: Create Obsidian base** +2. Describe what you want (e.g., "tasks not in Archive folder, grouped by status") +3. Choose folder and filename +4. Review and save + +### Create Canvas +1. Run command: **Apply OpenCode: Create canvas** +2. Describe what you want (e.g., "project roadmap with phases") +3. Choose folder and filename +4. Review and save + +### Weekly Summary +1. Run command: **Apply OpenCode: This week's summary** +2. Wait for analysis of recent notes +3. New summary note opens with insights + ### Bulk Operations (Settings) - **Bulk rename untitled files** - Rename all files with "Untitled" in the name - **Bulk enhance frontmatter** - Add frontmatter to all files missing it diff --git a/package-lock.json b/package-lock.json index 5a51a1a..de93f4d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "apply-opencode", - "version": "1.0.0", + "version": "1.1.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "apply-opencode", - "version": "1.0.0", + "version": "1.1.2", "license": "MIT", "dependencies": { "@pierre/diffs": "^1.0.4" @@ -20,7 +20,8 @@ "eslint-plugin-obsidianmd": "^0.1.9", "globals": "^17.0.0", "obsidian": "latest", - "typescript": "^5.3.0" + "typescript": "^5.3.0", + "vitest": "^4.0.16" } }, "node_modules/@codemirror/state": { @@ -405,6 +406,23 @@ "node": ">=18" } }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.2.tgz", + "integrity": "sha512-LRBbCmiU51IXfeXk59csuX/aSaToeG7w48nMwA6049Y4J4+VbWALAuXcs+qcD04rHDuSCSRKdmY63sruDS5qag==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, "node_modules/@esbuild/sunos-x64": { "version": "0.24.2", "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.24.2.tgz", @@ -755,6 +773,13 @@ "url": "https://github.com/sponsors/nzakas" } }, + "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/@marijn/find-cluster-break": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/@marijn/find-cluster-break/-/find-cluster-break-1.0.2.tgz", @@ -832,6 +857,356 @@ "url": "https://opencollective.com/unts" } }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.55.1.tgz", + "integrity": "sha512-9R0DM/ykwfGIlNu6+2U09ga0WXeZ9MRC2Ter8jnz8415VbuIykVuc6bhdrbORFZANDmTDvq26mJrEVTl8TdnDg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.55.1.tgz", + "integrity": "sha512-eFZCb1YUqhTysgW3sj/55du5cG57S7UTNtdMjCW7LwVcj3dTTcowCsC8p7uBdzKsZYa8J7IDE8lhMI+HX1vQvg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.55.1.tgz", + "integrity": "sha512-p3grE2PHcQm2e8PSGZdzIhCKbMCw/xi9XvMPErPhwO17vxtvCN5FEA2mSLgmKlCjHGMQTP6phuQTYWUnKewwGg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.55.1.tgz", + "integrity": "sha512-rDUjG25C9qoTm+e02Esi+aqTKSBYwVTaoS1wxcN47/Luqef57Vgp96xNANwt5npq9GDxsH7kXxNkJVEsWEOEaQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.55.1.tgz", + "integrity": "sha512-+JiU7Jbp5cdxekIgdte0jfcu5oqw4GCKr6i3PJTlXTCU5H5Fvtkpbs4XJHRmWNXF+hKmn4v7ogI5OQPaupJgOg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.55.1.tgz", + "integrity": "sha512-V5xC1tOVWtLLmr3YUk2f6EJK4qksksOYiz/TCsFHu/R+woubcLWdC9nZQmwjOAbmExBIVKsm1/wKmEy4z4u4Bw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.55.1.tgz", + "integrity": "sha512-Rn3n+FUk2J5VWx+ywrG/HGPTD9jXNbicRtTM11e/uorplArnXZYsVifnPPqNNP5BsO3roI4n8332ukpY/zN7rQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.55.1.tgz", + "integrity": "sha512-grPNWydeKtc1aEdrJDWk4opD7nFtQbMmV7769hiAaYyUKCT1faPRm2av8CX1YJsZ4TLAZcg9gTR1KvEzoLjXkg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.55.1.tgz", + "integrity": "sha512-a59mwd1k6x8tXKcUxSyISiquLwB5pX+fJW9TkWU46lCqD/GRDe9uDN31jrMmVP3feI3mhAdvcCClhV8V5MhJFQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.55.1.tgz", + "integrity": "sha512-puS1MEgWX5GsHSoiAsF0TYrpomdvkaXm0CofIMG5uVkP6IBV+ZO9xhC5YEN49nsgYo1DuuMquF9+7EDBVYu4uA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.55.1.tgz", + "integrity": "sha512-r3Wv40in+lTsULSb6nnoudVbARdOwb2u5fpeoOAZjFLznp6tDU8kd+GTHmJoqZ9lt6/Sys33KdIHUaQihFcu7g==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.55.1.tgz", + "integrity": "sha512-MR8c0+UxAlB22Fq4R+aQSPBayvYa3+9DrwG/i1TKQXFYEaoW3B5b/rkSRIypcZDdWjWnpcvxbNaAJDcSbJU3Lw==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.55.1.tgz", + "integrity": "sha512-3KhoECe1BRlSYpMTeVrD4sh2Pw2xgt4jzNSZIIPLFEsnQn9gAnZagW9+VqDqAHgm1Xc77LzJOo2LdigS5qZ+gw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.55.1.tgz", + "integrity": "sha512-ziR1OuZx0vdYZZ30vueNZTg73alF59DicYrPViG0NEgDVN8/Jl87zkAPu4u6VjZST2llgEUjaiNl9JM6HH1Vdw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.55.1.tgz", + "integrity": "sha512-uW0Y12ih2XJRERZ4jAfKamTyIHVMPQnTZcQjme2HMVDAHY4amf5u414OqNYC+x+LzRdRcnIG1YodLrrtA8xsxw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.55.1.tgz", + "integrity": "sha512-u9yZ0jUkOED1BFrqu3BwMQoixvGHGZ+JhJNkNKY/hyoEgOwlqKb62qu+7UjbPSHYjiVy8kKJHvXKv5coH4wDeg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.55.1.tgz", + "integrity": "sha512-/0PenBCmqM4ZUd0190j7J0UsQ/1nsi735iPRakO8iPciE7BQ495Y6msPzaOmvx0/pn+eJVVlZrNrSh4WSYLxNg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.55.1.tgz", + "integrity": "sha512-a8G4wiQxQG2BAvo+gU6XrReRRqj+pLS2NGXKm8io19goR+K8lw269eTrPkSdDTALwMmJp4th2Uh0D8J9bEV1vg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.55.1.tgz", + "integrity": "sha512-bD+zjpFrMpP/hqkfEcnjXWHMw5BIghGisOKPj+2NaNDuVT+8Ds4mPf3XcPHuat1tz89WRL+1wbcxKY3WSbiT7w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.55.1.tgz", + "integrity": "sha512-eLXw0dOiqE4QmvikfQ6yjgkg/xDM+MdU9YJuP4ySTibXU0oAvnEWXt7UDJmD4UkYialMfOGFPJnIHSe/kdzPxg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.55.1.tgz", + "integrity": "sha512-xzm44KgEP11te3S2HCSyYf5zIzWmx3n8HDCc7EE59+lTcswEWNpvMLfd9uJvVX8LCg9QWG67Xt75AuHn4vgsXw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.55.1.tgz", + "integrity": "sha512-yR6Bl3tMC/gBok5cz/Qi0xYnVbIxGx5Fcf/ca0eB6/6JwOY+SRUcJfI0OpeTpPls7f194as62thCt/2BjxYN8g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.55.1.tgz", + "integrity": "sha512-3fZBidchE0eY0oFZBnekYCfg+5wAB0mbpCBuofh5mZuzIU/4jIVkbESmd2dOsFNS78b53CYv3OAtwqkZZmU5nA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.55.1.tgz", + "integrity": "sha512-xGGY5pXj69IxKb4yv/POoocPy/qmEGhimy/FoTpTSVju3FYXUQQMFCaZZXJVidsmGxRioZAwpThl/4zX41gRKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.55.1.tgz", + "integrity": "sha512-SPEpaL6DX4rmcXtnhdrQYgzQ5W2uW3SCJch88lB2zImhJRhIIK44fkUrgIV/Q8yUNfw5oyZ5vkeQsZLhCb06lw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, "node_modules/@rtsao/scc": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz", @@ -916,6 +1291,24 @@ "integrity": "sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==", "license": "MIT" }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, "node_modules/@types/codemirror": { "version": "5.60.8", "resolved": "https://registry.npmjs.org/@types/codemirror/-/codemirror-5.60.8.tgz", @@ -926,6 +1319,13 @@ "@types/tern": "*" } }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/eslint": { "version": "8.56.2", "resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-8.56.2.tgz", @@ -982,6 +1382,7 @@ "integrity": "sha512-N2clP5pJhB2YnZJ3PIHFk5RkygRX5WO/5f0WC08tp0wd+sv0rsJk3MqWn3CbNmT2J505a5336jaQj4ph1AdMug==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "undici-types": "~6.21.0" } @@ -1242,8 +1643,119 @@ "integrity": "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==", "license": "ISC" }, - "node_modules/acorn": { - "version": "8.15.0", + "node_modules/@vitest/expect": { + "version": "4.0.17", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.0.17.tgz", + "integrity": "sha512-mEoqP3RqhKlbmUmntNDDCJeTDavDR+fVYkSOw8qRwJFaW/0/5zA9zFeTrHqNtcmwh6j26yMmwx2PqUDPzt5ZAQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.0.17", + "@vitest/utils": "4.0.17", + "chai": "^6.2.1", + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.0.17", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.0.17.tgz", + "integrity": "sha512-+ZtQhLA3lDh1tI2wxe3yMsGzbp7uuJSWBM1iTIKCbppWTSBN09PUC+L+fyNlQApQoR+Ps8twt2pbSSXg2fQVEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.0.17", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0-0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.0.17", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.0.17.tgz", + "integrity": "sha512-Ah3VAYmjcEdHg6+MwFE17qyLqBHZ+ni2ScKCiW2XrlSBV4H3Z7vYfPfz7CWQ33gyu76oc0Ai36+kgLU3rfF4nw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.0.17", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.0.17.tgz", + "integrity": "sha512-JmuQyf8aMWoo/LmNFppdpkfRVHJcsgzkbCA+/Bk7VfNH7RE6Ut2qxegeyx2j3ojtJtKIbIGy3h+KxGfYfk28YQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.0.17", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.0.17", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.0.17.tgz", + "integrity": "sha512-npPelD7oyL+YQM2gbIYvlavlMVWUfNNGZPcu0aEUQXt7FXTuqhmgiYupPnAanhKvyP6Srs2pIbWo30K0RbDtRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.0.17", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.0.17", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.0.17.tgz", + "integrity": "sha512-I1bQo8QaP6tZlTomQNWKJE6ym4SHf3oLS7ceNjozxxgzavRAgZDc06T7kD8gb9bXKEgcLNt00Z+kZO6KaJ62Ew==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.0.17", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.0.17.tgz", + "integrity": "sha512-RG6iy+IzQpa9SB8HAFHJ9Y+pTzI+h8553MrciN9eC6TFBErqrQaTas4vG+MVj8S4uKk8uTT2p0vgZPnTdxd96w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.0.17", + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/acorn": { + "version": "8.15.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, @@ -1466,6 +1978,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/async-function": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/async-function/-/async-function-1.0.0.tgz", @@ -1579,6 +2101,16 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -1990,6 +2522,13 @@ "node": ">= 0.4" } }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true, + "license": "MIT" + }, "node_modules/es-object-atoms": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", @@ -2792,6 +3331,16 @@ "node": ">=4.0" } }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, "node_modules/esutils": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", @@ -2802,6 +3351,16 @@ "node": ">=0.10.0" } }, + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -2925,6 +3484,21 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/function-bind": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", @@ -3922,6 +4496,16 @@ "integrity": "sha512-I+lBvqMMFfqaV8CJCISjI3wbjmwVu/VyOoU7+qtu9d7ioW5klMgsTTiUOUp+DJvfTTzKXoPbyC6YfgkNcyPSOg==", "license": "MIT" }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -4092,6 +4676,25 @@ "dev": true, "license": "MIT" }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, "node_modules/natural-compare": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", @@ -4237,6 +4840,17 @@ "@codemirror/view": "6.38.6" } }, + "node_modules/obug": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", + "integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT" + }, "node_modules/oniguruma-parser": { "version": "0.12.1", "resolved": "https://registry.npmjs.org/oniguruma-parser/-/oniguruma-parser-0.12.1.tgz", @@ -4362,6 +4976,20 @@ "dev": true, "license": "MIT" }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, "node_modules/picomatch": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", @@ -4386,6 +5014,35 @@ "node": ">= 0.4" } }, + "node_modules/postcss": { + "version": "8.5.6", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", + "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, "node_modules/prelude-ls": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", @@ -4597,6 +5254,51 @@ "node": ">=0.12" } }, + "node_modules/rollup": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.55.1.tgz", + "integrity": "sha512-wDv/Ht1BNHB4upNbK74s9usvl7hObDnvVzknxqY/E/O3X6rW1U1rV1aENEfJ54eFZDTNo7zv1f5N4edCluH7+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.55.1", + "@rollup/rollup-android-arm64": "4.55.1", + "@rollup/rollup-darwin-arm64": "4.55.1", + "@rollup/rollup-darwin-x64": "4.55.1", + "@rollup/rollup-freebsd-arm64": "4.55.1", + "@rollup/rollup-freebsd-x64": "4.55.1", + "@rollup/rollup-linux-arm-gnueabihf": "4.55.1", + "@rollup/rollup-linux-arm-musleabihf": "4.55.1", + "@rollup/rollup-linux-arm64-gnu": "4.55.1", + "@rollup/rollup-linux-arm64-musl": "4.55.1", + "@rollup/rollup-linux-loong64-gnu": "4.55.1", + "@rollup/rollup-linux-loong64-musl": "4.55.1", + "@rollup/rollup-linux-ppc64-gnu": "4.55.1", + "@rollup/rollup-linux-ppc64-musl": "4.55.1", + "@rollup/rollup-linux-riscv64-gnu": "4.55.1", + "@rollup/rollup-linux-riscv64-musl": "4.55.1", + "@rollup/rollup-linux-s390x-gnu": "4.55.1", + "@rollup/rollup-linux-x64-gnu": "4.55.1", + "@rollup/rollup-linux-x64-musl": "4.55.1", + "@rollup/rollup-openbsd-x64": "4.55.1", + "@rollup/rollup-openharmony-arm64": "4.55.1", + "@rollup/rollup-win32-arm64-msvc": "4.55.1", + "@rollup/rollup-win32-ia32-msvc": "4.55.1", + "@rollup/rollup-win32-x64-gnu": "4.55.1", + "@rollup/rollup-win32-x64-msvc": "4.55.1", + "fsevents": "~2.3.2" + } + }, "node_modules/safe-array-concat": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.1.3.tgz", @@ -4866,6 +5568,23 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/space-separated-tokens": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/space-separated-tokens/-/space-separated-tokens-2.0.2.tgz", @@ -4876,6 +5595,20 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, "node_modules/stop-iteration-iterator": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz", @@ -5089,6 +5822,23 @@ "url": "https://opencollective.com/webpack" } }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.2.tgz", + "integrity": "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/tinyglobby": { "version": "0.2.15", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", @@ -5106,6 +5856,16 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, + "node_modules/tinyrainbow": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.0.3.tgz", + "integrity": "sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/toml-eslint-parser": { "version": "0.9.3", "resolved": "https://registry.npmjs.org/toml-eslint-parser/-/toml-eslint-parser-0.9.3.tgz", @@ -5441,44 +6201,664 @@ "url": "https://opencollective.com/unified" } }, - "node_modules/w3c-keyname": { - "version": "2.2.8", - "resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz", - "integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/which": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", - "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "node_modules/vite": { + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz", + "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", "dev": true, - "license": "ISC", + "license": "MIT", "dependencies": { - "isexe": "^2.0.0" + "esbuild": "^0.27.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" }, "bin": { - "node-which": "bin/node-which" + "vite": "bin/vite.js" }, "engines": { - "node": ">= 8" + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "lightningcss": "^1.21.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } } }, - "node_modules/which-boxed-primitive": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/which-boxed-primitive/-/which-boxed-primitive-1.1.1.tgz", - "integrity": "sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==", + "node_modules/vite/node_modules/@esbuild/aix-ppc64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.2.tgz", + "integrity": "sha512-GZMB+a0mOMZs4MpDbj8RJp4cw+w1WV5NYD6xzgvzUJ5Ek2jerwfO2eADyI6ExDSUED+1X8aMbegahsJi+8mgpw==", + "cpu": [ + "ppc64" + ], "dev": true, "license": "MIT", - "dependencies": { - "is-bigint": "^1.1.0", - "is-boolean-object": "^1.2.1", - "is-number-object": "^1.1.1", - "is-string": "^1.1.1", - "is-symbol": "^1.1.1" - }, + "optional": true, + "os": [ + "aix" + ], "engines": { - "node": ">= 0.4" + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/android-arm": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.2.tgz", + "integrity": "sha512-DVNI8jlPa7Ujbr1yjU2PfUSRtAUZPG9I1RwW4F4xFB1Imiu2on0ADiI/c3td+KmDtVKNbi+nffGDQMfcIMkwIA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/android-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.2.tgz", + "integrity": "sha512-pvz8ZZ7ot/RBphf8fv60ljmaoydPU12VuXHImtAs0XhLLw+EXBi2BLe3OYSBslR4rryHvweW5gmkKFwTiFy6KA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/android-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.2.tgz", + "integrity": "sha512-z8Ank4Byh4TJJOh4wpz8g2vDy75zFL0TlZlkUkEwYXuPSgX8yzep596n6mT7905kA9uHZsf/o2OJZubl2l3M7A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/darwin-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.2.tgz", + "integrity": "sha512-davCD2Zc80nzDVRwXTcQP/28fiJbcOwvdolL0sOiOsbwBa72kegmVU0Wrh1MYrbuCL98Omp5dVhQFWRKR2ZAlg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/darwin-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.2.tgz", + "integrity": "sha512-ZxtijOmlQCBWGwbVmwOF/UCzuGIbUkqB1faQRf5akQmxRJ1ujusWsb3CVfk/9iZKr2L5SMU5wPBi1UWbvL+VQA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.2.tgz", + "integrity": "sha512-lS/9CN+rgqQ9czogxlMcBMGd+l8Q3Nj1MFQwBZJyoEKI50XGxwuzznYdwcav6lpOGv5BqaZXqvBSiB/kJ5op+g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/freebsd-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.2.tgz", + "integrity": "sha512-tAfqtNYb4YgPnJlEFu4c212HYjQWSO/w/h/lQaBK7RbwGIkBOuNKQI9tqWzx7Wtp7bTPaGC6MJvWI608P3wXYA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-arm": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.2.tgz", + "integrity": "sha512-vWfq4GaIMP9AIe4yj1ZUW18RDhx6EPQKjwe7n8BbIecFtCQG4CfHGaHuh7fdfq+y3LIA2vGS/o9ZBGVxIDi9hw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.2.tgz", + "integrity": "sha512-hYxN8pr66NsCCiRFkHUAsxylNOcAQaxSSkHMMjcpx0si13t1LHFphxJZUiGwojB1a/Hd5OiPIqDdXONia6bhTw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-ia32": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.2.tgz", + "integrity": "sha512-MJt5BRRSScPDwG2hLelYhAAKh9imjHK5+NE/tvnRLbIqUWa+0E9N4WNMjmp/kXXPHZGqPLxggwVhz7QP8CTR8w==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-loong64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.2.tgz", + "integrity": "sha512-lugyF1atnAT463aO6KPshVCJK5NgRnU4yb3FUumyVz+cGvZbontBgzeGFO1nF+dPueHD367a2ZXe1NtUkAjOtg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-mips64el": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.2.tgz", + "integrity": "sha512-nlP2I6ArEBewvJ2gjrrkESEZkB5mIoaTswuqNFRv/WYd+ATtUpe9Y09RnJvgvdag7he0OWgEZWhviS1OTOKixw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-ppc64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.2.tgz", + "integrity": "sha512-C92gnpey7tUQONqg1n6dKVbx3vphKtTHJaNG2Ok9lGwbZil6DrfyecMsp9CrmXGQJmZ7iiVXvvZH6Ml5hL6XdQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-riscv64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.2.tgz", + "integrity": "sha512-B5BOmojNtUyN8AXlK0QJyvjEZkWwy/FKvakkTDCziX95AowLZKR6aCDhG7LeF7uMCXEJqwa8Bejz5LTPYm8AvA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-s390x": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.2.tgz", + "integrity": "sha512-p4bm9+wsPwup5Z8f4EpfN63qNagQ47Ua2znaqGH6bqLlmJ4bx97Y9JdqxgGZ6Y8xVTixUnEkoKSHcpRlDnNr5w==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.2.tgz", + "integrity": "sha512-uwp2Tip5aPmH+NRUwTcfLb+W32WXjpFejTIOWZFw/v7/KnpCDKG66u4DLcurQpiYTiYwQ9B7KOeMJvLCu/OvbA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.2.tgz", + "integrity": "sha512-Kj6DiBlwXrPsCRDeRvGAUb/LNrBASrfqAIok+xB0LxK8CHqxZ037viF13ugfsIpePH93mX7xfJp97cyDuTZ3cw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/netbsd-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.2.tgz", + "integrity": "sha512-HwGDZ0VLVBY3Y+Nw0JexZy9o/nUAWq9MlV7cahpaXKW6TOzfVno3y3/M8Ga8u8Yr7GldLOov27xiCnqRZf0tCA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.2.tgz", + "integrity": "sha512-DNIHH2BPQ5551A7oSHD0CKbwIA/Ox7+78/AWkbS5QoRzaqlev2uFayfSxq68EkonB+IKjiuxBFoV8ESJy8bOHA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/openbsd-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.2.tgz", + "integrity": "sha512-/it7w9Nb7+0KFIzjalNJVR5bOzA9Vay+yIPLVHfIQYG/j+j9VTH84aNB8ExGKPU4AzfaEvN9/V4HV+F+vo8OEg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/sunos-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.2.tgz", + "integrity": "sha512-kMtx1yqJHTmqaqHPAzKCAkDaKsffmXkPHThSfRwZGyuqyIeBvf08KSsYXl+abf5HDAPMJIPnbBfXvP2ZC2TfHg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/win32-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.2.tgz", + "integrity": "sha512-Yaf78O/B3Kkh+nKABUF++bvJv5Ijoy9AN1ww904rOXZFLWVc5OLOfL56W+C8F9xn5JQZa3UX6m+IktJnIb1Jjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/win32-ia32": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.2.tgz", + "integrity": "sha512-Iuws0kxo4yusk7sw70Xa2E2imZU5HoixzxfGCdxwBdhiDgt9vX9VUCBhqcwY7/uh//78A1hMkkROMJq9l27oLQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/win32-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.2.tgz", + "integrity": "sha512-sRdU18mcKf7F+YgheI/zGf5alZatMUTKj/jNS6l744f9u3WFu4v7twcUI9vu4mknF4Y9aDlblIie0IM+5xxaqQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/esbuild": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.2.tgz", + "integrity": "sha512-HyNQImnsOC7X9PMNaCIeAm4ISCQXs5a5YasTXVliKv4uuBo1dKrG0A+uQS8M5eXjVMnLg3WgXaKvprHlFJQffw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.2", + "@esbuild/android-arm": "0.27.2", + "@esbuild/android-arm64": "0.27.2", + "@esbuild/android-x64": "0.27.2", + "@esbuild/darwin-arm64": "0.27.2", + "@esbuild/darwin-x64": "0.27.2", + "@esbuild/freebsd-arm64": "0.27.2", + "@esbuild/freebsd-x64": "0.27.2", + "@esbuild/linux-arm": "0.27.2", + "@esbuild/linux-arm64": "0.27.2", + "@esbuild/linux-ia32": "0.27.2", + "@esbuild/linux-loong64": "0.27.2", + "@esbuild/linux-mips64el": "0.27.2", + "@esbuild/linux-ppc64": "0.27.2", + "@esbuild/linux-riscv64": "0.27.2", + "@esbuild/linux-s390x": "0.27.2", + "@esbuild/linux-x64": "0.27.2", + "@esbuild/netbsd-arm64": "0.27.2", + "@esbuild/netbsd-x64": "0.27.2", + "@esbuild/openbsd-arm64": "0.27.2", + "@esbuild/openbsd-x64": "0.27.2", + "@esbuild/openharmony-arm64": "0.27.2", + "@esbuild/sunos-x64": "0.27.2", + "@esbuild/win32-arm64": "0.27.2", + "@esbuild/win32-ia32": "0.27.2", + "@esbuild/win32-x64": "0.27.2" + } + }, + "node_modules/vitest": { + "version": "4.0.17", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.0.17.tgz", + "integrity": "sha512-FQMeF0DJdWY0iOnbv466n/0BudNdKj1l5jYgl5JVTwjSsZSlqyXFt/9+1sEyhR6CLowbZpV7O1sCHrzBhucKKg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.0.17", + "@vitest/mocker": "4.0.17", + "@vitest/pretty-format": "4.0.17", + "@vitest/runner": "4.0.17", + "@vitest/snapshot": "4.0.17", + "@vitest/spy": "4.0.17", + "@vitest/utils": "4.0.17", + "es-module-lexer": "^1.7.0", + "expect-type": "^1.2.2", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^3.10.0", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.0.3", + "vite": "^6.0.0 || ^7.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.0.17", + "@vitest/browser-preview": "4.0.17", + "@vitest/browser-webdriverio": "4.0.17", + "@vitest/ui": "4.0.17", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "node_modules/w3c-keyname": { + "version": "2.2.8", + "resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz", + "integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/which-boxed-primitive": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/which-boxed-primitive/-/which-boxed-primitive-1.1.1.tgz", + "integrity": "sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-bigint": "^1.1.0", + "is-boolean-object": "^1.2.1", + "is-number-object": "^1.1.1", + "is-string": "^1.1.1", + "is-symbol": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" }, "funding": { "url": "https://github.com/sponsors/ljharb" @@ -5553,6 +6933,23 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/word-wrap": { "version": "1.2.5", "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", diff --git a/src/base-generator.ts b/src/base-generator.ts new file mode 100644 index 0000000..08558a8 --- /dev/null +++ b/src/base-generator.ts @@ -0,0 +1,319 @@ +import { spawn } from "child_process"; +import { parseYaml } from "obsidian"; +import { getSkillManager } from "./skills"; +import { VaultContext, formatVaultContext } from "./vault-context"; + +export interface BaseGeneratorOptions { + opencodePath: string; + model: string; + vaultContext?: VaultContext; +} + +interface OpenCodeJsonEvent { + type: string; + part?: { + type: string; + text?: string; + }; +} + +const DEFAULT_OPENCODE_PATH = "/Users/dps/.opencode/bin/opencode"; + +function resolveOpenCodePath(path: string): string { + if (path && path !== "opencode") return path; + return DEFAULT_OPENCODE_PATH; +} + +function extractTextFromJsonOutput(output: string): string { + const lines = output.trim().split("\n"); + const textParts: string[] = []; + + for (const line of lines) { + try { + const event = JSON.parse(line) as OpenCodeJsonEvent; + if (event.type === "text" && event.part?.text) { + textParts.push(event.part.text); + } + } catch { + continue; + } + } + + return textParts.join(""); +} + +function runOpenCode(opencodePath: string, model: string, prompt: string): Promise { + return new Promise((resolve, reject) => { + const args = ["run", "--format", "json", "-m", model, "--", prompt]; + const resolvedPath = resolveOpenCodePath(opencodePath); + console.debug("[Apply OpenCode] Running OpenCode for Base generation, prompt length:", prompt.length); + const proc = spawn(resolvedPath, args, { + stdio: ["pipe", "pipe", "pipe"], + env: { ...process.env, PATH: `${process.env.PATH}:/usr/local/bin:/opt/homebrew/bin:${process.env.HOME}/.opencode/bin` }, + }); + + proc.stdin.end(); + + let stdout = ""; + let stderr = ""; + + proc.stdout.on("data", (data: Buffer) => { + stdout += data.toString(); + }); + + proc.stderr.on("data", (data: Buffer) => { + stderr += data.toString(); + }); + + proc.on("close", (code) => { + if (code === 0) { + const text = extractTextFromJsonOutput(stdout); + resolve(text); + } else { + console.error("[Apply OpenCode] stderr:", stderr); + reject(new Error(`OpenCode exited with code ${code}: ${stderr}`)); + } + }); + + proc.on("error", (err) => { + reject(new Error(`Failed to spawn OpenCode: ${err.message}`)); + }); + }); +} + +function cleanYamlResponse(response: string): string { + let yaml = response.trim(); + + // Remove markdown fences + if (yaml.startsWith("```yaml")) { + yaml = yaml.slice(7); + } else if (yaml.startsWith("```")) { + yaml = yaml.slice(3); + } + if (yaml.endsWith("```")) { + yaml = yaml.slice(0, -3); + } + + return yaml.trim(); +} + +interface BaseView { + type?: string; + name?: string; + [key: string]: unknown; +} + +interface FilterObject { + and?: unknown[]; + or?: unknown[]; + not?: unknown[]; + [key: string]: unknown; +} + +interface ParsedBase { + views?: BaseView[]; + filters?: FilterObject; + formulas?: Record; + properties?: Record; + [key: string]: unknown; +} + +const VALID_FILTER_KEYS = ["and", "or", "not"]; + +/** + * Validate that a filters object uses only and/or/not keys + */ +function validateFilters(filters: unknown, context: string): { valid: boolean; error?: string } { + if (!filters || typeof filters !== "object") { + return { valid: true }; // No filters is fine + } + + const filterObj = filters as FilterObject; + const keys = Object.keys(filterObj); + + // Filters must have exactly one of: and, or, not + const validKeys = keys.filter(k => VALID_FILTER_KEYS.includes(k)); + const invalidKeys = keys.filter(k => !VALID_FILTER_KEYS.includes(k)); + + if (invalidKeys.length > 0) { + return { + valid: false, + error: `${context}: filters may only have "and", "or", or "not" keys. Found invalid keys: ${invalidKeys.join(", ")}` + }; + } + + if (validKeys.length === 0) { + return { + valid: false, + error: `${context}: filters must have one of "and", "or", or "not" keys` + }; + } + + if (validKeys.length > 1) { + return { + valid: false, + error: `${context}: filters may only have ONE of "and", "or", or "not" at the top level. Found: ${validKeys.join(", ")}` + }; + } + + return { valid: true }; +} + +/** + * Validate that the generated content is valid Base YAML + */ +export function validateBase(content: string): { valid: boolean; error?: string } { + try { + const parsed = parseYaml(content) as ParsedBase | null; + + if (!parsed || typeof parsed !== "object") { + return { valid: false, error: "Invalid YAML structure" }; + } + + // Check for required 'views' array + if (!parsed.views || !Array.isArray(parsed.views)) { + return { valid: false, error: "Base must have a 'views' array" }; + } + + // Validate top-level filters if present + if (parsed.filters) { + const filterValidation = validateFilters(parsed.filters, "Top-level filters"); + if (!filterValidation.valid) { + return filterValidation; + } + } + + // Check each view has required fields + for (let i = 0; i < parsed.views.length; i++) { + const view = parsed.views[i]; + if (!view.type) { + return { valid: false, error: `View ${i + 1} is missing 'type' field` }; + } + if (!["table", "cards", "list", "map"].includes(view.type)) { + return { valid: false, error: `View ${i + 1} has invalid type: ${view.type}` }; + } + + // Validate view-level filters if present + if (view.filters) { + const viewFilterValidation = validateFilters(view.filters, `View ${i + 1} (${view.name || view.type}) filters`); + if (!viewFilterValidation.valid) { + return viewFilterValidation; + } + } + } + + return { valid: true }; + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + return { valid: false, error: `YAML parse error: ${message}` }; + } +} + +/** + * Generate a new Obsidian Base from natural language description + */ +export async function generateBase( + description: string, + options: BaseGeneratorOptions +): Promise { + const skillManager = getSkillManager(); + const skillContent = skillManager.getSkill("obsidian-bases"); + const vaultContextSection = options.vaultContext ? formatVaultContext(options.vaultContext) : ""; + + const prompt = `${skillContent} + +--- +${vaultContextSection} +--- + +Generate an Obsidian Base file (.base) based on this description: + +${description} + +CRITICAL FILTER SYNTAX RULES: +- Filters MUST use "and:", "or:", or "not:" as the ONLY top-level key +- WRONG: \`filters: file.hasTag("task")\` +- CORRECT: \`filters:\\n and:\\n - file.hasTag("task")\` +- Each condition must be a list item under and/or/not + +Example filter structure: +\`\`\`yaml +filters: + and: + - file.hasTag("task") + - 'file.folder != "Archive"' +\`\`\` + +OUTPUT RULES: +1. Output ONLY valid YAML for the .base file +2. Do NOT include markdown fences +3. Do NOT include any explanation or commentary +4. Include at least one view in the views array +5. When referencing folders, tags, or properties, use EXACT names from the vault context above + +Output the .base YAML content:`; + + const response = await runOpenCode(options.opencodePath, options.model, prompt); + return cleanYamlResponse(response); +} + +/** + * Edit an existing Obsidian Base based on instruction + */ +export async function editBase( + currentContent: string, + instruction: string, + options: BaseGeneratorOptions +): Promise { + const skillManager = getSkillManager(); + const skillContent = skillManager.getSkill("obsidian-bases"); + const vaultContextSection = options.vaultContext ? formatVaultContext(options.vaultContext) : ""; + + const prompt = `${skillContent} + +--- +${vaultContextSection} +--- + +Current .base file content: +${currentContent} + +--- + +Edit instruction: ${instruction} + +CRITICAL FILTER SYNTAX RULES: +- Filters MUST use "and:", "or:", or "not:" as the ONLY top-level key +- WRONG: \`filters: file.hasTag("task")\` +- CORRECT: \`filters:\\n and:\\n - file.hasTag("task")\` + +OUTPUT RULES: +1. Output the complete modified .base file content +2. Output ONLY valid YAML - no markdown fences, no explanation +3. Preserve existing structure unless the instruction asks to change it +4. When referencing folders, tags, or properties, use EXACT names from the vault context above + +Output the modified .base YAML content:`; + + const response = await runOpenCode(options.opencodePath, options.model, prompt); + return cleanYamlResponse(response); +} + +/** + * Generate a suggested filename from the description + */ +export function suggestBaseFilename(description: string): string { + // Extract first few meaningful words + const words = description + .toLowerCase() + .replace(/[^a-z0-9\s]/g, "") + .split(/\s+/) + .filter(w => w.length > 2) + .slice(0, 4); + + if (words.length === 0) { + return "new-base"; + } + + return words.join("-"); +} diff --git a/src/canvas-generator.ts b/src/canvas-generator.ts new file mode 100644 index 0000000..11eccf5 --- /dev/null +++ b/src/canvas-generator.ts @@ -0,0 +1,388 @@ +import { spawn } from "child_process"; +import { getSkillManager } from "./skills"; +import { VaultContext, formatVaultContext } from "./vault-context"; + +export interface CanvasGeneratorOptions { + opencodePath: string; + model: string; + vaultContext?: VaultContext; +} + +interface OpenCodeJsonEvent { + type: string; + part?: { + type: string; + text?: string; + }; +} + +interface CanvasNode { + id: string; + type: "text" | "file" | "link" | "group"; + x: number; + y: number; + width: number; + height: number; + text?: string; + file?: string; + url?: string; + label?: string; + color?: string; + subpath?: string; + background?: string; + backgroundStyle?: string; +} + +interface CanvasEdge { + id: string; + fromNode: string; + toNode: string; + fromSide?: "top" | "right" | "bottom" | "left"; + toSide?: "top" | "right" | "bottom" | "left"; + fromEnd?: "none" | "arrow"; + toEnd?: "none" | "arrow"; + color?: string; + label?: string; +} + +interface CanvasData { + nodes?: CanvasNode[]; + edges?: CanvasEdge[]; +} + +const DEFAULT_OPENCODE_PATH = "/Users/dps/.opencode/bin/opencode"; + +function resolveOpenCodePath(path: string): string { + if (path && path !== "opencode") return path; + return DEFAULT_OPENCODE_PATH; +} + +function extractTextFromJsonOutput(output: string): string { + const lines = output.trim().split("\n"); + const textParts: string[] = []; + + for (const line of lines) { + try { + const event = JSON.parse(line) as OpenCodeJsonEvent; + if (event.type === "text" && event.part?.text) { + textParts.push(event.part.text); + } + } catch { + continue; + } + } + + return textParts.join(""); +} + +function runOpenCode(opencodePath: string, model: string, prompt: string): Promise { + return new Promise((resolve, reject) => { + const args = ["run", "--format", "json", "-m", model, "--", prompt]; + const resolvedPath = resolveOpenCodePath(opencodePath); + console.debug("[Apply OpenCode] Running OpenCode for Canvas generation, prompt length:", prompt.length); + const proc = spawn(resolvedPath, args, { + stdio: ["pipe", "pipe", "pipe"], + env: { ...process.env, PATH: `${process.env.PATH}:/usr/local/bin:/opt/homebrew/bin:${process.env.HOME}/.opencode/bin` }, + }); + + proc.stdin.end(); + + let stdout = ""; + let stderr = ""; + + proc.stdout.on("data", (data: Buffer) => { + stdout += data.toString(); + }); + + proc.stderr.on("data", (data: Buffer) => { + stderr += data.toString(); + }); + + proc.on("close", (code) => { + if (code === 0) { + const text = extractTextFromJsonOutput(stdout); + resolve(text); + } else { + console.error("[Apply OpenCode] stderr:", stderr); + reject(new Error(`OpenCode exited with code ${code}: ${stderr}`)); + } + }); + + proc.on("error", (err) => { + reject(new Error(`Failed to spawn OpenCode: ${err.message}`)); + }); + }); +} + +function cleanJsonResponse(response: string): string { + let json = response.trim(); + + // Remove markdown fences + if (json.startsWith("```json")) { + json = json.slice(7); + } else if (json.startsWith("```")) { + json = json.slice(3); + } + if (json.endsWith("```")) { + json = json.slice(0, -3); + } + + // Find the JSON object boundaries + const startBrace = json.indexOf("{"); + const endBrace = json.lastIndexOf("}"); + + if (startBrace !== -1 && endBrace !== -1 && endBrace > startBrace) { + json = json.slice(startBrace, endBrace + 1); + } + + return json.trim(); +} + +/** + * Validate that the generated content is valid Canvas JSON + */ +export function validateCanvas(content: string): { valid: boolean; error?: string } { + try { + const parsed = JSON.parse(content) as CanvasData; + + if (!parsed || typeof parsed !== "object") { + return { valid: false, error: "Invalid JSON structure" }; + } + + // Canvas must have nodes or edges arrays (both optional but at least one should exist) + if (!parsed.nodes && !parsed.edges) { + return { valid: false, error: "Canvas must have 'nodes' or 'edges' array" }; + } + + // Validate nodes if present + if (parsed.nodes) { + if (!Array.isArray(parsed.nodes)) { + return { valid: false, error: "'nodes' must be an array" }; + } + + const nodeIds = new Set(); + for (let i = 0; i < parsed.nodes.length; i++) { + const node = parsed.nodes[i]; + + // Check required fields + if (!node.id) { + return { valid: false, error: `Node ${i + 1} is missing 'id'` }; + } + if (nodeIds.has(node.id)) { + return { valid: false, error: `Duplicate node id: ${node.id}` }; + } + nodeIds.add(node.id); + + if (!node.type) { + return { valid: false, error: `Node ${i + 1} is missing 'type'` }; + } + if (!["text", "file", "link", "group"].includes(node.type)) { + return { valid: false, error: `Node ${i + 1} has invalid type: ${node.type}` }; + } + + if (typeof node.x !== "number" || typeof node.y !== "number") { + return { valid: false, error: `Node ${i + 1} has invalid x/y coordinates` }; + } + if (typeof node.width !== "number" || typeof node.height !== "number") { + return { valid: false, error: `Node ${i + 1} has invalid width/height` }; + } + + // Type-specific validation + if (node.type === "text" && typeof node.text !== "string") { + return { valid: false, error: `Text node ${i + 1} is missing 'text' field` }; + } + if (node.type === "file" && typeof node.file !== "string") { + return { valid: false, error: `File node ${i + 1} is missing 'file' field` }; + } + if (node.type === "link" && typeof node.url !== "string") { + return { valid: false, error: `Link node ${i + 1} is missing 'url' field` }; + } + } + + // Validate edges if present + if (parsed.edges) { + if (!Array.isArray(parsed.edges)) { + return { valid: false, error: "'edges' must be an array" }; + } + + const edgeIds = new Set(); + for (let i = 0; i < parsed.edges.length; i++) { + const edge = parsed.edges[i]; + + if (!edge.id) { + return { valid: false, error: `Edge ${i + 1} is missing 'id'` }; + } + if (edgeIds.has(edge.id)) { + return { valid: false, error: `Duplicate edge id: ${edge.id}` }; + } + edgeIds.add(edge.id); + + if (!edge.fromNode || !edge.toNode) { + return { valid: false, error: `Edge ${i + 1} is missing 'fromNode' or 'toNode'` }; + } + + // Check that referenced nodes exist + if (!nodeIds.has(edge.fromNode)) { + return { valid: false, error: `Edge ${i + 1} references non-existent node: ${edge.fromNode}` }; + } + if (!nodeIds.has(edge.toNode)) { + return { valid: false, error: `Edge ${i + 1} references non-existent node: ${edge.toNode}` }; + } + + // Validate side values if present + const validSides = ["top", "right", "bottom", "left"]; + if (edge.fromSide && !validSides.includes(edge.fromSide)) { + return { valid: false, error: `Edge ${i + 1} has invalid fromSide: ${edge.fromSide}` }; + } + if (edge.toSide && !validSides.includes(edge.toSide)) { + return { valid: false, error: `Edge ${i + 1} has invalid toSide: ${edge.toSide}` }; + } + + // Validate end shapes if present + const validEnds = ["none", "arrow"]; + if (edge.fromEnd && !validEnds.includes(edge.fromEnd)) { + return { valid: false, error: `Edge ${i + 1} has invalid fromEnd: ${edge.fromEnd}` }; + } + if (edge.toEnd && !validEnds.includes(edge.toEnd)) { + return { valid: false, error: `Edge ${i + 1} has invalid toEnd: ${edge.toEnd}` }; + } + } + } + } + + return { valid: true }; + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + return { valid: false, error: `JSON parse error: ${message}` }; + } +} + +/** + * Generate a new JSON Canvas from natural language description + */ +export async function generateCanvas( + description: string, + options: CanvasGeneratorOptions +): Promise { + const skillManager = getSkillManager(); + const skillContent = skillManager.getSkill("json-canvas"); + const vaultContextSection = options.vaultContext ? formatVaultContext(options.vaultContext) : ""; + + const prompt = `${skillContent} + +--- +${vaultContextSection} +--- + +Generate a JSON Canvas file (.canvas) based on this description: + +${description} + +CRITICAL LAYOUT RULES (prevent overlapping): +- MINIMUM 100px vertical gap between nodes (node1.y + node1.height + 100 <= node2.y) +- MINIMUM 100px horizontal gap between adjacent columns +- Groups must be sized to fit ALL children with 50px padding on each side +- Calculate group height as: sum of children heights + (100px gap × (children-1)) + 100px top/bottom padding +- Place group label nodes INSIDE group boundaries +- When nodes are in columns inside groups, offset each column by (column_width + 100px) + +SIZING GUIDELINES: +- Text nodes with bullet lists: height = 60 + (line_count × 24) +- Section headers: 60-80px height +- Cards with 4-6 bullets: 200-250px height +- Groups: calculate based on contents, never hardcode + +OUTPUT RULES: +1. Output ONLY valid JSON for the .canvas file +2. Do NOT include markdown fences (\`\`\`json or \`\`\`) +3. Do NOT include any explanation or commentary +4. The output must be directly usable as a .canvas file +5. Use 16-character hexadecimal IDs for nodes and edges +6. When referencing files or folders, use EXACT paths from the vault context above + +Output the .canvas JSON content:`; + + const response = await runOpenCode(options.opencodePath, options.model, prompt); + const cleaned = cleanJsonResponse(response); + + // Pretty-print the JSON for readability + try { + const parsed = JSON.parse(cleaned) as CanvasData; + return JSON.stringify(parsed, null, 2); + } catch { + return cleaned; + } +} + +/** + * Edit an existing JSON Canvas based on instruction + */ +export async function editCanvas( + currentContent: string, + instruction: string, + options: CanvasGeneratorOptions +): Promise { + const skillManager = getSkillManager(); + const skillContent = skillManager.getSkill("json-canvas"); + const vaultContextSection = options.vaultContext ? formatVaultContext(options.vaultContext) : ""; + + const prompt = `${skillContent} + +--- +${vaultContextSection} +--- + +Current .canvas file content: +${currentContent} + +--- + +Edit instruction: ${instruction} + +CRITICAL LAYOUT RULES (prevent overlapping): +- MINIMUM 100px vertical gap between nodes +- MINIMUM 100px horizontal gap between adjacent columns +- Groups must be sized to fit ALL children with 50px padding on each side +- If adding nodes, ensure they don't overlap with existing nodes +- Recalculate group sizes if adding/removing children + +OUTPUT RULES: +1. Output the complete modified .canvas file content +2. Output ONLY valid JSON - no markdown fences, no explanation +3. Preserve existing node/edge IDs unless removing them +4. Generate new 16-character hex IDs for any new nodes/edges +5. The output must be directly usable as a .canvas file +6. When referencing files or folders, use EXACT paths from the vault context above + +Output the modified .canvas JSON content:`; + + const response = await runOpenCode(options.opencodePath, options.model, prompt); + const cleaned = cleanJsonResponse(response); + + // Pretty-print the JSON for readability + try { + const parsed = JSON.parse(cleaned) as CanvasData; + return JSON.stringify(parsed, null, 2); + } catch { + return cleaned; + } +} + +/** + * Generate a suggested filename from the description + */ +export function suggestCanvasFilename(description: string): string { + // Extract first few meaningful words + const words = description + .toLowerCase() + .replace(/[^a-z0-9\s]/g, "") + .split(/\s+/) + .filter(w => w.length > 2) + .slice(0, 4); + + if (words.length === 0) { + return "new-canvas"; + } + + return words.join("-"); +} diff --git a/src/content-diff-modal.ts b/src/content-diff-modal.ts index 5fc07ef..e720f28 100644 --- a/src/content-diff-modal.ts +++ b/src/content-diff-modal.ts @@ -57,7 +57,7 @@ export class ContentDiffModal extends Modal { const reviseBtn = revisionContainer.createEl("button", { text: "Revise", - cls: "mod-primary revision-btn", + cls: "revision-btn", }); reviseBtn.addEventListener("click", () => void this.handleRevision()); } @@ -66,7 +66,7 @@ export class ContentDiffModal extends Modal { const cancelBtn = buttonContainer.createEl("button", { text: "Cancel", - cls: "mod-warning", + cls: "aoc-btn aoc-btn-secondary", }); cancelBtn.addEventListener("click", () => { this.resolve({ applied: false, modifiedContent: this.modifiedContent }); @@ -75,7 +75,7 @@ export class ContentDiffModal extends Modal { const applyBtn = buttonContainer.createEl("button", { text: "Apply changes", - cls: "mod-cta", + cls: "aoc-btn aoc-btn-primary", }); applyBtn.addEventListener("click", () => { this.resolve({ applied: true, modifiedContent: this.modifiedContent }); diff --git a/src/diff-modal.ts b/src/diff-modal.ts index 6ea7b81..32c25aa 100644 --- a/src/diff-modal.ts +++ b/src/diff-modal.ts @@ -58,7 +58,7 @@ export class DiffModal extends Modal { const reviseBtn = revisionContainer.createEl("button", { text: "Revise", - cls: "mod-primary revision-btn", + cls: "revision-btn", }); reviseBtn.addEventListener("click", () => void this.handleRevision()); } @@ -67,7 +67,7 @@ export class DiffModal extends Modal { const cancelBtn = buttonContainer.createEl("button", { text: "Cancel", - cls: "mod-warning", + cls: "aoc-btn aoc-btn-secondary", }); cancelBtn.addEventListener("click", () => { this.resolve({ applied: false, modifiedYaml: this.modifiedAfterYaml }); @@ -76,7 +76,7 @@ export class DiffModal extends Modal { const applyBtn = buttonContainer.createEl("button", { text: "Apply changes", - cls: "mod-cta", + cls: "aoc-btn aoc-btn-primary", }); applyBtn.addEventListener("click", () => { this.resolve({ applied: true, modifiedYaml: this.modifiedAfterYaml }); diff --git a/src/file-create-modal.ts b/src/file-create-modal.ts new file mode 100644 index 0000000..04788b0 --- /dev/null +++ b/src/file-create-modal.ts @@ -0,0 +1,277 @@ +import { App, Modal, TFolder, FuzzySuggestModal, setIcon } from "obsidian"; + +export interface FileCreateResult { + description: string; + filename: string; + folder: string; + cancelled: boolean; +} + +type FileType = "base" | "canvas"; + +class FolderSuggestModal extends FuzzySuggestModal { + private folders: TFolder[]; + private onChoose: (folder: TFolder) => void; + + constructor(app: App, folders: TFolder[], onChoose: (folder: TFolder) => void) { + super(app); + this.folders = folders; + this.onChoose = onChoose; + this.setPlaceholder("Select folder..."); + } + + getItems(): TFolder[] { + return this.folders; + } + + getItemText(folder: TFolder): string { + return folder.path || "/"; + } + + onChooseItem(folder: TFolder): void { + this.onChoose(folder); + } +} + +export class FileCreateModal extends Modal { + private description = ""; + private filename = ""; + private folder = ""; + private fileType: FileType; + private suggestFilename: (description: string) => string; + private resolve: (result: FileCreateResult) => void; + private resolved = false; + private filenameInput: HTMLInputElement | null = null; + private folderPathText: HTMLElement | null = null; + + constructor( + app: App, + fileType: FileType, + suggestFilename: (description: string) => string, + resolve: (result: FileCreateResult) => void, + defaultFolder?: string + ) { + super(app); + this.fileType = fileType; + this.suggestFilename = suggestFilename; + this.resolve = resolve; + this.folder = defaultFolder || ""; + } + + onOpen() { + const { contentEl, modalEl } = this; + contentEl.empty(); + + modalEl.addClass("apply-opencode-file-create"); + modalEl.dataset.type = this.fileType; + + const typeLabel = this.fileType === "base" ? "Base File" : "Canvas"; + const extension = this.fileType === "base" ? ".base" : ".canvas"; + const typeDesc = this.fileType === "base" + ? "Create a structured database for tracking data." + : "Create an infinite canvas for visual thinking."; + const iconName = this.fileType === "base" ? "table" : "layout-dashboard"; + + // --- Header --- + const header = contentEl.createDiv({ cls: "file-create-header" }); + + const iconContainer = header.createDiv({ cls: "file-create-icon" }); + setIcon(iconContainer, iconName); + + const titleContainer = header.createDiv({ cls: "file-create-title" }); + titleContainer.createEl("h2", { text: `New ${typeLabel}` }); + titleContainer.createEl("p", { text: typeDesc }); + + // --- Body --- + const body = contentEl.createDiv({ cls: "file-create-body" }); + + // Description Section + const descSection = body.createDiv({ cls: "file-create-section" }); + descSection.createDiv({ cls: "file-create-label", text: "Description" }); + + const descInput = descSection.createEl("textarea", { + cls: "file-create-description", + attr: { + placeholder: `Describe what you want to create...`, + rows: "4" + } + }); + + // Filename & Folder Section + const metaSection = body.createDiv({ cls: "file-create-section" }); + metaSection.createDiv({ cls: "file-create-label", text: "Location & Name" }); + + const metaRow = metaSection.createDiv({ cls: "file-create-filename-group" }); + + // Folder Picker + const folderPicker = metaRow.createDiv({ cls: "file-create-folder-picker" }); + folderPicker.title = "Change folder"; + + const folderIcon = folderPicker.createDiv({ cls: "file-create-folder-icon" }); + setIcon(folderIcon, "folder"); + + this.folderPathText = folderPicker.createDiv({ cls: "file-create-folder-path" }); + this.updateFolderDisplay(); + + const folderArrow = folderPicker.createDiv({ cls: "file-create-folder-arrow" }); + setIcon(folderArrow, "chevron-down"); + + folderPicker.addEventListener("click", () => this.openFolderPicker()); + + // Filename Input + const filenameContainer = metaRow.createDiv({ cls: "file-create-filename-input-container" }); + + this.filenameInput = filenameContainer.createEl("input", { + type: "text", + cls: "file-create-filename-input", + attr: { + placeholder: `my-${this.fileType}` + } + }); + + filenameContainer.createDiv({ cls: "file-create-extension", text: extension }); + + // --- Footer --- + const footer = contentEl.createDiv({ cls: "file-create-footer" }); + + const cancelBtn = footer.createEl("button", { cls: "aoc-btn aoc-btn-secondary", text: "Cancel" }); + cancelBtn.addEventListener("click", () => this.cancel()); + + const createBtn = footer.createEl("button", { cls: "aoc-btn aoc-btn-primary", text: "Create" }); + createBtn.addEventListener("click", () => this.submit()); + + // --- Event Listeners --- + + descInput.addEventListener("input", (e) => { + const value = (e.target as HTMLTextAreaElement).value; + this.description = value; + + // Auto-suggest filename + if (value.trim().length > 5 && !this.filename) { + const suggested = this.suggestFilename(value); + if (this.filenameInput) { + this.filenameInput.value = suggested; + this.filename = suggested; + } + } + }); + + // Focus description on open + setTimeout(() => descInput.focus(), 50); + + this.filenameInput.addEventListener("input", (e) => { + const value = (e.target as HTMLInputElement).value; + // Remove extension if typed + this.filename = value.replace(new RegExp(`\\${extension}$`), ""); + }); + + // Handle Enter to submit (if focused on inputs) + contentEl.addEventListener("keydown", (e) => { + if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) { + e.preventDefault(); + this.submit(); + } + if (e.key === "Escape") { + e.preventDefault(); + this.cancel(); + } + }); + } + + private updateFolderDisplay() { + if (this.folderPathText) { + this.folderPathText.setText(this.folder || "/ (Vault Root)"); + } + } + + private openFolderPicker() { + const folders = this.getAllFolders(); + const modal = new FolderSuggestModal(this.app, folders, (folder) => { + this.folder = folder.path; + this.updateFolderDisplay(); + }); + modal.open(); + } + + private getAllFolders(): TFolder[] { + const folders: TFolder[] = []; + const rootFolder = this.app.vault.getRoot(); + + folders.push(rootFolder); + + const collectFolders = (folder: TFolder) => { + for (const child of folder.children) { + if (child instanceof TFolder) { + folders.push(child); + collectFolders(child); + } + } + }; + + collectFolders(rootFolder); + return folders.sort((a, b) => a.path.localeCompare(b.path)); + } + + private submit() { + if (!this.description.trim()) { + const descInput = this.contentEl.querySelector(".file-create-description") as HTMLElement; + if (descInput) { + descInput.addClass("has-error"); + setTimeout(() => descInput.removeClass("has-error"), 2000); + descInput.focus(); + } + return; + } + + if (!this.filename.trim()) { + this.filename = this.suggestFilename(this.description); + } + + this.resolved = true; + this.resolve({ + description: this.description.trim(), + filename: this.filename.trim(), + folder: this.folder, + cancelled: false, + }); + this.close(); + } + + private cancel() { + this.resolved = true; + this.resolve({ + description: "", + filename: "", + folder: "", + cancelled: true, + }); + this.close(); + } + + onClose() { + const { contentEl, modalEl } = this; + modalEl.removeClass("apply-opencode-file-create"); + contentEl.empty(); + + if (!this.resolved) { + this.resolve({ + description: "", + filename: "", + folder: "", + cancelled: true, + }); + } + } +} + +export function showFileCreateModal( + app: App, + fileType: "base" | "canvas", + suggestFilename: (description: string) => string, + defaultFolder?: string +): Promise { + return new Promise((resolve) => { + const modal = new FileCreateModal(app, fileType, suggestFilename, resolve, defaultFolder); + modal.open(); + }); +} diff --git a/src/main.ts b/src/main.ts index 4ce4bd2..c153355 100644 --- a/src/main.ts +++ b/src/main.ts @@ -10,13 +10,28 @@ import { loadTemplateSchemas, TemplateSchema } from "./template-schema"; import { TemplatePickerModal } from "./template-picker-modal"; import { showContentInputModal } from "./content-input-modal"; import { generateWikiLinks, validateWikiLinkChanges } from "./wiki-link-generator"; +import { getSkillManager, SkillCache } from "./skills"; +import { generateBase, editBase, validateBase, suggestBaseFilename } from "./base-generator"; +import { generateCanvas, editCanvas, validateCanvas, suggestCanvasFilename } from "./canvas-generator"; +import { showFileCreateModal } from "./file-create-modal"; +import { collectVaultContext, extractKeywords } from "./vault-context"; +import { collectWeeklyNotes, generateWeeklySummary } from "./weekly-summary"; + +interface PluginData { + settings?: Partial; + skillCache?: SkillCache; +} export default class ApplyOpenCodePlugin extends Plugin { settings: ApplyOpenCodeSettings; + private skillManager = getSkillManager(); async onload() { await this.loadSettings(); + // Initialize skill manager and check for updates silently + void this.initializeSkills(); + this.addCommand({ id: "enhance-frontmatter", name: "Enhance note frontmatter", @@ -213,7 +228,7 @@ export default class ApplyOpenCodePlugin extends Plugin { // Content generation command this.addCommand({ id: "generate-content", - name: "Generate content at cursor", + name: "Edit or append content at selection", editorCallback: async (editor, view) => { if (!(view instanceof MarkdownView)) { new Notice("No active Markdown file", 8000); @@ -425,9 +440,427 @@ export default class ApplyOpenCodePlugin extends Plugin { await this.generateTitleForFile(activeFile); }); + // Create Base command + this.addCommand({ + id: "create-base", + name: "Create Obsidian base", + callback: async () => { + const activeFile = this.app.workspace.getActiveFile(); + const defaultFolder = activeFile?.parent?.path || ""; + + const result = await showFileCreateModal( + this.app, + "base", + suggestBaseFilename, + defaultFolder + ); + + if (result.cancelled) { + return; + } + + const loadingNotice = new Notice("Generating base...", 0); + + try { + const keywords = extractKeywords(result.description); + const vaultContext = await collectVaultContext(this.app, undefined, { + keywords, + fileType: "base", + maxExamples: 3, + }); + const content = await generateBase(result.description, { + opencodePath: this.settings.opencodePath, + model: this.settings.model, + vaultContext, + }); + + loadingNotice.hide(); + + // Validate the generated content + const validation = validateBase(content); + if (!validation.valid) { + new Notice(`Generated Base is invalid: ${validation.error}`, 10000); + console.error("[Apply OpenCode] Base validation failed:", validation.error); + console.debug("[Apply OpenCode] Generated content:", content); + return; + } + + // Create the file + const filename = result.filename.endsWith(".base") + ? result.filename + : `${result.filename}.base`; + const filePath = result.folder + ? `${result.folder}/${filename}` + : filename; + + // Check if file already exists + const existing = this.app.vault.getAbstractFileByPath(filePath); + if (existing) { + new Notice(`File already exists: ${filePath}`, 10000); + return; + } + + await this.app.vault.create(filePath, content); + + // Open the new file + const newFile = this.app.vault.getAbstractFileByPath(filePath); + if (newFile instanceof TFile) { + await this.app.workspace.getLeaf().openFile(newFile); + } + + new Notice(`Created: ${filePath}`, 6000); + } catch (err) { + loadingNotice.hide(); + const message = err instanceof Error ? err.message : String(err); + new Notice(`Failed to create Base: ${message}`, 10000); + console.error("[Apply OpenCode] Create Base error:", err); + } + }, + }); + + // Edit Base command + this.addCommand({ + id: "edit-base", + name: "Edit current base", + checkCallback: (checking) => { + const activeFile = this.app.workspace.getActiveFile(); + if (!activeFile || activeFile.extension !== "base") { + return false; + } + if (checking) { + return true; + } + void this.editCurrentBase(activeFile); + return true; + }, + }); + + // Create Canvas command + this.addCommand({ + id: "create-canvas", + name: "Create canvas", + callback: async () => { + const activeFile = this.app.workspace.getActiveFile(); + const defaultFolder = activeFile?.parent?.path || ""; + + const result = await showFileCreateModal( + this.app, + "canvas", + suggestCanvasFilename, + defaultFolder + ); + + if (result.cancelled) { + return; + } + + const loadingNotice = new Notice("Generating canvas...", 0); + + try { + const keywords = extractKeywords(result.description); + const vaultContext = await collectVaultContext(this.app, undefined, { + keywords, + fileType: "canvas", + maxExamples: 2, + }); + const content = await generateCanvas(result.description, { + opencodePath: this.settings.opencodePath, + model: this.settings.model, + vaultContext, + }); + + loadingNotice.hide(); + + // Validate the generated content + const validation = validateCanvas(content); + if (!validation.valid) { + new Notice(`Generated Canvas is invalid: ${validation.error}`, 10000); + console.error("[Apply OpenCode] Canvas validation failed:", validation.error); + console.debug("[Apply OpenCode] Generated content:", content); + return; + } + + // Create the file + const filename = result.filename.endsWith(".canvas") + ? result.filename + : `${result.filename}.canvas`; + const filePath = result.folder + ? `${result.folder}/${filename}` + : filename; + + // Check if file already exists + const existing = this.app.vault.getAbstractFileByPath(filePath); + if (existing) { + new Notice(`File already exists: ${filePath}`, 10000); + return; + } + + await this.app.vault.create(filePath, content); + + // Open the new file + const newFile = this.app.vault.getAbstractFileByPath(filePath); + if (newFile instanceof TFile) { + await this.app.workspace.getLeaf().openFile(newFile); + } + + new Notice(`Created: ${filePath}`, 6000); + } catch (err) { + loadingNotice.hide(); + const message = err instanceof Error ? err.message : String(err); + new Notice(`Failed to create Canvas: ${message}`, 10000); + console.error("[Apply OpenCode] Create Canvas error:", err); + } + }, + }); + + // Edit Canvas command + this.addCommand({ + id: "edit-canvas", + name: "Edit current canvas", + checkCallback: (checking) => { + const activeFile = this.app.workspace.getActiveFile(); + if (!activeFile || activeFile.extension !== "canvas") { + return false; + } + if (checking) { + return true; + } + void this.editCurrentCanvas(activeFile); + return true; + }, + }); + + // Weekly Summary command + this.addCommand({ + id: "this-weeks-summary", + name: "This week's summary", + callback: async () => { + const loadingNotice = new Notice("Collecting this week's notes...", 0); + + try { + const notes = await collectWeeklyNotes(this.app); + + if (notes.length === 0) { + loadingNotice.hide(); + new Notice("No notes created or modified in the past 7 days.", 8000); + return; + } + + loadingNotice.setMessage(`Analyzing ${notes.length} notes...`); + + const summary = await generateWeeklySummary(notes, { + opencodePath: this.settings.opencodePath, + model: this.settings.model, + }); + + loadingNotice.hide(); + + // Create a new note with the summary + const today = new Date(); + const dateStr = today.toISOString().slice(0, 10); + const filename = `Weekly Summary ${dateStr}.md`; + + // Check for existing file + let filePath = filename; + const existing = this.app.vault.getAbstractFileByPath(filePath); + if (existing) { + // Add timestamp to make unique + const timestamp = today.toTimeString().slice(0, 5).replace(":", ""); + filePath = `Weekly Summary ${dateStr} ${timestamp}.md`; + } + + const content = `--- +title: Weekly Summary ${dateStr} +created: ${today.toISOString()} +tags: [weekly-summary] +--- + +# Weekly Summary: ${today.toLocaleDateString("en-US", { month: "long", day: "numeric", year: "numeric" })} + +${summary} +`; + + await this.app.vault.create(filePath, content); + + // Open the new file + const newFile = this.app.vault.getAbstractFileByPath(filePath); + if (newFile instanceof TFile) { + await this.app.workspace.getLeaf().openFile(newFile); + } + + new Notice(`Created: ${filePath}`, 6000); + } catch (err) { + loadingNotice.hide(); + const message = err instanceof Error ? err.message : String(err); + new Notice(`Failed to generate summary: ${message}`, 10000); + console.error("[Apply OpenCode] Weekly summary error:", err); + } + }, + }); + this.addSettingTab(new ApplyOpenCodeSettingTab(this.app, this)); } + /** + * Initialize skill manager and check for updates from GitHub + */ + private async initializeSkills(): Promise { + try { + await this.skillManager.checkForUpdates(); + // Save updated cache + await this.saveSkillCache(); + } catch (err) { + // Silent failure - skills will use embedded fallbacks + console.debug("[Apply OpenCode] Skill update check failed:", err); + } + } + + /** + * Save skill cache to plugin data + */ + private async saveSkillCache(): Promise { + const data = await this.loadData() as PluginData | null; + const newData: PluginData = { + ...data, + skillCache: this.skillManager.getCache(), + }; + await this.saveData(newData); + } + + /** + * Edit the currently open .base file + */ + private async editCurrentBase(file: TFile): Promise { + const inputResult = await showContentInputModal(this.app, false); + if (inputResult.cancelled || !inputResult.instruction.trim()) { + return; + } + + let currentContent: string; + try { + currentContent = await this.app.vault.read(file); + } catch { + new Notice("Cannot read file content", 10000); + return; + } + + const loadingNotice = new Notice("Editing base...", 0); + + try { + const keywords = extractKeywords(inputResult.instruction); + const vaultContext = await collectVaultContext(this.app, file, { + keywords, + fileType: "base", + maxExamples: 3, + }); + const modified = await editBase(currentContent, inputResult.instruction, { + opencodePath: this.settings.opencodePath, + model: this.settings.model, + vaultContext, + }); + + loadingNotice.hide(); + + // Validate the modified content + const validation = validateBase(modified); + if (!validation.valid) { + new Notice(`Modified Base is invalid: ${validation.error}`, 10000); + console.error("[Apply OpenCode] Base validation failed:", validation.error); + return; + } + + // Show diff modal + const result = await showContentDiffModal( + this.app, + currentContent, + modified, + this.settings.diffStyle, + "Review base changes" + ); + + if (result === null) { + new Notice("No changes to apply.", 5000); + } else if (result.applied) { + await this.app.vault.modify(file, result.modifiedContent); + new Notice("Base updated.", 6000); + } else { + new Notice("Changes discarded.", 5000); + } + } catch (err) { + loadingNotice.hide(); + const message = err instanceof Error ? err.message : String(err); + new Notice(`Failed to edit Base: ${message}`, 10000); + console.error("[Apply OpenCode] Edit Base error:", err); + } + } + + /** + * Edit the currently open .canvas file + */ + private async editCurrentCanvas(file: TFile): Promise { + const inputResult = await showContentInputModal(this.app, false); + if (inputResult.cancelled || !inputResult.instruction.trim()) { + return; + } + + let currentContent: string; + try { + currentContent = await this.app.vault.read(file); + } catch { + new Notice("Cannot read file content", 10000); + return; + } + + const loadingNotice = new Notice("Editing canvas...", 0); + + try { + const keywords = extractKeywords(inputResult.instruction); + const vaultContext = await collectVaultContext(this.app, file, { + keywords, + fileType: "canvas", + maxExamples: 2, + }); + const modified = await editCanvas(currentContent, inputResult.instruction, { + opencodePath: this.settings.opencodePath, + model: this.settings.model, + vaultContext, + }); + + loadingNotice.hide(); + + // Validate the modified content + const validation = validateCanvas(modified); + if (!validation.valid) { + new Notice(`Modified Canvas is invalid: ${validation.error}`, 10000); + console.error("[Apply OpenCode] Canvas validation failed:", validation.error); + return; + } + + // Show diff modal + const result = await showContentDiffModal( + this.app, + currentContent, + modified, + this.settings.diffStyle, + "Review canvas changes" + ); + + if (result === null) { + new Notice("No changes to apply.", 5000); + } else if (result.applied) { + await this.app.vault.modify(file, result.modifiedContent); + new Notice("Canvas updated.", 6000); + } else { + new Notice("Changes discarded.", 5000); + } + } catch (err) { + loadingNotice.hide(); + const message = err instanceof Error ? err.message : String(err); + new Notice(`Failed to edit Canvas: ${message}`, 10000); + console.error("[Apply OpenCode] Edit Canvas error:", err); + } + } + async generateTitleForFile(file: TFile): Promise { let content: string; try { @@ -538,11 +971,20 @@ export default class ApplyOpenCodePlugin extends Plugin { } async loadSettings() { - const data = await this.loadData() as Partial | null; - this.settings = Object.assign({}, DEFAULT_SETTINGS, data ?? {}); + const data = await this.loadData() as PluginData | null; + this.settings = Object.assign({}, DEFAULT_SETTINGS, data?.settings ?? {}); + + // Load skill cache + if (data?.skillCache) { + this.skillManager.loadCache(data.skillCache); + } } async saveSettings() { - await this.saveData(this.settings); + const data: PluginData = { + settings: this.settings, + skillCache: this.skillManager.getCache(), + }; + await this.saveData(data); } } diff --git a/src/opencode.ts b/src/opencode.ts index 78217af..070eb1b 100644 --- a/src/opencode.ts +++ b/src/opencode.ts @@ -4,6 +4,7 @@ import { SimilarNote, formatExamplesForPrompt, ExamplesPromptData } from "./vaul import { TitleExtractor, TITLE_GENERATION_PROMPT } from "./title-generator"; import { TemplateSchema, formatSchemaForPrompt } from "./template-schema"; import { WikiLinkSpan } from "./wiki-link-generator"; +import { getSkillManager } from "./skills"; export interface OpenCodeOptions { opencodePath: string; @@ -380,8 +381,20 @@ export async function generateTitle( } function buildContentGenerationPrompt(instruction?: string, isReplacement = false): string { + // Get obsidian-markdown skill for syntax awareness + const skillManager = getSkillManager(); + const markdownSkill = skillManager.getSkill("obsidian-markdown"); + + // Extract just the syntax reference portion (truncated for prompt efficiency) + const syntaxContext = markdownSkill.length > 3000 + ? markdownSkill.slice(0, 3000) + "\n...[truncated]" + : markdownSkill; + const baseRules = isReplacement - ? `You are a writing assistant. Replace the selected text based on the context provided. + ? `You are a writing assistant for Obsidian notes. Replace the selected text based on the context provided. + +OBSIDIAN MARKDOWN SYNTAX REFERENCE: +${syntaxContext} RULES: 1. Write 500-1000 characters of content unless the user specifies otherwise @@ -389,8 +402,12 @@ RULES: 3. Use the note title and frontmatter as additional context for relevance 4. Output ONLY the replacement text - no explanations, no markdown fences, no meta-commentary 5. The replacement should fit naturally between the text before and after the selection -6. Consider what the selected text was about when writing the replacement` - : `You are a writing assistant. Generate content based on the context provided. +6. Consider what the selected text was about when writing the replacement +7. Use proper Obsidian syntax: [[wikilinks]], callouts, embeds, etc. when appropriate` + : `You are a writing assistant for Obsidian notes. Generate content based on the context provided. + +OBSIDIAN MARKDOWN SYNTAX REFERENCE: +${syntaxContext} RULES: 1. Write 500-1000 characters of content unless the user specifies otherwise @@ -398,11 +415,12 @@ RULES: 3. Use the note title and frontmatter as additional context for relevance 4. Output ONLY the generated text - no explanations, no markdown fences, no meta-commentary 5. Do not repeat or rephrase the existing content -6. If there is text after the cursor position, write content that bridges naturally to it`; +6. If there is text after the cursor position, write content that bridges naturally to it +7. Use proper Obsidian syntax: [[wikilinks]], callouts, embeds, etc. when appropriate`; if (instruction) { return `${baseRules} -7. Follow the user's specific instruction for what to write +8. Follow the user's specific instruction for what to write USER INSTRUCTION: ${instruction} diff --git a/src/progress-modal.ts b/src/progress-modal.ts index bae33d5..2d154ca 100644 --- a/src/progress-modal.ts +++ b/src/progress-modal.ts @@ -51,7 +51,7 @@ export class ProgressModal extends Modal { // Cancel button const btnContainer = contentEl.createDiv({ cls: "progress-buttons" }); - this.cancelBtn = btnContainer.createEl("button", { text: "Cancel" }); + this.cancelBtn = btnContainer.createEl("button", { text: "Cancel", cls: "aoc-btn aoc-btn-secondary" }); this.cancelBtn.addEventListener("click", () => { this.state.cancelled = true; this.cancelBtn.disabled = true; diff --git a/src/skills/embedded.ts b/src/skills/embedded.ts new file mode 100644 index 0000000..91eab70 --- /dev/null +++ b/src/skills/embedded.ts @@ -0,0 +1,987 @@ +// Embedded skill content from https://github.com/kepano/obsidian-skills +// These serve as fallbacks when GitHub is unreachable + +export const EMBEDDED_OBSIDIAN_BASES = `--- +name: obsidian-bases +description: Create and edit Obsidian Bases (.base files) with views, filters, formulas, and summaries. Use when working with .base files, creating database-like views of notes, or when the user mentions Bases, table views, card views, filters, or formulas in Obsidian. +--- + +# Obsidian Bases Skill + +This skill enables Claude Code to create and edit valid Obsidian Bases (\`.base\` files) including views, filters, formulas, and all related configurations. + +## Overview + +Obsidian Bases are YAML-based files that define dynamic views of notes in an Obsidian vault. A Base file can contain multiple views, global filters, formulas, property configurations, and custom summaries. + +## File Format + +Base files use the \`.base\` extension and contain valid YAML. They can also be embedded in Markdown code blocks. + +## Complete Schema + +\`\`\`yaml +# Global filters apply to ALL views in the base +filters: + # Can be a single filter string + # OR a recursive filter object with and/or/not + and: [] + or: [] + not: [] + +# Define formula properties that can be used across all views +formulas: + formula_name: 'expression' + +# Configure display names and settings for properties +properties: + property_name: + displayName: "Display Name" + formula.formula_name: + displayName: "Formula Display Name" + file.ext: + displayName: "Extension" + +# Define custom summary formulas +summaries: + custom_summary_name: 'values.mean().round(3)' + +# Define one or more views +views: + - type: table | cards | list | map + name: "View Name" + limit: 10 # Optional: limit results + groupBy: # Optional: group results + property: property_name + direction: ASC | DESC + filters: # View-specific filters + and: [] + order: # Properties to display in order + - file.name + - property_name + - formula.formula_name + summaries: # Map properties to summary formulas + property_name: Average +\`\`\` + +## Filter Syntax + +Filters narrow down results. They can be applied globally or per-view. + +### Filter Structure + +\`\`\`yaml +# Single filter +filters: 'status == "done"' + +# AND - all conditions must be true +filters: + and: + - 'status == "done"' + - 'priority > 3' + +# OR - any condition can be true +filters: + or: + - 'file.hasTag("book")' + - 'file.hasTag("article")' + +# NOT - exclude matching items +filters: + not: + - 'file.hasTag("archived")' + +# Nested filters +filters: + or: + - file.hasTag("tag") + - and: + - file.hasTag("book") + - file.hasLink("Textbook") + - not: + - file.hasTag("book") + - file.inFolder("Required Reading") +\`\`\` + +### Filter Operators + +| Operator | Description | +|----------|-------------| +| \`==\` | equals | +| \`!=\` | not equal | +| \`>\` | greater than | +| \`<\` | less than | +| \`>=\` | greater than or equal | +| \`<=\` | less than or equal | +| \`&&\` | logical and | +| \`\\|\\|\` | logical or | +| \`!\` | logical not | + +## Properties + +### Three Types of Properties + +1. **Note properties** - From frontmatter: \`note.author\` or just \`author\` +2. **File properties** - File metadata: \`file.name\`, \`file.mtime\`, etc. +3. **Formula properties** - Computed values: \`formula.my_formula\` + +### File Properties Reference + +| Property | Type | Description | +|----------|------|-------------| +| \`file.name\` | String | File name | +| \`file.basename\` | String | File name without extension | +| \`file.path\` | String | Full path to file | +| \`file.folder\` | String | Parent folder path | +| \`file.ext\` | String | File extension | +| \`file.size\` | Number | File size in bytes | +| \`file.ctime\` | Date | Created time | +| \`file.mtime\` | Date | Modified time | +| \`file.tags\` | List | All tags in file | +| \`file.links\` | List | Internal links in file | +| \`file.backlinks\` | List | Files linking to this file | +| \`file.embeds\` | List | Embeds in the note | +| \`file.properties\` | Object | All frontmatter properties | + +## Formula Syntax + +Formulas compute values from properties. Defined in the \`formulas\` section. + +\`\`\`yaml +formulas: + # Simple arithmetic + total: "price * quantity" + + # Conditional logic + status_icon: 'if(done, "✅", "⏳")' + + # String formatting + formatted_price: 'if(price, price.toFixed(2) + " dollars")' + + # Date formatting + created: 'file.ctime.format("YYYY-MM-DD")' + + # Complex expressions + days_old: '((now() - file.ctime) / 86400000).round(0)' +\`\`\` + +## Functions Reference + +### Global Functions + +| Function | Signature | Description | +|----------|-----------|-------------| +| \`date()\` | \`date(string): date\` | Parse string to date. Format: \`YYYY-MM-DD HH:mm:ss\` | +| \`duration()\` | \`duration(string): duration\` | Parse duration string | +| \`now()\` | \`now(): date\` | Current date and time | +| \`today()\` | \`today(): date\` | Current date (time = 00:00:00) | +| \`if()\` | \`if(condition, trueResult, falseResult?)\` | Conditional | +| \`min()\` | \`min(n1, n2, ...): number\` | Smallest number | +| \`max()\` | \`max(n1, n2, ...): number\` | Largest number | +| \`number()\` | \`number(any): number\` | Convert to number | +| \`link()\` | \`link(path, display?): Link\` | Create a link | +| \`list()\` | \`list(element): List\` | Wrap in list if not already | +| \`file()\` | \`file(path): file\` | Get file object | +| \`image()\` | \`image(path): image\` | Create image for rendering | +| \`icon()\` | \`icon(name): icon\` | Lucide icon by name | +| \`html()\` | \`html(string): html\` | Render as HTML | +| \`escapeHTML()\` | \`escapeHTML(string): string\` | Escape HTML characters | + +### Date Functions & Fields + +**Fields:** \`date.year\`, \`date.month\`, \`date.day\`, \`date.hour\`, \`date.minute\`, \`date.second\`, \`date.millisecond\` + +| Function | Signature | Description | +|----------|-----------|-------------| +| \`date()\` | \`date.date(): date\` | Remove time portion | +| \`format()\` | \`date.format(string): string\` | Format with Moment.js pattern | +| \`time()\` | \`date.time(): string\` | Get time as string | +| \`relative()\` | \`date.relative(): string\` | Human-readable relative time | +| \`isEmpty()\` | \`date.isEmpty(): boolean\` | Always false for dates | + +### String Functions + +**Field:** \`string.length\` + +| Function | Signature | Description | +|----------|-----------|-------------| +| \`contains()\` | \`string.contains(value): boolean\` | Check substring | +| \`containsAll()\` | \`string.containsAll(...values): boolean\` | All substrings present | +| \`containsAny()\` | \`string.containsAny(...values): boolean\` | Any substring present | +| \`startsWith()\` | \`string.startsWith(query): boolean\` | Starts with query | +| \`endsWith()\` | \`string.endsWith(query): boolean\` | Ends with query | +| \`isEmpty()\` | \`string.isEmpty(): boolean\` | Empty or not present | +| \`lower()\` | \`string.lower(): string\` | To lowercase | +| \`title()\` | \`string.title(): string\` | To Title Case | +| \`trim()\` | \`string.trim(): string\` | Remove whitespace | +| \`replace()\` | \`string.replace(pattern, replacement): string\` | Replace pattern | +| \`split()\` | \`string.split(separator, n?): list\` | Split to list | + +### Number Functions + +| Function | Signature | Description | +|----------|-----------|-------------| +| \`abs()\` | \`number.abs(): number\` | Absolute value | +| \`ceil()\` | \`number.ceil(): number\` | Round up | +| \`floor()\` | \`number.floor(): number\` | Round down | +| \`round()\` | \`number.round(digits?): number\` | Round to digits | +| \`toFixed()\` | \`number.toFixed(precision): string\` | Fixed-point notation | +| \`isEmpty()\` | \`number.isEmpty(): boolean\` | Not present | + +### List Functions + +**Field:** \`list.length\` + +| Function | Signature | Description | +|----------|-----------|-------------| +| \`contains()\` | \`list.contains(value): boolean\` | Element exists | +| \`containsAll()\` | \`list.containsAll(...values): boolean\` | All elements exist | +| \`containsAny()\` | \`list.containsAny(...values): boolean\` | Any element exists | +| \`filter()\` | \`list.filter(expression): list\` | Filter by condition (uses \`value\`, \`index\`) | +| \`map()\` | \`list.map(expression): list\` | Transform elements (uses \`value\`, \`index\`) | +| \`flat()\` | \`list.flat(): list\` | Flatten nested lists | +| \`join()\` | \`list.join(separator): string\` | Join to string | +| \`reverse()\` | \`list.reverse(): list\` | Reverse order | +| \`slice()\` | \`list.slice(start, end?): list\` | Sublist | +| \`sort()\` | \`list.sort(): list\` | Sort ascending | +| \`unique()\` | \`list.unique(): list\` | Remove duplicates | +| \`isEmpty()\` | \`list.isEmpty(): boolean\` | No elements | + +### File Functions + +| Function | Signature | Description | +|----------|-----------|-------------| +| \`asLink()\` | \`file.asLink(display?): Link\` | Convert to link | +| \`hasLink()\` | \`file.hasLink(otherFile): boolean\` | Has link to file | +| \`hasTag()\` | \`file.hasTag(...tags): boolean\` | Has any of the tags | +| \`hasProperty()\` | \`file.hasProperty(name): boolean\` | Has property | +| \`inFolder()\` | \`file.inFolder(folder): boolean\` | In folder or subfolder | + +## View Types + +### Table View + +\`\`\`yaml +views: + - type: table + name: "My Table" + order: + - file.name + - status + - due_date + summaries: + price: Sum + count: Average +\`\`\` + +### Cards View + +\`\`\`yaml +views: + - type: cards + name: "Gallery" + order: + - file.name + - cover_image + - description +\`\`\` + +### List View + +\`\`\`yaml +views: + - type: list + name: "Simple List" + order: + - file.name + - status +\`\`\` + +## Default Summary Formulas + +| Name | Input Type | Description | +|------|------------|-------------| +| \`Average\` | Number | Mathematical mean | +| \`Min\` | Number | Smallest number | +| \`Max\` | Number | Largest number | +| \`Sum\` | Number | Sum of all numbers | +| \`Range\` | Number | Max - Min | +| \`Median\` | Number | Mathematical median | +| \`Earliest\` | Date | Earliest date | +| \`Latest\` | Date | Latest date | +| \`Checked\` | Boolean | Count of true values | +| \`Unchecked\` | Boolean | Count of false values | +| \`Empty\` | Any | Count of empty values | +| \`Filled\` | Any | Count of non-empty values | +| \`Unique\` | Any | Count of unique values | + +## Complete Example + +\`\`\`yaml +filters: + and: + - file.hasTag("task") + - 'file.ext == "md"' + +formulas: + days_until_due: 'if(due, ((date(due) - today()) / 86400000).round(0), "")' + is_overdue: 'if(due, date(due) < today() && status != "done", false)' + priority_label: 'if(priority == 1, "🔴 High", if(priority == 2, "🟡 Medium", "🟢 Low"))' + +properties: + status: + displayName: Status + formula.days_until_due: + displayName: "Days Until Due" + formula.priority_label: + displayName: Priority + +views: + - type: table + name: "Active Tasks" + filters: + and: + - 'status != "done"' + order: + - file.name + - status + - formula.priority_label + - due + - formula.days_until_due + groupBy: + property: status + direction: ASC + summaries: + formula.days_until_due: Average + + - type: table + name: "Completed" + filters: + and: + - 'status == "done"' + order: + - file.name + - completed_date +\`\`\` + +## References + +- [Bases Syntax](https://help.obsidian.md/bases/syntax) +- [Functions](https://help.obsidian.md/bases/functions) +- [Views](https://help.obsidian.md/bases/views) +- [Formulas](https://help.obsidian.md/formulas) +`; + +export const EMBEDDED_JSON_CANVAS = `--- +name: json-canvas +description: Create and edit JSON Canvas files (.canvas) with nodes, edges, groups, and connections. Use when working with .canvas files, creating visual canvases, mind maps, flowcharts, or when the user mentions Canvas files in Obsidian. +--- + +# JSON Canvas Skill + +This skill enables Claude Code to create and edit valid JSON Canvas files (\`.canvas\`) used in Obsidian and other applications. + +## Overview + +JSON Canvas is an open file format for infinite canvas data. Canvas files use the \`.canvas\` extension and contain valid JSON following the [JSON Canvas Spec 1.0](https://jsoncanvas.org/spec/1.0/). + +## File Structure + +A canvas file contains two top-level arrays: + +\`\`\`json +{ + "nodes": [], + "edges": [] +} +\`\`\` + +- \`nodes\` (optional): Array of node objects +- \`edges\` (optional): Array of edge objects connecting nodes + +## Nodes + +Nodes are objects placed on the canvas. There are four node types: +- \`text\` - Text content with Markdown +- \`file\` - Reference to files/attachments +- \`link\` - External URL +- \`group\` - Visual container for other nodes + +### Generic Node Attributes + +All nodes share these attributes: + +| Attribute | Required | Type | Description | +|-----------|----------|------|-------------| +| \`id\` | Yes | string | Unique identifier for the node | +| \`type\` | Yes | string | Node type: \`text\`, \`file\`, \`link\`, or \`group\` | +| \`x\` | Yes | integer | X position in pixels | +| \`y\` | Yes | integer | Y position in pixels | +| \`width\` | Yes | integer | Width in pixels | +| \`height\` | Yes | integer | Height in pixels | +| \`color\` | No | canvasColor | Node color (see Color section) | + +### Text Nodes + +\`\`\`json +{ + "id": "6f0ad84f44ce9c17", + "type": "text", + "x": 0, + "y": 0, + "width": 400, + "height": 200, + "text": "# Hello World\\n\\nThis is **Markdown** content." +} +\`\`\` + +### File Nodes + +\`\`\`json +{ + "id": "a1b2c3d4e5f67890", + "type": "file", + "x": 500, + "y": 0, + "width": 400, + "height": 300, + "file": "Attachments/diagram.png" +} +\`\`\` + +### Link Nodes + +\`\`\`json +{ + "id": "c3d4e5f678901234", + "type": "link", + "x": 1000, + "y": 0, + "width": 400, + "height": 200, + "url": "https://obsidian.md" +} +\`\`\` + +### Group Nodes + +\`\`\`json +{ + "id": "d4e5f6789012345a", + "type": "group", + "x": -50, + "y": -50, + "width": 1000, + "height": 600, + "label": "Project Overview", + "color": "4" +} +\`\`\` + +| Attribute | Required | Type | Description | +|-----------|----------|------|-------------| +| \`label\` | No | string | Text label for the group | +| \`background\` | No | string | Path to background image | +| \`backgroundStyle\` | No | string | Background rendering style: \`cover\`, \`ratio\`, \`repeat\` | + +## Edges + +Edges are lines connecting nodes. + +\`\`\`json +{ + "id": "f67890123456789a", + "fromNode": "6f0ad84f44ce9c17", + "toNode": "a1b2c3d4e5f67890" +} +\`\`\` + +| Attribute | Required | Type | Default | Description | +|-----------|----------|------|---------|-------------| +| \`id\` | Yes | string | - | Unique identifier for the edge | +| \`fromNode\` | Yes | string | - | Node ID where connection starts | +| \`fromSide\` | No | string | - | Side where edge starts: \`top\`, \`right\`, \`bottom\`, \`left\` | +| \`fromEnd\` | No | string | \`none\` | Shape at edge start: \`none\`, \`arrow\` | +| \`toNode\` | Yes | string | - | Node ID where connection ends | +| \`toSide\` | No | string | - | Side where edge ends | +| \`toEnd\` | No | string | \`arrow\` | Shape at edge end | +| \`color\` | No | canvasColor | - | Line color | +| \`label\` | No | string | - | Text label for the edge | + +## Colors + +The \`canvasColor\` type can be specified in two ways: + +### Hex Colors +\`\`\`json +{ "color": "#FF0000" } +\`\`\` + +### Preset Colors +\`\`\`json +{ "color": "1" } +\`\`\` + +| Preset | Color | +|--------|-------| +| \`"1"\` | Red | +| \`"2"\` | Orange | +| \`"3"\` | Yellow | +| \`"4"\` | Green | +| \`"5"\` | Cyan | +| \`"6"\` | Purple | + +## ID Generation + +Node and edge IDs must be unique strings. Use 16-character hexadecimal IDs: +\`\`\` +"id": "6f0ad84f44ce9c17" +\`\`\` + +## Layout Guidelines + +### Positioning +- Coordinates can be negative (canvas extends infinitely) +- \`x\` increases to the right +- \`y\` increases downward +- Position refers to top-left corner of node + +### Recommended Sizes + +| Node Type | Suggested Width | Suggested Height | +|-----------|-----------------|------------------| +| Small text | 200-300 | 80-150 | +| Medium text | 300-450 | 150-300 | +| Large text | 400-600 | 300-500 | +| File preview | 300-500 | 200-400 | +| Link preview | 250-400 | 100-200 | + +### Spacing +- Leave 20-50px padding inside groups +- Space nodes 50-100px apart for readability +- Align nodes to grid (multiples of 10 or 20) for cleaner layouts + +## Complete Examples + +### Simple Canvas with Text and Connections + +\`\`\`json +{ + "nodes": [ + { + "id": "8a9b0c1d2e3f4a5b", + "type": "text", + "x": 0, + "y": 0, + "width": 300, + "height": 150, + "text": "# Main Idea\\n\\nThis is the central concept." + }, + { + "id": "1a2b3c4d5e6f7a8b", + "type": "text", + "x": 400, + "y": -100, + "width": 250, + "height": 100, + "text": "## Supporting Point A\\n\\nDetails here." + }, + { + "id": "2b3c4d5e6f7a8b9c", + "type": "text", + "x": 400, + "y": 100, + "width": 250, + "height": 100, + "text": "## Supporting Point B\\n\\nMore details." + } + ], + "edges": [ + { + "id": "3c4d5e6f7a8b9c0d", + "fromNode": "8a9b0c1d2e3f4a5b", + "fromSide": "right", + "toNode": "1a2b3c4d5e6f7a8b", + "toSide": "left" + }, + { + "id": "4d5e6f7a8b9c0d1e", + "fromNode": "8a9b0c1d2e3f4a5b", + "fromSide": "right", + "toNode": "2b3c4d5e6f7a8b9c", + "toSide": "left" + } + ] +} +\`\`\` + +### Project Board with Groups + +\`\`\`json +{ + "nodes": [ + { + "id": "5e6f7a8b9c0d1e2f", + "type": "group", + "x": 0, + "y": 0, + "width": 300, + "height": 500, + "label": "To Do", + "color": "1" + }, + { + "id": "6f7a8b9c0d1e2f3a", + "type": "group", + "x": 350, + "y": 0, + "width": 300, + "height": 500, + "label": "In Progress", + "color": "3" + }, + { + "id": "7a8b9c0d1e2f3a4b", + "type": "group", + "x": 700, + "y": 0, + "width": 300, + "height": 500, + "label": "Done", + "color": "4" + }, + { + "id": "8b9c0d1e2f3a4b5c", + "type": "text", + "x": 20, + "y": 50, + "width": 260, + "height": 80, + "text": "## Task 1\\n\\nImplement feature X" + } + ], + "edges": [] +} +\`\`\` + +### Flowchart + +\`\`\`json +{ + "nodes": [ + { + "id": "a0b1c2d3e4f5a6b7", + "type": "text", + "x": 200, + "y": 0, + "width": 150, + "height": 60, + "text": "**Start**", + "color": "4" + }, + { + "id": "b1c2d3e4f5a6b7c8", + "type": "text", + "x": 200, + "y": 100, + "width": 150, + "height": 60, + "text": "Step 1:\\nGather data" + }, + { + "id": "c2d3e4f5a6b7c8d9", + "type": "text", + "x": 200, + "y": 200, + "width": 150, + "height": 80, + "text": "**Decision**\\n\\nIs data valid?", + "color": "3" + } + ], + "edges": [ + { + "id": "a6b7c8d9e0f1a2b3", + "fromNode": "a0b1c2d3e4f5a6b7", + "fromSide": "bottom", + "toNode": "b1c2d3e4f5a6b7c8", + "toSide": "top" + }, + { + "id": "b7c8d9e0f1a2b3c4", + "fromNode": "b1c2d3e4f5a6b7c8", + "fromSide": "bottom", + "toNode": "c2d3e4f5a6b7c8d9", + "toSide": "top" + } + ] +} +\`\`\` + +## Validation Rules + +1. All \`id\` values must be unique across nodes and edges +2. \`fromNode\` and \`toNode\` must reference existing node IDs +3. Required fields must be present for each node type +4. \`type\` must be one of: \`text\`, \`file\`, \`link\`, \`group\` +5. \`fromSide\`, \`toSide\` must be one of: \`top\`, \`right\`, \`bottom\`, \`left\` +6. \`fromEnd\`, \`toEnd\` must be one of: \`none\`, \`arrow\` +7. Color presets must be \`"1"\` through \`"6"\` or valid hex color + +## References + +- [JSON Canvas Spec 1.0](https://jsoncanvas.org/spec/1.0/) +- [JSON Canvas GitHub](https://github.com/obsidianmd/jsoncanvas) +`; + +export const EMBEDDED_OBSIDIAN_MARKDOWN = `--- +name: obsidian-markdown +description: Create and edit Obsidian Flavored Markdown with wikilinks, embeds, callouts, properties, and other Obsidian-specific syntax. Use when working with .md files in Obsidian, or when the user mentions wikilinks, callouts, frontmatter, tags, embeds, or Obsidian notes. +--- + +# Obsidian Flavored Markdown Skill + +This skill enables Claude Code to create and edit valid Obsidian Flavored Markdown, including all Obsidian-specific syntax extensions. + +## Overview + +Obsidian uses a combination of Markdown flavors: +- [CommonMark](https://commonmark.org/) +- [GitHub Flavored Markdown](https://github.github.com/gfm/) +- [LaTeX](https://www.latex-project.org/) for math +- Obsidian-specific extensions (wikilinks, callouts, embeds, etc.) + +## Internal Links (Wikilinks) + +### Basic Links +\`\`\`markdown +[[Note Name]] +[[Note Name.md]] +[[Note Name|Display Text]] +\`\`\` + +### Link to Headings +\`\`\`markdown +[[Note Name#Heading]] +[[Note Name#Heading|Custom Text]] +[[#Heading in same note]] +\`\`\` + +### Link to Blocks +\`\`\`markdown +[[Note Name#^block-id]] +[[Note Name#^block-id|Custom Text]] +\`\`\` + +Define a block ID by adding \`^block-id\` at the end of a paragraph: +\`\`\`markdown +This is a paragraph that can be linked to. ^my-block-id +\`\`\` + +## Embeds + +### Embed Notes +\`\`\`markdown +![[Note Name]] +![[Note Name#Heading]] +![[Note Name#^block-id]] +\`\`\` + +### Embed Images +\`\`\`markdown +![[image.png]] +![[image.png|640x480]] Width x Height +![[image.png|300]] Width only (maintains aspect ratio) +\`\`\` + +### Embed PDF +\`\`\`markdown +![[document.pdf]] +![[document.pdf#page=3]] +![[document.pdf#height=400]] +\`\`\` + +## Callouts + +### Basic Callout +\`\`\`markdown +> [!note] +> This is a note callout. + +> [!info] Custom Title +> This callout has a custom title. +\`\`\` + +### Foldable Callouts +\`\`\`markdown +> [!faq]- Collapsed by default +> This content is hidden until expanded. + +> [!faq]+ Expanded by default +> This content is visible but can be collapsed. +\`\`\` + +### Supported Callout Types + +| Type | Aliases | Description | +|------|---------|-------------| +| \`note\` | - | Blue, pencil icon | +| \`abstract\` | \`summary\`, \`tldr\` | Teal, clipboard icon | +| \`info\` | - | Blue, info icon | +| \`todo\` | - | Blue, checkbox icon | +| \`tip\` | \`hint\`, \`important\` | Cyan, flame icon | +| \`success\` | \`check\`, \`done\` | Green, checkmark icon | +| \`question\` | \`help\`, \`faq\` | Yellow, question mark | +| \`warning\` | \`caution\`, \`attention\` | Orange, warning icon | +| \`failure\` | \`fail\`, \`missing\` | Red, X icon | +| \`danger\` | \`error\` | Red, zap icon | +| \`bug\` | - | Red, bug icon | +| \`example\` | - | Purple, list icon | +| \`quote\` | \`cite\` | Gray, quote icon | + +## Text Formatting + +| Style | Syntax | Example | +|-------|--------|---------| +| Bold | \`**text**\` | **Bold** | +| Italic | \`*text*\` | *Italic* | +| Bold + Italic | \`***text***\` | ***Both*** | +| Strikethrough | \`~~text~~\` | ~~Striked~~ | +| Highlight | \`==text==\` | ==Highlighted== | +| Inline code | \`\\\`code\\\`\` | \`code\` | + +## Task Lists + +\`\`\`markdown +- [ ] Incomplete task +- [x] Completed task +- [ ] Task with sub-tasks + - [ ] Subtask 1 + - [x] Subtask 2 +\`\`\` + +## Code Blocks + +\`\`\`\`markdown +\`\`\`javascript +function hello() { + console.log("Hello, world!"); +} +\`\`\` +\`\`\`\` + +## Math (LaTeX) + +### Inline Math +\`\`\`markdown +This is inline math: $e^{i\\pi} + 1 = 0$ +\`\`\` + +### Block Math +\`\`\`markdown +$$ +\\begin{vmatrix} +a & b \\\\ +c & d +\\end{vmatrix} = ad - bc +$$ +\`\`\` + +## Diagrams (Mermaid) + +\`\`\`\`markdown +\`\`\`mermaid +graph TD + A[Start] --> B{Decision} + B -->|Yes| C[Do this] + B -->|No| D[Do that] +\`\`\` +\`\`\`\` + +## Footnotes + +\`\`\`markdown +This sentence has a footnote[^1]. + +[^1]: This is the footnote content. + +Inline footnotes are also supported.^[This is an inline footnote.] +\`\`\` + +## Comments + +\`\`\`markdown +This is visible %%but this is hidden%% text. + +%% +This entire block is hidden. +It won't appear in reading view. +%% +\`\`\` + +## Properties (Frontmatter) + +Properties use YAML frontmatter at the start of a note: + +\`\`\`yaml +--- +title: My Note Title +date: 2024-01-15 +tags: + - project + - important +aliases: + - My Note + - Alternative Name +cssclasses: + - custom-class +status: in-progress +rating: 4.5 +completed: false +due: 2024-02-01T14:30:00 +--- +\`\`\` + +### Property Types + +| Type | Example | +|------|---------| +| Text | \`title: My Title\` | +| Number | \`rating: 4.5\` | +| Checkbox | \`completed: true\` | +| Date | \`date: 2024-01-15\` | +| Date & Time | \`due: 2024-01-15T14:30:00\` | +| List | \`tags: [one, two]\` or YAML list | +| Links | \`related: "[[Other Note]]"\` | + +### Default Properties +- \`tags\` - Note tags +- \`aliases\` - Alternative names for the note +- \`cssclasses\` - CSS classes applied to the note + +## Tags + +\`\`\`markdown +#tag +#nested/tag +#tag-with-dashes +#tag_with_underscores +\`\`\` + +Tags can contain: +- Letters (any language) +- Numbers (not as first character) +- Underscores \`_\` +- Hyphens \`-\` +- Forward slashes \`/\` (for nesting) + +## References + +- [Basic formatting syntax](https://help.obsidian.md/syntax) +- [Advanced formatting syntax](https://help.obsidian.md/advanced-syntax) +- [Obsidian Flavored Markdown](https://help.obsidian.md/obsidian-flavored-markdown) +- [Internal links](https://help.obsidian.md/links) +- [Embed files](https://help.obsidian.md/embeds) +- [Callouts](https://help.obsidian.md/callouts) +- [Properties](https://help.obsidian.md/properties) +`; diff --git a/src/skills/index.ts b/src/skills/index.ts new file mode 100644 index 0000000..1b572e7 --- /dev/null +++ b/src/skills/index.ts @@ -0,0 +1,4 @@ +export { SkillManager, getSkillManager } from "./manager"; +export type { SkillCache, SkillCacheEntry } from "./types"; +export { SKILL_SOURCES } from "./types"; +export { EMBEDDED_OBSIDIAN_BASES, EMBEDDED_JSON_CANVAS, EMBEDDED_OBSIDIAN_MARKDOWN } from "./embedded"; diff --git a/src/skills/manager.ts b/src/skills/manager.ts new file mode 100644 index 0000000..59e6eda --- /dev/null +++ b/src/skills/manager.ts @@ -0,0 +1,150 @@ +import { requestUrl } from "obsidian"; +import { SkillSource, SkillCache, SKILL_SOURCES, KEPANO_REPO } from "./types"; +import { EMBEDDED_OBSIDIAN_BASES, EMBEDDED_JSON_CANVAS, EMBEDDED_OBSIDIAN_MARKDOWN } from "./embedded"; + +interface GitHubContentResponse { + content: string; + sha: string; + encoding: string; +} + +const EMBEDDED_SKILLS: Record = { + "obsidian-bases": EMBEDDED_OBSIDIAN_BASES, + "json-canvas": EMBEDDED_JSON_CANVAS, + "obsidian-markdown": EMBEDDED_OBSIDIAN_MARKDOWN, +}; + +export class SkillManager { + private cache: SkillCache = {}; + private updatePromise: Promise | null = null; + + /** + * Load skill cache from plugin data + */ + loadCache(data: SkillCache | undefined): void { + if (data) { + this.cache = data; + } + } + + /** + * Get current cache for persistence + */ + getCache(): SkillCache { + return this.cache; + } + + /** + * Get skill content by name + * Returns cached version if available, otherwise embedded fallback + */ + getSkill(name: string): string { + const cached = this.cache[name]; + if (cached?.content) { + return cached.content; + } + return EMBEDDED_SKILLS[name] || ""; + } + + /** + * Check for updates from GitHub and update cache silently + * Called on plugin load + */ + async checkForUpdates(): Promise { + // Prevent concurrent update checks + if (this.updatePromise) { + return this.updatePromise; + } + + this.updatePromise = this.performUpdateCheck(); + try { + await this.updatePromise; + } finally { + this.updatePromise = null; + } + } + + private async performUpdateCheck(): Promise { + const updatePromises = SKILL_SOURCES.map(source => this.updateSkillIfNeeded(source)); + await Promise.allSettled(updatePromises); + } + + private async updateSkillIfNeeded(source: SkillSource): Promise { + try { + const response = await this.fetchSkillFromGitHub(source); + if (!response) return; + + const cached = this.cache[source.name]; + + // Update if SHA differs or no cache exists + if (!cached || cached.sha !== response.sha) { + const content = this.decodeBase64(response.content); + this.cache[source.name] = { + content, + sha: response.sha, + lastChecked: Date.now(), + }; + console.debug(`[Apply OpenCode] Updated skill: ${source.name}`); + } else { + // Update lastChecked even if content unchanged + cached.lastChecked = Date.now(); + } + } catch (err) { + // Silent failure - will use embedded fallback + console.debug(`[Apply OpenCode] Failed to fetch skill ${source.name}:`, err); + } + } + + private async fetchSkillFromGitHub(source: SkillSource): Promise { + const url = `https://api.github.com/repos/${KEPANO_REPO}/contents/${source.path}`; + + try { + const response = await requestUrl({ + url, + method: "GET", + headers: { + "Accept": "application/vnd.github.v3+json", + "User-Agent": "ApplyOpenCode-Obsidian-Plugin", + }, + }); + + if (response.status === 200) { + return response.json as GitHubContentResponse; + } + return null; + } catch { + return null; + } + } + + private decodeBase64(content: string): string { + // GitHub returns base64 encoded content with newlines + const cleaned = content.replace(/\n/g, ""); + // Use Buffer in Node.js environment (Obsidian uses Electron) + return Buffer.from(cleaned, "base64").toString("utf-8"); + } + + /** + * Force refresh all skills from GitHub + */ + async forceRefresh(): Promise { + // Clear SHA to force re-download + for (const source of SKILL_SOURCES) { + const cached = this.cache[source.name]; + if (cached) { + cached.sha = ""; + } + } + await this.checkForUpdates(); + } +} + +// Singleton instance +let skillManagerInstance: SkillManager | null = null; + +export function getSkillManager(): SkillManager { + if (!skillManagerInstance) { + skillManagerInstance = new SkillManager(); + } + return skillManagerInstance; +} diff --git a/src/skills/types.ts b/src/skills/types.ts new file mode 100644 index 0000000..62ce5dc --- /dev/null +++ b/src/skills/types.ts @@ -0,0 +1,22 @@ +export interface SkillSource { + name: string; + path: string; // GitHub API path: skills/{name}/SKILL.md +} + +export interface SkillCacheEntry { + content: string; + sha: string; + lastChecked: number; +} + +export interface SkillCache { + [skillName: string]: SkillCacheEntry; +} + +export const SKILL_SOURCES: SkillSource[] = [ + { name: "obsidian-bases", path: "skills/obsidian-bases/SKILL.md" }, + { name: "json-canvas", path: "skills/json-canvas/SKILL.md" }, + { name: "obsidian-markdown", path: "skills/obsidian-markdown/SKILL.md" }, +]; + +export const KEPANO_REPO = "kepano/obsidian-skills"; diff --git a/src/vault-context.ts b/src/vault-context.ts new file mode 100644 index 0000000..0d0826e --- /dev/null +++ b/src/vault-context.ts @@ -0,0 +1,382 @@ +import { App, TFile, TFolder } from "obsidian"; + +export interface FileExample { + path: string; + content: string; +} + +export interface VaultContext { + folders: string[]; + tags: string[]; + properties: string[]; + noteTitles: string[]; + baseExamples: FileExample[]; + canvasExamples: FileExample[]; + relevantNotes: FileExample[]; +} + +export interface CollectVaultContextOptions { + /** Keywords to find relevant notes */ + keywords?: string[]; + /** File type context: 'base' | 'canvas' | 'general' */ + fileType?: "base" | "canvas" | "general"; + /** Maximum number of example files to include */ + maxExamples?: number; +} + +/** + * Collect vault context for AI generation prompts. + * Includes folders, tags, frontmatter properties, note titles, and example files. + */ +export async function collectVaultContext( + app: App, + excludeFile?: TFile, + options: CollectVaultContextOptions = {} +): Promise { + const { keywords = [], fileType = "general", maxExamples = 3 } = options; + + const folders = collectFolders(app); + const tags = collectTags(app); + const properties = collectProperties(app); + const noteTitles = collectNoteTitles(app, excludeFile); + + // Collect example files based on file type + const baseExamples = fileType === "base" || fileType === "general" + ? await collectBaseExamples(app, excludeFile, maxExamples) + : []; + const canvasExamples = fileType === "canvas" || fileType === "general" + ? await collectCanvasExamples(app, excludeFile, maxExamples) + : []; + + // Find relevant notes based on keywords + const relevantNotes = keywords.length > 0 + ? await findRelevantNotes(app, keywords, excludeFile, maxExamples) + : []; + + return { folders, tags, properties, noteTitles, baseExamples, canvasExamples, relevantNotes }; +} + +/** + * Get all folder paths in the vault + */ +function collectFolders(app: App): string[] { + const folders: string[] = []; + + const traverse = (folder: TFolder) => { + // Include all folders except root + if (folder.path) { + folders.push(folder.path); + } + for (const child of folder.children) { + if (child instanceof TFolder) { + traverse(child); + } + } + }; + + const root = app.vault.getRoot(); + traverse(root); + + return folders.sort(); +} + +/** + * Collect all unique tags used across the vault + */ +function collectTags(app: App): string[] { + const tags = new Set(); + + for (const file of app.vault.getMarkdownFiles()) { + const cache = app.metadataCache.getFileCache(file); + if (!cache) continue; + + // Tags from frontmatter + if (cache.frontmatter?.tags) { + const fmTags = Array.isArray(cache.frontmatter.tags) + ? cache.frontmatter.tags + : [cache.frontmatter.tags]; + fmTags.forEach((t: string) => tags.add(String(t))); + } + + // Inline tags from body + if (cache.tags) { + cache.tags.forEach((t: { tag: string }) => tags.add(t.tag.replace(/^#/, ""))); + } + } + + return [...tags].sort(); +} + +/** + * Collect all unique frontmatter property names used across the vault + */ +function collectProperties(app: App): string[] { + const props = new Set(); + + for (const file of app.vault.getMarkdownFiles()) { + const cache = app.metadataCache.getFileCache(file); + if (!cache?.frontmatter) continue; + + for (const key of Object.keys(cache.frontmatter)) { + // Skip internal Obsidian keys + if (key !== "position") { + props.add(key); + } + } + } + + return [...props].sort(); +} + +/** + * Get all note titles (basenames) in the vault + */ +function collectNoteTitles(app: App, excludeFile?: TFile): string[] { + return app.vault + .getMarkdownFiles() + .filter((file) => !excludeFile || file.path !== excludeFile.path) + .map((file) => file.basename) + .sort(); +} + +/** + * Collect example .base files from the vault + */ +async function collectBaseExamples( + app: App, + excludeFile?: TFile, + limit: number = 3 +): Promise { + const examples: FileExample[] = []; + const files = app.vault.getFiles() + .filter(f => f.extension === "base" && f.path !== excludeFile?.path) + .sort((a, b) => b.stat.mtime - a.stat.mtime) // Most recent first + .slice(0, limit); + + for (const file of files) { + try { + const content = await app.vault.read(file); + // Limit content size to avoid huge prompts + const truncated = content.length > 2000 + ? content.slice(0, 2000) + "\n# ... (truncated)" + : content; + examples.push({ path: file.path, content: truncated }); + } catch { + continue; + } + } + + return examples; +} + +/** + * Collect example .canvas files from the vault + */ +async function collectCanvasExamples( + app: App, + excludeFile?: TFile, + limit: number = 2 +): Promise { + const examples: FileExample[] = []; + const files = app.vault.getFiles() + .filter(f => f.extension === "canvas" && f.path !== excludeFile?.path) + .sort((a, b) => b.stat.mtime - a.stat.mtime) + .slice(0, limit); + + for (const file of files) { + try { + const content = await app.vault.read(file); + // Limit content size - canvas files can be large + const truncated = content.length > 3000 + ? content.slice(0, 3000) + '\n// ... (truncated)' + : content; + examples.push({ path: file.path, content: truncated }); + } catch { + continue; + } + } + + return examples; +} + +/** + * Find notes relevant to the given keywords + */ +async function findRelevantNotes( + app: App, + keywords: string[], + excludeFile?: TFile, + limit: number = 5 +): Promise { + const normalizedKeywords = keywords.map(k => k.toLowerCase()); + const scored: Array<{ file: TFile; score: number }> = []; + + for (const file of app.vault.getMarkdownFiles()) { + if (file.path === excludeFile?.path) continue; + + let score = 0; + const cache = app.metadataCache.getFileCache(file); + const basename = file.basename.toLowerCase(); + const folderPath = file.parent?.path.toLowerCase() || ""; + + // Score based on filename match + for (const kw of normalizedKeywords) { + if (basename.includes(kw)) score += 10; + if (folderPath.includes(kw)) score += 5; + } + + // Score based on tags + if (cache?.frontmatter?.tags) { + const fileTags = Array.isArray(cache.frontmatter.tags) + ? cache.frontmatter.tags + : [cache.frontmatter.tags]; + for (const tag of fileTags) { + const tagLower = String(tag).toLowerCase(); + for (const kw of normalizedKeywords) { + if (tagLower.includes(kw)) score += 8; + } + } + } + + // Score based on frontmatter values + if (cache?.frontmatter) { + for (const value of Object.values(cache.frontmatter)) { + if (typeof value === "string") { + const valueLower = value.toLowerCase(); + for (const kw of normalizedKeywords) { + if (valueLower.includes(kw)) score += 3; + } + } + } + } + + if (score > 0) { + scored.push({ file, score }); + } + } + + // Sort by score and take top results + scored.sort((a, b) => b.score - a.score); + const topFiles = scored.slice(0, limit); + + const examples: FileExample[] = []; + for (const { file } of topFiles) { + try { + const content = await app.vault.read(file); + // Truncate to reasonable size - focus on frontmatter and beginning + const truncated = content.length > 1500 + ? content.slice(0, 1500) + "\n\n... (truncated)" + : content; + examples.push({ path: file.path, content: truncated }); + } catch { + continue; + } + } + + return examples; +} + +/** + * Format vault context for inclusion in AI prompts + */ +export function formatVaultContext(context: VaultContext): string { + const sections: string[] = []; + + // Folders (limit to 100) + if (context.folders.length > 0) { + const folderList = context.folders.slice(0, 100); + sections.push(`VAULT FOLDERS: +${folderList.join("\n")}${context.folders.length > 100 ? `\n(and ${context.folders.length - 100} more)` : ""}`); + } + + // Tags (limit to 100) + if (context.tags.length > 0) { + const tagList = context.tags.slice(0, 100); + sections.push(`VAULT TAGS: +${tagList.join(", ")}${context.tags.length > 100 ? ` (and ${context.tags.length - 100} more)` : ""}`); + } + + // Properties (limit to 50) + if (context.properties.length > 0) { + const propList = context.properties.slice(0, 50); + sections.push(`VAULT PROPERTIES (frontmatter fields): +${propList.join(", ")}${context.properties.length > 50 ? ` (and ${context.properties.length - 50} more)` : ""}`); + } + + // Note titles (limit to 200) + if (context.noteTitles.length > 0) { + const titleList = context.noteTitles.slice(0, 200); + sections.push(`VAULT NOTE TITLES (for file references): +${titleList.join(", ")}${context.noteTitles.length > 200 ? ` (and ${context.noteTitles.length - 200} more)` : ""}`); + } + + // Base file examples + if (context.baseExamples.length > 0) { + const baseSection = context.baseExamples + .map(ex => `--- ${ex.path} ---\n${ex.content}`) + .join("\n\n"); + sections.push(`EXAMPLE .base FILES FROM THIS VAULT (use similar patterns): +${baseSection}`); + } + + // Canvas file examples + if (context.canvasExamples.length > 0) { + const canvasSection = context.canvasExamples + .map(ex => `--- ${ex.path} ---\n${ex.content}`) + .join("\n\n"); + sections.push(`EXAMPLE .canvas FILES FROM THIS VAULT (use similar layout patterns): +${canvasSection}`); + } + + // Relevant notes + if (context.relevantNotes.length > 0) { + const notesSection = context.relevantNotes + .map(ex => `--- ${ex.path} ---\n${ex.content}`) + .join("\n\n"); + sections.push(`RELEVANT NOTES FROM THIS VAULT (reference these for context): +${notesSection}`); + } + + if (sections.length === 0) { + return ""; + } + + return ` +VAULT CONTEXT - Use exact names from this vault: +${sections.join("\n\n")} + +IMPORTANT: When referencing folders, tags, properties, or files, use the EXACT names from the lists above. Do not invent or misspell names. +`; +} + +/** + * Extract keywords from a description for finding relevant notes + */ +export function extractKeywords(description: string): string[] { + // Remove common stop words and extract meaningful terms + const stopWords = new Set([ + "a", "an", "the", "is", "are", "was", "were", "be", "been", "being", + "have", "has", "had", "do", "does", "did", "will", "would", "could", + "should", "may", "might", "must", "shall", "can", "need", "dare", + "to", "of", "in", "for", "on", "with", "at", "by", "from", "as", + "into", "through", "during", "before", "after", "above", "below", + "between", "under", "again", "further", "then", "once", "here", + "there", "when", "where", "why", "how", "all", "each", "few", + "more", "most", "other", "some", "such", "no", "nor", "not", + "only", "own", "same", "so", "than", "too", "very", "just", + "and", "but", "if", "or", "because", "until", "while", "this", + "that", "these", "those", "what", "which", "who", "whom", + "create", "make", "show", "display", "list", "filter", "exclude", + "include", "add", "remove", "edit", "update", "change", "modify", + "want", "like", "need", "file", "files", "note", "notes", "base", "canvas" + ]); + + const words = description + .toLowerCase() + .replace(/[^a-z0-9\s-]/g, " ") + .split(/\s+/) + .filter(word => word.length > 2 && !stopWords.has(word)); + + // Return unique keywords + return [...new Set(words)]; +} diff --git a/src/weekly-summary.ts b/src/weekly-summary.ts new file mode 100644 index 0000000..4eddefb --- /dev/null +++ b/src/weekly-summary.ts @@ -0,0 +1,288 @@ +import { App } from "obsidian"; +import { spawn } from "child_process"; + +export interface WeeklySummaryOptions { + opencodePath: string; + model: string; +} + +export interface WeeklyNote { + path: string; + title: string; + created: number; + modified: number; + isNew: boolean; + content: string; + tags: string[]; + folder: string; +} + +interface OpenCodeJsonEvent { + type: string; + part?: { + type: string; + text?: string; + }; +} + +const DEFAULT_OPENCODE_PATH = "/Users/dps/.opencode/bin/opencode"; +const MS_PER_DAY = 1000 * 60 * 60 * 24; +const DAYS_IN_WEEK = 7; + +function resolveOpenCodePath(path: string): string { + if (path && path !== "opencode") return path; + return DEFAULT_OPENCODE_PATH; +} + +function extractTextFromJsonOutput(output: string): string { + const lines = output.trim().split("\n"); + const textParts: string[] = []; + + for (const line of lines) { + try { + const event = JSON.parse(line) as OpenCodeJsonEvent; + if (event.type === "text" && event.part?.text) { + textParts.push(event.part.text); + } + } catch { + continue; + } + } + + return textParts.join(""); +} + +function runOpenCode(opencodePath: string, model: string, prompt: string): Promise { + return new Promise((resolve, reject) => { + const args = ["run", "--format", "json", "-m", model, "--", prompt]; + const resolvedPath = resolveOpenCodePath(opencodePath); + console.debug("[Apply OpenCode] Running OpenCode for weekly summary, prompt length:", prompt.length); + const proc = spawn(resolvedPath, args, { + stdio: ["pipe", "pipe", "pipe"], + env: { ...process.env, PATH: `${process.env.PATH}:/usr/local/bin:/opt/homebrew/bin:${process.env.HOME}/.opencode/bin` }, + }); + + proc.stdin.end(); + + let stdout = ""; + let stderr = ""; + + proc.stdout.on("data", (data: Buffer) => { + stdout += data.toString(); + }); + + proc.stderr.on("data", (data: Buffer) => { + stderr += data.toString(); + }); + + proc.on("close", (code) => { + if (code === 0) { + const text = extractTextFromJsonOutput(stdout); + resolve(text); + } else { + console.error("[Apply OpenCode] stderr:", stderr); + reject(new Error(`OpenCode exited with code ${code}: ${stderr}`)); + } + }); + + proc.on("error", (err) => { + reject(new Error(`Failed to spawn OpenCode: ${err.message}`)); + }); + }); +} + +/** + * Collect all notes created or modified in the past 7 days + */ +export async function collectWeeklyNotes(app: App): Promise { + const now = Date.now(); + const weekAgo = now - (DAYS_IN_WEEK * MS_PER_DAY); + const notes: WeeklyNote[] = []; + + for (const file of app.vault.getMarkdownFiles()) { + const created = file.stat.ctime; + const modified = file.stat.mtime; + + // Include if created or modified in past 7 days + if (created >= weekAgo || modified >= weekAgo) { + const cache = app.metadataCache.getFileCache(file); + + // Get tags + const tags: string[] = []; + if (cache?.frontmatter?.tags) { + const fmTags = Array.isArray(cache.frontmatter.tags) + ? cache.frontmatter.tags + : [cache.frontmatter.tags]; + tags.push(...fmTags.map((t: string) => String(t))); + } + if (cache?.tags) { + tags.push(...cache.tags.map((t: { tag: string }) => t.tag.replace(/^#/, ""))); + } + + // Read content (truncate for large files) + let content = ""; + try { + const fullContent = await app.vault.read(file); + content = fullContent.length > 2000 + ? fullContent.slice(0, 2000) + "\n... (truncated)" + : fullContent; + } catch { + content = "(unable to read)"; + } + + notes.push({ + path: file.path, + title: file.basename, + created, + modified, + isNew: created >= weekAgo, + content, + tags: [...new Set(tags)], + folder: file.parent?.path || "", + }); + } + } + + // Sort by modified date, most recent first + notes.sort((a, b) => b.modified - a.modified); + + return notes; +} + +/** + * Format notes data for the prompt + */ +function formatNotesForPrompt(notes: WeeklyNote[]): string { + const newNotes = notes.filter(n => n.isNew); + const modifiedNotes = notes.filter(n => !n.isNew); + + const formatDate = (ts: number) => new Date(ts).toLocaleDateString("en-US", { + weekday: "short", + month: "short", + day: "numeric", + }); + + let output = ""; + + if (newNotes.length > 0) { + output += `## NEW NOTES (${newNotes.length})\n\n`; + for (const note of newNotes) { + output += `### ${note.title}\n`; + output += `- Path: ${note.path}\n`; + output += `- Created: ${formatDate(note.created)}\n`; + if (note.tags.length > 0) { + output += `- Tags: ${note.tags.join(", ")}\n`; + } + output += `\nContent:\n${note.content}\n\n---\n\n`; + } + } + + if (modifiedNotes.length > 0) { + output += `## MODIFIED NOTES (${modifiedNotes.length})\n\n`; + for (const note of modifiedNotes) { + output += `### ${note.title}\n`; + output += `- Path: ${note.path}\n`; + output += `- Modified: ${formatDate(note.modified)}\n`; + if (note.tags.length > 0) { + output += `- Tags: ${note.tags.join(", ")}\n`; + } + output += `\nContent:\n${note.content}\n\n---\n\n`; + } + } + + return output; +} + +/** + * Compute basic statistics about the week's activity + */ +function computeStats(notes: WeeklyNote[]): string { + const newCount = notes.filter(n => n.isNew).length; + const modifiedCount = notes.filter(n => !n.isNew).length; + + // Tag frequency + const tagCounts: Record = {}; + for (const note of notes) { + for (const tag of note.tags) { + tagCounts[tag] = (tagCounts[tag] || 0) + 1; + } + } + const topTags = Object.entries(tagCounts) + .sort((a, b) => b[1] - a[1]) + .slice(0, 10) + .map(([tag, count]) => `${tag} (${count})`); + + // Folder activity + const folderCounts: Record = {}; + for (const note of notes) { + const folder = note.folder || "(root)"; + folderCounts[folder] = (folderCounts[folder] || 0) + 1; + } + const topFolders = Object.entries(folderCounts) + .sort((a, b) => b[1] - a[1]) + .slice(0, 5) + .map(([folder, count]) => `${folder} (${count})`); + + // Activity by day + const dayActivity: Record = {}; + for (const note of notes) { + const day = new Date(note.isNew ? note.created : note.modified) + .toLocaleDateString("en-US", { weekday: "long" }); + dayActivity[day] = (dayActivity[day] || 0) + 1; + } + + return `ACTIVITY STATISTICS: +- New notes: ${newCount} +- Modified notes: ${modifiedCount} +- Total activity: ${notes.length} notes + +Top tags: ${topTags.length > 0 ? topTags.join(", ") : "(none)"} + +Most active folders: ${topFolders.join(", ")} + +Activity by day: ${Object.entries(dayActivity).map(([d, c]) => `${d}: ${c}`).join(", ")} +`; +} + +/** + * Generate a weekly summary using AI + */ +export async function generateWeeklySummary( + notes: WeeklyNote[], + options: WeeklySummaryOptions +): Promise { + if (notes.length === 0) { + return "No notes were created or modified in the past 7 days."; + } + + const stats = computeStats(notes); + const notesContent = formatNotesForPrompt(notes); + + const prompt = `You are analyzing a personal knowledge base (Obsidian vault) to provide a weekly summary. + +${stats} + +--- + +${notesContent} + +--- + +Provide a comprehensive weekly summary that includes: + +1. **Overview**: A 2-3 sentence high-level summary of what the user worked on this week. + +2. **Key Themes**: What topics, projects, or areas received the most attention? Look for patterns across notes. + +3. **Notable Progress**: Highlight any significant developments, completions, or milestones evident from the notes. + +4. **Connections**: Identify any interesting relationships between notes or topics that emerged this week. + +5. **Open Threads**: What work appears to be in progress or might need follow-up? + +6. **Reflection Prompts**: 2-3 thoughtful questions the user might consider based on their week's activity. + +Format the response in clean Markdown suitable for an Obsidian note. Be specific and reference actual note titles when relevant. Keep the tone helpful and insightful, not generic.`; + + return runOpenCode(options.opencodePath, options.model, prompt); +} diff --git a/styles.css b/styles.css index 39338f8..1a9485f 100644 --- a/styles.css +++ b/styles.css @@ -8,6 +8,108 @@ to { opacity: 1; transform: translateX(0); } } +/* ========================================== + UNIFIED BUTTON SYSTEM + ========================================== */ + +/* Base button styles */ +.aoc-btn { + padding: 8px 20px; + border-radius: 6px; + font-weight: 600; + font-size: 14px; + cursor: pointer; + height: 36px; + border: 1px solid transparent; + transition: all 0.2s cubic-bezier(0.16, 1, 0.3, 1); + display: inline-flex; + align-items: center; + justify-content: center; + gap: 6px; + white-space: nowrap; +} + +.aoc-btn:focus-visible { + outline: 2px solid var(--interactive-accent); + outline-offset: 2px; +} + +/* Primary button (main action) */ +.aoc-btn-primary { + background: var(--interactive-accent); + color: var(--text-on-accent); + box-shadow: 0 2px 4px rgba(0,0,0,0.1); +} + +.aoc-btn-primary:hover { + background: var(--interactive-accent-hover); + transform: translateY(-1px); + box-shadow: 0 4px 8px rgba(0,0,0,0.15); +} + +.aoc-btn-primary:active { + transform: translateY(0); + box-shadow: 0 2px 4px rgba(0,0,0,0.1); +} + +.aoc-btn-primary:disabled { + opacity: 0.5; + cursor: not-allowed; + transform: none; + box-shadow: none; +} + +/* Secondary button (cancel, dismiss) */ +.aoc-btn-secondary { + background: transparent; + border-color: var(--background-modifier-border); + color: var(--text-muted); +} + +.aoc-btn-secondary:hover { + border-color: var(--text-normal); + color: var(--text-normal); + background: var(--background-modifier-hover); +} + +.aoc-btn-secondary:active { + background: var(--background-modifier-active-hover); +} + +/* Destructive/warning button */ +.aoc-btn-destructive { + background: transparent; + border-color: var(--background-modifier-border); + color: var(--text-muted); +} + +.aoc-btn-destructive:hover { + color: var(--text-error); + border-color: var(--text-error); + background: rgba(var(--color-red-rgb), 0.1); +} + +/* Ghost button (minimal) */ +.aoc-btn-ghost { + background: transparent; + border: none; + color: var(--text-muted); + padding: 8px 12px; +} + +.aoc-btn-ghost:hover { + color: var(--text-normal); + background: var(--background-modifier-hover); +} + +/* Button group container */ +.aoc-btn-group { + display: flex; + justify-content: flex-end; + gap: 12px; + flex-shrink: 0; +} + /* Style the modal itself (class applied to .modal element) */ .modal.apply-opencode-diff-modal { width: 80vw !important; @@ -149,50 +251,54 @@ justify-content: flex-end; gap: 12px; padding-top: 0; - margin-top: auto; /* Push to bottom */ + margin-top: auto; flex-shrink: 0; } +/* Use unified button system - keeping legacy classes for compatibility */ .diff-buttons button { - transition: all 0.2s cubic-bezier(0.16, 1, 0.3, 1); + padding: 8px 20px; border-radius: 6px; font-weight: 600; - padding: 8px 20px; - cursor: pointer; - height: 40px; font-size: 14px; + cursor: pointer; + height: 36px; + border: 1px solid transparent; + transition: all 0.2s cubic-bezier(0.16, 1, 0.3, 1); } -.diff-buttons button:hover { - transform: translateY(-1px); - box-shadow: 0 4px 12px rgba(0,0,0,0.1); -} - -.diff-buttons button:active { - transform: translateY(0); - box-shadow: none; +.diff-buttons button.mod-cta, +.diff-buttons button.aoc-btn-primary { + background: var(--interactive-accent); + color: var(--text-on-accent); + box-shadow: 0 2px 4px rgba(0,0,0,0.1); } -.diff-buttons button.mod-cta { - background-color: var(--interactive-accent); - color: var(--text-on-accent); - border: none; +.diff-buttons button.mod-cta:hover, +.diff-buttons button.aoc-btn-primary:hover { + background: var(--interactive-accent-hover); + transform: translateY(-1px); + box-shadow: 0 4px 8px rgba(0,0,0,0.15); } -.diff-buttons button.mod-cta:hover { - background-color: var(--interactive-accent-hover); +.diff-buttons button.mod-cta:active, +.diff-buttons button.aoc-btn-primary:active { + transform: translateY(0); + box-shadow: 0 2px 4px rgba(0,0,0,0.1); } -.diff-buttons button.mod-warning { - background-color: transparent; +.diff-buttons button.mod-warning, +.diff-buttons button.aoc-btn-secondary { + background: transparent; + border-color: var(--background-modifier-border); color: var(--text-muted); - border: 1px solid var(--background-modifier-border); } -.diff-buttons button.mod-warning:hover { - color: var(--text-error); - border-color: var(--background-modifier-error); - background-color: var(--background-modifier-error-hover); +.diff-buttons button.mod-warning:hover, +.diff-buttons button.aoc-btn-secondary:hover { + border-color: var(--text-normal); + color: var(--text-normal); + background: var(--background-modifier-hover); } .diff-line { @@ -289,9 +395,27 @@ } .apply-opencode-progress .progress-buttons button { - padding: 6px 16px; + padding: 8px 20px; border-radius: 6px; + font-weight: 600; + font-size: 14px; cursor: pointer; + height: 36px; + border: 1px solid var(--background-modifier-border); + background: transparent; + color: var(--text-muted); + transition: all 0.2s cubic-bezier(0.16, 1, 0.3, 1); +} + +.apply-opencode-progress .progress-buttons button:hover { + border-color: var(--text-normal); + color: var(--text-normal); + background: var(--background-modifier-hover); +} + +.apply-opencode-progress .progress-buttons button:disabled { + opacity: 0.5; + cursor: not-allowed; } /* Template Picker Modal */ @@ -455,36 +579,36 @@ cursor: not-allowed; } +/* Revision button - compact pill variant of primary button */ .revision-btn { flex-shrink: 0; height: 32px; min-height: 32px; padding: 0 16px; - border-radius: 16px; + border-radius: 16px; /* Pill shape for inline context */ font-weight: 600; font-size: 13px; cursor: pointer; white-space: nowrap; background: var(--interactive-accent); - color: var(--text-on-accent, #fff); + color: var(--text-on-accent); border: none; transition: all 0.2s cubic-bezier(0.16, 1, 0.3, 1); display: flex; align-items: center; justify-content: center; - margin-bottom: 1px; /* Optical alignment with text */ + box-shadow: 0 2px 4px rgba(0,0,0,0.1); } .revision-btn:hover:not(:disabled) { - background: var(--interactive-accent-hover, var(--interactive-accent)); + background: var(--interactive-accent-hover); transform: translateY(-1px); - box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); - filter: brightness(1.1); + box-shadow: 0 4px 8px rgba(0,0,0,0.15); } .revision-btn:active:not(:disabled) { transform: translateY(0); - box-shadow: none; + box-shadow: 0 2px 4px rgba(0,0,0,0.1); } .revision-btn:disabled { @@ -495,3 +619,305 @@ transform: none; box-shadow: none; } + +/* FILE CREATE MODAL */ +.modal.apply-opencode-file-create { + width: 500px; + max-width: 90vw; + padding: 0; + border-radius: 16px; + overflow: hidden; + background: var(--background-primary); + display: flex; + flex-direction: column; +} + +.modal.apply-opencode-file-create .modal-close-button { + top: 16px; + right: 16px; + z-index: 10; + background: var(--background-secondary); + border-radius: 50%; + width: 28px; + height: 28px; + display: flex; + align-items: center; + justify-content: center; + opacity: 0.6; +} + +.modal.apply-opencode-file-create .modal-close-button:hover { + opacity: 1; + background: var(--background-modifier-hover); +} + +.modal.apply-opencode-file-create .modal-content { + padding: 0 !important; + margin: 0 !important; + gap: 0; + display: flex; + flex-direction: column; +} + +/* Header */ +.file-create-header { + padding: 24px 24px 20px; + background: var(--background-secondary); + border-bottom: 1px solid var(--background-modifier-border); + display: flex; + align-items: center; + gap: 16px; + position: relative; +} + +.file-create-icon { + width: 48px; + height: 48px; + background: var(--background-primary); + border-radius: 12px; + display: flex; + align-items: center; + justify-content: center; + color: var(--interactive-accent); + box-shadow: 0 2px 8px rgba(0,0,0,0.05); + flex-shrink: 0; + border: 1px solid var(--background-modifier-border); +} + +.file-create-icon svg { + width: 26px; + height: 26px; +} + +.file-create-title { + display: flex; + flex-direction: column; + gap: 4px; +} + +.file-create-title h2 { + margin: 0; + font-size: 18px; + font-weight: 600; + line-height: 1.2; + color: var(--text-normal); +} + +.file-create-title p { + margin: 0; + font-size: 13px; + color: var(--text-muted); + line-height: 1.4; +} + +/* Body */ +.file-create-body { + padding: 24px; + display: flex; + flex-direction: column; + gap: 24px; + flex: 1; + overflow-y: auto; +} + +.file-create-section { + display: flex; + flex-direction: column; + gap: 8px; +} + +.file-create-label { + font-size: 12px; + font-weight: 600; + color: var(--text-muted); + text-transform: uppercase; + letter-spacing: 0.05em; + padding-left: 2px; +} + +/* Description Input */ +.file-create-description { + width: 100%; + resize: none; + padding: 12px 14px; + background: var(--background-primary); + border: 1px solid var(--background-modifier-border); + border-radius: 8px; + transition: all 0.2s ease; + font-family: var(--font-text); + font-size: 14px; + line-height: 1.5; + color: var(--text-normal); +} + +.file-create-description:focus { + border-color: var(--interactive-accent); + box-shadow: 0 0 0 2px var(--background-modifier-border-focus); + outline: none; +} + +.file-create-description.has-error { + border-color: var(--text-error); + animation: shake 0.3s ease-in-out; +} + +@keyframes shake { + 0%, 100% { transform: translateX(0); } + 25% { transform: translateX(-4px); } + 75% { transform: translateX(4px); } +} + +/* Location & Name */ +.file-create-filename-group { + display: flex; + flex-direction: column; + gap: 12px; +} + +/* Folder Picker */ +.file-create-folder-picker { + display: flex; + align-items: center; + gap: 10px; + padding: 10px 12px; + background: var(--background-secondary-alt); + border: 1px solid var(--background-modifier-border); + border-radius: 8px; + cursor: pointer; + transition: all 0.2s ease; + color: var(--text-normal); + user-select: none; +} + +.file-create-folder-picker:hover { + border-color: var(--text-muted); + background: var(--background-primary); +} + +.file-create-folder-icon { + color: var(--text-muted); + display: flex; + align-items: center; +} + +.file-create-folder-path { + flex: 1; + font-family: var(--font-monospace); + font-size: 13px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + direction: rtl; /* Truncate from left */ + text-align: left; +} + +.file-create-folder-arrow { + color: var(--text-muted); + opacity: 0.5; +} + +/* Filename Input */ +.file-create-filename-input-container { + position: relative; + display: flex; + align-items: center; +} + +.file-create-filename-input { + width: 100%; + padding: 10px 12px; + padding-right: 60px; /* Space for extension */ + background: var(--background-primary); + border: 1px solid var(--background-modifier-border); + border-radius: 8px; + font-size: 14px; + color: var(--text-normal); + font-family: var(--font-monospace); +} + +.file-create-filename-input:focus { + border-color: var(--interactive-accent); + box-shadow: 0 0 0 2px var(--background-modifier-border-focus); + outline: none; +} + +.file-create-extension { + position: absolute; + right: 12px; + top: 50%; + transform: translateY(-50%); + color: var(--text-muted); + font-family: var(--font-monospace); + font-size: 13px; + pointer-events: none; + background: var(--background-primary-alt); + padding: 2px 6px; + border-radius: 4px; +} + +/* Footer */ +.file-create-footer { + padding: 16px 24px; + border-top: 1px solid var(--background-modifier-border); + display: flex; + justify-content: flex-end; + gap: 12px; + background: var(--background-secondary); +} + +/* File create buttons use unified system */ +.file-create-footer button { + padding: 8px 20px; + border-radius: 6px; + font-weight: 600; + font-size: 14px; + cursor: pointer; + height: 36px; + border: 1px solid transparent; + transition: all 0.2s cubic-bezier(0.16, 1, 0.3, 1); +} + +.file-create-btn-cancel, +.file-create-footer .aoc-btn-secondary { + background: transparent; + border-color: var(--background-modifier-border); + color: var(--text-muted); +} + +.file-create-btn-cancel:hover, +.file-create-footer .aoc-btn-secondary:hover { + border-color: var(--text-normal); + color: var(--text-normal); + background: var(--background-modifier-hover); +} + +.file-create-btn-create, +.file-create-footer .aoc-btn-primary { + background: var(--interactive-accent); + color: var(--text-on-accent); + box-shadow: 0 2px 4px rgba(0,0,0,0.1); +} + +.file-create-btn-create:hover, +.file-create-footer .aoc-btn-primary:hover { + background: var(--interactive-accent-hover); + transform: translateY(-1px); + box-shadow: 0 4px 8px rgba(0,0,0,0.15); +} + +.file-create-btn-create:active, +.file-create-footer .aoc-btn-primary:active { + transform: translateY(0); + box-shadow: 0 2px 4px rgba(0,0,0,0.1); +} + +/* Type specific accents */ +.apply-opencode-file-create[data-type="base"] .file-create-icon { + color: var(--color-purple); + border-color: rgba(var(--color-purple-rgb), 0.2); +} + +.apply-opencode-file-create[data-type="canvas"] .file-create-icon { + color: var(--color-orange); + border-color: rgba(var(--color-orange-rgb), 0.2); +}